Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd739e16c0 | ||
|
|
e345063720 | ||
|
|
ecc4f19076 | ||
|
|
2664e7e52f | ||
|
|
f2cb8d664d | ||
|
|
1b725ec9a7 | ||
|
|
254c09b771 | ||
|
|
da604c1945 | ||
|
|
c59c26bbe0 | ||
|
|
40a8602b46 | ||
|
|
6fab319fbb | ||
|
|
9d140a5880 | ||
|
|
4060e40d9e | ||
|
|
50527a363c | ||
|
|
399b6200fa | ||
|
|
0c224e16a0 | ||
|
|
f0b9546fdc | ||
|
|
d77f9e643e | ||
|
|
8065af3fec | ||
|
|
452a58c154 | ||
|
|
7398be4151 |
No files matched your search
@@ -1,141 +0,0 @@
|
||||
# Gitea Actions: quick notes 🔧
|
||||
|
||||
## What this adds
|
||||
- `.gitea/workflows/ci.yml` — runs backend Maven tests and builds the frontend on every `push`.
|
||||
|
||||
## Requirements (quick checklist)
|
||||
1. **Enable Actions** for this repository: Repository Settings → Actions → Enable Repository Actions.
|
||||
2. **Runner (act_runner)**: you must register at least one runner so jobs can execute.
|
||||
- Prefer running the runner on a separate machine from the Gitea server.
|
||||
- You can use the `gitea/act_runner` docker image or the binaries from Gitea downloads.
|
||||
|
||||
### Example runner registration (local machine)
|
||||
1. On your host, download or pull the runner image: `docker pull gitea/act_runner:latest`.
|
||||
2. Start a container for registration only (refer to docs for token and instance):
|
||||
- Get a token from Gitea UI: **Settings** → **Actions** → **Runners** → **Generate token** (repo/org/instance level).
|
||||
- Run the register command (example using container; adjust instance and token):
|
||||
```bash
|
||||
docker run --rm -it -v $PWD:/data gitea/act_runner register --instance https://your.gitea.host --token <TOKEN>
|
||||
```
|
||||
3. Start the runner daemon (on the machine where you registered):
|
||||
```bash
|
||||
./act_runner daemon
|
||||
# or use the docker image as a long-lived container after registration
|
||||
```
|
||||
|
||||
## Secrets & env
|
||||
- If tests or builds need credentials, add them to **Settings → Secrets** at repo/org/instance level and reference via `${{ secrets.MY_SECRET }}`.
|
||||
|
||||
## Notes & tips
|
||||
- The workflow uses `actions/setup-java` and `actions/setup-node` which fetch from public actions. If your runner has restricted network access, adjust steps to install local toolchains instead.
|
||||
- If you want to run integration tests or deploy artifacts, tell me which target host or container you'll use and I can add deploy steps.
|
||||
|
||||
---
|
||||
|
||||
## Deploying the runner to all workers (recommended for docker-executor)
|
||||
If you want the runner to run on every worker node, use the provided `gitea-act-runner-stack.yml` which runs the service in `mode: global` with a placement constraint for workers:
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
mode: global
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == worker
|
||||
```
|
||||
|
||||
Notes:
|
||||
- This will start one runner instance on each worker node. If you need fewer runners, switch to `mode: replicated` and use `replicas` with a node label constraint (e.g., `node.labels.runner == true`).
|
||||
- Mounting `/var/run/docker.sock` gives the runner the ability to run job containers (docker-executor) but is a security-sensitive choice — only run this on trusted worker nodes.
|
||||
|
||||
### Quick deploy
|
||||
```bash
|
||||
# from a manager node
|
||||
docker stack deploy -c gitea-act-runner-stack.yml act-runner
|
||||
```
|
||||
|
||||
### Optional: label nodes instead of using node.role
|
||||
If you prefer to run on a subset of workers, add a label to desired nodes and update the `placement.constraints`:
|
||||
|
||||
```bash
|
||||
docker node update --label-add runner=true <node-id-or-hostname>
|
||||
```
|
||||
|
||||
Then use:
|
||||
|
||||
```yaml
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.runner == true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registration examples & troubleshooting 🔍
|
||||
If you see errors like `instance address is empty`, it means the runner registration command wasn't given an instance URL (via `--instance` or `GITEA_INSTANCE_URL`). Use one of these approaches depending on your environment.
|
||||
|
||||
1) One-shot CLI (internal Swarm network)
|
||||
```bash
|
||||
docker run --rm -it --network gitea -v act_runner-data:/data gitea/act_runner register \
|
||||
--instance http://gitea_server:3000 --token <TOKEN> --no-interactive --name swarm-runner
|
||||
```
|
||||
Use the internal service name (`gitea_server:3000`) when registering from inside the Swarm `gitea` network.
|
||||
|
||||
2) One-shot (public/Traefik URL)
|
||||
```bash
|
||||
docker run --rm -it -v act_runner-data:/data gitea/act_runner register \
|
||||
--instance https://git.mariuszpacek.pl --token <TOKEN> --no-interactive --name swarm-runner
|
||||
```
|
||||
If you use an HTTPS URL, ensure the container trusts the cert (or use the internal HTTP address if available).
|
||||
|
||||
3) Auto-register & run with env vars (recommended for simple Docker usage)
|
||||
This will register (if needed) and start the runner in one step:
|
||||
```bash
|
||||
docker run -d --name gitea-runner \
|
||||
-v act_runner-data:/data -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e GITEA_INSTANCE_URL=https://git.mariuszpacek.pl \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
||||
-e GITEA_RUNNER_NAME=swarm-runner \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
4) Portainer notes
|
||||
- For a temporary registration container, set image `gitea/act_runner:latest`, **Command** to:
|
||||
`register --instance http://gitea_server:3000 --token <TOKEN> --no-interactive`
|
||||
- Or set environment variables `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN` and start the container (detached) to auto-register.
|
||||
|
||||
5) Debugging checks
|
||||
- Test reachability from the registrator container:
|
||||
```bash
|
||||
docker run --rm --network gitea curlimages/curl curl -sS http://gitea_server:3000/api/v1/version
|
||||
```
|
||||
- Verify `.runner` written to the volume:
|
||||
```bash
|
||||
docker run --rm -v act_runner-data:/data alpine sh -c "ls -la /data && cat /data/.runner"
|
||||
```
|
||||
- Check logs of the register container / service for precise errors.
|
||||
|
||||
If the error persists, paste the exact register command you ran and its container logs and I'll inspect them.
|
||||
|
||||
---
|
||||
|
||||
## Portainer: secure register + deploy (recommended)
|
||||
Use a one-shot *registration stack* (deploy it, wait for logs, then remove it) and then deploy the long-running daemon stack that uses a Docker secret for the registration token.
|
||||
|
||||
1) **Create a secret** in Portainer (or via CLI):
|
||||
- Portainer: *Secrets → Add secret* → name: `registration_token` → value: `<your_token>`
|
||||
- CLI: `echo "<your_token>" | docker secret create registration_token -`
|
||||
|
||||
2) **Register (one-shot)** — Stack: `.gitea/act-runner-register-stack.yml`
|
||||
- Deploy in Portainer → set stack variables:
|
||||
- `INSTANCE_URL` = `http://gitea_server:3000` (or `https://git.mariuszpacek.pl`)
|
||||
- `REGISTRATION_TOKEN` = *your token* (or use a temporary secret and modify command to read it)
|
||||
- Check service logs for success, then remove this stack.
|
||||
|
||||
3) **Deploy daemon (global on workers)** — Stack: `.gitea/act-runner-deploy-stack.yml`
|
||||
- Ensure secret `registration_token` exists (see step 1).
|
||||
- Deploy stack and set `INSTANCE_URL` and `RUNNER_NAME` as environment variables in Portainer.
|
||||
- Runner will start as a global service on workers and use `/run/secrets/registration_token` (no token stored in repo).
|
||||
|
||||
Notes:
|
||||
- **Do not commit tokens to the repo.** Use Docker secrets or Portainer's secret store.
|
||||
- If you prefer a quick test, the register stack accepts `REGISTRATION_TOKEN` as an env var (less secure). After successful registration, remove the env token and rely on the Docker secret for the daemon stack.
|
||||
+15
-48
@@ -2,14 +2,17 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests (Maven)
|
||||
runs-on: ['ubuntu-latest']
|
||||
container:
|
||||
image: maven:3.9-jdk-21
|
||||
name: Backend tests (Maven / Java 21)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -19,47 +22,11 @@ jobs:
|
||||
echo "Runner name: ${{ runner.name }}"
|
||||
echo "Runner labels: ${{ toJSON(runner.labels) }}"
|
||||
|
||||
- name: Cache Maven
|
||||
uses: actions/cache@v4
|
||||
- name: Set up Java 21 + Maven
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
path: |
|
||||
~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Run Maven tests
|
||||
run: mvn -B test
|
||||
|
||||
frontend:
|
||||
name: Frontend build (node)
|
||||
runs-on: ['ubuntu-latest']
|
||||
container:
|
||||
image: node:18-bullseye
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show runner info
|
||||
run: |
|
||||
echo "Runner name: ${{ runner.name }}"
|
||||
echo "Runner labels: ${{ toJSON(runner.labels) }}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install & Build
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: maven
|
||||
- name: Run Maven tests (parallel)
|
||||
run: mvn -B -T 1C test
|
||||
@@ -5,16 +5,45 @@ A Java application that reads data from a heat pump via Modbus TCP and sends met
|
||||
## Features
|
||||
|
||||
- Reads Modbus holding registers from a heat pump
|
||||
- Caches readings for 60 seconds to reduce load
|
||||
- Sends temperature and pump status data to InfluxDB every minute
|
||||
- Configurable send interval to InfluxDB (default: 5 minutes)
|
||||
- Automatic buffering when InfluxDB is unavailable (up to 50,000 data points)
|
||||
- Health check endpoint for monitoring application status
|
||||
- Automatic restart on Modbus connection failures
|
||||
- Graceful shutdown with InfluxDB buffer flush
|
||||
- Systemd service with automatic recovery
|
||||
- Configurable via JSON file
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
mvn clean package
|
||||
java -jar target/home-pump-1.0.0.jar
|
||||
```
|
||||
|
||||
### Production Deployment (Raspberry Pi / Ubuntu)
|
||||
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete installation and configuration guide.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
mvn clean package
|
||||
|
||||
# Install as systemd service
|
||||
sudo scripts/install-service.sh
|
||||
|
||||
# Check status
|
||||
systemctl status home-pump.service
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `src/main/resources/registryMapping.json` to configure:
|
||||
|
||||
- Modbus TCP connection (address, port)
|
||||
- InfluxDB connection (address, username, password)
|
||||
- Send interval (influxSendIntervalSeconds, default: 300)
|
||||
- Connection timeouts and retry settings
|
||||
- List of registers to read with descriptions
|
||||
|
||||
Additional mapping fields (optional):
|
||||
@@ -24,12 +53,110 @@ Additional mapping fields (optional):
|
||||
- `unitId` (Integer): Modbus slave/unit id for the register (default: 1)
|
||||
- `scale` (Double): Multiply raw device value by this to obtain display value (default: 0.1)
|
||||
- `dataType` (String): Data type used for the locator (e.g. `TWO_BYTE_INT_SIGNED`). Supported values: `TWO_BYTE_INT_SIGNED`, `TWO_BYTE_INT_UNSIGNED`, `FOUR_BYTE_INT_SIGNED`, `FOUR_BYTE_INT_UNSIGNED`, `FOUR_BYTE_FLOAT` (the latter reads two adjacent registers and interprets as IEEE754 float). For devices with non-standard word order, the following swapped variants are supported: `FOUR_BYTE_INT_SWAPPED`, `FOUR_BYTE_INT_UNSIGNED_SWAPPED`, `FOUR_BYTE_FLOAT_SWAPPED` (word order low/high).
|
||||
- `signedLegacyWrap` (Boolean): When true, apply legacy negative wrap decoding for values near -32768 (default: true)
|
||||
- # Example Configuration
|
||||
```json
|
||||
{
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"useMock": false,
|
||||
"influxSendIntervalSeconds": 300,
|
||||
"timeoutMs": 5000,
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 1000,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.example.com",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "External temperature",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Build and Run
|
||||
## Monitoring & Health Check
|
||||
|
||||
The application exposes a health check endpoint on port 8080:
|
||||
|
||||
```bash
|
||||
mvn clean package
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
**Healthy response (HTTP 200):**
|
||||
```json
|
||||
{
|
||||
"status": "UP",
|
||||
"lastSuccessfulRead": "2026-02-06T19:30:45Z",
|
||||
"message": "Last successful read 15 seconds ago"
|
||||
}
|
||||
```
|
||||
|
||||
**Unhealthy response (HTTP 503):**
|
||||
```json
|
||||
{
|
||||
"status": "DOWN",
|
||||
"lastSuccessfulRead": "2026-02-06T19:20:45Z",
|
||||
"message": "No successful read for 620 seconds. Last error: Connection timeout"
|
||||
}
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **InfluxDB failures do NOT trigger unhealthy status** - data is buffered in memory
|
||||
- Only Modbus read failures trigger unhealthy status and potential restart
|
||||
- Health check considers the application unhealthy if no successful Modbus read within 2× send interval
|
||||
- Automatic monitoring script restarts the service on health check failures
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production use on Raspberry Pi or Ubuntu server:
|
||||
|
||||
1. **Install as systemd service** - Auto-start on boot with resource limits
|
||||
2. **Enable health monitoring** - Cron job checks health every 2 minutes
|
||||
3. **Automatic recovery** - Restarts service on Modbus failures (max 3 attempts per 10 minutes)
|
||||
4. **Graceful shutdown** - Flushes buffered data to InfluxDB before exit
|
||||
|
||||
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions.
|
||||
|
||||
## Data Buffering
|
||||
|
||||
When InfluxDB is unavailable, data is automatically buffered:
|
||||
|
||||
- **Buffer size**: 50,000 data points in memory
|
||||
- **Batch size**: 50 points per flush
|
||||
- **Flush interval**: 180 seconds (3 minutes)
|
||||
- **No data loss**: Buffer persists during InfluxDB outages
|
||||
- **Automatic retry**: Built-in retry mechanism in InfluxDB Java client
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check application logs
|
||||
```bash
|
||||
journalctl -u home-pump.service -f
|
||||
```
|
||||
|
||||
### Test health endpoint
|
||||
```bash
|
||||
./scripts/test-health.sh
|
||||
```
|
||||
|
||||
### View monitor logs
|
||||
```bash
|
||||
tail -f /var/log/home-pump-monitor.log
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Service won't start**: Check JAR file exists and Java 21 is installed
|
||||
2. **Health check fails**: Verify Modbus device is accessible
|
||||
3. **InfluxDB connection errors**: Check network and credentials (data will be buffered)
|
||||
4. **Too many restarts**: Check Modbus device connectivity and network stability clean package
|
||||
java -jar target/home-pump-1.0.0.jar
|
||||
```
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Home Pump Frontend
|
||||
|
||||
This frontend is a small React + TypeScript app (Vite) that connects to the backend via REST and SSE.
|
||||
|
||||
Development:
|
||||
|
||||
1. cd frontend
|
||||
2. npm install
|
||||
3. npm run dev
|
||||
|
||||
The dev server proxies `/api` and `/sse` to the backend. Override the backend URL/port used by the dev server with `VITE_BACKEND_URL` in a `.env` file (for example see `.env.example`). Default: `http://localhost:7000`.
|
||||
|
||||
Dev mode tip: If your development environment does not have a Modbus device available, set `"useMock": true` in `src/main/resources/registryMapping.json` (or your deployed configuration). The backend will use a `MockRepository` that serves realistic fake values and accepts writes.
|
||||
|
||||
Build for production:
|
||||
|
||||
1. npm run build
|
||||
2. Serve the `dist/` folder by any static server, or copy it into the backend `src/main/resources/static/` for integrated deployment.
|
||||
|
||||
Integrated deployment (option B):
|
||||
- After `npm run build`, copy `frontend/dist` into `src/main/resources/static` (or configure your CI to do that) so that the backend serves the static frontend files from the JAR.
|
||||
|
||||
Integrated deployment (option B):
|
||||
- After `npm run build`, copy `frontend/dist` into `src/main/resources/static` (or configure your CI to do that) so that the backend serves the static frontend files from the JAR.
|
||||
|
||||
SSE endpoint: `/sse/live` (server sends arrays of points: [{registry,ts,value}, ...])
|
||||
|
||||
API endpoints used by the frontend:
|
||||
- GET /api/registries
|
||||
- GET /api/live
|
||||
- POST /api/registry/:id/write { value }
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Home Pump Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-3262
File diff suppressed because it is too large.
Load diff
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "home-pump-frontend",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^6.0.22",
|
||||
"@mantine/notifications": "^6.0.22",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"axios": "1.4.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"recharts": "2.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-dom": "18.2.6",
|
||||
"@vitejs/plugin-react": "5.1.0",
|
||||
"typescript": "5.4.2",
|
||||
"vite": "5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react'
|
||||
import { AppShell, MantineProvider } from '@mantine/core'
|
||||
import Dashboard from './components/Dashboard'
|
||||
import HeaderBar from './components/HeaderBar'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppShell
|
||||
padding="md"
|
||||
header={<HeaderBar />}
|
||||
styles={(theme) => ({
|
||||
main: { background: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[0] }
|
||||
})}
|
||||
>
|
||||
<Dashboard />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Button, TextInput, Grid, Select } from '@mantine/core'
|
||||
|
||||
type Reg = { registry: number; desc?: string }
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const [registries, setRegistries] = useState<Reg[]>([])
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const [value, setValue] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/registries').then(r => setRegistries(r.data || []))
|
||||
}, [])
|
||||
|
||||
const onWrite = async () => {
|
||||
if (!selected) return
|
||||
await axios.post(`/api/registry/${selected}/write`, { value: Number(value) })
|
||||
setValue('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label="Registry"
|
||||
data={registries.map(r => ({ value: String(r.registry), label: `${r.registry} — ${r.desc || ''}` }))}
|
||||
value={selected ? String(selected) : undefined}
|
||||
onChange={v => setSelected(v ? Number(v) : null)}
|
||||
searchable
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<TextInput label="Value" value={value} onChange={(e) => setValue(e.currentTarget.value)} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<Button onClick={onWrite} style={{ marginTop: 24 }}>
|
||||
Write
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Card, Stack, Group, Text, NumberInput, Switch, Button, Grid } from '@mantine/core'
|
||||
import axios from 'axios'
|
||||
import { showNotification } from '@mantine/notifications'
|
||||
|
||||
const iconFor = (desc: string | undefined) => {
|
||||
if (!desc) return '⚙️'
|
||||
const d = desc.toLowerCase()
|
||||
if (d.includes('temp') || d.includes('temp')) return '🌡️'
|
||||
if (d.includes('grzalka') || d.includes('grz')) return '🔥'
|
||||
if (d.includes('pompa')) return '💧'
|
||||
if (d.includes('sprez') || d.includes('spręż')) return '🧊'
|
||||
return '⚙️'
|
||||
}
|
||||
|
||||
export default function ControlsPanel({ mappings, buffer }: { mappings: any[], buffer: Record<number, any[]> }) {
|
||||
const setpoints = mappings.filter(m => m.controlType === 'setpoint')
|
||||
const toggles = mappings.filter(m => m.controlType === 'toggle')
|
||||
const others = mappings.filter(m => !m.controlType)
|
||||
|
||||
return (
|
||||
<Stack spacing="sm">
|
||||
{setpoints.length > 0 && (
|
||||
<div>
|
||||
<Text weight={700} mb={6}>Nastawy</Text>
|
||||
<Grid>
|
||||
{setpoints.map(m => (
|
||||
<Grid.Col xs={12} sm={6} md={4} key={m.registry}>
|
||||
<ControlRow mapping={m} samples={buffer[m.registry] || []} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toggles.length > 0 && (
|
||||
<div>
|
||||
<Text weight={700} mb={6}>Aktuatory</Text>
|
||||
<Grid>
|
||||
{toggles.map(m => (
|
||||
<Grid.Col xs={12} sm={6} md={4} key={m.registry}>
|
||||
<ControlRow mapping={m} samples={buffer[m.registry] || []} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{others.length > 0 && (
|
||||
<div>
|
||||
<Text weight={700} mb={6}>Inne</Text>
|
||||
<Grid>
|
||||
{others.map(m => (
|
||||
<Grid.Col xs={12} sm={6} md={4} key={m.registry}>
|
||||
<ControlRow mapping={m} samples={buffer[m.registry] || []} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function precisionFromStep(step: number) {
|
||||
const s = String(step)
|
||||
if (!s.includes('.')) return 0
|
||||
return s.split('.')[1].length
|
||||
}
|
||||
|
||||
function ControlRow({ mapping, samples, compact = false }: { mapping: any, samples: any[], compact?: boolean }) {
|
||||
const [value, setValue] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [flash, setFlash] = useState(false)
|
||||
|
||||
const lastSample = samples.length ? samples[samples.length - 1] : null
|
||||
const last = lastSample ? lastSample.value : null
|
||||
const lastTs = lastSample ? new Date(lastSample.ts).toLocaleTimeString() : null
|
||||
const cardPadding = compact ? 'sm' : 'md'
|
||||
|
||||
useEffect(() => {
|
||||
if (last !== null && (value === null || isNaN(value))) {
|
||||
const num = Number(last)
|
||||
if (!isNaN(num)) setValue(num)
|
||||
}
|
||||
}, [last])
|
||||
|
||||
const flashOnce = () => {
|
||||
setFlash(true)
|
||||
setTimeout(() => setFlash(false), 1200)
|
||||
}
|
||||
|
||||
const onWrite = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const v = value !== null ? value : (last !== null ? Number(last) : null)
|
||||
if (v === null || isNaN(v)) {
|
||||
showNotification({ title: 'Błąd', message: 'Brak wartości do zapisu', color: 'red' })
|
||||
return
|
||||
}
|
||||
await axios.post(`/api/registry/${mapping.registry}/write`, { value: v })
|
||||
showNotification({ title: 'Zapisano', message: `${mapping.desc}: ${v} ${mapping.unit || ''}`, color: 'teal' })
|
||||
flashOnce()
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.response?.data || e?.message || 'Nieznany błąd')
|
||||
showNotification({ title: 'Błąd', message: msg, color: 'red' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.controlType === 'setpoint') {
|
||||
const step = mapping.step || 0.1
|
||||
const precision = precisionFromStep(step)
|
||||
const inc = () => setValue(prev => {
|
||||
const base = prev !== null ? prev : (last !== null ? Number(last) : (mapping.min ?? 0))
|
||||
return Number((base + step).toFixed(precision))
|
||||
})
|
||||
const dec = () => setValue(prev => {
|
||||
const base = prev !== null ? prev : (last !== null ? Number(last) : (mapping.min ?? 0))
|
||||
return Number((base - step).toFixed(precision))
|
||||
})
|
||||
|
||||
return (
|
||||
<Card shadow="xs" p={cardPadding} style={{ transition: 'box-shadow 200ms, transform 200ms', boxShadow: flash ? '0 0 0 6px rgba(16, 185, 129, 0.12)' : undefined, transform: flash ? 'translateY(-2px)' : undefined }}>
|
||||
<Group position="apart" align="center" spacing="md">
|
||||
<Group>
|
||||
<Text>{iconFor(mapping.desc)} </Text>
|
||||
<div>
|
||||
<Text weight={600}>{mapping.desc} <small>({mapping.registry})</small></Text>
|
||||
<Text size="xs" color="dimmed">{last !== null ? `${last} ${mapping.unit || ''}` : '—'} {lastTs && <small>· {lastTs}</small>} </Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group spacing="xs">
|
||||
<Button size="xs" variant="outline" onClick={dec}>-</Button>
|
||||
<NumberInput
|
||||
value={value === null ? undefined : value}
|
||||
onChange={(v: any) => setValue(v === undefined ? null : v)}
|
||||
step={step}
|
||||
min={mapping.min}
|
||||
max={mapping.max}
|
||||
precision={precision}
|
||||
placeholder={mapping.min !== undefined ? mapping.min : undefined}
|
||||
size="xs"
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<Button size="xs" variant="outline" onClick={inc}>+</Button>
|
||||
<Button loading={loading} onClick={onWrite} size="xs">Zastosuj</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (mapping.controlType === 'toggle') {
|
||||
const cur = last !== null ? (Number(last) !== 0) : false
|
||||
const [state, setState] = useState<boolean>(cur)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// keep toggle state in sync with incoming samples
|
||||
setState(last !== null ? (Number(last) !== 0) : false)
|
||||
}, [last])
|
||||
|
||||
const onToggle = async (v: boolean) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await axios.post(`/api/registry/${mapping.registry}/write`, { value: v ? 1 : 0 })
|
||||
setState(v)
|
||||
const msg = `${mapping.desc}: ${v ? 'Włączono' : 'Wyłączono'}`
|
||||
showNotification({ title: 'Zapisano', message: msg, color: 'teal' })
|
||||
flashOnce()
|
||||
} catch (e: any) {
|
||||
const err = String(e?.response?.data || e?.message || 'Nieznany błąd')
|
||||
showNotification({ title: 'Błąd', message: err, color: 'red' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card shadow="xs" style={{ transition: 'box-shadow 200ms, transform 200ms', boxShadow: flash ? '0 0 0 6px rgba(16, 185, 129, 0.12)' : undefined, transform: flash ? 'translateY(-2px)' : undefined }}>
|
||||
<Group position="apart" align="center">
|
||||
<Group>
|
||||
<Text>{iconFor(mapping.desc)} </Text>
|
||||
<div>
|
||||
<Text weight={600}>{mapping.desc} <small>({mapping.registry})</small></Text>
|
||||
<Text size="sm">Stan: <span style={{ color: state ? 'green' : 'gray', fontWeight: 700 }}>{state ? 'Włączony' : 'Wyłączony'}</span> {lastTs && <small>· {lastTs}</small>}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Switch checked={state} onChange={(e) => onToggle(e.currentTarget.checked)} disabled={busy} color={state ? 'teal' : 'gray'} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card shadow="xs">
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<Text>{iconFor(mapping.desc)} </Text>
|
||||
<div>
|
||||
<Text weight={600}>{mapping.desc} <small>({mapping.registry})</small></Text>
|
||||
<Text size="sm">Aktualnie: {last !== null ? `${last} ${mapping.unit || ''}` : '—'} {lastTs && <small>· {lastTs}</small>}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import SseService, { Point } from '../services/sseService'
|
||||
import LiveChart from './LiveChart'
|
||||
import ControlsPanel from './ControlsPanel'
|
||||
import SummaryPanel from './SummaryPanel'
|
||||
import axios from 'axios'
|
||||
import { Card, Grid, Select, Text, Divider, Group, Button, Tooltip, ActionIcon, MultiSelect, Loader } from '@mantine/core'
|
||||
|
||||
|
||||
export default function Dashboard() {
|
||||
const [buffer, setBuffer] = useState<Record<number, { ts: number; value: number }[]>>({})
|
||||
const [registries, setRegistries] = useState<any[]>([])
|
||||
const [mappings, setMappings] = useState<any[]>([])
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const [connected, setConnected] = useState<boolean>(true)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
// suppress offline banner until timestamp (ms) to avoid flicker during a manual refresh
|
||||
const [suppressOfflineUntil, setSuppressOfflineUntil] = useState<number>(0)
|
||||
const sse = useRef(new SseService('/sse/live'))
|
||||
|
||||
useEffect(() => {
|
||||
// initial snapshots
|
||||
axios.get('/api/live').then(r => setBuffer(r.data || {}))
|
||||
axios.get('/api/registries').then(r => setRegistries(r.data || []))
|
||||
axios.get('/api/mappings').then(r => setMappings((r.data && r.data.registry_mapping) || []))
|
||||
|
||||
const svc = sse.current
|
||||
const unsub = svc.subscribe((pts) => {
|
||||
setBuffer(prev => {
|
||||
const next = { ...prev }
|
||||
pts.forEach(p => {
|
||||
next[p.registry] = (next[p.registry] || []).concat({ ts: p.ts, value: p.value }).slice(-5000)
|
||||
})
|
||||
return next
|
||||
})
|
||||
})
|
||||
|
||||
const unsubStatus = svc.subscribeStatus((s) => {
|
||||
setConnected(s)
|
||||
})
|
||||
svc.start()
|
||||
return () => { unsub(); try { unsubStatus(); } catch (e) {} ; svc.close() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// set default selected to first setpoint mapping if none
|
||||
if (!selected && mappings.length > 0) {
|
||||
const setpoint = mappings.find(m => m.controlType === 'setpoint')
|
||||
if (setpoint) setSelected(setpoint.registry)
|
||||
}
|
||||
}, [mappings])
|
||||
|
||||
const data = selected && buffer[selected] ? buffer[selected] : []
|
||||
|
||||
// prepare series list for LiveChart
|
||||
const seriesList = mappings.map(m => ({ registry: m.registry, label: m.desc || String(m.registry), samples: buffer[m.registry] || [] }))
|
||||
|
||||
// initialize visible from localStorage if present; otherwise set defaults after mappings load
|
||||
const [visible, setVisible] = useState<Set<number>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('visibleSeries')
|
||||
if (stored) {
|
||||
const arr = JSON.parse(stored)
|
||||
return new Set<number>((arr || []).map((v: any) => Number(v)))
|
||||
}
|
||||
} catch (e) {}
|
||||
return new Set<number>()
|
||||
})
|
||||
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(new Date())
|
||||
|
||||
// when mappings load and user hasn't chosen series, set sensible defaults (up to 3 setpoints)
|
||||
useEffect(() => {
|
||||
if (visible.size === 0 && mappings.length > 0) {
|
||||
const setpoints = mappings.filter(m => m.controlType === 'setpoint')
|
||||
const defaultRegs = (setpoints.length ? setpoints.slice(0,3) : mappings.slice(0,3)).map(m => m.registry)
|
||||
setVisible(new Set<number>(defaultRegs))
|
||||
}
|
||||
}, [mappings])
|
||||
|
||||
// persist visible set to localStorage
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('visibleSeries', JSON.stringify(Array.from(visible))) } catch (e) {}
|
||||
}, [visible])
|
||||
|
||||
const toggleVisible = (id: number) => {
|
||||
setVisible(prev => {
|
||||
const copy = new Set(prev)
|
||||
if (copy.has(id)) copy.delete(id); else copy.add(id)
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
{(!connected && Date.now() > suppressOfflineUntil) && <div style={{ marginBottom: 8 }}><Card shadow="xs" style={{ background: '#fff6f6', border: '1px solid #f1c0c0' }}><Text color="red" weight={700}>Offline — backend Modbus niedostępny lub rozłączone SSE</Text></Card></div>}
|
||||
|
||||
<SummaryPanel registries={registries} />
|
||||
|
||||
<Grid mt="md">
|
||||
<Grid.Col xs={12} md={8}>
|
||||
<Card shadow="sm">
|
||||
<Text weight={500} mb={8}>Wykres na żywo</Text>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<MultiSelect
|
||||
data={mappings.map(m => ({ value: String(m.registry), label: `${m.registry} — ${m.desc || ''}` }))}
|
||||
placeholder="Wybierz serie do wyświetlenia"
|
||||
searchable
|
||||
clearable
|
||||
value={Array.from(visible).map(String)}
|
||||
onChange={(vals) => {
|
||||
const s = new Set<number>();
|
||||
(vals || []).forEach(v => s.add(Number(v)))
|
||||
setVisible(s)
|
||||
}}
|
||||
style={{ minWidth: 240 }}
|
||||
/>
|
||||
<Tooltip label="Odśwież dane">
|
||||
<ActionIcon size="sm" onClick={async () => {
|
||||
setIsRefreshing(true)
|
||||
setSuppressOfflineUntil(Date.now() + 1500) // hide offline banner for next 1.5s
|
||||
try {
|
||||
const [live, regs, maps] = await Promise.all([
|
||||
axios.get('/api/live'),
|
||||
axios.get('/api/registries'),
|
||||
axios.get('/api/mappings')
|
||||
])
|
||||
setBuffer(live.data || {})
|
||||
setRegistries(regs.data || [])
|
||||
setMappings((maps.data && maps.data.registry_mapping) || [])
|
||||
setLastRefreshed(new Date())
|
||||
} catch (e: any) {
|
||||
throw e
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}}>
|
||||
{isRefreshing ? <Loader size={16} /> : '🔄'}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<LiveChart series={seriesList} visible={visible} onToggle={(id) => toggleVisible(id)} />
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" mt="md">
|
||||
<Text weight={500} mb={8}>Sterowanie</Text>
|
||||
<ControlsPanel mappings={mappings} buffer={buffer} />
|
||||
</Card>
|
||||
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col xs={12} md={4}>
|
||||
<Card shadow="sm">
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={500}>System</Text>
|
||||
<Group>
|
||||
<Text size="xs" color="dimmed">Ostatnie odświeżenie: {lastRefreshed ? lastRefreshed.toLocaleTimeString() : '—'}</Text>
|
||||
<Tooltip label="Odśwież dane">
|
||||
<ActionIcon size="sm" onClick={() => { axios.get('/api/live').then(r => setBuffer(r.data || {})); axios.get('/api/registries').then(r => setRegistries(r.data || [])); axios.get('/api/mappings').then(r => setMappings((r.data && r.data.registry_mapping) || [])); setLastRefreshed(new Date()) }}>
|
||||
🔄
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" color="dimmed">Mapa rejestrów: {mappings.length}</Text>
|
||||
<Text size="sm" color="dimmed">Rejestry: {registries.length}</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Header, Group, Title, Image, useMantineTheme } from '@mantine/core'
|
||||
|
||||
const Logo = () => (
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="6" width="22" height="12" rx="2" fill="#FFB454" />
|
||||
<path d="M6 9v6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M12 9v6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M18 9v6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="18" cy="6" r="2" fill="#7AE582" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function HeaderBar() {
|
||||
const theme = useMantineTheme()
|
||||
return (
|
||||
<Header height={64} p="sm" style={{ borderBottom: '1px solid rgba(0,0,0,0.04)', background: theme.white }}>
|
||||
<Group position="apart" align="center" style={{ height: '100%' }}>
|
||||
<Group align="center">
|
||||
<Logo />
|
||||
<Title order={3} style={{ marginLeft: 8 }}>Panel Pompy</Title>
|
||||
</Group>
|
||||
</Group>
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react'
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer, Legend } from 'recharts'
|
||||
|
||||
type Sample = { ts: number; value: number }
|
||||
|
||||
export default function LiveChart({ series, visible, onToggle }: { series: { registry: number; label: string; samples: Sample[] }[], visible: Set<number>, onToggle?: (id: number) => void }) {
|
||||
// Build unified time axis from all samples
|
||||
const tsSet = new Set<number>()
|
||||
series.forEach(s => s.samples.forEach(p => tsSet.add(p.ts)))
|
||||
const tsArr = Array.from(tsSet).sort()
|
||||
|
||||
const data = tsArr.map(ts => {
|
||||
const point: any = { t: new Date(ts).toLocaleTimeString() }
|
||||
series.forEach(s => {
|
||||
const found = s.samples.find(p => p.ts === ts)
|
||||
point[String(s.registry)] = found ? found.value : null
|
||||
})
|
||||
return point
|
||||
})
|
||||
|
||||
const colors = ["#4c6ef5", "#20c997", "#ffd43b", "#ff6b6b", "#15aabf", "#f06595", "#845ef7"]
|
||||
|
||||
const handleLegendClick = (e: any) => {
|
||||
if (!e || !e.dataKey) return
|
||||
const id = Number(e.dataKey)
|
||||
if (onToggle) onToggle(id)
|
||||
}
|
||||
|
||||
const CustomLegend = (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{payload && payload.map((p: any, i: number) => {
|
||||
const id = Number(p.dataKey)
|
||||
const active = visible.has(id)
|
||||
const color = colors[i % colors.length]
|
||||
return (
|
||||
<div key={p.dataKey} onClick={() => handleLegendClick(p)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: active ? 1 : 0.35, transition: 'opacity 220ms' }}>
|
||||
<svg width="12" height="12" style={{ marginRight: 6 }}>
|
||||
<rect width="12" height="12" fill={color} rx="2" />
|
||||
</svg>
|
||||
<span style={{ fontSize: 12 }}>{p.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 360 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="t" minTickGap={20} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend content={CustomLegend} />
|
||||
{series.map((s, idx) => (
|
||||
<Line key={s.registry} type="monotone" dataKey={String(s.registry)} name={s.label} stroke={colors[idx % colors.length]} strokeOpacity={visible.has(s.registry) ? 1 : 0.15} strokeWidth={2} dot={false} isAnimationActive={false} style={{ transition: 'stroke-opacity 300ms' }} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Card, Text, Group, Badge, ScrollArea, Button } from '@mantine/core'
|
||||
import eventService, { AppEvent } from '../services/eventService'
|
||||
|
||||
export default function LogsPanel() {
|
||||
const [events, setEvents] = useState<AppEvent[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = eventService.subscribe((evs) => setEvents(evs))
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
const clear = () => {
|
||||
eventService.clear()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card shadow="sm">
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={500}>Logi i zdarzenia</Text>
|
||||
<Button size="xs" variant="outline" onClick={clear}>Wyczyść</Button>
|
||||
</Group>
|
||||
<ScrollArea style={{ height: 220, marginTop: 8 }}>
|
||||
{events.length === 0 && <Text size="sm" color="dimmed">Brak zdarzeń</Text>}
|
||||
{events.map(ev => (
|
||||
<div key={ev.id} style={{ padding: '8px 0', borderBottom: '1px solid rgba(0,0,0,0.04)' }}>
|
||||
<Group position="apart">
|
||||
<div>
|
||||
<Text size="sm" weight={600}>{ev.title || (ev.type === 'system' ? 'System' : ev.type === 'error' ? 'Błąd' : 'Informacja')}</Text>
|
||||
<Text size="xs" color="dimmed">{new Date(ev.ts).toLocaleTimeString()}</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Badge color={ev.type === 'error' ? 'red' : ev.type === 'success' ? 'teal' : 'gray'}>{ev.type}</Badge>
|
||||
<Text size="sm">{ev.message}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Group, Card, Text, Badge, Grid } from '@mantine/core'
|
||||
|
||||
export default function SummaryPanel({ registries }: { registries: any[] }) {
|
||||
const find = (id: number) => registries.find((r: any) => r.registry === id)
|
||||
const tExt = find(4065)
|
||||
const tIn = find(4070)
|
||||
const setCo = find(4202)
|
||||
const pump = find(4117)
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col xs={12} sm={4}>
|
||||
<Card shadow="sm" p="md">
|
||||
<Text size="xs" color="dimmed">Na zewnątrz</Text>
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={700} size="xl">{tExt ? tExt.value : '—'}°C</Text>
|
||||
<Badge color="teal">Sprawne</Badge>
|
||||
</Group>
|
||||
<Text size="xs" color="dimmed" mt={6}>{pump ? (Number(pump.value) ? 'Pompa włączona' : 'Pompa wyłączona') : ''}</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} sm={4}>
|
||||
<Card shadow="sm" p="md">
|
||||
<Text size="xs" color="dimmed">Wewnątrz</Text>
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={700} size="xl">{tIn ? tIn.value : '—'}°C</Text>
|
||||
<Badge color="yellow">Stabilnie</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} sm={4}>
|
||||
<Card shadow="sm" p="md">
|
||||
<Text size="xs" color="dimmed">Nastawa (CO)</Text>
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={700} size="xl">{setCo ? setCo.value : '—'}°C</Text>
|
||||
<Badge color="blue">Nastawa</Badge>
|
||||
</Group>
|
||||
<Text size="xs" color="dimmed" mt={6}>Nastawa centralnego ogrzewania</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
:root{
|
||||
--accent: #ffb454;
|
||||
--accent-2: #7ae582;
|
||||
--bg: linear-gradient(180deg, #f7fbfc 0%, #ffffff 100%);
|
||||
}
|
||||
html, body, #root { height: 100%; margin: 0; font-family: Inter, system-ui, sans-serif; background: radial-gradient(circle at 10% 10%, rgba(12,18,32,1) 0%, rgba(6,12,22,1) 40%, rgba(3,6,10,1) 100%); color: #e6eef6 }
|
||||
|
||||
/* subtle card lift */
|
||||
.mantine-Card-root {
|
||||
transition: box-shadow 120ms ease, transform 120ms ease;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.mantine-Card-root:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 30px rgba(2,6,23,0.6);
|
||||
}
|
||||
|
||||
.mantine-Header-root {
|
||||
background: linear-gradient(90deg, rgba(20,30,48,1) 0%, rgba(12,18,32,1) 100%);
|
||||
color: #f1f6fb;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.mantine-Title-root {
|
||||
color: #e6eef6;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider withGlobalStyles withNormalizeCSS>
|
||||
<Notifications />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
export type EventType = 'info' | 'success' | 'error' | 'system'
|
||||
|
||||
export type AppEvent = {
|
||||
id: string
|
||||
ts: number
|
||||
type: EventType
|
||||
title?: string
|
||||
message: string
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
type Listener = (ev: AppEvent[]) => void
|
||||
|
||||
const MAX_EVENTS = 100
|
||||
let events: AppEvent[] = []
|
||||
let listeners: Listener[] = []
|
||||
|
||||
function pushEvent(e: Omit<AppEvent, 'id' | 'ts'>) {
|
||||
const ev: AppEvent = { ...e, id: String(Math.random()).slice(2), ts: Date.now() }
|
||||
events = [ev, ...events].slice(0, MAX_EVENTS)
|
||||
listeners.forEach(l => l(events))
|
||||
}
|
||||
|
||||
function subscribe(fn: Listener) {
|
||||
listeners.push(fn)
|
||||
// initial snapshot
|
||||
fn(events)
|
||||
return () => { listeners = listeners.filter(l => l !== fn) }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
events = []
|
||||
listeners.forEach(l => l(events))
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return events.slice()
|
||||
}
|
||||
|
||||
export default { pushEvent, subscribe, clear, snapshot }
|
||||
@@ -1,99 +0,0 @@
|
||||
export type Point = { registry: number; ts: number; value: number }
|
||||
|
||||
type Listener = (points: Point[]) => void
|
||||
type StatusListener = (connected: boolean) => void
|
||||
|
||||
export default class SseService {
|
||||
private source: EventSource | null = null
|
||||
private listeners: Listener[] = []
|
||||
private statusListeners: StatusListener[] = []
|
||||
private connected = false
|
||||
private url: string
|
||||
private reconnectMs = 5000 // start with 5s backoff
|
||||
private reconnectScheduled = false
|
||||
|
||||
// Debounce offline status to avoid quick flicker in the UI when SSE reconnects rapidly
|
||||
private offlineDelayMs = 3000
|
||||
private offlineTimeout: number | null = null
|
||||
|
||||
constructor(url = '/sse/live') {
|
||||
this.url = url
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.source) return
|
||||
try {
|
||||
this.source = new EventSource(this.url)
|
||||
this.source.onmessage = (ev) => {
|
||||
try {
|
||||
const pts = JSON.parse(ev.data) as Point[]
|
||||
this.listeners.forEach(l => l(pts))
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.source.onopen = () => { this.reconnectMs = 5000; this.setConnected(true); this.reconnectScheduled = false }
|
||||
this.source.onerror = () => {
|
||||
this.setConnected(false)
|
||||
// Schedule a reconnect with exponential backoff + jitter; avoid scheduling multiple
|
||||
if (this.reconnectScheduled) return
|
||||
this.reconnectScheduled = true
|
||||
const jitter = Math.floor(Math.random() * 1000)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.close()
|
||||
this.start()
|
||||
} finally {
|
||||
this.reconnectMs = Math.min(60000, this.reconnectMs * 2)
|
||||
this.reconnectScheduled = false
|
||||
}
|
||||
}, this.reconnectMs + jitter)
|
||||
}
|
||||
} catch (e) {
|
||||
setTimeout(() => this.start(), this.reconnectMs)
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.source) {
|
||||
try { this.source.close() } catch (e) {}
|
||||
this.source = null
|
||||
this.setConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
private setConnected(v: boolean) {
|
||||
// When marking disconnected, wait offlineDelayMs before notifying so brief blips don't show the banner
|
||||
if (!v) {
|
||||
if (this.offlineTimeout != null) return // already waiting
|
||||
this.offlineTimeout = window.setTimeout(() => {
|
||||
this.offlineTimeout = null
|
||||
this.connected = false
|
||||
this.statusListeners.forEach(l => { try { l(false) } catch (e) {} })
|
||||
}, this.offlineDelayMs)
|
||||
return
|
||||
}
|
||||
|
||||
// on connect, cancel any pending offline timer and notify immediately
|
||||
if (this.offlineTimeout != null) {
|
||||
clearTimeout(this.offlineTimeout)
|
||||
this.offlineTimeout = null
|
||||
}
|
||||
if (!this.connected) {
|
||||
this.connected = true
|
||||
this.statusListeners.forEach(l => { try { l(true) } catch (e) {} })
|
||||
}
|
||||
}
|
||||
|
||||
subscribeStatus(fn: StatusListener) {
|
||||
this.statusListeners.push(fn)
|
||||
return () => { this.statusListeners = this.statusListeners.filter(l => l !== fn) }
|
||||
}
|
||||
|
||||
isConnected() { return this.connected }
|
||||
|
||||
subscribe(fn: Listener) {
|
||||
this.listeners.push(fn)
|
||||
return () => { this.listeners = this.listeners.filter(l => l !== fn) }
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM"],
|
||||
"types": [],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// Proxy API requests to backend (default http://localhost:7000). Override with VITE_BACKEND_URL in .env
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Vite exposes env variables prefixed with VITE_ to the client. Allow setting backend target via VITE_BACKEND_URL
|
||||
const backend = process.env.VITE_BACKEND_URL || 'http://localhost:7000'
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': backend,
|
||||
'/sse': backend
|
||||
}
|
||||
},
|
||||
define: {
|
||||
__VITE_BACKEND_URL__: JSON.stringify(backend)
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.home</groupId>
|
||||
<artifactId>home-pump</artifactId>
|
||||
@@ -12,6 +12,11 @@
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.20.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>2.20.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
@@ -47,14 +52,9 @@
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.28</version>
|
||||
<version>1.18.38</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>5.6.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
@@ -62,17 +62,6 @@
|
||||
<test.excluded.tags>integration</test.excluded.tags>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>with-integration-tests</id>
|
||||
<activation>
|
||||
<activeByDefault>false</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<test.excluded.tags></test.excluded.tags>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
<repositories>
|
||||
<repository>
|
||||
<releases>
|
||||
@@ -103,11 +92,8 @@
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<excludedTags>${test.excluded.tags}</excludedTags>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
@@ -144,15 +130,8 @@
|
||||
<source>21</source>
|
||||
<encoding>UTF-8</encoding>
|
||||
<showWarnings>true</showWarnings>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
</project>
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"useMock": false,
|
||||
"pollIntervalMs": 30000,
|
||||
"windowSeconds": 300,
|
||||
"influxSendIntervalSeconds": 300,
|
||||
"timeoutMs": 5000,
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 1000,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.mariuszpacek.pl",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "External temperature",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4070,
|
||||
"desc": "Temp in",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4071,
|
||||
"desc": "Temp out",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4072,
|
||||
"desc": "Temp pc out",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4067,
|
||||
"desc": "Temp co",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4069,
|
||||
"desc": "Temp cwu",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4066,
|
||||
"desc": "Temp wew",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4199,
|
||||
"desc": "Temp zsr",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.mariuszpacek.pl",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "External temperature"
|
||||
},
|
||||
{
|
||||
"registry": 4070,
|
||||
"desc": "Temp in"
|
||||
},
|
||||
{
|
||||
"registry": 4071,
|
||||
"desc": "Temp out"
|
||||
},
|
||||
{
|
||||
"registry": 4072,
|
||||
"desc": "Temp pc out"
|
||||
},
|
||||
{
|
||||
"registry": 4067,
|
||||
"desc": "Temp co"
|
||||
},
|
||||
{
|
||||
"registry": 4202,
|
||||
"desc": "Set Temp co"
|
||||
},
|
||||
{
|
||||
"registry": 4069,
|
||||
"desc": "Temp cwu"
|
||||
},
|
||||
{
|
||||
"registry": 4203,
|
||||
"desc": "Set Temp cwu"
|
||||
},
|
||||
{
|
||||
"registry": 4066,
|
||||
"desc": "Tem wew"
|
||||
},
|
||||
{
|
||||
"registry": 4201,
|
||||
"desc": "Set Temp wew"
|
||||
},
|
||||
{
|
||||
"registry": 4199,
|
||||
"desc": "Temp zsr"
|
||||
},
|
||||
{
|
||||
"registry": 4117,
|
||||
"desc": "Pompa Dz"
|
||||
},
|
||||
{
|
||||
"registry": 4113,
|
||||
"desc": "Pompa BUF"
|
||||
},
|
||||
{
|
||||
"registry": 4112,
|
||||
"desc": "Pompa CO"
|
||||
},
|
||||
{
|
||||
"registry": 4118,
|
||||
"desc": "Pompa CWU"
|
||||
},
|
||||
{
|
||||
"registry": 4114,
|
||||
"desc": "Sprezarka"
|
||||
},
|
||||
{
|
||||
"registry": 4115,
|
||||
"desc": "GrzalkaCWU"
|
||||
},
|
||||
{
|
||||
"registry": 4119,
|
||||
"desc": "WyCHL"
|
||||
},
|
||||
{
|
||||
"registry": 4116,
|
||||
"desc": "Pompa CCW"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
# Health check and restart script for home-pump service
|
||||
# Run this via cron every 2 minutes: */2 * * * * /home/pi/home-pump/scripts/healthcheck-monitor.sh
|
||||
|
||||
SERVICE_NAME="home-pump.service"
|
||||
HEALTH_ENDPOINT="http://localhost:8080/health"
|
||||
LOG_FILE="/var/log/home-pump-monitor.log"
|
||||
MAX_RESTART_ATTEMPTS=3
|
||||
RESTART_WINDOW_SECONDS=600
|
||||
|
||||
RESTART_COUNT_FILE="/tmp/home-pump-restart-count"
|
||||
RESTART_TIMESTAMP_FILE="/tmp/home-pump-restart-timestamp"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Function to check if service is running
|
||||
is_service_running() {
|
||||
systemctl is-active --quiet "$SERVICE_NAME"
|
||||
}
|
||||
|
||||
# Function to get restart count in the current window
|
||||
get_restart_count() {
|
||||
if [ -f "$RESTART_COUNT_FILE" ]; then
|
||||
cat "$RESTART_COUNT_FILE"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get last restart timestamp
|
||||
get_last_restart_time() {
|
||||
if [ -f "$RESTART_TIMESTAMP_FILE" ]; then
|
||||
cat "$RESTART_TIMESTAMP_FILE"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to reset restart counter if window expired
|
||||
reset_counter_if_expired() {
|
||||
local last_restart=$(get_last_restart_time)
|
||||
local now=$(date +%s)
|
||||
local diff=$((now - last_restart))
|
||||
|
||||
if [ "$diff" -gt "$RESTART_WINDOW_SECONDS" ]; then
|
||||
echo "0" > "$RESTART_COUNT_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to increment restart counter
|
||||
increment_restart_count() {
|
||||
local count=$(get_restart_count)
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$RESTART_COUNT_FILE"
|
||||
date +%s > "$RESTART_TIMESTAMP_FILE"
|
||||
}
|
||||
|
||||
# Check if service is running
|
||||
if ! is_service_running; then
|
||||
log "ERROR: Service $SERVICE_NAME is not running. Attempting to start..."
|
||||
sudo systemctl start "$SERVICE_NAME"
|
||||
sleep 5
|
||||
if is_service_running; then
|
||||
log "SUCCESS: Service restarted successfully"
|
||||
else
|
||||
log "CRITICAL: Failed to start service $SERVICE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check health endpoint
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
log "WARNING: Health check failed with HTTP code: $HTTP_CODE"
|
||||
|
||||
# Reset counter if restart window expired
|
||||
reset_counter_if_expired
|
||||
|
||||
# Check if we can restart
|
||||
restart_count=$(get_restart_count)
|
||||
|
||||
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||
log "CRITICAL: Maximum restart attempts ($MAX_RESTART_ATTEMPTS) reached in the last $RESTART_WINDOW_SECONDS seconds. Manual intervention required."
|
||||
# Send alert (could be email, Pushover, etc.)
|
||||
# Example: echo "Home pump service needs manual intervention" | mail -s "Critical: Home Pump Alert" admin@example.com
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log "Attempting to restart service (attempt $((restart_count + 1))/$MAX_RESTART_ATTEMPTS)..."
|
||||
increment_restart_count
|
||||
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
sleep 10
|
||||
|
||||
# Verify restart
|
||||
if is_service_running; then
|
||||
NEW_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
|
||||
if [ "$NEW_HTTP_CODE" == "200" ]; then
|
||||
log "SUCCESS: Service restarted and health check passed"
|
||||
else
|
||||
log "WARNING: Service restarted but health check still failing (HTTP $NEW_HTTP_CODE)"
|
||||
fi
|
||||
else
|
||||
log "ERROR: Failed to restart service"
|
||||
fi
|
||||
else
|
||||
# Health check passed, reset counter
|
||||
echo "0" > "$RESTART_COUNT_FILE"
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# DEPRECATED: This script is no longer used
|
||||
#
|
||||
# Previously this script was used with cron to monitor and restart the service.
|
||||
# Now systemd handles all restart logic automatically.
|
||||
#
|
||||
# The application monitors health and exits with status 1 when failures exceed threshold,
|
||||
# causing systemd to automatically restart the service (Restart=on-failure).
|
||||
#
|
||||
# This approach is simpler and doesn't require external monitoring.
|
||||
# The service unit (home-pump.service) handles all restart logic.
|
||||
|
||||
echo "This script is deprecated. Systemd automat handles service restarts."
|
||||
exit 0
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Home Pump Automation Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/home-pump
|
||||
ExecStart=/usr/bin/java -jar /home/pi/home-pump/target/home-pump-1.0.0.jar
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Resource limits
|
||||
MemoryLimit=512M
|
||||
CPUQuota=50%
|
||||
|
||||
# Security
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# Installation script for home-pump service on Raspberry Pi/Ubuntu
|
||||
|
||||
set -e
|
||||
|
||||
echo "Home Pump Service Installation Script"
|
||||
echo "======================================"
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="/home/pi/home-pump"
|
||||
SERVICE_FILE="scripts/home-pump.service"
|
||||
JAR_FILE="target/home-pump-1.0.0.jar"
|
||||
|
||||
# Check if project directory exists
|
||||
if [ ! -d "$PROJECT_DIR" ]; then
|
||||
echo "Error: Project directory $PROJECT_DIR not found"
|
||||
echo "Please adjust PROJECT_DIR in this script or create the directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check if JAR file exists
|
||||
if [ ! -f "$JAR_FILE" ]; then
|
||||
echo "Error: JAR file not found at $JAR_FILE"
|
||||
echo "Please build the project first using: mvn clean package"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install systemd service
|
||||
echo "Installing systemd service..."
|
||||
cp "$SERVICE_FILE" /etc/systemd/system/home-pump.service
|
||||
systemctl daemon-reload
|
||||
echo "✓ Systemd service installed"
|
||||
|
||||
# Enable service to start on boot
|
||||
systemctl enable home-pump.service
|
||||
echo "✓ Service enabled to start on boot"
|
||||
|
||||
# Start the service
|
||||
systemctl start home-pump.service
|
||||
echo "✓ Service started"
|
||||
|
||||
# Wait a bit and check status
|
||||
sleep 3
|
||||
if systemctl is-active --quiet home-pump.service; then
|
||||
echo ""
|
||||
echo "✓✓✓ Installation completed successfully! ✓✓✓"
|
||||
echo ""
|
||||
echo "Service status:"
|
||||
systemctl status home-pump.service --no-pager -l
|
||||
echo ""
|
||||
echo "How it works:"
|
||||
echo " - Application monitors health internally"
|
||||
echo " - On consecutive failures (default 3), app exits with error code"
|
||||
echo " - Systemd automatically restarts the service (Restart=on-failure)"
|
||||
echo " - No external monitoring script needed"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " - View logs: journalctl -u home-pump.service -f"
|
||||
echo " - Check status: systemctl status home-pump.service"
|
||||
echo " - Restart service: sudo systemctl restart home-pump.service"
|
||||
echo " - Stop service: sudo systemctl stop home-pump.service"
|
||||
echo " - Health check: curl http://localhost:8080/health"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠ Warning: Service installed but not running properly"
|
||||
echo "Check logs with: journalctl -u home-pump.service -n 50"
|
||||
fi
|
||||
@@ -10,6 +10,7 @@ const options = {
|
||||
'Content-Length': Buffer.byteLength(data)
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log('STATUS', res.statusCode);
|
||||
res.setEncoding('utf8');
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Quick test script for local development
|
||||
|
||||
echo "Testing health endpoint..."
|
||||
curl -v http://localhost:8080/health
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "Expected response when healthy:"
|
||||
echo '{"status":"UP","lastSuccessfulRead":"2026-02-06T19:30:45Z","message":"Last successful read 15 seconds ago"}'
|
||||
|
||||
echo ""
|
||||
echo "Expected response when unhealthy (503):"
|
||||
echo '{"status":"DOWN","lastSuccessfulRead":"2026-02-06T19:20:45Z","message":"No successful read for 620 seconds. Last error: Connection timeout"}'
|
||||
@@ -3,12 +3,16 @@ package home.automation;
|
||||
import home.automation.configuration.JsonFileBasedConfiguration;
|
||||
import home.automation.configuration.RegistryConfiguration;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import home.automation.health.HealthCheckServer;
|
||||
import home.automation.health.HealthMonitor;
|
||||
import home.automation.influxdb.InfluxConnection;
|
||||
import home.automation.influxdb.InfluxMetricModbusSender;
|
||||
import home.automation.modbus.ModbusConnection;
|
||||
import home.automation.modbus.ModbusPollingService;
|
||||
import home.automation.modbus.ModbusRtuTcpRepository;
|
||||
import home.automation.modbus.ModbusService;
|
||||
import home.automation.live.ModbusPoller;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -16,51 +20,84 @@ import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class Main {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(Main.class);
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
public static void main(String[] args) throws Exception {
|
||||
|
||||
final JsonFileBasedConfiguration jsonFileBasedConfiguration = new JsonFileBasedConfiguration();
|
||||
final JsonFileBasedConfiguration jsonFileBasedConfiguration = new JsonFileBasedConfiguration();
|
||||
|
||||
final RegistryConfiguration config = jsonFileBasedConfiguration
|
||||
.getConfig()
|
||||
.orElseThrow(() -> new Exception("Missing configuration file"));
|
||||
final RegistryConfiguration config = jsonFileBasedConfiguration
|
||||
.getConfig()
|
||||
.orElseThrow(() -> new Exception("Missing configuration file"));
|
||||
|
||||
final List<RegistryMapping> registryMappings = config.getRegistry_mapping();
|
||||
final List<RegistryMapping> registryMappings = config.getRegistry_mapping();
|
||||
|
||||
final InfluxConnection connection = new InfluxConnection(config.getInflux_db());
|
||||
final InfluxConnection connection = new InfluxConnection(config.getInflux_db());
|
||||
|
||||
final var useMock = config.getUseMock() != null && config.getUseMock();
|
||||
ModbusService service;
|
||||
if (useMock) {
|
||||
service = new ModbusService(new home.automation.modbus.MockRepository(registryMappings));
|
||||
} else {
|
||||
service = new ModbusService(
|
||||
new ModbusRtuTcpRepository(
|
||||
new ModbusConnection(jsonFileBasedConfiguration), registryMappings)
|
||||
);
|
||||
// Health monitoring setup
|
||||
final int maxHealthCheckIntervalSeconds = (config.getInfluxSendIntervalSeconds() == null ? 300
|
||||
: config.getInfluxSendIntervalSeconds()) * 2;
|
||||
final int healthCheckPort = 8080;
|
||||
|
||||
final var useMock = config.getUseMock() != null && config.getUseMock();
|
||||
ModbusService service;
|
||||
ModbusConnection modbusConnection = null;
|
||||
if (useMock) {
|
||||
service = new ModbusService(new home.automation.modbus.MockRepository(registryMappings));
|
||||
} else {
|
||||
modbusConnection = new ModbusConnection(jsonFileBasedConfiguration);
|
||||
service = new ModbusService(
|
||||
new ModbusRtuTcpRepository(
|
||||
modbusConnection,
|
||||
registryMappings));
|
||||
}
|
||||
|
||||
final ModbusPollingService pollingService = new ModbusPollingService(service,
|
||||
maxHealthCheckIntervalSeconds);
|
||||
final HealthMonitor healthMonitor = new HealthMonitor(List.of(pollingService));
|
||||
final HealthCheckServer healthCheckServer = new HealthCheckServer(healthCheckPort, healthMonitor);
|
||||
healthCheckServer.start();
|
||||
|
||||
// Shutdown hook for graceful cleanup
|
||||
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
ScheduledExecutorService healthScheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
LOGGER.info("Shutdown signal received, cleaning up resources...");
|
||||
try {
|
||||
scheduler.shutdownNow();
|
||||
healthScheduler.shutdownNow();
|
||||
healthCheckServer.close();
|
||||
if (modbusConnection != null) {
|
||||
modbusConnection.close();
|
||||
}
|
||||
connection.close();
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Error during shutdown cleanup", e);
|
||||
}
|
||||
}));
|
||||
|
||||
final int influxSendIntervalSeconds = config.getInfluxSendIntervalSeconds() == null
|
||||
? 300
|
||||
: config.getInfluxSendIntervalSeconds();
|
||||
|
||||
scheduler.scheduleAtFixedRate(
|
||||
new InfluxMetricModbusSender(connection.getConnection(), pollingService),
|
||||
0,
|
||||
influxSendIntervalSeconds,
|
||||
TimeUnit.SECONDS);
|
||||
|
||||
// Monitor health and trigger restart if needed
|
||||
healthScheduler.scheduleAtFixedRate(() -> {
|
||||
if (healthMonitor.hasExceededFailureThreshold()) {
|
||||
LOGGER.error("Health monitor threshold exceeded ({} consecutive failures). Initiating shutdown for restart.",
|
||||
healthMonitor.getConsecutiveFailures());
|
||||
System.exit(1);
|
||||
}
|
||||
}, 10, 10, TimeUnit.SECONDS);
|
||||
|
||||
LOGGER.info("Application started successfully. Health check available at http://localhost:{}/health",
|
||||
healthCheckPort);
|
||||
}
|
||||
|
||||
// Start live poller for realtime dashboard
|
||||
final ModbusPoller poller = new ModbusPoller(service, registryMappings, config.getPollIntervalMs(), config.getWindowSeconds());
|
||||
ScheduledExecutorService liveScheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
liveScheduler.scheduleAtFixedRate(
|
||||
poller::pollOnce,
|
||||
0,
|
||||
config.getPollIntervalMs(),
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
|
||||
// Start web server (REST + WebSocket)
|
||||
final home.automation.web.WebServer webServer = new home.automation.web.WebServer(service, poller, 7000);
|
||||
webServer.start();
|
||||
|
||||
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
scheduler.scheduleAtFixedRate(
|
||||
new InfluxMetricModbusSender(connection.getConnection(), service),
|
||||
0,
|
||||
1,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,19 +2,24 @@ package home.automation.configuration;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import static java.util.Optional.*;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ConfigurationStrategy {
|
||||
Logger LOGGER = LoggerFactory.getLogger(ConfigurationStrategy.class);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
Optional<RegistryConfiguration> getConfig();
|
||||
|
||||
default Optional<RegistryConfiguration> create(String json) {
|
||||
try {
|
||||
return Optional.of(mapper.readValue(json, RegistryConfiguration.class));
|
||||
return of(mapper.readValue(json, RegistryConfiguration.class));
|
||||
} catch (JsonProcessingException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("Failed to parse configuration JSON: {}", e.getMessage(), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,67 @@
|
||||
package home.automation.configuration;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.Objects.*;
|
||||
|
||||
public class JsonFileBasedConfiguration implements ConfigurationStrategy {
|
||||
private final String CONFIGURATION_NAME = "registryMapping.json";
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(JsonFileBasedConfiguration.class);
|
||||
|
||||
@Override
|
||||
public Optional<RegistryConfiguration> getConfig() {
|
||||
final InputStream resource = Thread.currentThread().getContextClassLoader().getResourceAsStream(CONFIGURATION_NAME);
|
||||
Objects.requireNonNull(resource, "Configuration file not present please check the path:" + System.getProperty("usr.dir"));
|
||||
String configurationName = "registryMapping.json";
|
||||
final InputStream resource = Thread.currentThread().getContextClassLoader()
|
||||
.getResourceAsStream(configurationName);
|
||||
requireNonNull(resource,
|
||||
"Configuration file not present. Check the path: " + System.getProperty("user.dir"));
|
||||
String jsonConfiguration;
|
||||
try {
|
||||
try (resource) {
|
||||
jsonConfiguration = new String(resource.readAllBytes(), UTF_8);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException("Cannot read the file");
|
||||
LOGGER.error("Cannot read configuration file {}: {}", configurationName, e.getMessage(), e);
|
||||
throw new RuntimeException("Cannot read the file", e);
|
||||
}
|
||||
final java.util.Optional<RegistryConfiguration> opt = create(jsonConfiguration);
|
||||
opt.ifPresent(cfg -> {
|
||||
// Basic validation and sensible defaults
|
||||
if (cfg.getAddress() == null) {
|
||||
throw new RuntimeException("Configuration missing 'address' field");
|
||||
}
|
||||
if (cfg.getPort() == null) {
|
||||
cfg.setPort(502);
|
||||
}
|
||||
if (cfg.getRegistry_mapping() == null || cfg.getRegistry_mapping().isEmpty()) {
|
||||
throw new RuntimeException("Configuration contains no registry mappings");
|
||||
}
|
||||
cfg.getRegistry_mapping().forEach(m -> {
|
||||
if (m.getUnitId() == null) m.setUnitId(1);
|
||||
if (m.getScale() == null) m.setScale(0.1);
|
||||
if (m.getDataType() == null) m.setDataType("TWO_BYTE_INT_SIGNED");
|
||||
if (m.getSignedLegacyWrap() == null) m.setSignedLegacyWrap(true);
|
||||
// control metadata defaults (no control by default)
|
||||
if (m.getControlType() == null) m.setControlType(null);
|
||||
if (m.getMin() == null) m.setMin(null);
|
||||
if (m.getMax() == null) m.setMax(null);
|
||||
if (m.getStep() == null) m.setStep(null);
|
||||
if (m.getUnit() == null) m.setUnit(null);
|
||||
});
|
||||
|
||||
// Live polling defaults
|
||||
if (cfg.getPollIntervalMs() == null) cfg.setPollIntervalMs(2000);
|
||||
if (cfg.getWindowSeconds() == null) cfg.setWindowSeconds(300);
|
||||
// Mock mode default
|
||||
if (cfg.getUseMock() == null) cfg.setUseMock(false);
|
||||
});
|
||||
opt.ifPresent(this::applyDefaults);
|
||||
return opt;
|
||||
}
|
||||
|
||||
private void applyDefaults(RegistryConfiguration cfg) {
|
||||
// Basic validation and sensible defaults
|
||||
if (cfg.getAddress() == null) {
|
||||
throw new RuntimeException("Configuration missing 'address' field");
|
||||
}
|
||||
|
||||
cfg.setPort(defaultIfNull(cfg.getPort(), 502));
|
||||
|
||||
if (cfg.getRegistry_mapping() == null || cfg.getRegistry_mapping().isEmpty()) {
|
||||
throw new RuntimeException("Configuration contains no registry mappings");
|
||||
}
|
||||
|
||||
cfg.getRegistry_mapping().forEach(this::applyMappingDefaults);
|
||||
|
||||
// Live polling defaults
|
||||
cfg.setPollIntervalMs(defaultIfNull(cfg.getPollIntervalMs(), 2000));
|
||||
cfg.setWindowSeconds(defaultIfNull(cfg.getWindowSeconds(), 300));
|
||||
|
||||
// Mock mode default
|
||||
cfg.setUseMock(defaultIfNull(cfg.getUseMock(), false));
|
||||
}
|
||||
|
||||
private void applyMappingDefaults(RegistryMapping m) {
|
||||
m.setUnitId(defaultIfNull(m.getUnitId(), 1));
|
||||
m.setScale(defaultIfNull(m.getScale(), 0.1));
|
||||
m.setDataType(defaultIfNull(m.getDataType(), "TWO_BYTE_INT_SIGNED"));
|
||||
m.setSignedLegacyWrap(defaultIfNull(m.getSignedLegacyWrap(), true));
|
||||
}
|
||||
|
||||
private static <T> T defaultIfNull(T value, T fallback) {
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
}
|
||||
@@ -32,18 +32,26 @@ public class RegistryConfiguration {
|
||||
private Integer timeoutMs;
|
||||
|
||||
/**
|
||||
* Maximum number of retries to attempt when establishing a Modbus connection. Defaults to 2.
|
||||
* Maximum number of retries to attempt when establishing a Modbus connection.
|
||||
* Defaults to 2.
|
||||
*/
|
||||
private Integer maxRetries;
|
||||
|
||||
/**
|
||||
* Base backoff in milliseconds between retries (exponential backoff applied). Defaults to 1000.
|
||||
* Base backoff in milliseconds between retries (exponential backoff applied).
|
||||
* Defaults to 1000.
|
||||
*/
|
||||
private Integer retryBackoffMs;
|
||||
|
||||
/**
|
||||
* Use a mock repository instead of a real Modbus connection. Useful for local development
|
||||
* Use a mock repository instead of a real Modbus connection. Useful for local
|
||||
* development
|
||||
* and environments without device access. Defaults to false.
|
||||
*/
|
||||
private Boolean useMock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send interval in seconds for Influx writes. Defaults to 300 (5 minutes).
|
||||
*/
|
||||
private Integer influxSendIntervalSeconds;
|
||||
}
|
||||
@@ -11,36 +11,79 @@ public class RegistryMapping {
|
||||
private Integer registry;
|
||||
private String desc;
|
||||
|
||||
/** Unit id (Modbus slave id) to use for this mapping. Defaults to 1 when missing. */
|
||||
/**
|
||||
* Unit id (Modbus slave id) to use for this mapping. Defaults to 1 when
|
||||
* missing.
|
||||
*/
|
||||
private Integer unitId;
|
||||
|
||||
/** Scaling factor applied to the raw device value (e.g. 0.1). Defaults to 0.1 when missing. */
|
||||
/**
|
||||
* Scaling factor applied to the raw device value (e.g. 0.1). Defaults to 0.1
|
||||
* when missing.
|
||||
*/
|
||||
private Double scale;
|
||||
|
||||
/** Data type name used when creating the locator. E.g. "TWO_BYTE_INT_SIGNED". Defaults to TWO_BYTE_INT_SIGNED. */
|
||||
/**
|
||||
* Data type name used when creating the locator. E.g. "TWO_BYTE_INT_SIGNED".
|
||||
* Defaults to TWO_BYTE_INT_SIGNED.
|
||||
*/
|
||||
private String dataType;
|
||||
|
||||
/** Whether to apply legacy signed wrap decoding for values near -32768. Defaults to true. */
|
||||
/**
|
||||
* Whether to apply legacy signed wrap decoding for values near -32768. Defaults
|
||||
* to true.
|
||||
*/
|
||||
private Boolean signedLegacyWrap;
|
||||
|
||||
// Explicit getters and setters in case Lombok annotation processing isn't available.
|
||||
public Integer getRegistry() { return registry; }
|
||||
public void setRegistry(Integer registry) { this.registry = registry; }
|
||||
// Explicit getters and setters in case Lombok annotation processing isn't
|
||||
// available.
|
||||
public Integer getRegistry() {
|
||||
return registry;
|
||||
}
|
||||
|
||||
public String getDesc() { return desc; }
|
||||
public void setDesc(String desc) { this.desc = desc; }
|
||||
public void setRegistry(Integer registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
public Integer getUnitId() { return unitId; }
|
||||
public void setUnitId(Integer unitId) { this.unitId = unitId; }
|
||||
public String getDesc() {
|
||||
return desc;
|
||||
}
|
||||
|
||||
public Double getScale() { return scale; }
|
||||
public void setScale(Double scale) { this.scale = scale; }
|
||||
public void setDesc(String desc) {
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
public String getDataType() { return dataType; }
|
||||
public void setDataType(String dataType) { this.dataType = dataType; }
|
||||
public Integer getUnitId() {
|
||||
return unitId;
|
||||
}
|
||||
|
||||
public Boolean getSignedLegacyWrap() { return signedLegacyWrap; }
|
||||
public void setSignedLegacyWrap(Boolean signedLegacyWrap) { this.signedLegacyWrap = signedLegacyWrap; }
|
||||
public void setUnitId(Integer unitId) {
|
||||
this.unitId = unitId;
|
||||
}
|
||||
|
||||
public Double getScale() {
|
||||
return scale;
|
||||
}
|
||||
|
||||
public void setScale(Double scale) {
|
||||
this.scale = scale;
|
||||
}
|
||||
|
||||
public String getDataType() {
|
||||
return dataType;
|
||||
}
|
||||
|
||||
public void setDataType(String dataType) {
|
||||
this.dataType = dataType;
|
||||
}
|
||||
|
||||
public Boolean getSignedLegacyWrap() {
|
||||
return signedLegacyWrap;
|
||||
}
|
||||
|
||||
public void setSignedLegacyWrap(Boolean signedLegacyWrap) {
|
||||
this.signedLegacyWrap = signedLegacyWrap;
|
||||
}
|
||||
|
||||
// Optional UI control metadata
|
||||
private String controlType;
|
||||
@@ -49,15 +92,57 @@ public class RegistryMapping {
|
||||
private Double step;
|
||||
private String unit;
|
||||
|
||||
public String getControlType() { return controlType; }
|
||||
public void setControlType(String controlType) { this.controlType = controlType; }
|
||||
public Double getMin() { return min; }
|
||||
public void setMin(Double min) { this.min = min; }
|
||||
public Double getMax() { return max; }
|
||||
public void setMax(Double max) { this.max = max; }
|
||||
public Double getStep() { return step; }
|
||||
public void setStep(Double step) { this.step = step; }
|
||||
public String getUnit() { return unit; }
|
||||
public void setUnit(String unit) { this.unit = unit; }
|
||||
}
|
||||
/**
|
||||
* If true, this registry will not be included in polling reads (write-only
|
||||
* parameter)
|
||||
*/
|
||||
private Boolean skipPolling;
|
||||
|
||||
public String getControlType() {
|
||||
return controlType;
|
||||
}
|
||||
|
||||
public void setControlType(String controlType) {
|
||||
this.controlType = controlType;
|
||||
}
|
||||
|
||||
public Double getMin() {
|
||||
return min;
|
||||
}
|
||||
|
||||
public void setMin(Double min) {
|
||||
this.min = min;
|
||||
}
|
||||
|
||||
public Double getMax() {
|
||||
return max;
|
||||
}
|
||||
|
||||
public void setMax(Double max) {
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
public Double getStep() {
|
||||
return step;
|
||||
}
|
||||
|
||||
public void setStep(Double step) {
|
||||
this.step = step;
|
||||
}
|
||||
|
||||
public String getUnit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public void setUnit(String unit) {
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public Boolean getSkipPolling() {
|
||||
return skipPolling;
|
||||
}
|
||||
|
||||
public void setSkipPolling(Boolean skipPolling) {
|
||||
this.skipPolling = skipPolling;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package home.automation.health;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public class ComponentHealth {
|
||||
private final String component;
|
||||
private final HealthState state;
|
||||
private final Instant lastSuccessfulRead;
|
||||
private final String message;
|
||||
|
||||
public ComponentHealth(String component, HealthState state, Instant lastSuccessfulRead, String message) {
|
||||
this.component = component;
|
||||
this.state = state;
|
||||
this.lastSuccessfulRead = lastSuccessfulRead;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getComponent() {
|
||||
return component;
|
||||
}
|
||||
|
||||
public HealthState getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public Instant getLastSuccessfulRead() {
|
||||
return lastSuccessfulRead;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package home.automation.health;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class HealthCheckServer implements AutoCloseable {
|
||||
private static final String CONTENT_TYPE = "Content-Type";
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(HealthCheckServer.class);
|
||||
private static final String CONTENT_TYPE_JSON = "application/json";
|
||||
private final HttpServer server;
|
||||
private final HealthMonitor healthMonitor;
|
||||
|
||||
public HealthCheckServer(int port, HealthMonitor healthMonitor) throws IOException {
|
||||
this.healthMonitor = healthMonitor;
|
||||
this.server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
this.server.createContext("/health", this::handleHealthCheck);
|
||||
this.server.setExecutor(Executors.newSingleThreadExecutor());
|
||||
LOGGER.info("Health check server initialized on port {}", port);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server.start();
|
||||
LOGGER.info("Health check server started");
|
||||
}
|
||||
|
||||
private void handleHealthCheck(HttpExchange exchange) throws IOException {
|
||||
try {
|
||||
final HealthStatus status = healthMonitor.getHealthStatus();
|
||||
final HealthResponse response = HealthResponse.fromHealthStatus(status);
|
||||
final String jsonResponse = response.toJson();
|
||||
final byte[] body = jsonResponse.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
exchange.getResponseHeaders().set(CONTENT_TYPE, CONTENT_TYPE_JSON);
|
||||
exchange.sendResponseHeaders(response.getHttpStatusCode(), body.length);
|
||||
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(body);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Error handling health check", e);
|
||||
final HealthResponse errorResponse = HealthResponse.error("Internal error");
|
||||
final String jsonResponse = errorResponse.toJson();
|
||||
final byte[] body = jsonResponse.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
exchange.getResponseHeaders().set(CONTENT_TYPE, CONTENT_TYPE_JSON);
|
||||
exchange.sendResponseHeaders(errorResponse.getHttpStatusCode(), body.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (server != null) {
|
||||
server.stop(2);
|
||||
LOGGER.info("Health check server stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.automation.health;
|
||||
|
||||
public interface HealthIndicator {
|
||||
String getName();
|
||||
|
||||
ComponentHealth check();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package home.automation.health;
|
||||
|
||||
import static home.automation.health.HealthState.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class HealthMonitor {
|
||||
private final List<HealthIndicator> indicators;
|
||||
private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
|
||||
private final int failureThreshold;
|
||||
|
||||
public HealthMonitor(List<HealthIndicator> indicators) {
|
||||
this(indicators, 3);
|
||||
}
|
||||
|
||||
public HealthMonitor(List<HealthIndicator> indicators, int failureThreshold) {
|
||||
this.indicators = indicators;
|
||||
this.failureThreshold = failureThreshold;
|
||||
}
|
||||
|
||||
public HealthStatus getHealthStatus() {
|
||||
final List<ComponentHealth> componentHealth = indicators.stream()
|
||||
.map(HealthIndicator::check)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
final HealthState overallState = componentHealth.stream()
|
||||
.map(ComponentHealth::getState)
|
||||
.max(Comparator.comparingInt(HealthMonitor::statePriority))
|
||||
.orElse(UP);
|
||||
|
||||
// Track consecutive failures
|
||||
if (overallState != UP) {
|
||||
consecutiveFailures.incrementAndGet();
|
||||
} else {
|
||||
consecutiveFailures.set(0);
|
||||
}
|
||||
|
||||
final Instant lastSuccessfulRead = componentHealth.stream()
|
||||
.map(ComponentHealth::getLastSuccessfulRead)
|
||||
.filter(value -> value != null)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(null);
|
||||
|
||||
final String message;
|
||||
if (overallState == UP) {
|
||||
message = "All components healthy";
|
||||
} else {
|
||||
final String affected = componentHealth.stream()
|
||||
.filter(component -> component.getState() != UP)
|
||||
.map(ComponentHealth::getComponent)
|
||||
.collect(Collectors.joining(", "));
|
||||
message = String.format("Unhealthy components: %s (consecutive failures: %d/%d)", affected,
|
||||
consecutiveFailures.get(), failureThreshold);
|
||||
}
|
||||
|
||||
return new HealthStatus(overallState, lastSuccessfulRead, message, componentHealth);
|
||||
}
|
||||
|
||||
public boolean hasExceededFailureThreshold() {
|
||||
return consecutiveFailures.get() >= failureThreshold;
|
||||
}
|
||||
|
||||
public int getConsecutiveFailures() {
|
||||
return consecutiveFailures.get();
|
||||
}
|
||||
|
||||
private static int statePriority(HealthState state) {
|
||||
return switch (state) {
|
||||
case UP -> 0;
|
||||
case DOWN -> 1;
|
||||
case ERROR -> 2;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package home.automation.health;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public class HealthResponse {
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
static {
|
||||
OBJECT_MAPPER.registerModule(new JavaTimeModule());
|
||||
OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
}
|
||||
|
||||
@JsonProperty("status")
|
||||
private final HealthState status;
|
||||
|
||||
@JsonProperty("lastSuccessfulRead")
|
||||
private final Instant lastSuccessfulRead;
|
||||
|
||||
@JsonProperty("message")
|
||||
private final String message;
|
||||
|
||||
@JsonProperty("components")
|
||||
private final List<ComponentHealth> components;
|
||||
|
||||
public HealthResponse(HealthState status, Instant lastSuccessfulRead, String message,
|
||||
List<ComponentHealth> components) {
|
||||
this.status = status;
|
||||
this.lastSuccessfulRead = lastSuccessfulRead;
|
||||
this.message = message;
|
||||
this.components = components;
|
||||
}
|
||||
|
||||
public static HealthResponse fromHealthStatus(HealthStatus healthStatus) {
|
||||
return new HealthResponse(
|
||||
healthStatus.getState(),
|
||||
healthStatus.getLastSuccessfulRead(),
|
||||
healthStatus.getMessage(),
|
||||
healthStatus.getComponents());
|
||||
}
|
||||
|
||||
public static HealthResponse error(String message) {
|
||||
return new HealthResponse(HealthState.ERROR, Instant.now(), message, List.of());
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
return serialize(this, HealthResponse.error("Failed to serialize response"));
|
||||
}
|
||||
|
||||
private static String serialize(HealthResponse response, HealthResponse fallback) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(response);
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(fallback);
|
||||
} catch (Exception ignored) {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int getHttpStatusCode() {
|
||||
return switch (status) {
|
||||
case UP -> 200;
|
||||
case DOWN -> 503;
|
||||
default -> 500;
|
||||
};
|
||||
}
|
||||
|
||||
public HealthState getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Instant getLastSuccessfulRead() {
|
||||
return lastSuccessfulRead;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public List<ComponentHealth> getComponents() {
|
||||
return components;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.automation.health;
|
||||
|
||||
public enum HealthState {
|
||||
UP,
|
||||
DOWN,
|
||||
ERROR
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.automation.health;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public class HealthStatus {
|
||||
private final HealthState state;
|
||||
private final Instant lastSuccessfulRead;
|
||||
private final String message;
|
||||
private final List<ComponentHealth> components;
|
||||
|
||||
public HealthStatus(HealthState state, Instant lastSuccessfulRead, String message,
|
||||
List<ComponentHealth> components) {
|
||||
this.state = state;
|
||||
this.lastSuccessfulRead = lastSuccessfulRead;
|
||||
this.message = message;
|
||||
this.components = components;
|
||||
}
|
||||
|
||||
public HealthState getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public Instant getLastSuccessfulRead() {
|
||||
return lastSuccessfulRead;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public List<ComponentHealth> getComponents() {
|
||||
return components;
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,45 @@ package home.automation.influxdb;
|
||||
import home.automation.configuration.InfluxDBConfiguration;
|
||||
import org.influxdb.InfluxDB;
|
||||
import org.influxdb.InfluxDBFactory;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class InfluxConnection {
|
||||
public class InfluxConnection implements AutoCloseable {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(InfluxConnection.class);
|
||||
private final InfluxDB connection;
|
||||
|
||||
public InfluxConnection(InfluxDBConfiguration configuration) {
|
||||
if (configuration.getUsername().isBlank()) {
|
||||
final String username = configuration.getUsername();
|
||||
if (username == null || username.isBlank()) {
|
||||
this.connection = InfluxDBFactory.connect(configuration.getAddress());
|
||||
} else {
|
||||
this.connection = InfluxDBFactory.connect(
|
||||
configuration.getAddress(),
|
||||
configuration.getUsername(),
|
||||
configuration.getPassword()
|
||||
);
|
||||
username,
|
||||
configuration.getPassword());
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(connection::close));
|
||||
}
|
||||
|
||||
public InfluxDB getConnection() {
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
LOGGER.info("Closing InfluxDB connection, flushing buffer...");
|
||||
try {
|
||||
connection.flush();
|
||||
LOGGER.info("InfluxDB buffer flushed successfully");
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Error flushing InfluxDB buffer", e);
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
connection.close();
|
||||
LOGGER.info("InfluxDB connection closed");
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Error closing InfluxDB connection", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package home.automation.influxdb;
|
||||
|
||||
import home.automation.modbus.ModbusService;
|
||||
import home.automation.modbus.ModbusPollingService;
|
||||
import home.automation.modbus.Registry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.influxdb.BatchOptions;
|
||||
import org.influxdb.InfluxDB;
|
||||
import org.influxdb.dto.Point;
|
||||
import org.influxdb.dto.Query;
|
||||
@@ -13,13 +14,19 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnable {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(InfluxMetricModbusSender.class);
|
||||
private final ModbusService modbusService;
|
||||
private final ModbusPollingService modbusService;
|
||||
private final InfluxDB influxConnection;
|
||||
|
||||
public InfluxMetricModbusSender(InfluxDB connection, ModbusService service) {
|
||||
public InfluxMetricModbusSender(InfluxDB connection, ModbusPollingService service) {
|
||||
this.modbusService = service;
|
||||
this.influxConnection = connection;
|
||||
connection.enableBatch();
|
||||
final BatchOptions batchOptions = BatchOptions.DEFAULTS
|
||||
.actions(50)
|
||||
.flushDuration(180)
|
||||
.jitterDuration(5)
|
||||
.bufferLimit(500000)
|
||||
.dropActionsOnQueueExhaustion(false);
|
||||
connection.enableBatch(batchOptions);
|
||||
connection.enableGzip();
|
||||
String databaseName = "Dom_Pompa";
|
||||
connection.query(new Query("CREATE DATABASE " + databaseName));
|
||||
@@ -29,15 +36,15 @@ public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnabl
|
||||
|
||||
@Override
|
||||
public boolean send(List<Registry> metricsToSend) {
|
||||
metricsToSend.forEach(registry ->
|
||||
metricsToSend.forEach(registry -> {
|
||||
if (registry.getValue() != null) {
|
||||
influxConnection.write(
|
||||
Point.measurement("POMPA")
|
||||
.addField(registry.getDesc(), Double.valueOf(registry.getValue()))
|
||||
.addField(registry.getDesc(), registry.asDouble())
|
||||
.time(System.currentTimeMillis(), TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
)
|
||||
);
|
||||
influxConnection.flush();
|
||||
.build());
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -47,6 +54,7 @@ public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnabl
|
||||
final List<Registry> registries = modbusService.readAll();
|
||||
if (!registries.isEmpty()) {
|
||||
send(registries);
|
||||
LOGGER.debug("Successfully read {} registries from Modbus", registries.size());
|
||||
} else {
|
||||
LOGGER.warn("No registries read from Modbus");
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package home.automation.live;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class LiveRegistryBuffer {
|
||||
public static class Sample {
|
||||
public final long ts;
|
||||
public final double value;
|
||||
|
||||
public Sample(long ts, double value) {
|
||||
this.ts = ts;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CircularBuffer {
|
||||
private final Deque<Sample> buffer;
|
||||
private final int capacity;
|
||||
|
||||
CircularBuffer(int capacity) {
|
||||
this.capacity = Math.max(1, capacity);
|
||||
this.buffer = new ArrayDeque<>(this.capacity);
|
||||
}
|
||||
|
||||
synchronized void add(Sample s) {
|
||||
buffer.addLast(s);
|
||||
if (buffer.size() > capacity) buffer.removeFirst();
|
||||
}
|
||||
|
||||
synchronized List<Sample> snapshot() {
|
||||
return new ArrayList<>(buffer);
|
||||
}
|
||||
|
||||
synchronized int size() { return buffer.size(); }
|
||||
}
|
||||
|
||||
private final Map<Integer, CircularBuffer> map = new ConcurrentHashMap<>();
|
||||
private final int capacity;
|
||||
|
||||
public LiveRegistryBuffer(int capacity) {
|
||||
this.capacity = Math.max(1, capacity);
|
||||
}
|
||||
|
||||
public void addSample(int registry, long ts, double value) {
|
||||
map.computeIfAbsent(registry, r -> new CircularBuffer(capacity)).add(new Sample(ts, value));
|
||||
}
|
||||
|
||||
public List<Sample> getSamples(int registry) {
|
||||
CircularBuffer cb = map.get(registry);
|
||||
return cb == null ? Collections.emptyList() : cb.snapshot();
|
||||
}
|
||||
|
||||
public int size(int registry) {
|
||||
CircularBuffer cb = map.get(registry);
|
||||
return cb == null ? 0 : cb.size();
|
||||
}
|
||||
|
||||
public Map<Integer, List<Sample>> snapshotAll() {
|
||||
Map<Integer, List<Sample>> snap = new HashMap<>();
|
||||
map.forEach((k, v) -> snap.put(k, v.snapshot()));
|
||||
return snap;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package home.automation.live;
|
||||
|
||||
import home.automation.modbus.ModbusService;
|
||||
import home.automation.modbus.Registry;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class ModbusPoller {
|
||||
private final ModbusService service;
|
||||
private final LiveRegistryBuffer buffer;
|
||||
private final List<RegistryMapping> mappings;
|
||||
|
||||
public static class Point {
|
||||
public final int registry;
|
||||
public final long ts;
|
||||
public final double value;
|
||||
|
||||
public Point(int registry, long ts, double value) {
|
||||
this.registry = registry;
|
||||
this.ts = ts;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private final List<Consumer<List<Point>>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
public ModbusPoller(ModbusService service, List<RegistryMapping> mappings, int pollIntervalMs, int windowSeconds) {
|
||||
this.service = Objects.requireNonNull(service);
|
||||
this.mappings = Objects.requireNonNull(mappings);
|
||||
final int capacity = Math.max(1, (windowSeconds * 1000) / Math.max(1, pollIntervalMs));
|
||||
this.buffer = new LiveRegistryBuffer(capacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single poll. Public for testing and manual triggering.
|
||||
*/
|
||||
public void pollOnce() {
|
||||
final List<Registry> regs = service.readAll();
|
||||
final long ts = System.currentTimeMillis();
|
||||
if (regs == null) return;
|
||||
final List<Point> points = new java.util.ArrayList<>();
|
||||
for (Registry r : regs) {
|
||||
if (r == null || r.getValue() == null) continue;
|
||||
try {
|
||||
final double v = Double.parseDouble(r.getValue());
|
||||
buffer.addSample(r.getRegistry(), ts, v);
|
||||
points.add(new Point(r.getRegistry(), ts, v));
|
||||
} catch (NumberFormatException nfe) {
|
||||
// ignore non-numeric values for live buffer
|
||||
}
|
||||
}
|
||||
if (!points.isEmpty()) {
|
||||
listeners.forEach(l -> {
|
||||
try {
|
||||
l.accept(points);
|
||||
} catch (Exception e) {
|
||||
// ignore listener errors
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public LiveRegistryBuffer getBuffer() {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public void addListener(Consumer<List<Point>> l) { listeners.add(l); }
|
||||
|
||||
public void removeListener(Consumer<List<Point>> l) { listeners.remove(l); }
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import java.util.Random;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Simple in-memory mock repository for development and testing when no Modbus device is available.
|
||||
* Simple in-memory mock repository for development and testing when no Modbus
|
||||
* device is available.
|
||||
*/
|
||||
public class MockRepository implements Repository {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(MockRepository.class);
|
||||
@@ -24,16 +25,17 @@ public class MockRepository implements Repository {
|
||||
if ("setpoint".equals(m.getControlType())) {
|
||||
final double min = m.getMin() == null ? 0.0 : m.getMin();
|
||||
final double max = m.getMax() == null ? (min + 10.0) : m.getMax();
|
||||
initial = Math.round(((min + max) / 2.0) / (m.getStep() == null ? 1.0 : m.getStep())) * (m.getStep() == null ? 1.0 : m.getStep());
|
||||
} else if ("toggle".equals(m.getControlType())) {
|
||||
initial = Math.round(((min + max) / 2.0) / (m.getStep() == null ? 1.0 : m.getStep()))
|
||||
* (m.getStep() == null ? 1.0 : m.getStep());
|
||||
} else if ("toggle".equals(m.getControlType()) || "status".equals(m.getControlType())) {
|
||||
initial = r.nextBoolean() ? 1.0 : 0.0;
|
||||
} else {
|
||||
// sensor-like default using mapping scale
|
||||
final double scale = m.getScale() == null ? 0.1 : m.getScale();
|
||||
initial = Math.round((20.0 / scale)); // will be converted below
|
||||
initial = Math.round((20.0 / scale)) * scale; // normalized value
|
||||
}
|
||||
final String val = String.valueOf(initial);
|
||||
state.put(m.getRegistry(), new Registry(m.getRegistry(), m.getDesc(), val));
|
||||
// Store as Double (numeric) instead of String
|
||||
state.put(m.getRegistry(), new Registry(m.getRegistry(), m.getDesc(), initial));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +52,10 @@ public class MockRepository implements Repository {
|
||||
@Override
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
final Registry r = state.get(register);
|
||||
if (r == null) throw new Exception("Unknown registry " + register);
|
||||
// no explicit range validation here; callers may expect IllegalArgumentException when invalid
|
||||
state.put(register, new Registry(register, r.getDesc(), Double.toString(scaledValue)));
|
||||
if (r == null)
|
||||
throw new Exception("Unknown registry " + register);
|
||||
// Store as Double (numeric) instead of String
|
||||
state.put(register, new Registry(register, r.getDesc(), scaledValue));
|
||||
LOGGER.info("Mock write {} = {}", register, scaledValue);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import org.slf4j.LoggerFactory;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static java.util.Collections.singletonList;
|
||||
import java.util.Collections;
|
||||
|
||||
public class ModbusCachedWrapper implements Repository {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ModbusCachedWrapper.class);
|
||||
@@ -19,13 +18,13 @@ public class ModbusCachedWrapper implements Repository {
|
||||
private final Repository repository;
|
||||
public static final int ALL_ELEMENTS = 1;
|
||||
|
||||
public ModbusCachedWrapper(Repository repository, Duration prevenstionTime) {
|
||||
public ModbusCachedWrapper(Repository repository, Duration preventionTime) {
|
||||
this.repository = repository;
|
||||
repositoryCacheAllElements = CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(prevenstionTime)
|
||||
.expireAfterWrite(preventionTime)
|
||||
.build();
|
||||
repositoryCache = CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(prevenstionTime)
|
||||
.expireAfterWrite(preventionTime)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -35,7 +34,7 @@ public class ModbusCachedWrapper implements Repository {
|
||||
return repositoryCacheAllElements.get(ALL_ELEMENTS, repository::getAll);
|
||||
} catch (ExecutionException e) {
|
||||
LOGGER.error("Cannot get all keys due to: ", e);
|
||||
return singletonList(new Registry(0, "", ""));
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +44,7 @@ public class ModbusCachedWrapper implements Repository {
|
||||
return repositoryCache.get(register, () -> repository.get(register));
|
||||
} catch (ExecutionException e) {
|
||||
LOGGER.error("Cannot get {} due to: ", register, e);
|
||||
return new Registry(0, "", "");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +52,8 @@ public class ModbusCachedWrapper implements Repository {
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
try {
|
||||
repository.write(register, scaledValue);
|
||||
// invalidate cache for this register and the all-elements cache so subsequent reads are fresh
|
||||
// invalidate cache for this register and the all-elements cache so subsequent
|
||||
// reads are fresh
|
||||
repositoryCache.invalidate(register);
|
||||
repositoryCacheAllElements.invalidate(ALL_ELEMENTS);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -4,24 +4,20 @@ import com.serotonin.modbus4j.ModbusFactory;
|
||||
import com.serotonin.modbus4j.ModbusMaster;
|
||||
import com.serotonin.modbus4j.ip.IpParameters;
|
||||
import home.automation.configuration.ConfigurationStrategy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import static java.util.Objects.*;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.util.Objects;
|
||||
|
||||
import static java.util.Objects.isNull;
|
||||
|
||||
public class ModbusConnection implements Connection, AutoCloseable {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ModbusConnection.class);
|
||||
private final ConfigurationStrategy config;
|
||||
private volatile ModbusMaster connection;
|
||||
|
||||
public ModbusConnection(ConfigurationStrategy config) {
|
||||
this.config = config;
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
try {
|
||||
this.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private Pair<InetAddress, Integer> getPortAndAddressFromConfig(ConfigurationStrategy config) {
|
||||
@@ -39,16 +35,18 @@ public class ModbusConnection implements Connection, AutoCloseable {
|
||||
}
|
||||
|
||||
private int getRetryBackoffMsFromConfig() {
|
||||
return config.getConfig().map(cfg -> cfg.getRetryBackoffMs() == null ? 1000 : cfg.getRetryBackoffMs()).orElse(1000);
|
||||
return config.getConfig().map(cfg -> cfg.getRetryBackoffMs() == null ? 1000 : cfg.getRetryBackoffMs())
|
||||
.orElse(1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
synchronized public ModbusMaster getConnection() throws Exception {
|
||||
if (isNull(connection) || !connection.isConnected()) {
|
||||
if (Objects.nonNull(connection)) {
|
||||
if (nonNull(connection)) {
|
||||
try {
|
||||
connection.destroy();
|
||||
} catch (Exception ignored) {
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("Error destroying old connection: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
final Pair<InetAddress, Integer> portAndAddressFromConfig = getPortAndAddressFromConfig(config);
|
||||
@@ -58,7 +56,8 @@ public class ModbusConnection implements Connection, AutoCloseable {
|
||||
Exception lastEx = null;
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
this.connection = initConnection(portAndAddressFromConfig.getFirst(), portAndAddressFromConfig.getSecond());
|
||||
this.connection = initConnection(portAndAddressFromConfig.getFirst(),
|
||||
portAndAddressFromConfig.getSecond());
|
||||
return connection;
|
||||
} catch (Exception e) {
|
||||
lastEx = e;
|
||||
@@ -104,7 +103,7 @@ public class ModbusConnection implements Connection, AutoCloseable {
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
if (Objects.nonNull(connection)) {
|
||||
if (nonNull(connection)) {
|
||||
try {
|
||||
connection.destroy();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import home.automation.health.ComponentHealth;
|
||||
import home.automation.health.HealthIndicator;
|
||||
import home.automation.health.HealthState;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class ModbusPollingService implements HealthIndicator {
|
||||
private final ModbusService modbusService;
|
||||
private final String componentName;
|
||||
private final long maxTimeSinceLastReadMs;
|
||||
private final AtomicLong lastSuccessfulReadTimestamp = new AtomicLong(System.currentTimeMillis());
|
||||
private final AtomicReference<String> lastError = new AtomicReference<>("");
|
||||
|
||||
public ModbusPollingService(ModbusService modbusService, int maxTimeSinceLastReadSeconds) {
|
||||
this(modbusService, "modbus", maxTimeSinceLastReadSeconds);
|
||||
}
|
||||
|
||||
public ModbusPollingService(ModbusService modbusService, String componentName, int maxTimeSinceLastReadSeconds) {
|
||||
this.modbusService = modbusService;
|
||||
this.componentName = componentName;
|
||||
this.maxTimeSinceLastReadMs = maxTimeSinceLastReadSeconds * 1000L;
|
||||
}
|
||||
|
||||
public List<Registry> readAll() throws Exception {
|
||||
try {
|
||||
final List<Registry> registries = modbusService.readAll();
|
||||
if (!registries.isEmpty()) {
|
||||
lastSuccessfulReadTimestamp.set(System.currentTimeMillis());
|
||||
lastError.set("");
|
||||
} else {
|
||||
lastError.set("No registries returned");
|
||||
}
|
||||
return registries;
|
||||
} catch (Exception e) {
|
||||
lastError.set(e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return componentName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ComponentHealth check() {
|
||||
final long now = System.currentTimeMillis();
|
||||
final long lastRead = lastSuccessfulReadTimestamp.get();
|
||||
final long timeSinceLastRead = now - lastRead;
|
||||
final boolean isHealthy = timeSinceLastRead < maxTimeSinceLastReadMs;
|
||||
|
||||
final Instant lastReadInstant = Instant.ofEpochMilli(lastRead);
|
||||
final HealthState state = isHealthy ? HealthState.UP : HealthState.DOWN;
|
||||
final String message;
|
||||
if (isHealthy) {
|
||||
message = String.format("Last successful read %d seconds ago", timeSinceLastRead / 1000);
|
||||
} else {
|
||||
message = String.format("No successful read for %d seconds. Last error: %s",
|
||||
timeSinceLastRead / 1000, lastError.get());
|
||||
}
|
||||
|
||||
return new ComponentHealth(componentName, state, lastReadInstant, message);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,6 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
public class ModbusRtuTcpRepository implements Repository {
|
||||
|
||||
@@ -29,82 +26,91 @@ public class ModbusRtuTcpRepository implements Repository {
|
||||
this.registryMappings = mappings;
|
||||
}
|
||||
|
||||
private String normalizeRegistryValue(Short value) {
|
||||
// Some devices encode small negative temperatures using an unsigned wrap (values near -32768).
|
||||
// If the raw short is very negative (device-specific sentinel), decode using legacy wrap rule.
|
||||
if (value < -32000) {
|
||||
return String.valueOf(-(value + 32768) / 10.0);
|
||||
/**
|
||||
* Normalizes a raw numeric value using the mapping's scale and legacy wrap
|
||||
* handling.
|
||||
* This is the single source of truth for value normalization.
|
||||
*/
|
||||
private Double normalizeValue(Number raw, RegistryMapping mapping) {
|
||||
if (raw == null)
|
||||
return null;
|
||||
|
||||
final double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
|
||||
final Boolean signedLegacyWrap = mapping.getSignedLegacyWrap();
|
||||
|
||||
if (raw instanceof Short) {
|
||||
short s = raw.shortValue();
|
||||
// Handle legacy negative wrap for very negative 16-bit values
|
||||
if ((signedLegacyWrap != null && signedLegacyWrap && s < -32000) || s < -32000) {
|
||||
return -((s + 32768.0) * scale);
|
||||
}
|
||||
}
|
||||
// Otherwise treat as regular signed value
|
||||
return String.valueOf(value / 10.0);
|
||||
|
||||
return raw.doubleValue() * scale;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Registry> getAll() {
|
||||
BatchRead<Integer> requestBatchRead = new BatchRead<>();
|
||||
registryMappings.forEach(elem -> {
|
||||
|
||||
// Filter out registries with skipPolling=true
|
||||
final List<RegistryMapping> mappingsToRead = registryMappings.stream()
|
||||
.filter(elem -> elem.getSkipPolling() == null || !elem.getSkipPolling())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
mappingsToRead.forEach(elem -> {
|
||||
final int unitId = elem.getUnitId() == null ? 1 : elem.getUnitId();
|
||||
final int dataType = resolveDataType(elem);
|
||||
requestBatchRead.addLocator(elem.getRegistry(), BaseLocator.holdingRegister(unitId, elem.getRegistry(), dataType));
|
||||
requestBatchRead.addLocator(elem.getRegistry(),
|
||||
BaseLocator.holdingRegister(unitId, elem.getRegistry(), dataType));
|
||||
});
|
||||
|
||||
try {
|
||||
final BatchResults<Integer> send = connection.getConnection().send(requestBatchRead);
|
||||
final java.util.List<Registry> results = new java.util.ArrayList<>();
|
||||
for (RegistryMapping mapping : registryMappings) {
|
||||
|
||||
for (RegistryMapping mapping : mappingsToRead) {
|
||||
Double normalizedValue = null;
|
||||
|
||||
try {
|
||||
// Prefer convenience method but be defensive against ClassCastException
|
||||
final double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
|
||||
final Double doubleValue = send.getDoubleValue(mapping.getRegistry()) * scale;
|
||||
final String formattedValue = ("FOUR_BYTE_FLOAT".equals(mapping.getDataType()))
|
||||
? BigDecimal.valueOf(doubleValue).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
|
||||
: Double.toString(doubleValue);
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), formattedValue));
|
||||
continue;
|
||||
// Try to get double value directly
|
||||
final Double rawDouble = send.getDoubleValue(mapping.getRegistry());
|
||||
normalizedValue = normalizeValue(rawDouble, mapping);
|
||||
} catch (ClassCastException cce) {
|
||||
// Some implementations return Short/Integer instead of Double. Try to handle generically.
|
||||
// Handle non-Double types
|
||||
try {
|
||||
final Object raw = send.getValue(mapping.getRegistry());
|
||||
LOGGER.debug("Batch read returned raw value (type {}): {} for registry {}", raw == null ? "null" : raw.getClass().getSimpleName(), raw, mapping.getRegistry());
|
||||
final double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
|
||||
if (raw instanceof Number) {
|
||||
// preserve legacy negative decoding for signed 16-bit values when requested
|
||||
if (raw instanceof Short) {
|
||||
final short s = (Short) raw;
|
||||
if (mapping.getSignedLegacyWrap() != null && mapping.getSignedLegacyWrap() && s < -32000) {
|
||||
final double v = -((s + 32768.0) * scale);
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), Double.toString(v)));
|
||||
continue;
|
||||
} else {
|
||||
final double v = ((Number) raw).doubleValue() * scale;
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), Double.toString(v)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
LOGGER.debug("Batch read returned raw value (type {}): {} for registry {}",
|
||||
raw == null ? "null" : raw.getClass().getSimpleName(), raw, mapping.getRegistry());
|
||||
|
||||
final double v = ((Number) raw).doubleValue() * scale;
|
||||
final String valStr = ("FOUR_BYTE_FLOAT".equals(mapping.getDataType()))
|
||||
? BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
|
||||
: Double.toString(v);
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), valStr));
|
||||
continue;
|
||||
if (raw instanceof Number) {
|
||||
normalizedValue = normalizeValue((Number) raw, mapping);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fall through to per-register read
|
||||
}
|
||||
|
||||
// Fallback: read individually using mapping's unitId and decode with mapping's settings
|
||||
try {
|
||||
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
|
||||
ReadHoldingRegistersResponse response = getRegistry(unitId, mapping.getRegistry(), isFourByte(mapping.getDataType()) ? 2 : 1);
|
||||
final String value = normalizeRegistryValue(response.getShortData()[0]);
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), value));
|
||||
continue;
|
||||
} catch (Exception exInner) {
|
||||
LOGGER.warn("Cannot read registry {} individually after batch type mismatch: {}", mapping.getRegistry(), exInner.getMessage());
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), null));
|
||||
// Fall through to individual read
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: read individually if batch failed
|
||||
if (normalizedValue == null) {
|
||||
try {
|
||||
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
|
||||
final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED"
|
||||
: mapping.getDataType();
|
||||
ReadHoldingRegistersResponse response = getRegistry(unitId, mapping.getRegistry(),
|
||||
isFourByte(dataType) ? 2 : 1);
|
||||
final short[] rawData = response.getShortData();
|
||||
normalizedValue = isFourByte(dataType)
|
||||
? decodeValueFromShorts(rawData, dataType, mapping.getScale(),
|
||||
mapping.getSignedLegacyWrap())
|
||||
: normalizeValue(rawData[0], mapping);
|
||||
} catch (Exception exInner) {
|
||||
LOGGER.warn("Cannot read registry {} individually: {}", mapping.getRegistry(),
|
||||
exInner.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), normalizedValue));
|
||||
}
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
@@ -112,6 +118,7 @@ public class ModbusRtuTcpRepository implements Repository {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Registry get(int register) {
|
||||
try {
|
||||
@@ -119,28 +126,41 @@ public class ModbusRtuTcpRepository implements Repository {
|
||||
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
|
||||
final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
|
||||
|
||||
ReadHoldingRegistersResponse response = isFourByte(dataType)
|
||||
final ReadHoldingRegistersResponse response = isFourByte(dataType)
|
||||
? getRegistry(unitId, register, 2)
|
||||
: getRegistry(unitId, register, 1);
|
||||
final short[] rawData = response.getShortData();
|
||||
|
||||
final String value;
|
||||
LOGGER.info("Registry {}: raw short data = {}, scale = {}",
|
||||
register, rawData[0], mapping.getScale());
|
||||
|
||||
final Double value;
|
||||
if (isFourByte(dataType)) {
|
||||
value = decodeValueFromShorts(response.getShortData(), dataType, mapping.getScale(), mapping.getSignedLegacyWrap());
|
||||
value = decodeValueFromShorts(rawData, dataType, mapping.getScale(),
|
||||
mapping.getSignedLegacyWrap());
|
||||
} else {
|
||||
value = normalizeRegistryValue(response.getShortData()[0]);
|
||||
value = normalizeValue(rawData[0], mapping);
|
||||
}
|
||||
|
||||
LOGGER.info("Registry {}: calculated value = {}", register, value);
|
||||
return new Registry(register, mapping.getDesc(), value);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Cannot get registry {} due to: ", register, e);
|
||||
throw new RuntimeException(e.getMessage());
|
||||
LOGGER.warn("Cannot get registry {}: {}", register, e.getMessage());
|
||||
LOGGER.debug("Cannot get registry {} stack trace", register, e);
|
||||
throw new RuntimeException("Cannot get registry " + register, e);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private ReadHoldingRegistersResponse getRegistry(int unitId, int register, int count) throws Exception {
|
||||
// Keep addressing consistent with the legacy implementation (do not subtract 1)
|
||||
ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(unitId, register, count);
|
||||
// Try different addressing schemes for setpoints (registers >= 4100)
|
||||
int protocolAddress = register;
|
||||
if (register >= 4100) {
|
||||
// Try without 4000 prefix (e.g. 4100 -> 100)
|
||||
protocolAddress = register - 4000;
|
||||
LOGGER.info("Registry {}: trying without 4000 prefix, using address {}", register, protocolAddress);
|
||||
}
|
||||
ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(unitId, protocolAddress, count);
|
||||
ReadHoldingRegistersResponse response = (ReadHoldingRegistersResponse) connection.getConnection().send(request);
|
||||
|
||||
if (response.isException()) {
|
||||
@@ -153,44 +173,49 @@ public class ModbusRtuTcpRepository implements Repository {
|
||||
return dataType != null && dataType.startsWith("FOUR_BYTE");
|
||||
}
|
||||
|
||||
private String decodeValueFromShorts(short[] data, String dataType, Double scaleObj, Boolean signedLegacyWrap) {
|
||||
/**
|
||||
* Decodes 4-byte data types from raw short array.
|
||||
* Returns Double value (numeric) instead of formatted String.
|
||||
*/
|
||||
private Double decodeValueFromShorts(short[] data, String dataType, Double scaleObj, Boolean signedLegacyWrap) {
|
||||
final double scale = scaleObj == null ? 0.1 : scaleObj;
|
||||
if (data == null || data.length < 1) return null;
|
||||
if (data == null || data.length < 1)
|
||||
return null;
|
||||
try {
|
||||
if (isFourByte(dataType)) {
|
||||
if (data.length < 2) return null;
|
||||
if (data.length < 2)
|
||||
return null;
|
||||
final boolean swapped = dataType != null && dataType.endsWith("_SWAPPED");
|
||||
final int high = swapped ? (data[1] & 0xFFFF) : (data[0] & 0xFFFF);
|
||||
final int low = swapped ? (data[0] & 0xFFFF) : (data[1] & 0xFFFF);
|
||||
final int combined = (high << 16) | low;
|
||||
switch (dataType) {
|
||||
final String dt = dataType == null ? "" : dataType;
|
||||
switch (dt) {
|
||||
case "FOUR_BYTE_INT_SIGNED":
|
||||
case "FOUR_BYTE_INT_SWAPPED":
|
||||
final int signedInt = combined;
|
||||
return String.valueOf(signedInt * scale);
|
||||
return (double) combined * scale;
|
||||
case "FOUR_BYTE_INT_UNSIGNED":
|
||||
case "FOUR_BYTE_INT_UNSIGNED_SWAPPED":
|
||||
final long unsigned = Integer.toUnsignedLong(combined);
|
||||
return String.valueOf(unsigned * scale);
|
||||
return (double) unsigned * scale;
|
||||
case "FOUR_BYTE_FLOAT":
|
||||
case "FOUR_BYTE_FLOAT_SWAPPED":
|
||||
final float f = Float.intBitsToFloat(combined);
|
||||
final double scaledFloat = f * scale;
|
||||
// Round float results to 2 decimals for human-friendly output
|
||||
return BigDecimal.valueOf(scaledFloat).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
|
||||
return (double) f * scale;
|
||||
default:
|
||||
return String.valueOf(combined * scale);
|
||||
return (double) combined * scale;
|
||||
}
|
||||
} else {
|
||||
// two-byte fallback
|
||||
final short s = data[0];
|
||||
if (signedLegacyWrap != null && signedLegacyWrap && s < -32000) {
|
||||
return String.valueOf(-(s + 32768) * scale);
|
||||
return -(s + 32768.0) * scale;
|
||||
}
|
||||
return String.valueOf(s * scale);
|
||||
return (double) s * scale;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to decode raw registers {} as {}: {}", java.util.Arrays.toString(data), dataType, e.getMessage());
|
||||
LOGGER.warn("Failed to decode raw registers {} as {}: {}", java.util.Arrays.toString(data), dataType,
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -236,22 +261,27 @@ public class ModbusRtuTcpRepository implements Repository {
|
||||
final Double min = mapping.getMin();
|
||||
final Double max = mapping.getMax();
|
||||
if (min != null && scaledValue < min) {
|
||||
throw new IllegalArgumentException("Value " + scaledValue + " below minimum " + min + " for registry " + register);
|
||||
throw new IllegalArgumentException(
|
||||
"Value " + scaledValue + " below minimum " + min + " for registry " + register);
|
||||
}
|
||||
if (max != null && scaledValue > max) {
|
||||
throw new IllegalArgumentException("Value " + scaledValue + " above maximum " + max + " for registry " + register);
|
||||
throw new IllegalArgumentException(
|
||||
"Value " + scaledValue + " above maximum " + max + " for registry " + register);
|
||||
}
|
||||
|
||||
if (isFourByte(dataType)) {
|
||||
throw new UnsupportedOperationException("Writes for 4-byte data types are not implemented in this repository");
|
||||
throw new UnsupportedOperationException(
|
||||
"Writes for 4-byte data types are not implemented in this repository");
|
||||
}
|
||||
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
|
||||
final long raw = Math.round(scaledValue / scale);
|
||||
// Constrain raw to 16-bit signed range
|
||||
final short rawShort = (short) ((int) raw);
|
||||
try {
|
||||
final com.serotonin.modbus4j.msg.WriteRegisterRequest req = new com.serotonin.modbus4j.msg.WriteRegisterRequest(unitId, register, rawShort);
|
||||
final com.serotonin.modbus4j.msg.ModbusResponse resp = (com.serotonin.modbus4j.msg.ModbusResponse) connection.getConnection().send(req);
|
||||
final com.serotonin.modbus4j.msg.WriteRegisterRequest req = new com.serotonin.modbus4j.msg.WriteRegisterRequest(
|
||||
unitId, register, rawShort);
|
||||
final com.serotonin.modbus4j.msg.ModbusResponse resp = (com.serotonin.modbus4j.msg.ModbusResponse) connection
|
||||
.getConnection().send(req);
|
||||
if (resp != null && resp.isException()) {
|
||||
throw new Exception(resp.getExceptionMessage());
|
||||
}
|
||||
@@ -262,12 +292,4 @@ public class ModbusRtuTcpRepository implements Repository {
|
||||
throw new Exception("Write failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeRegistryValue(short value, Double scaleObj, Boolean signedLegacyWrap) {
|
||||
final double scale = scaleObj == null ? 0.1 : scaleObj;
|
||||
if (signedLegacyWrap != null && signedLegacyWrap && value < -32000) {
|
||||
return String.valueOf(-(value + 32768) * scale);
|
||||
}
|
||||
return String.valueOf(value * scale);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,86 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.*;
|
||||
|
||||
|
||||
@ToString
|
||||
public class Registry {
|
||||
private Integer registry;
|
||||
private String desc;
|
||||
private String value;
|
||||
private Object value;
|
||||
|
||||
public Registry(Integer registry, String desc, String value) {
|
||||
public Registry(Integer registry, String desc, Object value) {
|
||||
this.registry = registry;
|
||||
this.desc = desc;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public Integer getRegistry() { return registry; }
|
||||
public String getDesc() { return desc; }
|
||||
public String getValue() { return value; }
|
||||
public Integer getRegistry() {
|
||||
return registry;
|
||||
}
|
||||
|
||||
public void setValue(String v) { this.value = v; }
|
||||
public String getDesc() {
|
||||
return desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw value (Number or String for backwards compatibility).
|
||||
*/
|
||||
public Object getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns string representation of value (for JSON serialization
|
||||
* compatibility).
|
||||
*/
|
||||
public String getValueAsString() {
|
||||
if (value == null)
|
||||
return null;
|
||||
if (value instanceof Number) {
|
||||
double d = ((Number) value).doubleValue();
|
||||
// Format to reasonable precision
|
||||
if (d == Math.floor(d) && d < Long.MAX_VALUE) {
|
||||
return String.valueOf((long) d);
|
||||
}
|
||||
return String.valueOf(d);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns value as double. Throws if value is null or not convertible.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public double asDouble() {
|
||||
if (value == null) {
|
||||
throw new IllegalStateException("Value is null for registry " + registry);
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).doubleValue();
|
||||
}
|
||||
// Fallback: try parsing String
|
||||
return Double.parseDouble(value.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns value as double, or defaultValue if null/not parseable.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public double asDouble(double defaultValue) {
|
||||
if (value == null)
|
||||
return defaultValue;
|
||||
try {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).doubleValue();
|
||||
}
|
||||
return Double.parseDouble(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
public void setValue(Object v) {
|
||||
this.value = v;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package home.automation.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.sse.SseClient;
|
||||
import home.automation.live.ModbusPoller;
|
||||
import home.automation.live.LiveRegistryBuffer;
|
||||
import home.automation.modbus.ModbusService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class WebServer {
|
||||
private final ModbusService service;
|
||||
private final ModbusPoller poller;
|
||||
private final int port;
|
||||
private Javalin app;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(WebServer.class);
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final Set<SseClient> sessions = ConcurrentHashMap.newKeySet();
|
||||
private java.util.concurrent.ScheduledExecutorService heartbeatScheduler;
|
||||
|
||||
public WebServer(ModbusService service, ModbusPoller poller, int port) {
|
||||
this.service = service;
|
||||
this.poller = poller;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
app = Javalin.create();
|
||||
|
||||
// REST snapshot of registries
|
||||
app.get("/api/registries", ctx -> ctx.json(service.readAll()));
|
||||
|
||||
// REST snapshot of live buffer
|
||||
app.get("/api/live", ctx -> {
|
||||
final LiveRegistryBuffer buf = poller.getBuffer();
|
||||
ctx.json(buf.snapshotAll());
|
||||
});
|
||||
|
||||
// Provide full mapping configuration (including optional UI metadata)
|
||||
app.get("/api/mappings", ctx -> {
|
||||
final var is = Thread.currentThread().getContextClassLoader().getResourceAsStream("registryMapping.json");
|
||||
if (is == null) {
|
||||
ctx.status(500).result("Configuration missing");
|
||||
return;
|
||||
}
|
||||
final Object parsed = mapper.readValue(is, Object.class);
|
||||
ctx.json(parsed);
|
||||
});
|
||||
|
||||
// Write registry
|
||||
app.post("/api/registry/{id}/write", ctx -> {
|
||||
final int id = Integer.parseInt(ctx.pathParam("id"));
|
||||
final Map<String, Object> body = mapper.readValue(ctx.body(), Map.class);
|
||||
if (!body.containsKey("value")) {
|
||||
ctx.status(400).result("Missing 'value' in body");
|
||||
return;
|
||||
}
|
||||
final Object valObj = body.get("value");
|
||||
LOGGER.info("Write request for registry {} with body {}", id, body);
|
||||
try {
|
||||
if (valObj == null) {
|
||||
ctx.status(400).result("Missing 'value' in body");
|
||||
return;
|
||||
}
|
||||
final double val;
|
||||
if (valObj instanceof Number) {
|
||||
val = ((Number) valObj).doubleValue();
|
||||
} else {
|
||||
// try parse string
|
||||
try {
|
||||
val = Double.parseDouble(valObj.toString());
|
||||
} catch (NumberFormatException nfe) {
|
||||
ctx.status(400).result("Invalid 'value' in body");
|
||||
return;
|
||||
}
|
||||
}
|
||||
service.write(id, val);
|
||||
ctx.status(204);
|
||||
} catch (IllegalArgumentException iae) {
|
||||
ctx.status(400).result(iae.getMessage());
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Write to registry {} failed: {}", id, e.getMessage());
|
||||
// If the failure is due to device connectivity, return 503 so frontend can show offline state
|
||||
if (isDeviceUnavailable(e)) {
|
||||
ctx.status(503).result("Modbus device unavailable: " + e.getMessage());
|
||||
} else {
|
||||
ctx.status(500).result(e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to detect device unavailability via cause types or messages
|
||||
// Keep this package-private and simple: check for known exception types in cause chain or indicative messages
|
||||
// (e.g., SocketTimeoutException, ModbusInitException, or text like "timed out" or "Failed to establish connection")
|
||||
final java.util.function.Predicate<Throwable> isDeviceUnavailablePredicate = t -> {
|
||||
Throwable cur = t;
|
||||
while (cur != null) {
|
||||
final String cn = cur.getClass().getName();
|
||||
if (cn.contains("SocketTimeoutException") || cn.contains("ModbusInitException") || (cur.getMessage() != null && (cur.getMessage().toLowerCase().contains("connect timed out") || cur.getMessage().toLowerCase().contains("failed to establish connection")))) {
|
||||
return true;
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// SSE for live points
|
||||
app.sse("/sse/live", client -> {
|
||||
// register connected client; we'll remove clients if sending fails
|
||||
sessions.add(client);
|
||||
// ensure removal when client closes
|
||||
client.onClose(() -> {
|
||||
sessions.remove(client);
|
||||
LOGGER.info("SSE client closed and removed");
|
||||
});
|
||||
});
|
||||
|
||||
// Schedule heartbeat pings to keep connections alive and detect dead clients
|
||||
heartbeatScheduler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
heartbeatScheduler.scheduleAtFixedRate(() -> {
|
||||
sessions.forEach(s -> {
|
||||
try {
|
||||
s.sendEvent("ping", "");
|
||||
} catch (Exception e) {
|
||||
// remove failing client
|
||||
sessions.remove(s);
|
||||
LOGGER.info("Removed SSE client due to send failure: {}", e.getMessage());
|
||||
}
|
||||
});
|
||||
}, 10, 10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
|
||||
// Subscribe poller events and broadcast via SSE to connected clients
|
||||
poller.addListener(points -> {
|
||||
try {
|
||||
final String msg = mapper.writeValueAsString(points.stream().map(p -> Map.of("registry", p.registry, "ts", p.ts, "value", p.value)).collect(Collectors.toList()));
|
||||
sessions.forEach(s -> {
|
||||
try {
|
||||
s.sendEvent("update", msg);
|
||||
} catch (Exception e) {
|
||||
sessions.remove(s);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// ignore serialization errors
|
||||
}
|
||||
});
|
||||
|
||||
app.start(port);
|
||||
}
|
||||
|
||||
private boolean isDeviceUnavailable(Throwable t) {
|
||||
Throwable cur = t;
|
||||
while (cur != null) {
|
||||
final String cn = cur.getClass().getName();
|
||||
if (cn.contains("SocketTimeoutException") || cn.contains("ModbusInitException") || (cur.getMessage() != null && (cur.getMessage().toLowerCase().contains("connect timed out") || cur.getMessage().toLowerCase().contains("failed to establish connection")))) {
|
||||
return true;
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (heartbeatScheduler != null) {
|
||||
try { heartbeatScheduler.shutdownNow(); } catch (Exception ignored) {}
|
||||
}
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"useMock": true,
|
||||
"useMock": false,
|
||||
"pollIntervalMs": 30000,
|
||||
"windowSeconds": 300,
|
||||
"influxSendIntervalSeconds": 300,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.mariuszpacek.pl",
|
||||
"username": "",
|
||||
@@ -10,110 +13,35 @@
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "Temperatura zewnętrzna",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
"desc": "External temperature"
|
||||
},
|
||||
{
|
||||
"registry": 4070,
|
||||
"desc": "Temperatura wewnętrzna",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
"desc": "Temp in"
|
||||
},
|
||||
{
|
||||
"registry": 4071,
|
||||
"desc": "Temperatura wyjściowa"
|
||||
"desc": "Temp out"
|
||||
},
|
||||
{
|
||||
"registry": 4072,
|
||||
"desc": "Temperatura PC (wyjście)"
|
||||
"desc": "Temp pc out"
|
||||
},
|
||||
{
|
||||
"registry": 4067,
|
||||
"desc": "Temperatura CO"
|
||||
},
|
||||
{
|
||||
"registry": 4202,
|
||||
"desc": "Nastawa CO",
|
||||
"controlType": "setpoint",
|
||||
"min": 5.0,
|
||||
"max": 90.0,
|
||||
"step": 0.1,
|
||||
"unit": "°C"
|
||||
"desc": "Temp co"
|
||||
},
|
||||
{
|
||||
"registry": 4069,
|
||||
"desc": "Temperatura CWU"
|
||||
},
|
||||
{
|
||||
"registry": 4203,
|
||||
"desc": "Nastawa CWU",
|
||||
"controlType": "setpoint",
|
||||
"min": 20.0,
|
||||
"max": 80.0,
|
||||
"step": 0.1,
|
||||
"unit": "°C"
|
||||
"desc": "Temp cwu"
|
||||
},
|
||||
{
|
||||
"registry": 4066,
|
||||
"desc": "Temperatura wewnętrzna"
|
||||
},
|
||||
{
|
||||
"registry": 4201,
|
||||
"desc": "Nastawa wewnętrzna",
|
||||
"controlType": "setpoint",
|
||||
"min": 5.0,
|
||||
"max": 30.0,
|
||||
"step": 0.1,
|
||||
"unit": "°C"
|
||||
"desc": "Temp wew"
|
||||
},
|
||||
{
|
||||
"registry": 4199,
|
||||
"desc": "Temperatura ZSR"
|
||||
},
|
||||
{
|
||||
"registry": 4117,
|
||||
"desc": "Pompa Dz",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4113,
|
||||
"desc": "Pompa BUF",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4112,
|
||||
"desc": "Pompa CO",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4118,
|
||||
"desc": "Pompa CWU",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4114,
|
||||
"desc": "Sprężarka",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4115,
|
||||
"desc": "Grzałka CWU",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4119,
|
||||
"desc": "WyCHL",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4116,
|
||||
"desc": "Pompa CCW",
|
||||
"controlType": "toggle"
|
||||
"desc": "Temp zsr"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package home.automation.live;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LiveRegistryBufferTest {
|
||||
|
||||
@Test
|
||||
void testBufferWrap() {
|
||||
LiveRegistryBuffer buf = new LiveRegistryBuffer(3);
|
||||
buf.addSample(100, 1L, 1.0);
|
||||
buf.addSample(100, 2L, 2.0);
|
||||
buf.addSample(100, 3L, 3.0);
|
||||
assertEquals(3, buf.size(100));
|
||||
buf.addSample(100, 4L, 4.0);
|
||||
List<LiveRegistryBuffer.Sample> s = buf.getSamples(100);
|
||||
assertEquals(3, s.size());
|
||||
assertEquals(2.0, s.get(0).value);
|
||||
assertEquals(3.0, s.get(1).value);
|
||||
assertEquals(4.0, s.get(2).value);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package home.automation.live;
|
||||
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import home.automation.modbus.ModbusService;
|
||||
import home.automation.modbus.Registry;
|
||||
import home.automation.modbus.Repository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ModbusPollerTest {
|
||||
|
||||
@Test
|
||||
void pollOnceAddsSamples() {
|
||||
Repository repo = new Repository() {
|
||||
@Override
|
||||
public List<Registry> getAll() {
|
||||
return List.of(new Registry(5000, "t", "1.23"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Registry get(int register) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
// not used in this test
|
||||
}
|
||||
};
|
||||
|
||||
ModbusService service = new ModbusService(repo);
|
||||
List<RegistryMapping> mappings = List.of(new RegistryMapping() {{
|
||||
setRegistry(5000);
|
||||
setDesc("t");
|
||||
setUnitId(1);
|
||||
setScale(1.0);
|
||||
setDataType("FOUR_BYTE_FLOAT");
|
||||
setSignedLegacyWrap(false);
|
||||
}});
|
||||
|
||||
ModbusPoller poller = new ModbusPoller(service, mappings, 2000, 300);
|
||||
poller.pollOnce();
|
||||
|
||||
List<LiveRegistryBuffer.Sample> samples = poller.getBuffer().getSamples(5000);
|
||||
assertEquals(1, samples.size());
|
||||
assertEquals(1.23, samples.get(0).value, 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,10 @@ class MockRepositoryTest {
|
||||
assertNotNull(repo.get(1100));
|
||||
|
||||
repo.write(1000, 25.5);
|
||||
assertEquals("25.5", repo.get(1000).getValue());
|
||||
assertEquals(25.5, repo.get(1000).asDouble(), 0.001);
|
||||
|
||||
repo.write(1100, 0);
|
||||
assertEquals("0.0", repo.get(1100).getValue());
|
||||
assertEquals(0.0, repo.get(1100).asDouble(), 0.001);
|
||||
|
||||
assertThrows(Exception.class, () -> repo.write(9999, 1.0));
|
||||
}
|
||||
|
||||
@@ -50,12 +50,12 @@ class ModbusConnectionRetryTests {
|
||||
ModbusConnection.TestConfiguration cfg = new ModbusConnection.TestConfiguration(rc);
|
||||
|
||||
ModbusMaster mockMaster = Mockito.mock(ModbusMaster.class);
|
||||
TestModbusConnection conn = new TestModbusConnection(cfg, 2, mockMaster);
|
||||
|
||||
final ModbusMaster master = conn.getConnection();
|
||||
assertEquals(mockMaster, master);
|
||||
// attempts should be 3 (0,1 fail; 2 succeed)
|
||||
assertEquals(3, conn.getAttempts());
|
||||
try (TestModbusConnection conn = new TestModbusConnection(cfg, 2, mockMaster)) {
|
||||
final ModbusMaster master = conn.getConnection();
|
||||
assertEquals(mockMaster, master);
|
||||
// attempts should be 3 (0,1 fail; 2 succeed)
|
||||
assertEquals(3, conn.getAttempts());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,10 +68,10 @@ class ModbusConnectionRetryTests {
|
||||
ModbusConnection.TestConfiguration cfg = new ModbusConnection.TestConfiguration(rc);
|
||||
|
||||
ModbusMaster mockMaster = Mockito.mock(ModbusMaster.class);
|
||||
TestModbusConnection conn = new TestModbusConnection(cfg, 5, mockMaster);
|
||||
|
||||
assertThrows(Exception.class, conn::getConnection);
|
||||
// attempts should be 2
|
||||
assertEquals(2, conn.getAttempts());
|
||||
try (TestModbusConnection conn = new TestModbusConnection(cfg, 5, mockMaster)) {
|
||||
assertThrows(Exception.class, conn::getConnection);
|
||||
// attempts should be 2
|
||||
assertEquals(2, conn.getAttempts());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,14 @@ class ModbusConnectionTests {
|
||||
@Test
|
||||
void GivenJsonConfiguration_When_getRemoteEndpoint_Then_returnConfiguredAddressAndPort() throws Exception {
|
||||
ConfigurationStrategy conf = new JsonFileBasedConfiguration();
|
||||
ModbusConnection connection = new ModbusConnection(conf);
|
||||
try (ModbusConnection connection = new ModbusConnection(conf)) {
|
||||
var endpoint = connection.getRemoteEndpoint();
|
||||
InetAddress addr = endpoint.getFirst();
|
||||
Integer port = endpoint.getSecond();
|
||||
|
||||
var endpoint = connection.getRemoteEndpoint();
|
||||
InetAddress addr = endpoint.getFirst();
|
||||
Integer port = endpoint.getSecond();
|
||||
|
||||
var confVal = conf.getConfig().orElseThrow();
|
||||
assertEquals(confVal.getAddress().getHostAddress(), addr.getHostAddress());
|
||||
assertEquals(confVal.getPort(), port);
|
||||
var confVal = conf.getConfig().orElseThrow();
|
||||
assertEquals(confVal.getAddress().getHostAddress(), addr.getHostAddress());
|
||||
assertEquals(confVal.getPort(), port);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ class ModbusIntegrationMockTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
void givenMockServer_When_readingRegisters_Then_normalizedValuesMatchLegacy(MockModbusServer server) throws Exception {
|
||||
void givenMockServer_When_readingRegisters_Then_normalizedValuesMatchLegacy(MockModbusServer server)
|
||||
throws Exception {
|
||||
// set some registers used in tests (values are raw device shorts)
|
||||
// 4201 -> 100 => normalized 10.0
|
||||
server.setRegister(4201, (short) 100);
|
||||
@@ -48,9 +49,15 @@ class ModbusIntegrationMockTests {
|
||||
rc.setPort(server.getPort());
|
||||
|
||||
// minimal registry mappings for the test
|
||||
RegistryMapping m1 = new RegistryMapping(); m1.setRegistry(4201); m1.setDesc("Set Temp wew");
|
||||
RegistryMapping m2 = new RegistryMapping(); m2.setRegistry(4065); m2.setDesc("External temperature");
|
||||
RegistryMapping m3 = new RegistryMapping(); m3.setRegistry(4066); m3.setDesc("Tem wew");
|
||||
RegistryMapping m1 = new RegistryMapping();
|
||||
m1.setRegistry(4201);
|
||||
m1.setDesc("Set Temp wew");
|
||||
RegistryMapping m2 = new RegistryMapping();
|
||||
m2.setRegistry(4065);
|
||||
m2.setDesc("External temperature");
|
||||
RegistryMapping m3 = new RegistryMapping();
|
||||
m3.setRegistry(4066);
|
||||
m3.setDesc("Tem wew");
|
||||
|
||||
ModbusConnection connection = new ModbusConnection(new TestConfiguration(rc));
|
||||
ModbusRtuTcpRepository repo = new ModbusRtuTcpRepository(connection, List.of(m1, m2, m3));
|
||||
@@ -60,14 +67,14 @@ class ModbusIntegrationMockTests {
|
||||
|
||||
Registry r4201 = all.stream().filter(r -> r.getRegistry() == 4201).findFirst().orElse(null);
|
||||
assertNotNull(r4201);
|
||||
assertEquals("10.0", r4201.getValue());
|
||||
assertEquals(10.0, r4201.asDouble(), 0.001);
|
||||
|
||||
Registry r4065 = all.stream().filter(r -> r.getRegistry() == 4065).findFirst().orElse(null);
|
||||
assertNotNull(r4065);
|
||||
assertEquals("-5.0", r4065.getValue());
|
||||
assertEquals(-5.0, r4065.asDouble(), 0.001);
|
||||
|
||||
Registry r4066 = all.stream().filter(r -> r.getRegistry() == 4066).findFirst().orElse(null);
|
||||
assertNotNull(r4066);
|
||||
assertEquals("20.4", r4066.getValue());
|
||||
assertEquals(20.4, r4066.asDouble(), 0.001);
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,12 @@ class ModbusParserTests {
|
||||
}, java.util.Collections.emptyList());
|
||||
}
|
||||
|
||||
private String invokeDecode(ModbusRtuTcpRepository repo, short[] data, String dataType, Double scale, Boolean legacy) throws Exception {
|
||||
Method m = ModbusRtuTcpRepository.class.getDeclaredMethod("decodeValueFromShorts", short[].class, String.class, Double.class, Boolean.class);
|
||||
private Double invokeDecode(ModbusRtuTcpRepository repo, short[] data, String dataType, Double scale,
|
||||
Boolean legacy) throws Exception {
|
||||
Method m = ModbusRtuTcpRepository.class.getDeclaredMethod("decodeValueFromShorts", short[].class, String.class,
|
||||
Double.class, Boolean.class);
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(repo, (Object) data, dataType, scale, legacy);
|
||||
return (Double) m.invoke(repo, (Object) data, dataType, scale, legacy);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -30,8 +32,8 @@ class ModbusParserTests {
|
||||
int bits = Float.floatToIntBits(value);
|
||||
short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
short low = (short) (bits & 0xFFFF);
|
||||
String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_FLOAT", 1.0, null);
|
||||
assertEquals("12.34", decoded);
|
||||
Double decoded = invokeDecode(repo, new short[] { high, low }, "FOUR_BYTE_FLOAT", 1.0, null);
|
||||
assertEquals(12.34, decoded, 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -42,9 +44,8 @@ class ModbusParserTests {
|
||||
short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
short low = (short) (bits & 0xFFFF);
|
||||
// swapped: pass low first, then high and use SWAPPED dataType
|
||||
String decoded = invokeDecode(repo, new short[]{low, high}, "FOUR_BYTE_FLOAT_SWAPPED", 1.0, null);
|
||||
// rounding to 2 decimals expected by implementation
|
||||
assertEquals("-5.5", decoded);
|
||||
Double decoded = invokeDecode(repo, new short[] { low, high }, "FOUR_BYTE_FLOAT_SWAPPED", 1.0, null);
|
||||
assertEquals(-5.5, decoded, 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -54,8 +55,8 @@ class ModbusParserTests {
|
||||
int bits = (int) unsignedVal; // low 32 bits
|
||||
short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
short low = (short) (bits & 0xFFFF);
|
||||
String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_INT_UNSIGNED", 1.0, null);
|
||||
assertEquals((double) unsignedVal, Double.parseDouble(decoded), 0.0001);
|
||||
Double decoded = invokeDecode(repo, new short[] { high, low }, "FOUR_BYTE_INT_UNSIGNED", 1.0, null);
|
||||
assertEquals((double) unsignedVal, decoded, 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,25 +66,25 @@ class ModbusParserTests {
|
||||
int bits = signedVal;
|
||||
short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
short low = (short) (bits & 0xFFFF);
|
||||
String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_INT_SIGNED", 1.0, null);
|
||||
assertEquals((double) signedVal, Double.parseDouble(decoded), 0.0001);
|
||||
Double decoded = invokeDecode(repo, new short[] { high, low }, "FOUR_BYTE_INT_SIGNED", 1.0, null);
|
||||
assertEquals((double) signedVal, decoded, 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeTwoByteSignedLegacyWrap() throws Exception {
|
||||
ModbusRtuTcpRepository repo = createRepo();
|
||||
short s = (short) (-32760); // near legacy wrap sentinel
|
||||
String decoded = invokeDecode(repo, new short[]{s}, "TWO_BYTE_INT_SIGNED", 1.0, true);
|
||||
Double decoded = invokeDecode(repo, new short[] { s }, "TWO_BYTE_INT_SIGNED", 1.0, true);
|
||||
// calculation: -(s + 32768) * scale -> -( -32760 + 32768) = -8
|
||||
assertEquals("-8.0", decoded);
|
||||
assertEquals(-8.0, decoded, 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeTwoByteNormal() throws Exception {
|
||||
ModbusRtuTcpRepository repo = createRepo();
|
||||
short s = (short) (123);
|
||||
String decoded = invokeDecode(repo, new short[]{s}, "TWO_BYTE_INT_SIGNED", 0.1, false);
|
||||
short s = (short) (123);
|
||||
Double decoded = invokeDecode(repo, new short[] { s }, "TWO_BYTE_INT_SIGNED", 0.1, false);
|
||||
// 123 * 0.1 = 12.3
|
||||
assertEquals("12.3", decoded);
|
||||
assertEquals(12.3, decoded, 0.001);
|
||||
}
|
||||
}
|
||||
@@ -19,190 +19,220 @@ import static java.lang.Short.parseShort;
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ModbusRtuTcpRepositoryTests {
|
||||
|
||||
private ModbusRtuTcpRepository repository;
|
||||
@Mock
|
||||
private ModbusMaster modbusMaster;
|
||||
@Mock
|
||||
private ReadHoldingRegistersResponse response;
|
||||
JsonFileBasedConfiguration configuration = new JsonFileBasedConfiguration();
|
||||
private ModbusRtuTcpRepository repository;
|
||||
@Mock
|
||||
private ModbusMaster modbusMaster;
|
||||
@Mock
|
||||
private ReadHoldingRegistersResponse response;
|
||||
JsonFileBasedConfiguration configuration = new JsonFileBasedConfiguration();
|
||||
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
final List<RegistryMapping> registry_mapping = configuration.getConfig().get().getRegistry_mapping();
|
||||
this.repository = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), registry_mapping);
|
||||
@BeforeEach
|
||||
public void setup() {
|
||||
final List<RegistryMapping> registry_mapping = configuration.getConfig().get().getRegistry_mapping();
|
||||
this.repository = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), registry_mapping);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenModbusRepositoryWhenGetRegistryValueIsNormalizedAndReturned() throws Exception {
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = {parseShort("100")};
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
assert ("10.0".equals(repository.get(4201).getValue()));
|
||||
assert ("Set Temp wew".equals(repository.get(4201).getDesc()));
|
||||
}
|
||||
@Test
|
||||
public void givenModbusRepositoryWhenGetRegistryValueIsNormalizedAndReturned() throws Exception {
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = { parseShort("100") };
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
Assertions.assertEquals(10.0, repository.get(4201).asDouble(), 0.001);
|
||||
Assertions.assertEquals("Set Temp wew", repository.get(4201).getDesc());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenModbusRepositoryWhenGetRegistryIsNegativeValueIsNormalizedAndReturned() throws Exception {
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = {parseShort("-100")};
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
assert ("-10.0".equals(repository.get(4201).getValue()));
|
||||
assert ("Set Temp wew".equals(repository.get(4201).getDesc()));
|
||||
}
|
||||
@Test
|
||||
public void givenModbusRepositoryWhenGetRegistryIsNegativeValueIsNormalizedAndReturned() throws Exception {
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = { parseShort("-100") };
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
Assertions.assertEquals(-10.0, repository.get(4201).asDouble(), 0.001);
|
||||
Assertions.assertEquals("Set Temp wew", repository.get(4201).getDesc());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenModbusRepositoryWhenGetRegistryThenExecptionIsRised() throws Exception {
|
||||
when(response.isException()).thenReturn(true);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
Assertions.assertThrowsExactly(RuntimeException.class,() -> "-10.0".equals(repository.get(4201).getValue()));
|
||||
}
|
||||
@Test
|
||||
public void givenModbusRepositoryWhenGetRegistryThenExecptionIsRised() throws Exception {
|
||||
when(response.isException()).thenReturn(true);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
Assertions.assertThrowsExactly(RuntimeException.class, () -> repository.get(4201));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenBatchResultsReturnsShort_When_getAll_Then_valuesAreConverted() throws Exception {
|
||||
// Create a repository with a single mapping to keep the test focused
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
@Test
|
||||
public void givenBatchResultsReturnsShort_When_getAll_Then_valuesAreConverted() throws Exception {
|
||||
// Create a repository with a single mapping to keep the test focused
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
|
||||
java.util.List.of(mapping));
|
||||
|
||||
// mock a BatchResults that throws ClassCast when getDoubleValue is invoked for any registry
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenThrow(new ClassCastException());
|
||||
org.mockito.Mockito.when(batchResults.getValue(4201)).thenReturn(Short.valueOf((short)100));
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
|
||||
// mock a BatchResults that throws ClassCast when getDoubleValue is invoked for
|
||||
// any registry
|
||||
@SuppressWarnings("unchecked")
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = (com.serotonin.modbus4j.BatchResults<Integer>) org.mockito.Mockito
|
||||
.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt()))
|
||||
.thenThrow(new ClassCastException());
|
||||
org.mockito.Mockito.when(batchResults.getValue(4201)).thenReturn(Short.valueOf((short) 100));
|
||||
@SuppressWarnings("unchecked")
|
||||
com.serotonin.modbus4j.BatchRead<Integer> anyBatchRead = (com.serotonin.modbus4j.BatchRead<Integer>) any(
|
||||
com.serotonin.modbus4j.BatchRead.class);
|
||||
org.mockito.Mockito.when(modbusMaster.send(anyBatchRead))
|
||||
.thenReturn(batchResults);
|
||||
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
// ensure registry 4201 is present with normalized value 10.0 (scale 0.1 default)
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("10.0", r.getValue());
|
||||
}
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
// ensure registry 4201 is present with normalized value 10.0 (scale 0.1
|
||||
// default)
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(10.0, r.asDouble(), 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithCustomScale_When_getAll_Then_valueIsScaled() throws Exception {
|
||||
// Create mapping with custom scale 0.5
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
mapping.setScale(0.5);
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
@Test
|
||||
public void givenMappingWithCustomScale_When_getAll_Then_valueIsScaled() throws Exception {
|
||||
// Create mapping with custom scale 0.5
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
mapping.setScale(0.5);
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
|
||||
java.util.List.of(mapping));
|
||||
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
// getDoubleValue returns 100.0 -> scaled to 50.0
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenReturn(100.0);
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
|
||||
@SuppressWarnings("unchecked")
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = (com.serotonin.modbus4j.BatchResults<Integer>) org.mockito.Mockito
|
||||
.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
// getDoubleValue returns 100.0 -> scaled to 50.0
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt()))
|
||||
.thenReturn(100.0);
|
||||
@SuppressWarnings("unchecked")
|
||||
com.serotonin.modbus4j.BatchRead<Integer> anyBatchRead = (com.serotonin.modbus4j.BatchRead<Integer>) any(
|
||||
com.serotonin.modbus4j.BatchRead.class);
|
||||
org.mockito.Mockito.when(modbusMaster.send(anyBatchRead))
|
||||
.thenReturn(batchResults);
|
||||
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("50.0", r.getValue());
|
||||
}
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(50.0, r.asDouble(), 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithCustomUnitId_When_get_Then_requestUsesUnitId() throws Exception {
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
mapping.setUnitId(2);
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
@Test
|
||||
public void givenMappingWithCustomUnitId_When_get_Then_requestUsesUnitId() throws Exception {
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
mapping.setUnitId(2);
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
|
||||
java.util.List.of(mapping));
|
||||
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = {parseShort("100")};
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = { parseShort("100") };
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(ModbusRequest.class))).thenAnswer(invocation -> {
|
||||
final Object arg = invocation.getArgument(0);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(arg instanceof ReadHoldingRegistersRequest);
|
||||
ReadHoldingRegistersRequest req = (ReadHoldingRegistersRequest) arg;
|
||||
org.junit.jupiter.api.Assertions.assertEquals(2, req.getSlaveId());
|
||||
return response;
|
||||
});
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(ModbusRequest.class))).thenAnswer(invocation -> {
|
||||
final Object arg = invocation.getArgument(0);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(arg instanceof ReadHoldingRegistersRequest);
|
||||
ReadHoldingRegistersRequest req = (ReadHoldingRegistersRequest) arg;
|
||||
org.junit.jupiter.api.Assertions.assertEquals(2, req.getSlaveId());
|
||||
return response;
|
||||
});
|
||||
|
||||
final Registry r = repoSingle.get(4201);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("10.0", r.getValue());
|
||||
}
|
||||
final Registry r = repoSingle.get(4201);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(10.0, r.asDouble(), 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloat_When_getAll_Then_valueIsDecodedFromTwoRegisters() throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5000);
|
||||
mapping.setDesc("Example 4-byte float");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT");
|
||||
mapping.setScale(1.0);
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloat_When_getAll_Then_valueIsDecodedFromTwoRegisters() throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5000);
|
||||
mapping.setDesc("Example 4-byte float");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT");
|
||||
mapping.setScale(1.0);
|
||||
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
|
||||
java.util.List.of(mapping));
|
||||
|
||||
// Simulate BatchResults returning raw value as Number (Double)
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenThrow(new ClassCastException());
|
||||
// Return Float wrapped as Double
|
||||
org.mockito.Mockito.when(batchResults.getValue(5000)).thenReturn(Float.valueOf(12.34f));
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
|
||||
// Simulate BatchResults returning raw value as Number (Double)
|
||||
@SuppressWarnings("unchecked")
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = (com.serotonin.modbus4j.BatchResults<Integer>) org.mockito.Mockito
|
||||
.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt()))
|
||||
.thenThrow(new ClassCastException());
|
||||
// Return Float wrapped as Double
|
||||
org.mockito.Mockito.when(batchResults.getValue(5000)).thenReturn(Float.valueOf(12.34f));
|
||||
@SuppressWarnings("unchecked")
|
||||
com.serotonin.modbus4j.BatchRead<Integer> anyBatchRead = (com.serotonin.modbus4j.BatchRead<Integer>) any(
|
||||
com.serotonin.modbus4j.BatchRead.class);
|
||||
org.mockito.Mockito.when(modbusMaster.send(anyBatchRead))
|
||||
.thenReturn(batchResults);
|
||||
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 5000).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
// value is float 12.34 * scale 1.0
|
||||
org.junit.jupiter.api.Assertions.assertEquals("12.34", r.getValue());
|
||||
}
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 5000).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
// value is float 12.34 * scale 1.0
|
||||
org.junit.jupiter.api.Assertions.assertEquals(12.34, r.asDouble(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloat_When_get_Then_valueIsDecodedFromTwoRegisters() throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5000);
|
||||
mapping.setDesc("Example 4-byte float");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT");
|
||||
mapping.setScale(1.0);
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloat_When_get_Then_valueIsDecodedFromTwoRegisters() throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5000);
|
||||
mapping.setDesc("Example 4-byte float");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT");
|
||||
mapping.setScale(1.0);
|
||||
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
|
||||
java.util.List.of(mapping));
|
||||
|
||||
// Prepare ReadHoldingRegistersResponse that carries two 16-bit words representing 12.34f
|
||||
final float expected = 12.34f;
|
||||
final int bits = Float.floatToIntBits(expected);
|
||||
final short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
final short low = (short) (bits & 0xFFFF);
|
||||
// Prepare ReadHoldingRegistersResponse that carries two 16-bit words
|
||||
// representing 12.34f
|
||||
final float expected = 12.34f;
|
||||
final int bits = Float.floatToIntBits(expected);
|
||||
final short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
final short low = (short) (bits & 0xFFFF);
|
||||
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = {high, low};
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = { high, low };
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
|
||||
final Registry r = repoSingle.get(5000);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(String.valueOf(expected), r.getValue());
|
||||
}
|
||||
final Registry r = repoSingle.get(5000);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(expected, r.asDouble(), 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloatSwapped_When_get_Then_valueIsDecodedFromTwoRegistersSwapped() throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT_SWAPPED (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5002);
|
||||
mapping.setDesc("Example 4-byte float swapped");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT_SWAPPED");
|
||||
mapping.setScale(1.0);
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloatSwapped_When_get_Then_valueIsDecodedFromTwoRegistersSwapped()
|
||||
throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT_SWAPPED (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5002);
|
||||
mapping.setDesc("Example 4-byte float swapped");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT_SWAPPED");
|
||||
mapping.setScale(1.0);
|
||||
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
|
||||
java.util.List.of(mapping));
|
||||
|
||||
final float expected = 12.34f;
|
||||
final int bits2 = Float.floatToIntBits(expected);
|
||||
final short high2 = (short) ((bits2 >>> 16) & 0xFFFF);
|
||||
final short low2 = (short) (bits2 & 0xFFFF);
|
||||
final float expected = 12.34f;
|
||||
final int bits2 = Float.floatToIntBits(expected);
|
||||
final short high2 = (short) ((bits2 >>> 16) & 0xFFFF);
|
||||
final short low2 = (short) (bits2 & 0xFFFF);
|
||||
|
||||
// swapped word order: low, high
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue2 = {low2, high2};
|
||||
when(response.getShortData()).thenReturn(returnValue2);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
// swapped word order: low, high
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue2 = { low2, high2 };
|
||||
when(response.getShortData()).thenReturn(returnValue2);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
|
||||
final Registry r2 = repoSingle.get(5002);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(String.valueOf(expected), r2.getValue());
|
||||
}
|
||||
final Registry r2 = repoSingle.get(5002);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(expected, r2.asDouble(), 0.001);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user