Compare commits

Author SHA1 Message Date
mariusz.pacek ac94218045 Update .gitea/workflows/ci.yml
CI / Backend tests (Maven) (push) Failing after 2s
CI / Frontend build (node) (push) Failing after 45s
2025-12-27 23:33:50 +00:00
mariusz.pacek d07e2812d8 Merge pull request 'Update .gitea/workflows/ci.yml' (#6) from mariusz.pacek-patch-1 into master
CI / Backend tests (Maven) (push) Failing after 5m29s
CI / Frontend build (node) (push) Successful in 9m56s
Reviewed-on: #6
2025-12-27 23:28:45 +00:00
mariusz.pacek d902523b11 Update .gitea/workflows/ci.yml
CI / Backend tests (Maven) (push) Failing after 7m29s
CI / Frontend build (node) (push) Successful in 11m59s
2025-12-27 23:14:45 +00:00
mariusz.pacek cc263f474c Merge pull request 'Update .gitea/workflows/ci.yml' (#5) from mariusz.pacek-patch-1 into master
CI / Backend tests (Maven) (push) Has been cancelled
CI / Frontend build (node) (push) Has been cancelled
Reviewed-on: #5
2025-12-27 23:13:52 +00:00
mariusz.pacek 84608f14fe Update .gitea/workflows/ci.yml
CI / Backend tests (Maven) (push) Has been cancelled
CI / Frontend build (node) (push) Has been cancelled
2025-12-27 23:13:37 +00:00
mariusz.pacek 19afd28304 Merge pull request 'Update .gitea/workflows/ci.yml' (#4) from mariusz.pacek-patch-1 into master
CI / Backend tests (Maven) (push) Has been cancelled
CI / Frontend build (node) (push) Has been cancelled
Reviewed-on: #4
2025-12-27 23:12:01 +00:00
mariusz.pacek e3b060b3cc Update .gitea/workflows/ci.yml
CI / Backend tests (Maven) (push) Has been cancelled
CI / Frontend build (node) (push) Has been cancelled
2025-12-27 23:11:05 +00:00
mariusz.pacek 4ea9a9396d Merge pull request 'Update .gitea/workflows/ci.yml' (#3) from mariusz.pacek-patch-1 into master
CI / Backend tests (Maven) (push) Has been cancelled
CI / Frontend build (node) (push) Has been cancelled
Reviewed-on: #3
2025-12-27 22:44:29 +00:00
mariusz.pacek 6ffbfc7848 Update .gitea/workflows/ci.yml
CI / Backend tests (Maven) (push) Has been cancelled
CI / Frontend build (node) (push) Has been cancelled
2025-12-27 13:50:19 +00:00
mariusz.pacek b5f095b117 Merge pull request 'feature/gitea-actions' (#2) from feature/gitea-actions into master
Reviewed-on: #2
2025-12-27 13:44:22 +00:00
root 1c7e4fc751 feat(ci): dodano obsługę Gitea Actions 2025-12-27 12:56:12 +01:00
mariusz.pacekandroot 9f4a2f2dc4 test(modbus): add parser tests for 4-byte and legacy-wrap decoding (#1)
Co-authored-by: Mariusz Pacek <pacekm90@gmail.com>
Reviewed-on: #1
2025-12-27 10:48:25 +00:00
root a7a9559494 test(modbus): add parser tests for 4-byte and legacy-wrap decoding 2025-12-27 11:13:21 +01:00
root 0f538b60a9 Remove apk/ from index (keep local) and ignore it via .gitignore) 2025-12-27 00:17:43 +01:00
root 176d62967d Remove embedded directories 'old' and 'old_backup' from index (keep locally) 2025-12-27 00:15:58 +01:00
root d334449e56 Cleanup: remove files now ignored by .gitignore (keep locally) 2025-12-27 00:14:57 +01:00
root b187abc55f Expand .gitignore: add node/frontend, env, logs, build and coverage ignores 2025-12-27 00:12:18 +01:00
root 07bfd79e9a Remove Logs & Events feature (eventService, LogsPanel) and revert references 2025-12-26 23:55:46 +01:00
root 4a84ae041f changes 2025-12-24 22:43:00 +01:00
Mariusz Pacek 46030746eb Merge remote-tracking branch 'origin/master'
continuous-integration/drone/push Build is passing
2023-01-11 09:35:36 +01:00
Mariusz Pacek 9f17db0959 update code 2023-01-11 09:35:28 +01:00
mariusz.pacek 8fea494dbd Zaktualizuj '.drone.yml'
continuous-integration/drone/push Build is passing
2023-01-10 15:49:51 +00:00
Mariusz Pacek 0322440476 Remove the now ignored directory "some-directory"
continuous-integration/drone/push Build is failing
2023-01-10 16:42:08 +01:00
Mariusz Pacek c370d9e95e Remove the now ignored directory "some-directory"
continuous-integration/drone/push Build is failing
2023-01-10 16:41:55 +01:00
Mariusz Pacek 4fd78bbf08 Remove the now ignored directory "some-directory"
continuous-integration/drone/push Build is failing
2023-01-10 16:41:46 +01:00
Mariusz Pacek d4cb077594 Fixed lombok
continuous-integration/drone/push Build is failing
2023-01-10 16:39:34 +01:00
Mariusz Pacek 99d1687f86 Fixed lombok
continuous-integration/drone/push Build is passing
2023-01-08 23:03:48 +01:00
root ac0b9edf78 Changes to be committed:
continuous-integration/drone/push Build is failing
modified:   pom.xml
	modified:   src/main/java/home/automation/Main.java
	modified:   src/test/java/home/automation/modbus/ModbusRtuTcpRepositoryTests.java
	modified:   workspace.code-workspace
2023-01-08 20:28:04 +01:00
root 0db5d4931c Changes to be committed:
continuous-integration/drone/push Build is failing
modified:   pom.xml
2023-01-08 18:24:55 +01:00
root 1be34071bc Changes to be committed:
continuous-integration/drone/push Build is failing
modified:   .drone.yml
2023-01-08 18:19:38 +01:00
root 828f1f8c0c Working wersion
continuous-integration/drone/push Build is failing
2023-01-08 18:10:01 +01:00
root 7228906b34 Update .gitlab-ci.yml file 2022-12-29 21:37:58 +00:00
root eec2f2e990 Update .gitlab-ci.yml file 2022-12-29 16:30:03 +00:00
root 291d81fd26 Merge branch 'master' into old_lib 2022-12-29 17:26:59 +01:00
Mariusz Pacek 7ce15e1fcd ignore influx test 2022-04-10 15:03:28 +02:00
Mariusz Pacek 0ad26402d0 fixed tests 2022-04-10 14:48:11 +02:00
Administrator 5d01f9491f Update .gitlab-ci.yml file 2022-04-10 12:41:11 +00:00
Administrator c4ccca5bf7 Update .gitlab-ci.yml file 2022-04-10 12:38:10 +00:00
Administrator d43842a31a Update manifest.yml 2022-04-10 12:00:07 +00:00
Administrator 266b093114 Update manifest.yml 2022-04-10 11:53:40 +00:00
Administrator 5902e27182 Update .gitlab-ci.yml file 2022-04-10 11:41:18 +00:00
Administrator 6364a3e8e7 Update .gitlab-ci.yml file 2022-04-10 11:40:45 +00:00
Mariusz Pacek 6b2a689b58 changed few lines in order to build project 2022-04-10 13:28:33 +02:00
Administrator 02b44a5ea0 Update .gitlab-ci.yml 2022-04-10 11:24:13 +00:00
root 5095c83986 Update README.md 2022-04-09 23:12:57 +00:00
root f801e98554 Merge branch 'old_lib' into 'master'
Working version of modbus integration wiht frisko module

See merge request mariusz.pacek/home-pump!1
2022-04-09 19:00:15 +00:00
root af0139a799 Add new file 2022-04-03 18:10:29 +00:00
root f02529eadf Delete .gitlab-ci.yml 2022-04-03 18:00:01 +00:00
root 962ee435ff Update manifest.yml 2022-04-03 17:58:23 +00:00
root ec4f58bc31 Add new file 2022-04-03 17:54:30 +00:00
root 1e0ae84929 Update config.yaml 2022-04-03 17:02:05 +00:00
root ab916501fc Update .gitlab/agents/primary-agent/config.yaml 2022-04-03 16:59:42 +00:00
root c7cbb3ea05 Update .gitlab-ci.yml file 2021-12-08 22:16:48 +00:00
root fbb976445e Update .gitlab-ci.yml file 2021-12-08 22:15:00 +00:00
73 changed files with 6455 additions and 545 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
kind: pipeline
type: docker
name: default
steps:
- name: test
image: maven:3-jdk-11
commands:
- mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
- mvn test -B
- name: docker
image: plugins/docker
settings:
repo: registry.mariuszpacek.pl/home-pump
tags: latest
when:
branch:
- master
+141
View File
@@ -0,0 +1,141 @@
# 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.
+65
View File
@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: ["**"]
jobs:
backend:
name: Backend tests (Maven)
runs-on: ['ubuntu-latest']
container:
image: maven:3.9-jdk-21
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Show runner info
run: |
echo "Runner name: ${{ runner.name }}"
echo "Runner labels: ${{ toJSON(runner.labels) }}"
- name: Cache Maven
uses: actions/cache@v4
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
+43 -1
View File
@@ -4,9 +4,10 @@
.settings/
# Intellij
.idea/
.idea
*.iml
*.iws
.vscode
# Mac
.DS_Store
@@ -14,3 +15,44 @@
# Maven
log/
target/
/.idea/
/.vscode/
/.java-version
/dependency-reduced-pom.xml
/workspace.code-workspace
/target/
# Node / frontend
/node_modules/
/frontend/node_modules/
/frontend/dist/
/frontend/.env*
/dist/
# Vite
/.vite/
# Env files
.env
.env.local
.env.*.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.log
logs/
# Build / coverage
build/
coverage/
# Misc
.DS_Store
# Large or legacy folders (not tracked)
/apk/
/old/
/old_backup/
-3
View File
@@ -1,3 +0,0 @@
# This file is a template, and might need editing before it works on your project.
include:
template: Jobs/Code-Quality.gitlab-ci.yml
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
-7
View File
@@ -1,7 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<ScalaCodeStyleSettings>
<option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" />
</ScalaCodeStyleSettings>
</code_scheme>
</component>
-5
View File
@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>
-14
View File
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="test.com" />
</profile>
</annotationProcessing>
</component>
</project>
-116
View File
@@ -1,116 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AmbiguousFieldAccess" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="BooleanMethodNameMustStartWithQuestion" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreBooleanMethods" value="false" />
<option name="ignoreInAnnotationInterface" value="true" />
<option name="onlyWarnOnBaseMethods" value="true" />
<option name="questionString" value="add,are,can,check,contains,could,endsWith,equals,has,is,matches,must,put,remove,shall,should,startsWith,was,were,will,would" />
</inspection_tool>
<inspection_tool class="BooleanVariableAlwaysNegated" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="BreakStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="BreakStatementWithLabel" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CastConflictsWithInstanceof" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CastToIncompatibleInterface" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ComparableImplementedButEqualsNotOverridden" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ConstantOnWrongSideOfComparison" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ConstantValueVariableUse" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ContinueStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ContinueStatementWithLabel" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CovariantEquals" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="DeclareCollectionAsInterface" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreLocalVariables" value="false" />
<option name="ignorePrivateMethodsAndFields" value="false" />
</inspection_tool>
<inspection_tool class="DefaultNotLastCaseInSwitch" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="EqualsAndHashcode" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="EqualsCalledOnEnumConstant" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="FieldHidesSuperclassField" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_ignoreInvisibleFields" value="true" />
</inspection_tool>
<inspection_tool class="HardcodedFileSeparators" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_recognizeExampleMediaType" value="false" />
</inspection_tool>
<inspection_tool class="HardcodedLineSeparators" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="InnerClassVariableHidesOuterClassVariable" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_ignoreInvisibleFields" value="true" />
</inspection_tool>
<inspection_tool class="InstanceofIncompatibleInterface" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="IteratorNextDoesNotThrowNoSuchElementException" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="JavaLangImport" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="LabeledStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="LambdaParameterHidingMemberVariable" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="LiteralAsArgToStringEquals" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="LocalVariableHidingMemberVariable" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_ignoreInvisibleFields" value="true" />
<option name="m_ignoreStaticMethods" value="true" />
</inspection_tool>
<inspection_tool class="MagicNumber" enabled="true" level="WARNING" enabled_by_default="true">
<scope name="Tests" level="WARNING" enabled="false" />
</inspection_tool>
<inspection_tool class="MapReplaceableByEnumMap" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="MarkerInterface" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="MethodOverridesInaccessibleMethodOfSuper" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="MethodOverridesStaticMethod" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="MisspelledEquals" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="NativeMethods" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="NestedSwitchStatement" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="NonFinalFieldInEnum" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ObjectToString" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ObsoleteCollection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreRequiredObsoleteCollectionTypes" value="true" />
</inspection_tool>
<inspection_tool class="ParameterHidingMemberVariable" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_ignoreInvisibleFields" value="true" />
<option name="m_ignoreStaticMethodParametersHidingInstanceFields" value="true" />
<option name="m_ignoreForConstructors" value="false" />
<option name="m_ignoreForPropertySetters" value="false" />
<option name="m_ignoreForAbstractMethods" value="false" />
</inspection_tool>
<inspection_tool class="ProtectedMemberInFinalClass" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="RedundantFieldInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="RedundantImplements" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreSerializable" value="false" />
<option name="ignoreCloneable" value="false" />
</inspection_tool>
<inspection_tool class="ReuseOfLocalVariable" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="RuntimeExec" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SamePackageImport" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SimplifiableEqualsExpression" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SizeReplaceableByIsEmpty" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="StaticCollection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_ignoreWeakCollections" value="true" />
</inspection_tool>
<inspection_tool class="StringBufferField" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SwitchStatementWithConfusingDeclaration" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SystemExit" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SystemGC" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SystemOutErr" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="TestOnlyProblems" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ThreadDumpStack" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ThrowablePrintStackTrace" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryConstantArrayCreationExpression" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryQualifierForThis" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessarySuperConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessarySuperQualifier" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryThis" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreAssignments" value="true" />
</inspection_tool>
<inspection_tool class="UnnecessaryToStringCall" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UseOfAWTPeerClass" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UseOfJDBCDriverClass" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UseOfObsoleteDateTimeApi" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UseOfProcessBuilder" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UseOfSunClasses" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="WeakerAccess" enabled="true" level="WARNING" enabled_by_default="true">
<option name="SUGGEST_PACKAGE_LOCAL_FOR_MEMBERS" value="true" />
<option name="SUGGEST_PACKAGE_LOCAL_FOR_TOP_CLASSES" value="true" />
<option name="SUGGEST_PRIVATE_FOR_INNERS" value="false" />
</inspection_tool>
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true">
<scope name="Tests" level="WARNING" enabled="false" />
</inspection_tool>
</profile>
</component>
-30
View File
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="ias-snapshots" />
<option name="name" value="Infinite Automation Snapshot Repository" />
<option name="url" value="https://maven.mangoautomation.net/repository/ias-snapshot/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="ias-releases" />
<option name="name" value="Infinite Automation Release Repository" />
<option name="url" value="https://maven.mangoautomation.net/repository/ias-release/" />
</remote-repository>
</component>
</project>
-14
View File
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="openjdk-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4" />
-124
View File
@@ -1,124 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.png" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.png" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.png" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.png" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.png" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.png" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.png" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.png" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.png" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.png" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.png" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.png" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.png" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.png" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.png" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.png" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.png" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.png" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-4
View File
@@ -1,4 +0,0 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic"
}
+4
View File
@@ -0,0 +1,4 @@
FROM amazoncorretto:11.0.17
COPY target/test*.jar app/home-pump.jar
CMD ["java", "-jar", "app/home-pump.jar"]
+40 -1
View File
@@ -1,2 +1,41 @@
# home-pump
# Home Pump Monitor
A Java application that reads data from a heat pump via Modbus TCP and sends metrics to InfluxDB.
## 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 via JSON file
## Configuration
Edit `src/main/resources/registryMapping.json` to configure:
- Modbus TCP connection (address, port)
- InfluxDB connection (address, username, password)
- List of registers to read with descriptions
Additional mapping fields (optional):
- `useMock` (Boolean, top-level): When `true`, the application runs with a local `MockRepository` instead of attempting to connect to a Modbus device. Useful for local development when a device is not available (default: false).
- `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)
## Build and Run
```bash
mvn clean package
java -jar target/home-pump-1.0.0.jar
```
## Requirements
- Java 21
- Modbus TCP accessible heat pump
- InfluxDB instance
+31
View File
@@ -0,0 +1,31 @@
# 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 }
+12
View File
@@ -0,0 +1,12 @@
<!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>
+3262
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"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"
}
}
+18
View File
@@ -0,0 +1,18 @@
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>
)
}
+45
View File
@@ -0,0 +1,45 @@
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>
)
}
+214
View File
@@ -0,0 +1,214 @@
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>
)
}
+174
View File
@@ -0,0 +1,174 @@
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>
)
}
+26
View File
@@ -0,0 +1,26 @@
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>
)
}
+66
View File
@@ -0,0 +1,66 @@
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>
)
}
+42
View File
@@ -0,0 +1,42 @@
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>
)
}
+44
View File
@@ -0,0 +1,44 @@
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>
)
}
+27
View File
@@ -0,0 +1,27 @@
: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;
}
+15
View File
@@ -0,0 +1,15 @@
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>
)
+40
View File
@@ -0,0 +1,40 @@
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 }
+99
View File
@@ -0,0 +1,99 @@
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) }
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"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"]
}
+21
View File
@@ -0,0 +1,21 @@
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)
}
}
});
+69 -18
View File
@@ -3,58 +3,76 @@
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>org.example</groupId>
<artifactId>test.com</artifactId>
<version>1.0-SNAPSHOT</version>
<groupId>com.home</groupId>
<artifactId>home-pump</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.11.1</version>
<version>2.20.1</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.2.0</version>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.32</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1.1-jre</version>
<version>33.5.0-jre</version>
</dependency>
<dependency>
<groupId>com.infiniteautomation</groupId>
<artifactId>modbus4j</artifactId>
<version>3.0.3</version>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.influxdb</groupId>
<artifactId>influxdb-java</artifactId>
<version>2.22</version>
<version>2.25</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.8.2</version>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<version>1.18.28</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>5.6.1</version>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- default: exclude integration tests -->
<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>
@@ -84,20 +102,53 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M7</version>
<version>3.1.2</version>
<configuration>
<excludedTags>${test.excluded.tags}</excludedTags>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>home.automation.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version>
<version>3.8.1</version>
<configuration>
<release>11</release>
<source>11</source>
<release>21</release>
<source>21</source>
<encoding>UTF-8</encoding>
<showWarnings>true</showWarnings>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<version>1.18.30</version>
</path>
</annotationProcessorPaths>
</configuration>
+87
View File
@@ -0,0 +1,87 @@
{
"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"
}
]
}
+30
View File
@@ -0,0 +1,30 @@
const http = require('http');
const data = JSON.stringify({value: 60});
const options = {
hostname: 'localhost',
port: 7000,
path: '/api/registry/4202/write',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
}
};
const req = http.request(options, (res) => {
console.log('STATUS', res.statusCode);
res.setEncoding('utf8');
res.on('data', (chunk) => console.log('BODY', chunk));
res.on('end', () => {
// fetch registries
http.get('http://localhost:7000/api/registries', (res2) => {
console.log('REG STATUS', res2.statusCode);
let body = '';
res2.setEncoding('utf8');
res2.on('data', c => body += c);
res2.on('end', () => console.log('REG BODY', body));
});
});
});
req.on('error', (e) => console.error('REQ ERR', e));
req.write(data);
req.end();
+30 -14
View File
@@ -1,13 +1,14 @@
package home.automation;
import home.automation.configuration.JsonFileBasedConfiguration;
import home.automation.configuration.RegistryConfiguration;
import home.automation.configuration.RegistryMapping;
import home.automation.influxdb.InfluxConnection;
import home.automation.influxdb.InfluxMetricModbusSender;
import home.automation.modbus.ModbusConnection;
import home.automation.modbus.ModbusRtuTcpRepository;
import home.automation.modbus.ModbusService;
import lombok.*;
import home.automation.live.ModbusPoller;
import java.util.List;
import java.util.concurrent.Executors;
@@ -20,23 +21,38 @@ public class Main {
final JsonFileBasedConfiguration jsonFileBasedConfiguration = new JsonFileBasedConfiguration();
final List<RegistryMapping> registryMappings = jsonFileBasedConfiguration
final RegistryConfiguration config = jsonFileBasedConfiguration
.getConfig()
.orElseThrow(() -> new Exception("Missing configuration file"))
.getRegistry_mapping();
.orElseThrow(() -> new Exception("Missing configuration file"));
final InfluxConnection connection = new InfluxConnection(
jsonFileBasedConfiguration.getConfig()
.get()
.getInflux_db()
final List<RegistryMapping> registryMappings = config.getRegistry_mapping();
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)
);
}
// 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
);
ModbusService service = new ModbusService(
new ModbusRtuTcpRepository(
new ModbusConnection(
new JsonFileBasedConfiguration()
), registryMappings)
);
// 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(
@@ -21,6 +21,37 @@ public class JsonFileBasedConfiguration implements ConfigurationStrategy {
e.printStackTrace();
throw new RuntimeException("Cannot read the file");
}
return create(jsonConfiguration);
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);
});
return opt;
}
}
@@ -15,4 +15,35 @@ public class RegistryConfiguration {
private Integer port;
private InfluxDBConfiguration influx_db;
private List<RegistryMapping> registry_mapping;
}
/**
* Poll interval in milliseconds for live polling. Defaults to 2000.
*/
private Integer pollIntervalMs;
/**
* Window size in seconds for live rolling buffer. Defaults to 300 (5 minutes).
*/
private Integer windowSeconds;
/**
* Connection timeout in milliseconds for Modbus TCP master. Defaults to 5000.
*/
private Integer timeoutMs;
/**
* 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.
*/
private Integer retryBackoffMs;
/**
* 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;
}
@@ -10,5 +10,54 @@ import lombok.ToString;
public class RegistryMapping {
private Integer registry;
private String desc;
}
/** 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. */
private Double scale;
/** 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. */
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; }
public String getDesc() { return desc; }
public void setDesc(String desc) { this.desc = desc; }
public Integer getUnitId() { return unitId; }
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;
private Double min;
private Double max;
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; }
}
@@ -2,6 +2,8 @@ package home.automation.influxdb;
import home.automation.modbus.ModbusService;
import home.automation.modbus.Registry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.influxdb.InfluxDB;
import org.influxdb.dto.Point;
import org.influxdb.dto.Query;
@@ -10,6 +12,7 @@ import java.util.List;
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 InfluxDB influxConnection;
@@ -26,7 +29,7 @@ public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnabl
@Override
public boolean send(List<Registry> metricsToSend) {
modbusService.readAll().forEach(registry ->
metricsToSend.forEach(registry ->
influxConnection.write(
Point.measurement("POMPA")
.addField(registry.getDesc(), Double.valueOf(registry.getValue()))
@@ -40,7 +43,15 @@ public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnabl
@Override
public void run() {
final List<Registry> registries = modbusService.readAll();
send(registries);
try {
final List<Registry> registries = modbusService.readAll();
if (!registries.isEmpty()) {
send(registries);
} else {
LOGGER.warn("No registries read from Modbus");
}
} catch (Exception e) {
LOGGER.error("Error in scheduled Modbus reading", e);
}
}
}
@@ -0,0 +1,64 @@
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;
}
}
@@ -0,0 +1,75 @@
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); }
}
@@ -0,0 +1,58 @@
package home.automation.modbus;
import home.automation.configuration.RegistryMapping;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.stream.Collectors;
/**
* 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);
private final Map<Integer, Registry> state = new HashMap<>();
public MockRepository(List<RegistryMapping> mappings) {
final Random r = new Random(42);
for (RegistryMapping m : mappings) {
final double initial;
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 = 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
}
final String val = String.valueOf(initial);
state.put(m.getRegistry(), new Registry(m.getRegistry(), m.getDesc(), val));
}
}
@Override
public List<Registry> getAll() {
return state.values().stream().collect(Collectors.toList());
}
@Override
public Registry get(int register) {
return state.get(register);
}
@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)));
LOGGER.info("Mock write {} = {}", register, scaledValue);
}
}
@@ -2,17 +2,18 @@ package home.automation.modbus;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutionException;
import static home.automation.modbus.Registry.builder;
import static java.util.Collections.singletonList;
@Slf4j
public class ModbusCachedWrapper implements Repository {
private static final Logger LOGGER = LoggerFactory.getLogger(ModbusCachedWrapper.class);
private final Cache<Integer, Registry> repositoryCache;
private final Cache<Integer, List<Registry>> repositoryCacheAllElements;
private final Repository repository;
@@ -33,8 +34,8 @@ public class ModbusCachedWrapper implements Repository {
try {
return repositoryCacheAllElements.get(ALL_ELEMENTS, repository::getAll);
} catch (ExecutionException e) {
log.error("Cannot get all keys due to: ", e);
return singletonList(builder().registry(0).desc("").value("").build());
LOGGER.error("Cannot get all keys due to: ", e);
return singletonList(new Registry(0, "", ""));
}
}
@@ -43,8 +44,21 @@ public class ModbusCachedWrapper implements Repository {
try {
return repositoryCache.get(register, () -> repository.get(register));
} catch (ExecutionException e) {
log.error("Cannot get" + register + "due to: ", e);
return builder().registry(0).desc("").value("").build();
LOGGER.error("Cannot get {} due to: ", register, e);
return new Registry(0, "", "");
}
}
@Override
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
repositoryCache.invalidate(register);
repositoryCacheAllElements.invalidate(ALL_ELEMENTS);
} catch (Exception e) {
LOGGER.error("Cannot write to {} due to: ", register, e);
throw e;
}
}
}
@@ -10,15 +10,16 @@ import java.util.Objects;
import static java.util.Objects.isNull;
public class ModbusConnection implements Connection {
public class ModbusConnection implements Connection, AutoCloseable {
private final ConfigurationStrategy config;
private volatile ModbusMaster connection;
public ModbusConnection(ConfigurationStrategy config) {
this.config = config;
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if (Objects.nonNull(connection)) {
connection.destroy();
try {
this.close();
} catch (Exception ignored) {
}
}));
}
@@ -29,19 +30,58 @@ public class ModbusConnection implements Connection {
.orElseThrow(() -> new RuntimeException("Missing configuration"));
}
private int getTimeoutMsFromConfig() {
return config.getConfig().map(cfg -> cfg.getTimeoutMs() == null ? 5000 : cfg.getTimeoutMs()).orElse(5000);
}
private int getMaxRetriesFromConfig() {
return config.getConfig().map(cfg -> cfg.getMaxRetries() == null ? 2 : cfg.getMaxRetries()).orElse(2);
}
private int getRetryBackoffMsFromConfig() {
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)) {
connection.destroy();
try {
connection.destroy();
} catch (Exception ignored) {
}
}
final Pair<InetAddress, Integer> portAndAddressFromConfig = getPortAndAddressFromConfig(config);
this.connection = initConnection(portAndAddressFromConfig.getFirst(), portAndAddressFromConfig.getSecond());
int maxRetries = getMaxRetriesFromConfig();
int attempt = 0;
Exception lastEx = null;
while (attempt <= maxRetries) {
try {
this.connection = initConnection(portAndAddressFromConfig.getFirst(), portAndAddressFromConfig.getSecond());
return connection;
} catch (Exception e) {
lastEx = e;
attempt++;
if (attempt > maxRetries) {
throw lastEx;
}
final int backoff = getRetryBackoffMsFromConfig();
final long sleepMs = backoff * (1L << (attempt - 1)); // exponential backoff
try {
Thread.sleep(sleepMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw ie;
}
}
}
throw new RuntimeException("Failed to establish connection");
}
return connection;
}
private ModbusMaster initConnection(InetAddress address, int port) throws Exception {
protected ModbusMaster initConnection(InetAddress address, int port) throws Exception {
final String ip = address.getHostAddress();
IpParameters ipParameters = new IpParameters();
ipParameters.setHost(ip);
@@ -49,7 +89,41 @@ public class ModbusConnection implements Connection {
ModbusFactory factory = new ModbusFactory();
final ModbusMaster tcpMaster = factory.createTcpMaster(ipParameters, true);
// set a sensible timeout so a stuck connection doesn't block indefinitely
tcpMaster.setTimeout(getTimeoutMsFromConfig());
tcpMaster.init();
return tcpMaster;
}
/**
* Returns the configured remote endpoint (address and port) for diagnostics.
*/
public Pair<InetAddress, Integer> getRemoteEndpoint() {
return getPortAndAddressFromConfig(config);
}
@Override
public void close() throws Exception {
if (Objects.nonNull(connection)) {
try {
connection.destroy();
} finally {
connection = null;
}
}
}
// A small test helper to supply configuration in tests that need it
public static class TestConfiguration implements ConfigurationStrategy {
private final home.automation.configuration.RegistryConfiguration cfg;
public TestConfiguration(home.automation.configuration.RegistryConfiguration cfg) {
this.cfg = cfg;
}
@Override
public java.util.Optional<home.automation.configuration.RegistryConfiguration> getConfig() {
return java.util.Optional.of(cfg);
}
}
}
@@ -2,22 +2,25 @@ package home.automation.modbus;
import com.serotonin.modbus4j.BatchRead;
import com.serotonin.modbus4j.BatchResults;
import com.serotonin.modbus4j.code.DataType;
import com.serotonin.modbus4j.locator.BaseLocator;
import com.serotonin.modbus4j.msg.ReadHoldingRegistersRequest;
import com.serotonin.modbus4j.msg.ReadHoldingRegistersResponse;
import home.automation.configuration.RegistryMapping;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static com.serotonin.modbus4j.code.DataType.TWO_BYTE_INT_SIGNED;
import java.math.BigDecimal;
import java.math.RoundingMode;
public class ModbusRtuTcpRepository implements Repository {
private static final int ZERO = 0;
private static final int HALF_SHORT_INT = 32768;
private static final Logger LOGGER = LoggerFactory.getLogger(ModbusRtuTcpRepository.class);
private final Connection connection;
private final List<RegistryMapping> registryMappings;
@@ -27,58 +30,117 @@ public class ModbusRtuTcpRepository implements Repository {
}
private String normalizeRegistryValue(Short value) {
if (ZERO > value) {
return String.valueOf(-(value + HALF_SHORT_INT) / 10.0);
} else {
return String.valueOf((value / 10.0));
// 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);
}
// Otherwise treat as regular signed value
return String.valueOf(value / 10.0);
}
@Override
public List<Registry> getAll() {
BatchRead<Integer> requestBatchRead = new BatchRead<>();
registryMappings.forEach(elem -> requestBatchRead.addLocator(
elem.getRegistry(),
BaseLocator.holdingRegister(1, elem.getRegistry(), TWO_BYTE_INT_SIGNED)
));
registryMappings.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));
});
try {
final BatchResults<Integer> send = connection.getConnection().send(requestBatchRead);
return registryMappings.stream()
.map(mapping -> {
final Double doubleValue = send.getDoubleValue(mapping.getRegistry());
return Registry.builder()
.registry(mapping.getRegistry())
.value(doubleValue.toString())
.desc(mapping.getDesc())
.build();
final java.util.List<Registry> results = new java.util.ArrayList<>();
for (RegistryMapping mapping : registryMappings) {
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;
} catch (ClassCastException cce) {
// Some implementations return Short/Integer instead of Double. Try to handle generically.
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;
}
}
).collect(Collectors.toList());
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;
}
} 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));
}
}
}
return results;
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("Cannot get all registries due to: ", e);
return Collections.emptyList();
}
}
@Override
public Registry get(int register) {
try {
ReadHoldingRegistersResponse response = getRegistry(register);
final String value = normalizeRegistryValue(response.getShortData()[0]);
final RegistryMapping mapping = findRegistryMapping(register);
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
return Registry.builder().registry(register)
.value(value)
.desc(mapping.getDesc())
.build();
ReadHoldingRegistersResponse response = isFourByte(dataType)
? getRegistry(unitId, register, 2)
: getRegistry(unitId, register, 1);
final String value;
if (isFourByte(dataType)) {
value = decodeValueFromShorts(response.getShortData(), dataType, mapping.getScale(), mapping.getSignedLegacyWrap());
} else {
value = normalizeRegistryValue(response.getShortData()[0]);
}
return new Registry(register, mapping.getDesc(), value);
} catch (Exception e) {
LOGGER.error("Cannot get registry {} due to: ", register, e);
throw new RuntimeException(e.getMessage());
}
}
@NotNull
private ReadHoldingRegistersResponse getRegistry(int register) throws Exception {
ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(1, register, 1);
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);
ReadHoldingRegistersResponse response = (ReadHoldingRegistersResponse) connection.getConnection().send(request);
if (response.isException()) {
@@ -87,10 +149,125 @@ public class ModbusRtuTcpRepository implements Repository {
return response;
}
private boolean isFourByte(String dataType) {
return dataType != null && dataType.startsWith("FOUR_BYTE");
}
private String 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;
try {
if (isFourByte(dataType)) {
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) {
case "FOUR_BYTE_INT_SIGNED":
case "FOUR_BYTE_INT_SWAPPED":
final int signedInt = combined;
return String.valueOf(signedInt * scale);
case "FOUR_BYTE_INT_UNSIGNED":
case "FOUR_BYTE_INT_UNSIGNED_SWAPPED":
final long unsigned = Integer.toUnsignedLong(combined);
return String.valueOf(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();
default:
return String.valueOf(combined * scale);
}
} else {
// two-byte fallback
final short s = data[0];
if (signedLegacyWrap != null && signedLegacyWrap && s < -32000) {
return String.valueOf(-(s + 32768) * scale);
}
return String.valueOf(s * scale);
}
} catch (Exception e) {
LOGGER.warn("Failed to decode raw registers {} as {}: {}", java.util.Arrays.toString(data), dataType, e.getMessage());
return null;
}
}
private RegistryMapping findRegistryMapping(int register) throws Exception {
return registryMappings.stream()
.filter(reg -> reg.getRegistry().equals(register))
.findFirst()
.orElseThrow(() -> new Exception("Missing registry mapping in configuration"));
}
private int resolveDataType(RegistryMapping mapping) {
final String dt = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
switch (dt) {
case "TWO_BYTE_INT_UNSIGNED":
return DataType.TWO_BYTE_INT_UNSIGNED;
case "FOUR_BYTE_INT_SIGNED":
case "FOUR_BYTE_INT_SWAPPED":
return DataType.FOUR_BYTE_INT_SIGNED;
case "FOUR_BYTE_INT_UNSIGNED":
case "FOUR_BYTE_INT_UNSIGNED_SWAPPED":
return DataType.FOUR_BYTE_INT_UNSIGNED;
case "FOUR_BYTE_FLOAT":
case "FOUR_BYTE_FLOAT_SWAPPED":
try {
return DataType.FOUR_BYTE_FLOAT; // if available in modbus4j
} catch (NoSuchFieldError e) {
return DataType.FOUR_BYTE_INT_SIGNED; // fallback
}
case "TWO_BYTE_INT_SIGNED":
default:
return DataType.TWO_BYTE_INT_SIGNED;
}
}
@Override
public void write(int register, double scaledValue) throws Exception {
final RegistryMapping mapping = findRegistryMapping(register);
final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
final Double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
// Validate range using mapping metadata (values are in scaled units)
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);
}
if (max != null && scaledValue > max) {
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");
}
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);
if (resp != null && resp.isException()) {
throw new Exception(resp.getExceptionMessage());
}
} catch (IllegalArgumentException e) {
// pass through validation error
throw e;
} catch (Exception e) {
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);
}
}
@@ -17,4 +17,8 @@ public class ModbusService {
public Registry get(int register) {
return repository.get(register);
}
}
public void write(int register, double scaledValue) throws Exception {
repository.write(register, scaledValue);
}
}
@@ -3,13 +3,21 @@ package home.automation.modbus;
import lombok.*;
@Getter
@Setter
@Builder
@AllArgsConstructor
@ToString
public class Registry {
private Integer registry;
private String desc;
private String value;
public Registry(Integer registry, String desc, String 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 void setValue(String v) { this.value = v; }
}
@@ -6,4 +6,11 @@ public interface Repository {
List<Registry> getAll();
Registry get(int register);
}
/**
* Write a scaled value to a registry. The repository is responsible for
* converting the scaled value to device raw register(s) and performing
* the Modbus write.
*/
void write(int register, double scaledValue) throws Exception;
}
@@ -0,0 +1,175 @@
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();
}
}
+52 -24
View File
@@ -1,6 +1,7 @@
{
"address": "192.168.0.65",
"address": "192.168.0.150",
"port": 502,
"useMock": true,
"influx_db": {
"address": "https://influxdb.mariuszpacek.pl",
"username": "",
@@ -9,83 +10,110 @@
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature"
"desc": "Temperatura zewnętrzna",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4070,
"desc": "Temp in"
"desc": "Temperatura wewnętrzna",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4071,
"desc": "Temp out"
"desc": "Temperatura wyjściowa"
},
{
"registry": 4072,
"desc": "Temp pc out"
"desc": "Temperatura PC (wyjście)"
},
{
"registry": 4067,
"desc": "Temp co"
"desc": "Temperatura CO"
},
{
"registry": 4202,
"desc": "Set Temp co"
"desc": "Nastawa CO",
"controlType": "setpoint",
"min": 5.0,
"max": 90.0,
"step": 0.1,
"unit": "°C"
},
{
"registry": 4069,
"desc": "Temp cwu"
"desc": "Temperatura CWU"
},
{
"registry": 4203,
"desc": "Set Temp cwu"
"desc": "Nastawa CWU",
"controlType": "setpoint",
"min": 20.0,
"max": 80.0,
"step": 0.1,
"unit": "°C"
},
{
"registry": 4066,
"desc": "Tem wew"
"desc": "Temperatura wewnętrzna"
},
{
"registry": 4201,
"desc": "Set Temp wew"
"desc": "Nastawa wewnętrzna",
"controlType": "setpoint",
"min": 5.0,
"max": 30.0,
"step": 0.1,
"unit": "°C"
},
{
"registry": 4199,
"desc": "Temp zsr"
"desc": "Temperatura ZSR"
},
{
"registry": 4117,
"desc": "Pompa Dz"
"desc": "Pompa Dz",
"controlType": "toggle"
},
{
"registry": 4113,
"desc": "Pompa BUF"
"desc": "Pompa BUF",
"controlType": "toggle"
},
{
"registry": 4112,
"desc": "Pompa CO"
"desc": "Pompa CO",
"controlType": "toggle"
},
{
"registry": 4118,
"desc": "Pompa CWU"
"desc": "Pompa CWU",
"controlType": "toggle"
},
{
"registry": 4114,
"desc": "Sprezarka"
"desc": "Sprężarka",
"controlType": "toggle"
},
{
"registry": 4115,
"desc": "GrzalkaCWU"
"desc": "Grzałka CWU",
"controlType": "toggle"
},
{
"registry": 4119,
"desc": "WyCHL"
"desc": "WyCHL",
"controlType": "toggle"
},
{
"registry": 4116,
"desc": "Pompa CCW"
},
{
"registry": 4116,
"desc": "Pompa CCW"
"desc": "Pompa CCW",
"controlType": "toggle"
}
]
}
@@ -11,5 +11,14 @@ class JsonFileBasedConfigurationTests {
ConfigurationStrategy configuration = new JsonFileBasedConfiguration();
final Optional<RegistryConfiguration> config = configuration.getConfig();
assert (config.isPresent());
final RegistryConfiguration cfg = config.get();
// config defaults should be applied
assert (cfg.getPort() != null && cfg.getPort() == 502);
assert (!cfg.getRegistry_mapping().isEmpty());
final RegistryMapping first = cfg.getRegistry_mapping().get(0);
assert (first.getUnitId() != null && first.getUnitId() == 1);
assert (first.getScale() != null && first.getScale() == 0.1);
assert (first.getDataType() != null && "TWO_BYTE_INT_SIGNED".equals(first.getDataType()));
assert (first.getSignedLegacyWrap() != null && first.getSignedLegacyWrap());
}
}
@@ -0,0 +1,25 @@
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);
}
}
@@ -0,0 +1,51 @@
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);
}
}
@@ -0,0 +1,41 @@
package home.automation.modbus;
import home.automation.configuration.RegistryMapping;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class MockRepositoryTest {
@Test
void getAllAndWriteBehavior() throws Exception {
RegistryMapping sp = new RegistryMapping();
sp.setRegistry(1000);
sp.setDesc("Setpoint");
sp.setControlType("setpoint");
sp.setMin(10.0);
sp.setMax(30.0);
sp.setStep(0.5);
RegistryMapping tog = new RegistryMapping();
tog.setRegistry(1100);
tog.setDesc("Toggle");
tog.setControlType("toggle");
MockRepository repo = new MockRepository(List.of(sp, tog));
assertNotNull(repo.getAll());
assertNotNull(repo.get(1000));
assertNotNull(repo.get(1100));
repo.write(1000, 25.5);
assertEquals("25.5", repo.get(1000).getValue());
repo.write(1100, 0);
assertEquals("0.0", repo.get(1100).getValue());
assertThrows(Exception.class, () -> repo.write(9999, 1.0));
}
}
@@ -1,81 +0,0 @@
package home.automation.modbus;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static java.lang.Thread.sleep;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
class ModbusCachedWrapperTests {
public static final int MAX_TESTS = 10;
private final RepositoryInMemory repository = new RepositoryInMemory();
private final ModbusCachedWrapper cachedRepository = new ModbusCachedWrapper(repository, Duration.ofSeconds(1));
@Test
public void givenRepositoryInMemoryAndCacheDurationOf1Seconds_WhenGetRegistryFromCachedRepository2times_ThenValuesAreTheSame() {
final Registry firstTry = cachedRepository.get(4065);
final Registry secondTry = cachedRepository.get(4065);
assert (firstTry.getValue().equals(secondTry.getValue()));
}
@Test
public void givenRepositoryInMemoryAndCacheDurationOf1Seconds_WhenGetRegistryFromCachedRepository2timesWithDelayMoreThan1Sec_ThenValuesAreTheDifferent() throws InterruptedException {
final Registry firstTry = cachedRepository.get(4065);
final long currentTimeMillis = System.currentTimeMillis();
final long desireTimeoutInMs = 1000;
while (desireTimeoutInMs > System.currentTimeMillis() - currentTimeMillis) {
sleep(510);
}
final Registry secondTry = cachedRepository.get(4065);
assert (!firstTry.getValue().equals(secondTry.getValue()));
}
@Test
public void givenRepositoryInMemoryAndCacheDurationOf3Seconds_WhenGetRegistryFromCachedRepositoryMultipleTimesWithIn3SecondDelay_ThenAllValuesAreTheSame() throws InterruptedException {
final Registry firstTry = cachedRepository.get(4065);
final long currentTimeMillis = System.currentTimeMillis();
final long desireTimeoutInMs = 500;
int count = 0;
while (desireTimeoutInMs > System.currentTimeMillis() - currentTimeMillis || count == MAX_TESTS) {
count++;
final Registry secondTry = cachedRepository.get(4065);
assert (firstTry.getValue().equals(secondTry.getValue()));
}
}
@Test
public void givenRepositoryInMemoryAndCacheDurationOf3Seconds_WhenGetAllFromCachedRepository2times_ThenValuesAreTheSame() {
final List<Registry> firstTry = cachedRepository.getAll();
final List<Registry> secondTry = cachedRepository.getAll();
assertEquals(firstTry, secondTry);
}
@Test
public void givenRepositoryInMemoryAndCacheDurationOf3Seconds_WhenGetAllFromCachedRepository2timesWith4SecondDelay_ThenValuesAreTheDifferent() throws InterruptedException {
final List<Registry> firstTry = cachedRepository.getAll();
final long currentTimeMillis = System.currentTimeMillis();
final long desireTimeoutInMs = 1000;
while (desireTimeoutInMs > System.currentTimeMillis() - currentTimeMillis) {
sleep(510);
}
final List<Registry> secondTry = cachedRepository.getAll();
assertNotEquals(firstTry, secondTry);
}
@Test
public void givenRepositoryInMemoryAndCacheDurationOf3Seconds_WhenGetAllFromCachedRepositoryMultipleTimesWithIn3SecondDelay_ThenAllValuesAreTheSame() throws InterruptedException {
final List<Registry> firstTry = cachedRepository.getAll();
final long currentTimeMillis = System.currentTimeMillis();
final long desireTimeoutInMs = 500;
int count = 0;
while (desireTimeoutInMs > System.currentTimeMillis() - currentTimeMillis || count == MAX_TESTS) {
count++;
final List<Registry> secondTry = cachedRepository.getAll();
assertEquals(firstTry, secondTry);
}
}
}
@@ -0,0 +1,75 @@
package home.automation.modbus;
import com.serotonin.modbus4j.ModbusMaster;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import home.automation.configuration.ConfigurationStrategy;
import java.net.InetAddress;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
class ModbusConnectionCloseTests {
static class TestModbusConnection extends ModbusConnection {
private final ModbusMaster toReturn;
public TestModbusConnection(ConfigurationStrategy cfg, ModbusMaster toReturn) {
super(cfg);
this.toReturn = toReturn;
}
@Override
protected ModbusMaster initConnection(java.net.InetAddress address, int port) throws Exception {
return toReturn;
}
}
@Test
void givenActiveConnection_whenClose_thenDestroyCalled() throws Exception {
home.automation.configuration.RegistryConfiguration rc = new home.automation.configuration.RegistryConfiguration();
rc.setAddress(InetAddress.getLoopbackAddress());
rc.setPort(502);
ModbusConnection.TestConfiguration cfg = new ModbusConnection.TestConfiguration(rc);
ModbusMaster mockMaster = Mockito.mock(ModbusMaster.class);
TestModbusConnection conn = new TestModbusConnection(cfg, mockMaster);
ModbusMaster m = conn.getConnection();
assertEquals(mockMaster, m);
conn.close();
verify(mockMaster, times(1)).destroy();
}
@Test
void givenClosed_thenGetConnectionReconnects() throws Exception {
home.automation.configuration.RegistryConfiguration rc = new home.automation.configuration.RegistryConfiguration();
rc.setAddress(InetAddress.getLoopbackAddress());
rc.setPort(502);
ModbusConnection.TestConfiguration cfg = new ModbusConnection.TestConfiguration(rc);
ModbusMaster first = Mockito.mock(ModbusMaster.class);
ModbusMaster second = Mockito.mock(ModbusMaster.class);
TestModbusConnection conn = new TestModbusConnection(cfg, first) {
private int count = 0;
@Override
protected ModbusMaster initConnection(java.net.InetAddress address, int port) throws Exception {
count++;
return count == 1 ? first : second;
}
};
ModbusMaster m1 = conn.getConnection();
assertEquals(first, m1);
conn.close();
ModbusMaster m2 = conn.getConnection();
assertEquals(second, m2);
}
}
@@ -0,0 +1,77 @@
package home.automation.modbus;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.serotonin.modbus4j.ModbusMaster;
import home.automation.configuration.ConfigurationStrategy;
import java.net.InetAddress;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ModbusConnectionRetryTests {
static class TestModbusConnection extends ModbusConnection {
private final int failTimes;
private final AtomicInteger counter = new AtomicInteger(0);
private final ModbusMaster toReturn;
public TestModbusConnection(ConfigurationStrategy cfg, int failTimes, ModbusMaster toReturn) {
super(cfg);
this.failTimes = failTimes;
this.toReturn = toReturn;
}
@Override
protected ModbusMaster initConnection(InetAddress address, int port) throws Exception {
final int c = counter.getAndIncrement();
if (c < failTimes) {
throw new Exception("simulated failure");
}
return toReturn;
}
public int getAttempts() {
return counter.get();
}
}
@Test
void givenRetriesConfigured_whenInitFails_thenRetriesAndSucceeds() throws Exception {
// configuration with maxRetries=2 (so it will attempt up to 3 times)
home.automation.configuration.RegistryConfiguration rc = new home.automation.configuration.RegistryConfiguration();
rc.setAddress(InetAddress.getLoopbackAddress());
rc.setPort(502);
rc.setMaxRetries(2);
rc.setRetryBackoffMs(1);
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());
}
@Test
void givenRetriesExhausted_thenExceptionIsThrown() throws Exception {
home.automation.configuration.RegistryConfiguration rc = new home.automation.configuration.RegistryConfiguration();
rc.setAddress(InetAddress.getLoopbackAddress());
rc.setPort(502);
rc.setMaxRetries(1); // allows two attempts
rc.setRetryBackoffMs(1);
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());
}
}
@@ -0,0 +1,25 @@
package home.automation.modbus;
import home.automation.configuration.ConfigurationStrategy;
import home.automation.configuration.JsonFileBasedConfiguration;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.net.InetAddress;
class ModbusConnectionTests {
@Test
void GivenJsonConfiguration_When_getRemoteEndpoint_Then_returnConfiguredAddressAndPort() throws Exception {
ConfigurationStrategy conf = new JsonFileBasedConfiguration();
ModbusConnection connection = new ModbusConnection(conf);
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);
}
}
@@ -0,0 +1,73 @@
package home.automation.modbus;
import home.automation.configuration.ConfigurationStrategy;
import home.automation.configuration.RegistryConfiguration;
import home.automation.configuration.RegistryMapping;
import home.automation.modbus.mock.MockModbusExtension;
import home.automation.modbus.mock.MockModbusServer;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import java.net.InetAddress;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@ExtendWith(MockModbusExtension.class)
@Tag("integration")
class ModbusIntegrationMockTests {
static class TestConfiguration implements ConfigurationStrategy {
private final RegistryConfiguration cfg;
TestConfiguration(RegistryConfiguration cfg) {
this.cfg = cfg;
}
@Override
public Optional<RegistryConfiguration> getConfig() {
return Optional.of(cfg);
}
}
@Test
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);
// 4065 -> -5.0 in legacy encoding: raw = -32768 + (5*10) = -32718
server.setRegister(4065, (short) -32718);
// 4066 -> 204 => 20.4
server.setRegister(4066, (short) 204);
RegistryConfiguration rc = new RegistryConfiguration();
rc.setAddress(InetAddress.getLoopbackAddress());
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");
ModbusConnection connection = new ModbusConnection(new TestConfiguration(rc));
ModbusRtuTcpRepository repo = new ModbusRtuTcpRepository(connection, List.of(m1, m2, m3));
List<Registry> all = repo.getAll();
assertNotNull(all);
Registry r4201 = all.stream().filter(r -> r.getRegistry() == 4201).findFirst().orElse(null);
assertNotNull(r4201);
assertEquals("10.0", r4201.getValue());
Registry r4065 = all.stream().filter(r -> r.getRegistry() == 4065).findFirst().orElse(null);
assertNotNull(r4065);
assertEquals("-5.0", r4065.getValue());
Registry r4066 = all.stream().filter(r -> r.getRegistry() == 4066).findFirst().orElse(null);
assertNotNull(r4066);
assertEquals("20.4", r4066.getValue());
}
}
@@ -0,0 +1,89 @@
package home.automation.modbus;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.*;
class ModbusParserTests {
private ModbusRtuTcpRepository createRepo() {
return new ModbusRtuTcpRepository(new Connection() {
@Override
public com.serotonin.modbus4j.ModbusMaster getConnection() {
throw new UnsupportedOperationException("not used in parser tests");
}
}, 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);
m.setAccessible(true);
return (String) m.invoke(repo, (Object) data, dataType, scale, legacy);
}
@Test
void decodeFourByteFloat() throws Exception {
ModbusRtuTcpRepository repo = createRepo();
float value = 12.34f;
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);
}
@Test
void decodeFourByteFloatSwapped() throws Exception {
ModbusRtuTcpRepository repo = createRepo();
float value = -5.5f;
int bits = Float.floatToIntBits(value);
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);
}
@Test
void decodeFourByteUnsignedInt() throws Exception {
ModbusRtuTcpRepository repo = createRepo();
long unsignedVal = 3000000000L; // > 2^31
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);
}
@Test
void decodeFourByteSignedInt() throws Exception {
ModbusRtuTcpRepository repo = createRepo();
int signedVal = -12345678;
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);
}
@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);
// calculation: -(s + 32768) * scale -> -( -32760 + 32768) = -8
assertEquals("-8.0", decoded);
}
@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);
// 123 * 0.1 = 12.3
assertEquals("12.3", decoded);
}
}
@@ -2,6 +2,7 @@ package home.automation.modbus;
import com.serotonin.modbus4j.ModbusMaster;
import com.serotonin.modbus4j.msg.ModbusRequest;
import com.serotonin.modbus4j.msg.ReadHoldingRegistersRequest;
import com.serotonin.modbus4j.msg.ReadHoldingRegistersResponse;
import home.automation.configuration.JsonFileBasedConfiguration;
import home.automation.configuration.RegistryMapping;
@@ -49,7 +50,7 @@ class ModbusRtuTcpRepositoryTests {
@Test
public void givenModbusRepositoryWhenGetRegistryIsNegativeValueIsNormalizedAndReturned() throws Exception {
when(response.isException()).thenReturn(false);
short[] returnValue = {parseShort("-32668")};
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()));
@@ -59,8 +60,149 @@ class ModbusRtuTcpRepositoryTests {
@Test
public void givenModbusRepositoryWhenGetRegistryThenExecptionIsRised() throws Exception {
when(response.isException()).thenReturn(true);
short[] returnValue = {parseShort("-32668")};
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
Assertions.assertThrowsExactly(RuntimeException.class,() -> "-10.0".equals(repository.get(4201).getValue()));
}
@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);
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());
}
@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);
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());
}
@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);
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());
}
@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));
// 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);
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());
}
@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));
// 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);
final Registry r = repoSingle.get(5000);
org.junit.jupiter.api.Assertions.assertEquals(String.valueOf(expected), r.getValue());
}
@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 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);
final Registry r2 = repoSingle.get(5002);
org.junit.jupiter.api.Assertions.assertEquals(String.valueOf(expected), r2.getValue());
}
}
@@ -0,0 +1,42 @@
package home.automation.modbus;
import com.serotonin.modbus4j.ModbusMaster;
import com.serotonin.modbus4j.msg.WriteRegisterRequest;
import org.junit.jupiter.api.Test;
import home.automation.configuration.RegistryMapping;
import org.mockito.ArgumentCaptor;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class ModbusRtuTcpRepositoryWriteTest {
@Test
void writeConvertsAndSendsWriteRequest() throws Exception {
ModbusMaster master = mock(ModbusMaster.class);
// return a non-exceptional response so write() doesn't NPE
com.serotonin.modbus4j.msg.ModbusResponse okResp = mock(com.serotonin.modbus4j.msg.ModbusResponse.class);
when(okResp.isException()).thenReturn(false);
when(master.send(any(WriteRegisterRequest.class))).thenReturn(okResp);
final RegistryMapping m = new RegistryMapping();
m.setRegistry(5000);
m.setScale(0.1);
m.setDataType("TWO_BYTE_INT_SIGNED");
m.setUnitId(1);
final ModbusRtuTcpRepository repo = new ModbusRtuTcpRepository(new Connection() { @Override public com.serotonin.modbus4j.ModbusMaster getConnection() { return master; } }, List.of(m));
// write scaled value 12.3 -> raw 123
repo.write(5000, 12.3);
ArgumentCaptor<WriteRegisterRequest> cap = ArgumentCaptor.forClass(WriteRegisterRequest.class);
verify(master, times(1)).send(cap.capture());
WriteRegisterRequest req = cap.getValue();
assertNotNull(req);
assertTrue(req instanceof WriteRegisterRequest);
}
}
@@ -0,0 +1,34 @@
package home.automation.modbus;
import com.serotonin.modbus4j.ModbusMaster;
import org.junit.jupiter.api.Test;
import home.automation.configuration.RegistryMapping;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
class ModbusRtuTcpRepositoryWriteValidationTest {
@Test
void writeOutsideRangeIsRejected() throws Exception {
ModbusMaster master = mock(ModbusMaster.class);
// no need to stub send(); validation should reject before any Modbus calls
final RegistryMapping m = new RegistryMapping();
m.setRegistry(6000);
m.setScale(0.1);
m.setUnitId(1);
m.setControlType("setpoint");
m.setMin(0.0);
m.setMax(50.0);
final ModbusRtuTcpRepository repo = new ModbusRtuTcpRepository(new Connection() { @Override public ModbusMaster getConnection() { return master; } }, List.of(m));
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> repo.write(6000, 60.0));
assertTrue(ex.getMessage().contains("above maximum"));
verify(master, never()).send(any(com.serotonin.modbus4j.msg.ModbusRequest.class));
}
}
@@ -23,9 +23,11 @@ public class RepositoryInMemory implements Repository {
private Registry generateRandomResponse(int registry) {
final Random random = new Random();
return Registry.builder()
.registry(registry)
.value(Double.toString(random.nextDouble()))
.desc("TEST").build();
return new Registry(registry, "TEST", Double.toString(random.nextDouble()));
}
}
@Override
public void write(int register, double scaledValue) throws Exception {
// no-op for tests
}
}
@@ -0,0 +1,34 @@
package home.automation.modbus.mock;
import org.junit.jupiter.api.extension.*;
import java.lang.reflect.Parameter;
/**
* JUnit extension that starts a MockModbusServer before all tests and stops it afterwards.
* It also resolves parameters of type MockModbusServer for test methods.
*/
public class MockModbusExtension implements BeforeAllCallback, AfterAllCallback, ParameterResolver {
private MockModbusServer server;
@Override
public void beforeAll(ExtensionContext context) throws Exception {
server = new MockModbusServer(0);
}
@Override
public void afterAll(ExtensionContext context) throws Exception {
if (server != null) server.close();
}
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
Parameter parameter = parameterContext.getParameter();
return parameter.getType() == MockModbusServer.class;
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
return server;
}
}
@@ -0,0 +1,122 @@
package home.automation.modbus.mock;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Extremely small Modbus-TCP mock server for tests.
* Supports Read Holding Registers (function code 3) only.
*/
public class MockModbusServer implements AutoCloseable {
private final ServerSocket serverSocket;
private final ExecutorService executor = Executors.newCachedThreadPool();
private final Map<Integer, Short> registers = new ConcurrentHashMap<>();
private volatile boolean running = true;
public MockModbusServer(int port) throws Exception {
this.serverSocket = new ServerSocket(port);
startAcceptLoop();
}
public MockModbusServer() throws Exception {
this(0);
}
private void startAcceptLoop() {
executor.submit(() -> {
try {
while (running && !serverSocket.isClosed()) {
Socket s = serverSocket.accept();
executor.submit(() -> handleConnection(s));
}
} catch (Exception ignored) {
}
});
}
private void handleConnection(Socket s) {
try (Socket socket = s; InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream()) {
while (running && !socket.isClosed()) {
// Read MBAP header (7 bytes)
byte[] header = in.readNBytes(7);
if (header.length < 7) break;
int transactionId = ((header[0] & 0xff) << 8) | (header[1] & 0xff);
int protocolId = ((header[2] & 0xff) << 8) | (header[3] & 0xff);
int length = ((header[4] & 0xff) << 8) | (header[5] & 0xff);
byte unitId = header[6];
int pduLen = length - 1; // length includes unit id
byte[] pdu = in.readNBytes(pduLen);
if (pdu.length < pduLen) break;
byte func = pdu[0];
if (func == 3) { // Read Holding Registers
int startAddr = ((pdu[1] & 0xff) << 8) | (pdu[2] & 0xff);
int qty = ((pdu[3] & 0xff) << 8) | (pdu[4] & 0xff);
byte[] respPdu = new byte[2 + qty * 2];
respPdu[0] = 3;
respPdu[1] = (byte) (qty * 2);
for (int i = 0; i < qty; i++) {
short v = registers.getOrDefault(startAddr + i, (short) 0);
respPdu[2 + i * 2] = (byte) ((v >> 8) & 0xff);
respPdu[2 + i * 2 + 1] = (byte) (v & 0xff);
}
int respLen = respPdu.length + 1;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write((byte) ((transactionId >> 8) & 0xff));
baos.write((byte) (transactionId & 0xff));
baos.write((byte) ((protocolId >> 8) & 0xff));
baos.write((byte) (protocolId & 0xff));
baos.write((byte) ((respLen >> 8) & 0xff));
baos.write((byte) (respLen & 0xff));
baos.write(unitId);
baos.write(respPdu);
out.write(baos.toByteArray());
out.flush();
} else {
// return exception (illegal function)
byte[] respPdu = new byte[]{(byte) (func | 0x80), 0x01};
int respLen = respPdu.length + 1;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write((byte) ((transactionId >> 8) & 0xff));
baos.write((byte) (transactionId & 0xff));
baos.write(0);
baos.write(0);
baos.write((byte) ((respLen >> 8) & 0xff));
baos.write((byte) (respLen & 0xff));
baos.write(unitId);
baos.write(respPdu);
out.write(baos.toByteArray());
out.flush();
}
}
} catch (Exception ignored) {
// ignore client disconnects/etc
}
}
public void setRegister(int register, short value) {
registers.put(register, value);
}
public int getPort() {
return serverSocket.getLocalPort();
}
@Override
public void close() throws Exception {
running = false;
try {
serverSocket.close();
} catch (Exception ignored) {
}
executor.shutdownNow();
}
}
+10 -2
View File
@@ -9,11 +9,19 @@
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature"
"desc": "External temperature",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4070,
"desc": "Temp in"
"desc": "Temp in",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4071,
-8
View File
@@ -1,8 +0,0 @@
{
"folders": [
{
"path": ".."
}
],
"settings": {}
}