75 Commits
Author SHA1 Message Date
root dd739e16c0 chore: harden lifecycle, health checks and CI
CI / Backend tests (Maven / Java 21) (push) Failing after 7m9s
- add PR trigger and Maven cache in CI

- centralize shutdown cleanup in Main

- make HealthMonitor failure counter thread-safe

- fix UTF-8 response length handling in health endpoint

- improve Influx username null-safety

- reduce expected modbus read failures from ERROR to WARN
2026-02-12 22:50:37 +01:00
root e345063720 updated ci
CI / Backend tests (Maven / Java 21) (push) Successful in 46s
2026-02-11 23:18:30 +01:00
root ecc4f19076 removed unnessery files
CI / Frontend build (Node 18) (push) Failing after 20s
CI / Backend tests (Maven / Java 21) (push) Successful in 1m22s
2026-02-11 23:12:49 +01:00
root 2664e7e52f changes
CI / Backend tests (Maven / Java 21) (push) Has been cancelled
CI / Frontend build (Node 18) (push) Has been cancelled
2026-02-11 23:11:29 +01:00
root f2cb8d664d Removed frontend created systemd run 2026-02-11 23:10:56 +01:00
root 1b725ec9a7 changes
CI / Frontend build (Node 18) (push) Successful in 1m12s
CI / Backend tests (Maven / Java 21) (push) Successful in 1m52s
2026-01-04 10:25:47 +01:00
root 254c09b771 changes
CI / Backend tests (Maven / Java 21) (push) Has been cancelled
CI / Frontend build (Node 18) (push) Has been cancelled
2026-01-04 10:25:10 +01:00
mariusz.pacek da604c1945 Update .gitea/workflows/ci.yml
CI / Frontend build (Node 18) (push) Successful in 23s
CI / Backend tests (Maven / Java 21) (push) Successful in 49s
2025-12-28 10:17:08 +00:00
mariusz.pacek c59c26bbe0 Update .gitea/workflows/ci.yml
CI / Backend tests (Maven / Java 21) (push) Failing after 5m18s
CI / Frontend build (Node 18) (push) Successful in 21s
2025-12-28 10:09:20 +00:00
mariusz.pacek 40a8602b46 Merge pull request 'tests' (#9) from fix-pipeline into master
CI / Backend tests (Maven / Java 21) (push) Failing after 8s
CI / Frontend build (Node 18) (push) Successful in 21s
Reviewed-on: #9
2025-12-28 10:08:18 +00:00
root 6fab319fbb tests
CI / Frontend build (Node 18) (push) Successful in 22s
CI / Backend tests (Maven / Java 21) (push) Failing after 50s
2025-12-28 11:05:02 +01:00
mariusz.pacek 9d140a5880 Merge pull request 'fix-test' (#8) from fix-test into master
CI / Frontend build (Node 18) (push) Successful in 10m26s
CI / Backend tests (Maven / Java 21) (push) Successful in 11m25s
Reviewed-on: #8
2025-12-28 01:12:41 +00:00
root 4060e40d9e Merge remote-tracking branch 'origin/master' into fix-test
CI / Frontend build (Node 18) (push) Successful in 9m27s
CI / Backend tests (Maven / Java 21) (push) Successful in 10m19s
2025-12-28 02:01:43 +01:00
root 50527a363c fixed test 2025-12-28 02:00:53 +01:00
mariusz.pacek 399b6200fa Update .gitea/workflows/ci.yml
CI / Backend tests (Maven / Java 21) (push) Failing after 6m46s
CI / Frontend build (Node 18) (push) Successful in 9m24s
2025-12-28 00:35:39 +00:00
mariusz.pacek 0c224e16a0 Update .gitea/workflows/ci.yml
CI / Backend tests (Maven / Java 21) (push) Failing after 2s
CI / Frontend build (Node 18) (push) Successful in 9m56s
2025-12-28 00:21:03 +00:00
mariusz.pacek f0b9546fdc Update .gitea/workflows/ci.yml
CI / Backend tests (Maven / Java 21) (push) Failing after 5m45s
CI / Frontend build (Node 18) (push) Successful in 10m27s
2025-12-28 00:04:01 +00:00
mariusz.pacek d77f9e643e Update scripts/post-test.js
CI / Backend tests (Maven) (push) Failing after 24s
CI / Frontend build (node) (push) Successful in 9m25s
2025-12-27 23:50:42 +00:00
mariusz.pacek 8065af3fec Update .gitea/workflows/ci.yml
CI / Backend tests (Maven) (push) Failing after 10s
CI / Frontend build (node) (push) Has been cancelled
2025-12-27 23:44:05 +00:00
mariusz.pacek 452a58c154 Update .gitea/workflows/ci.yml
CI / Backend tests (Maven) (push) Failing after 9s
CI / Frontend build (node) (push) Has been cancelled
2025-12-27 23:40:17 +00:00
mariusz.pacek 7398be4151 Merge pull request 'Update .gitea/workflows/ci.yml' (#7) from mariusz.pacek-patch-1 into master
CI / Backend tests (Maven) (push) Failing after 2s
CI / Frontend build (node) (push) Failing after 11s
Reviewed-on: #7
2025-12-27 23:38:57 +00:00
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
64 changed files with 2784 additions and 667 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
+32
View File
@@ -0,0 +1,32 @@
name: CI
on:
push:
branches:
- "**"
pull_request:
branches:
- "**"
jobs:
backend:
name: Backend tests (Maven / Java 21)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Show runner info
run: |
echo "Runner name: ${{ runner.name }}"
echo "Runner labels: ${{ toJSON(runner.labels) }}"
- name: Set up Java 21 + Maven
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Run Maven tests (parallel)
run: mvn -B -T 1C test
+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"]
+167 -1
View File
@@ -1,2 +1,168 @@
# 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
- Configurable send interval to InfluxDB (default: 5 minutes)
- Automatic buffering when InfluxDB is unavailable (up to 50,000 data points)
- Health check endpoint for monitoring application status
- Automatic restart on Modbus connection failures
- Graceful shutdown with InfluxDB buffer flush
- Systemd service with automatic recovery
- Configurable via JSON file
## Quick Start
### Local Development
```bash
mvn clean package
java -jar target/home-pump-1.0.0.jar
```
### Production Deployment (Raspberry Pi / Ubuntu)
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete installation and configuration guide.
```bash
# Build
mvn clean package
# Install as systemd service
sudo scripts/install-service.sh
# Check status
systemctl status home-pump.service
curl http://localhost:8080/health
```
## Configuration
Edit `src/main/resources/registryMapping.json` to configure:
- Modbus TCP connection (address, port)
- InfluxDB connection (address, username, password)
- Send interval (influxSendIntervalSeconds, default: 300)
- Connection timeouts and retry settings
- List of registers to read with descriptions
Additional mapping fields (optional):
- `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).
- # Example Configuration
```json
{
"address": "192.168.0.150",
"port": 502,
"useMock": false,
"influxSendIntervalSeconds": 300,
"timeoutMs": 5000,
"maxRetries": 2,
"retryBackoffMs": 1000,
"influx_db": {
"address": "https://influxdb.example.com",
"username": "",
"password": ""
},
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED"
}
]
}
```
## Monitoring & Health Check
The application exposes a health check endpoint on port 8080:
```bash
curl http://localhost:8080/health
```
**Healthy response (HTTP 200):**
```json
{
"status": "UP",
"lastSuccessfulRead": "2026-02-06T19:30:45Z",
"message": "Last successful read 15 seconds ago"
}
```
**Unhealthy response (HTTP 503):**
```json
{
"status": "DOWN",
"lastSuccessfulRead": "2026-02-06T19:20:45Z",
"message": "No successful read for 620 seconds. Last error: Connection timeout"
}
```
### Important Notes
- **InfluxDB failures do NOT trigger unhealthy status** - data is buffered in memory
- Only Modbus read failures trigger unhealthy status and potential restart
- Health check considers the application unhealthy if no successful Modbus read within 2× send interval
- Automatic monitoring script restarts the service on health check failures
## Production Deployment
For production use on Raspberry Pi or Ubuntu server:
1. **Install as systemd service** - Auto-start on boot with resource limits
2. **Enable health monitoring** - Cron job checks health every 2 minutes
3. **Automatic recovery** - Restarts service on Modbus failures (max 3 attempts per 10 minutes)
4. **Graceful shutdown** - Flushes buffered data to InfluxDB before exit
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions.
## Data Buffering
When InfluxDB is unavailable, data is automatically buffered:
- **Buffer size**: 50,000 data points in memory
- **Batch size**: 50 points per flush
- **Flush interval**: 180 seconds (3 minutes)
- **No data loss**: Buffer persists during InfluxDB outages
- **Automatic retry**: Built-in retry mechanism in InfluxDB Java client
## Troubleshooting
### Check application logs
```bash
journalctl -u home-pump.service -f
```
### Test health endpoint
```bash
./scripts/test-health.sh
```
### View monitor logs
```bash
tail -f /var/log/home-pump-monitor.log
```
### Common Issues
1. **Service won't start**: Check JAR file exists and Java 21 is installed
2. **Health check fails**: Verify Modbus device is accessible
3. **InfluxDB connection errors**: Check network and credentials (data will be buffered)
4. **Too many restarts**: Check Modbus device connectivity and network stability clean package
java -jar target/home-pump-1.0.0.jar
```
## Requirements
- Java 21
- Modbus TCP accessible heat pump
- InfluxDB instance
+57 -27
View File
@@ -1,60 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>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>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.20.1</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<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.38</version>
<scope>provided</scope>
</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>
<repositories>
<repository>
<releases>
@@ -84,24 +91,47 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M7</version>
<version>3.1.2</version>
</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>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
</path>
</annotationProcessorPaths>
<release>21</release>
<source>21</source>
<encoding>UTF-8</encoding>
<showWarnings>true</showWarnings>
</configuration>
</plugin>
</plugins>
</build>
</project>
</project>
+82
View File
@@ -0,0 +1,82 @@
{
"address": "192.168.0.150",
"port": 502,
"useMock": false,
"pollIntervalMs": 30000,
"windowSeconds": 300,
"influxSendIntervalSeconds": 300,
"timeoutMs": 5000,
"maxRetries": 2,
"retryBackoffMs": 1000,
"influx_db": {
"address": "https://influxdb.mariuszpacek.pl",
"username": "",
"password": ""
},
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4070,
"desc": "Temp in",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4071,
"desc": "Temp out",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4072,
"desc": "Temp pc out",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4067,
"desc": "Temp co",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4069,
"desc": "Temp cwu",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4066,
"desc": "Temp wew",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4199,
"desc": "Temp zsr",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
}
]
}
+112
View File
@@ -0,0 +1,112 @@
#!/bin/bash
# Health check and restart script for home-pump service
# Run this via cron every 2 minutes: */2 * * * * /home/pi/home-pump/scripts/healthcheck-monitor.sh
SERVICE_NAME="home-pump.service"
HEALTH_ENDPOINT="http://localhost:8080/health"
LOG_FILE="/var/log/home-pump-monitor.log"
MAX_RESTART_ATTEMPTS=3
RESTART_WINDOW_SECONDS=600
RESTART_COUNT_FILE="/tmp/home-pump-restart-count"
RESTART_TIMESTAMP_FILE="/tmp/home-pump-restart-timestamp"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# Function to check if service is running
is_service_running() {
systemctl is-active --quiet "$SERVICE_NAME"
}
# Function to get restart count in the current window
get_restart_count() {
if [ -f "$RESTART_COUNT_FILE" ]; then
cat "$RESTART_COUNT_FILE"
else
echo "0"
fi
}
# Function to get last restart timestamp
get_last_restart_time() {
if [ -f "$RESTART_TIMESTAMP_FILE" ]; then
cat "$RESTART_TIMESTAMP_FILE"
else
echo "0"
fi
}
# Function to reset restart counter if window expired
reset_counter_if_expired() {
local last_restart=$(get_last_restart_time)
local now=$(date +%s)
local diff=$((now - last_restart))
if [ "$diff" -gt "$RESTART_WINDOW_SECONDS" ]; then
echo "0" > "$RESTART_COUNT_FILE"
fi
}
# Function to increment restart counter
increment_restart_count() {
local count=$(get_restart_count)
count=$((count + 1))
echo "$count" > "$RESTART_COUNT_FILE"
date +%s > "$RESTART_TIMESTAMP_FILE"
}
# Check if service is running
if ! is_service_running; then
log "ERROR: Service $SERVICE_NAME is not running. Attempting to start..."
sudo systemctl start "$SERVICE_NAME"
sleep 5
if is_service_running; then
log "SUCCESS: Service restarted successfully"
else
log "CRITICAL: Failed to start service $SERVICE_NAME"
exit 1
fi
fi
# Check health endpoint
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
if [ "$HTTP_CODE" != "200" ]; then
log "WARNING: Health check failed with HTTP code: $HTTP_CODE"
# Reset counter if restart window expired
reset_counter_if_expired
# Check if we can restart
restart_count=$(get_restart_count)
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
log "CRITICAL: Maximum restart attempts ($MAX_RESTART_ATTEMPTS) reached in the last $RESTART_WINDOW_SECONDS seconds. Manual intervention required."
# Send alert (could be email, Pushover, etc.)
# Example: echo "Home pump service needs manual intervention" | mail -s "Critical: Home Pump Alert" admin@example.com
exit 2
fi
log "Attempting to restart service (attempt $((restart_count + 1))/$MAX_RESTART_ATTEMPTS)..."
increment_restart_count
sudo systemctl restart "$SERVICE_NAME"
sleep 10
# Verify restart
if is_service_running; then
NEW_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
if [ "$NEW_HTTP_CODE" == "200" ]; then
log "SUCCESS: Service restarted and health check passed"
else
log "WARNING: Service restarted but health check still failing (HTTP $NEW_HTTP_CODE)"
fi
else
log "ERROR: Failed to restart service"
fi
else
# Health check passed, reset counter
echo "0" > "$RESTART_COUNT_FILE"
fi
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# DEPRECATED: This script is no longer used
#
# Previously this script was used with cron to monitor and restart the service.
# Now systemd handles all restart logic automatically.
#
# The application monitors health and exits with status 1 when failures exceed threshold,
# causing systemd to automatically restart the service (Restart=on-failure).
#
# This approach is simpler and doesn't require external monitoring.
# The service unit (home-pump.service) handles all restart logic.
echo "This script is deprecated. Systemd automat handles service restarts."
exit 0
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Home Pump Automation Service
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/home-pump
ExecStart=/usr/bin/java -jar /home/pi/home-pump/target/home-pump-1.0.0.jar
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
# Resource limits
MemoryLimit=512M
CPUQuota=50%
# Security
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# Installation script for home-pump service on Raspberry Pi/Ubuntu
set -e
echo "Home Pump Service Installation Script"
echo "======================================"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (use sudo)"
exit 1
fi
# Configuration
PROJECT_DIR="/home/pi/home-pump"
SERVICE_FILE="scripts/home-pump.service"
JAR_FILE="target/home-pump-1.0.0.jar"
# Check if project directory exists
if [ ! -d "$PROJECT_DIR" ]; then
echo "Error: Project directory $PROJECT_DIR not found"
echo "Please adjust PROJECT_DIR in this script or create the directory"
exit 1
fi
cd "$PROJECT_DIR"
# Check if JAR file exists
if [ ! -f "$JAR_FILE" ]; then
echo "Error: JAR file not found at $JAR_FILE"
echo "Please build the project first using: mvn clean package"
exit 1
fi
# Install systemd service
echo "Installing systemd service..."
cp "$SERVICE_FILE" /etc/systemd/system/home-pump.service
systemctl daemon-reload
echo "✓ Systemd service installed"
# Enable service to start on boot
systemctl enable home-pump.service
echo "✓ Service enabled to start on boot"
# Start the service
systemctl start home-pump.service
echo "✓ Service started"
# Wait a bit and check status
sleep 3
if systemctl is-active --quiet home-pump.service; then
echo ""
echo "✓✓✓ Installation completed successfully! ✓✓✓"
echo ""
echo "Service status:"
systemctl status home-pump.service --no-pager -l
echo ""
echo "How it works:"
echo " - Application monitors health internally"
echo " - On consecutive failures (default 3), app exits with error code"
echo " - Systemd automatically restarts the service (Restart=on-failure)"
echo " - No external monitoring script needed"
echo ""
echo "Useful commands:"
echo " - View logs: journalctl -u home-pump.service -f"
echo " - Check status: systemctl status home-pump.service"
echo " - Restart service: sudo systemctl restart home-pump.service"
echo " - Stop service: sudo systemctl stop home-pump.service"
echo " - Health check: curl http://localhost:8080/health"
else
echo ""
echo "⚠ Warning: Service installed but not running properly"
echo "Check logs with: journalctl -u home-pump.service -n 50"
fi
+31
View File
@@ -0,0 +1,31 @@
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();
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Quick test script for local development
echo "Testing health endpoint..."
curl -v http://localhost:8080/health
echo ""
echo ""
echo "Expected response when healthy:"
echo '{"status":"UP","lastSuccessfulRead":"2026-02-06T19:30:45Z","message":"Last successful read 15 seconds ago"}'
echo ""
echo "Expected response when unhealthy (503):"
echo '{"status":"DOWN","lastSuccessfulRead":"2026-02-06T19:20:45Z","message":"No successful read for 620 seconds. Last error: Connection timeout"}'
+79 -26
View File
@@ -1,13 +1,18 @@
package home.automation;
import home.automation.configuration.JsonFileBasedConfiguration;
import home.automation.configuration.RegistryConfiguration;
import home.automation.configuration.RegistryMapping;
import home.automation.health.HealthCheckServer;
import home.automation.health.HealthMonitor;
import home.automation.influxdb.InfluxConnection;
import home.automation.influxdb.InfluxMetricModbusSender;
import home.automation.modbus.ModbusConnection;
import home.automation.modbus.ModbusPollingService;
import home.automation.modbus.ModbusRtuTcpRepository;
import home.automation.modbus.ModbusService;
import lombok.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.concurrent.Executors;
@@ -15,36 +20,84 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class Main {
private static final Logger LOGGER = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) throws Exception {
public static void main(String[] args) throws Exception {
final JsonFileBasedConfiguration jsonFileBasedConfiguration = new JsonFileBasedConfiguration();
final JsonFileBasedConfiguration jsonFileBasedConfiguration = new JsonFileBasedConfiguration();
final List<RegistryMapping> registryMappings = jsonFileBasedConfiguration
.getConfig()
.orElseThrow(() -> new Exception("Missing configuration file"))
.getRegistry_mapping();
final RegistryConfiguration config = jsonFileBasedConfiguration
.getConfig()
.orElseThrow(() -> new Exception("Missing configuration file"));
final InfluxConnection connection = new InfluxConnection(
jsonFileBasedConfiguration.getConfig()
.get()
.getInflux_db()
);
final List<RegistryMapping> registryMappings = config.getRegistry_mapping();
ModbusService service = new ModbusService(
new ModbusRtuTcpRepository(
new ModbusConnection(
new JsonFileBasedConfiguration()
), registryMappings)
);
final InfluxConnection connection = new InfluxConnection(config.getInflux_db());
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(
new InfluxMetricModbusSender(connection.getConnection(), service),
0,
1,
TimeUnit.MINUTES
);
}
// Health monitoring setup
final int maxHealthCheckIntervalSeconds = (config.getInfluxSendIntervalSeconds() == null ? 300
: config.getInfluxSendIntervalSeconds()) * 2;
final int healthCheckPort = 8080;
final var useMock = config.getUseMock() != null && config.getUseMock();
ModbusService service;
ModbusConnection modbusConnection = null;
if (useMock) {
service = new ModbusService(new home.automation.modbus.MockRepository(registryMappings));
} else {
modbusConnection = new ModbusConnection(jsonFileBasedConfiguration);
service = new ModbusService(
new ModbusRtuTcpRepository(
modbusConnection,
registryMappings));
}
final ModbusPollingService pollingService = new ModbusPollingService(service,
maxHealthCheckIntervalSeconds);
final HealthMonitor healthMonitor = new HealthMonitor(List.of(pollingService));
final HealthCheckServer healthCheckServer = new HealthCheckServer(healthCheckPort, healthMonitor);
healthCheckServer.start();
// Shutdown hook for graceful cleanup
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
ScheduledExecutorService healthScheduler = Executors.newSingleThreadScheduledExecutor();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOGGER.info("Shutdown signal received, cleaning up resources...");
try {
scheduler.shutdownNow();
healthScheduler.shutdownNow();
healthCheckServer.close();
if (modbusConnection != null) {
modbusConnection.close();
}
connection.close();
} catch (Exception e) {
LOGGER.error("Error during shutdown cleanup", e);
}
}));
final int influxSendIntervalSeconds = config.getInfluxSendIntervalSeconds() == null
? 300
: config.getInfluxSendIntervalSeconds();
scheduler.scheduleAtFixedRate(
new InfluxMetricModbusSender(connection.getConnection(), pollingService),
0,
influxSendIntervalSeconds,
TimeUnit.SECONDS);
// Monitor health and trigger restart if needed
healthScheduler.scheduleAtFixedRate(() -> {
if (healthMonitor.hasExceededFailureThreshold()) {
LOGGER.error("Health monitor threshold exceeded ({} consecutive failures). Initiating shutdown for restart.",
healthMonitor.getConsecutiveFailures());
System.exit(1);
}
}, 10, 10, TimeUnit.SECONDS);
LOGGER.info("Application started successfully. Health check available at http://localhost:{}/health",
healthCheckPort);
}
}
@@ -2,19 +2,24 @@ package home.automation.configuration;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static java.util.Optional.*;
import java.util.Optional;
public interface ConfigurationStrategy {
Logger LOGGER = LoggerFactory.getLogger(ConfigurationStrategy.class);
ObjectMapper mapper = new ObjectMapper();
Optional<RegistryConfiguration> getConfig();
default Optional<RegistryConfiguration> create(String json) {
try {
return Optional.of(mapper.readValue(json, RegistryConfiguration.class));
return of(mapper.readValue(json, RegistryConfiguration.class));
} catch (JsonProcessingException e) {
e.printStackTrace();
LOGGER.error("Failed to parse configuration JSON: {}", e.getMessage(), e);
return Optional.empty();
}
}
@@ -1,26 +1,67 @@
package home.automation.configuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import java.util.Optional;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.*;
public class JsonFileBasedConfiguration implements ConfigurationStrategy {
private final String CONFIGURATION_NAME = "registryMapping.json";
private static final Logger LOGGER = LoggerFactory.getLogger(JsonFileBasedConfiguration.class);
@Override
public Optional<RegistryConfiguration> getConfig() {
final InputStream resource = Thread.currentThread().getContextClassLoader().getResourceAsStream(CONFIGURATION_NAME);
Objects.requireNonNull(resource, "Configuration file not present please check the path:" + System.getProperty("usr.dir"));
String configurationName = "registryMapping.json";
final InputStream resource = Thread.currentThread().getContextClassLoader()
.getResourceAsStream(configurationName);
requireNonNull(resource,
"Configuration file not present. Check the path: " + System.getProperty("user.dir"));
String jsonConfiguration;
try {
try (resource) {
jsonConfiguration = new String(resource.readAllBytes(), UTF_8);
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException("Cannot read the file");
LOGGER.error("Cannot read configuration file {}: {}", configurationName, e.getMessage(), e);
throw new RuntimeException("Cannot read the file", e);
}
return create(jsonConfiguration);
final java.util.Optional<RegistryConfiguration> opt = create(jsonConfiguration);
opt.ifPresent(this::applyDefaults);
return opt;
}
private void applyDefaults(RegistryConfiguration cfg) {
// Basic validation and sensible defaults
if (cfg.getAddress() == null) {
throw new RuntimeException("Configuration missing 'address' field");
}
cfg.setPort(defaultIfNull(cfg.getPort(), 502));
if (cfg.getRegistry_mapping() == null || cfg.getRegistry_mapping().isEmpty()) {
throw new RuntimeException("Configuration contains no registry mappings");
}
cfg.getRegistry_mapping().forEach(this::applyMappingDefaults);
// Live polling defaults
cfg.setPollIntervalMs(defaultIfNull(cfg.getPollIntervalMs(), 2000));
cfg.setWindowSeconds(defaultIfNull(cfg.getWindowSeconds(), 300));
// Mock mode default
cfg.setUseMock(defaultIfNull(cfg.getUseMock(), false));
}
private void applyMappingDefaults(RegistryMapping m) {
m.setUnitId(defaultIfNull(m.getUnitId(), 1));
m.setScale(defaultIfNull(m.getScale(), 0.1));
m.setDataType(defaultIfNull(m.getDataType(), "TWO_BYTE_INT_SIGNED"));
m.setSignedLegacyWrap(defaultIfNull(m.getSignedLegacyWrap(), true));
}
private static <T> T defaultIfNull(T value, T fallback) {
return value == null ? fallback : value;
}
}
@@ -15,4 +15,43 @@ 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;
/**
* Send interval in seconds for Influx writes. Defaults to 300 (5 minutes).
*/
private Integer influxSendIntervalSeconds;
}
@@ -10,5 +10,139 @@ 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;
/**
* If true, this registry will not be included in polling reads (write-only
* parameter)
*/
private Boolean skipPolling;
public String getControlType() {
return controlType;
}
public void setControlType(String controlType) {
this.controlType = controlType;
}
public Double getMin() {
return min;
}
public void setMin(Double min) {
this.min = min;
}
public Double getMax() {
return max;
}
public void setMax(Double max) {
this.max = max;
}
public Double getStep() {
return step;
}
public void setStep(Double step) {
this.step = step;
}
public String getUnit() {
return unit;
}
public void setUnit(String unit) {
this.unit = unit;
}
public Boolean getSkipPolling() {
return skipPolling;
}
public void setSkipPolling(Boolean skipPolling) {
this.skipPolling = skipPolling;
}
}
@@ -0,0 +1,33 @@
package home.automation.health;
import java.time.Instant;
public class ComponentHealth {
private final String component;
private final HealthState state;
private final Instant lastSuccessfulRead;
private final String message;
public ComponentHealth(String component, HealthState state, Instant lastSuccessfulRead, String message) {
this.component = component;
this.state = state;
this.lastSuccessfulRead = lastSuccessfulRead;
this.message = message;
}
public String getComponent() {
return component;
}
public HealthState getState() {
return state;
}
public Instant getLastSuccessfulRead() {
return lastSuccessfulRead;
}
public String getMessage() {
return message;
}
}
@@ -0,0 +1,68 @@
package home.automation.health;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
public class HealthCheckServer implements AutoCloseable {
private static final String CONTENT_TYPE = "Content-Type";
private static final Logger LOGGER = LoggerFactory.getLogger(HealthCheckServer.class);
private static final String CONTENT_TYPE_JSON = "application/json";
private final HttpServer server;
private final HealthMonitor healthMonitor;
public HealthCheckServer(int port, HealthMonitor healthMonitor) throws IOException {
this.healthMonitor = healthMonitor;
this.server = HttpServer.create(new InetSocketAddress(port), 0);
this.server.createContext("/health", this::handleHealthCheck);
this.server.setExecutor(Executors.newSingleThreadExecutor());
LOGGER.info("Health check server initialized on port {}", port);
}
public void start() {
server.start();
LOGGER.info("Health check server started");
}
private void handleHealthCheck(HttpExchange exchange) throws IOException {
try {
final HealthStatus status = healthMonitor.getHealthStatus();
final HealthResponse response = HealthResponse.fromHealthStatus(status);
final String jsonResponse = response.toJson();
final byte[] body = jsonResponse.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set(CONTENT_TYPE, CONTENT_TYPE_JSON);
exchange.sendResponseHeaders(response.getHttpStatusCode(), body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
} catch (Exception e) {
LOGGER.error("Error handling health check", e);
final HealthResponse errorResponse = HealthResponse.error("Internal error");
final String jsonResponse = errorResponse.toJson();
final byte[] body = jsonResponse.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set(CONTENT_TYPE, CONTENT_TYPE_JSON);
exchange.sendResponseHeaders(errorResponse.getHttpStatusCode(), body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
}
@Override
public void close() {
if (server != null) {
server.stop(2);
LOGGER.info("Health check server stopped");
}
}
}
@@ -0,0 +1,7 @@
package home.automation.health;
public interface HealthIndicator {
String getName();
ComponentHealth check();
}
@@ -0,0 +1,78 @@
package home.automation.health;
import static home.automation.health.HealthState.*;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
public class HealthMonitor {
private final List<HealthIndicator> indicators;
private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
private final int failureThreshold;
public HealthMonitor(List<HealthIndicator> indicators) {
this(indicators, 3);
}
public HealthMonitor(List<HealthIndicator> indicators, int failureThreshold) {
this.indicators = indicators;
this.failureThreshold = failureThreshold;
}
public HealthStatus getHealthStatus() {
final List<ComponentHealth> componentHealth = indicators.stream()
.map(HealthIndicator::check)
.collect(Collectors.toList());
final HealthState overallState = componentHealth.stream()
.map(ComponentHealth::getState)
.max(Comparator.comparingInt(HealthMonitor::statePriority))
.orElse(UP);
// Track consecutive failures
if (overallState != UP) {
consecutiveFailures.incrementAndGet();
} else {
consecutiveFailures.set(0);
}
final Instant lastSuccessfulRead = componentHealth.stream()
.map(ComponentHealth::getLastSuccessfulRead)
.filter(value -> value != null)
.max(Comparator.naturalOrder())
.orElse(null);
final String message;
if (overallState == UP) {
message = "All components healthy";
} else {
final String affected = componentHealth.stream()
.filter(component -> component.getState() != UP)
.map(ComponentHealth::getComponent)
.collect(Collectors.joining(", "));
message = String.format("Unhealthy components: %s (consecutive failures: %d/%d)", affected,
consecutiveFailures.get(), failureThreshold);
}
return new HealthStatus(overallState, lastSuccessfulRead, message, componentHealth);
}
public boolean hasExceededFailureThreshold() {
return consecutiveFailures.get() >= failureThreshold;
}
public int getConsecutiveFailures() {
return consecutiveFailures.get();
}
private static int statePriority(HealthState state) {
return switch (state) {
case UP -> 0;
case DOWN -> 1;
case ERROR -> 2;
};
}
}
@@ -0,0 +1,90 @@
package home.automation.health;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Instant;
import java.util.List;
public class HealthResponse {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static {
OBJECT_MAPPER.registerModule(new JavaTimeModule());
OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
@JsonProperty("status")
private final HealthState status;
@JsonProperty("lastSuccessfulRead")
private final Instant lastSuccessfulRead;
@JsonProperty("message")
private final String message;
@JsonProperty("components")
private final List<ComponentHealth> components;
public HealthResponse(HealthState status, Instant lastSuccessfulRead, String message,
List<ComponentHealth> components) {
this.status = status;
this.lastSuccessfulRead = lastSuccessfulRead;
this.message = message;
this.components = components;
}
public static HealthResponse fromHealthStatus(HealthStatus healthStatus) {
return new HealthResponse(
healthStatus.getState(),
healthStatus.getLastSuccessfulRead(),
healthStatus.getMessage(),
healthStatus.getComponents());
}
public static HealthResponse error(String message) {
return new HealthResponse(HealthState.ERROR, Instant.now(), message, List.of());
}
public String toJson() {
return serialize(this, HealthResponse.error("Failed to serialize response"));
}
private static String serialize(HealthResponse response, HealthResponse fallback) {
try {
return OBJECT_MAPPER.writeValueAsString(response);
} catch (Exception e) {
try {
return OBJECT_MAPPER.writeValueAsString(fallback);
} catch (Exception ignored) {
return "{}";
}
}
}
public int getHttpStatusCode() {
return switch (status) {
case UP -> 200;
case DOWN -> 503;
default -> 500;
};
}
public HealthState getStatus() {
return status;
}
public Instant getLastSuccessfulRead() {
return lastSuccessfulRead;
}
public String getMessage() {
return message;
}
public List<ComponentHealth> getComponents() {
return components;
}
}
@@ -0,0 +1,7 @@
package home.automation.health;
public enum HealthState {
UP,
DOWN,
ERROR
}
@@ -0,0 +1,35 @@
package home.automation.health;
import java.time.Instant;
import java.util.List;
public class HealthStatus {
private final HealthState state;
private final Instant lastSuccessfulRead;
private final String message;
private final List<ComponentHealth> components;
public HealthStatus(HealthState state, Instant lastSuccessfulRead, String message,
List<ComponentHealth> components) {
this.state = state;
this.lastSuccessfulRead = lastSuccessfulRead;
this.message = message;
this.components = components;
}
public HealthState getState() {
return state;
}
public Instant getLastSuccessfulRead() {
return lastSuccessfulRead;
}
public String getMessage() {
return message;
}
public List<ComponentHealth> getComponents() {
return components;
}
}
@@ -3,24 +3,45 @@ package home.automation.influxdb;
import home.automation.configuration.InfluxDBConfiguration;
import org.influxdb.InfluxDB;
import org.influxdb.InfluxDBFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class InfluxConnection {
public class InfluxConnection implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(InfluxConnection.class);
private final InfluxDB connection;
public InfluxConnection(InfluxDBConfiguration configuration) {
if (configuration.getUsername().isBlank()) {
final String username = configuration.getUsername();
if (username == null || username.isBlank()) {
this.connection = InfluxDBFactory.connect(configuration.getAddress());
} else {
this.connection = InfluxDBFactory.connect(
configuration.getAddress(),
configuration.getUsername(),
configuration.getPassword()
);
username,
configuration.getPassword());
}
Runtime.getRuntime().addShutdownHook(new Thread(connection::close));
}
public InfluxDB getConnection() {
return connection;
}
@Override
public void close() throws Exception {
LOGGER.info("Closing InfluxDB connection, flushing buffer...");
try {
connection.flush();
LOGGER.info("InfluxDB buffer flushed successfully");
} catch (Exception e) {
LOGGER.error("Error flushing InfluxDB buffer", e);
throw e;
} finally {
try {
connection.close();
LOGGER.info("InfluxDB connection closed");
} catch (Exception e) {
LOGGER.error("Error closing InfluxDB connection", e);
}
}
}
}
@@ -1,7 +1,10 @@
package home.automation.influxdb;
import home.automation.modbus.ModbusService;
import home.automation.modbus.ModbusPollingService;
import home.automation.modbus.Registry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.influxdb.BatchOptions;
import org.influxdb.InfluxDB;
import org.influxdb.dto.Point;
import org.influxdb.dto.Query;
@@ -10,13 +13,20 @@ import java.util.List;
import java.util.concurrent.TimeUnit;
public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnable {
private final ModbusService modbusService;
private static final Logger LOGGER = LoggerFactory.getLogger(InfluxMetricModbusSender.class);
private final ModbusPollingService modbusService;
private final InfluxDB influxConnection;
public InfluxMetricModbusSender(InfluxDB connection, ModbusService service) {
public InfluxMetricModbusSender(InfluxDB connection, ModbusPollingService service) {
this.modbusService = service;
this.influxConnection = connection;
connection.enableBatch();
final BatchOptions batchOptions = BatchOptions.DEFAULTS
.actions(50)
.flushDuration(180)
.jitterDuration(5)
.bufferLimit(500000)
.dropActionsOnQueueExhaustion(false);
connection.enableBatch(batchOptions);
connection.enableGzip();
String databaseName = "Dom_Pompa";
connection.query(new Query("CREATE DATABASE " + databaseName));
@@ -26,21 +36,30 @@ public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnabl
@Override
public boolean send(List<Registry> metricsToSend) {
modbusService.readAll().forEach(registry ->
metricsToSend.forEach(registry -> {
if (registry.getValue() != null) {
influxConnection.write(
Point.measurement("POMPA")
.addField(registry.getDesc(), Double.valueOf(registry.getValue()))
.addField(registry.getDesc(), registry.asDouble())
.time(System.currentTimeMillis(), TimeUnit.MILLISECONDS)
.build()
)
);
influxConnection.flush();
.build());
}
});
return true;
}
@Override
public void run() {
final List<Registry> registries = modbusService.readAll();
send(registries);
try {
final List<Registry> registries = modbusService.readAll();
if (!registries.isEmpty()) {
send(registries);
LOGGER.debug("Successfully read {} registries from Modbus", registries.size());
} else {
LOGGER.warn("No registries read from Modbus");
}
} catch (Exception e) {
LOGGER.error("Error in scheduled Modbus reading", e);
}
}
}
@@ -0,0 +1,61 @@
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()) || "status".equals(m.getControlType())) {
initial = r.nextBoolean() ? 1.0 : 0.0;
} else {
// sensor-like default using mapping scale
final double scale = m.getScale() == null ? 0.1 : m.getScale();
initial = Math.round((20.0 / scale)) * scale; // normalized value
}
// Store as Double (numeric) instead of String
state.put(m.getRegistry(), new Registry(m.getRegistry(), m.getDesc(), initial));
}
}
@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);
// Store as Double (numeric) instead of String
state.put(register, new Registry(register, r.getDesc(), scaledValue));
LOGGER.info("Mock write {} = {}", register, scaledValue);
}
}
@@ -2,29 +2,29 @@ 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 java.util.Collections;
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;
public static final int ALL_ELEMENTS = 1;
public ModbusCachedWrapper(Repository repository, Duration prevenstionTime) {
public ModbusCachedWrapper(Repository repository, Duration preventionTime) {
this.repository = repository;
repositoryCacheAllElements = CacheBuilder.newBuilder()
.expireAfterWrite(prevenstionTime)
.expireAfterWrite(preventionTime)
.build();
repositoryCache = CacheBuilder.newBuilder()
.expireAfterWrite(prevenstionTime)
.expireAfterWrite(preventionTime)
.build();
}
@@ -33,8 +33,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 Collections.emptyList();
}
}
@@ -43,8 +43,22 @@ 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 null;
}
}
@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;
}
}
}
@@ -4,23 +4,20 @@ import com.serotonin.modbus4j.ModbusFactory;
import com.serotonin.modbus4j.ModbusMaster;
import com.serotonin.modbus4j.ip.IpParameters;
import home.automation.configuration.ConfigurationStrategy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static java.util.Objects.*;
import java.net.InetAddress;
import java.util.Objects;
import static java.util.Objects.isNull;
public class ModbusConnection implements Connection {
public class ModbusConnection implements Connection, AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(ModbusConnection.class);
private final ConfigurationStrategy config;
private volatile ModbusMaster connection;
public ModbusConnection(ConfigurationStrategy config) {
this.config = config;
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if (Objects.nonNull(connection)) {
connection.destroy();
}
}));
}
private Pair<InetAddress, Integer> getPortAndAddressFromConfig(ConfigurationStrategy config) {
@@ -29,19 +26,61 @@ 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();
if (nonNull(connection)) {
try {
connection.destroy();
} catch (Exception e) {
LOGGER.debug("Error destroying old connection: {}", e.getMessage());
}
}
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 +88,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 (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);
}
}
}
@@ -0,0 +1,69 @@
package home.automation.modbus;
import home.automation.health.ComponentHealth;
import home.automation.health.HealthIndicator;
import home.automation.health.HealthState;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
public class ModbusPollingService implements HealthIndicator {
private final ModbusService modbusService;
private final String componentName;
private final long maxTimeSinceLastReadMs;
private final AtomicLong lastSuccessfulReadTimestamp = new AtomicLong(System.currentTimeMillis());
private final AtomicReference<String> lastError = new AtomicReference<>("");
public ModbusPollingService(ModbusService modbusService, int maxTimeSinceLastReadSeconds) {
this(modbusService, "modbus", maxTimeSinceLastReadSeconds);
}
public ModbusPollingService(ModbusService modbusService, String componentName, int maxTimeSinceLastReadSeconds) {
this.modbusService = modbusService;
this.componentName = componentName;
this.maxTimeSinceLastReadMs = maxTimeSinceLastReadSeconds * 1000L;
}
public List<Registry> readAll() throws Exception {
try {
final List<Registry> registries = modbusService.readAll();
if (!registries.isEmpty()) {
lastSuccessfulReadTimestamp.set(System.currentTimeMillis());
lastError.set("");
} else {
lastError.set("No registries returned");
}
return registries;
} catch (Exception e) {
lastError.set(e.getMessage());
throw e;
}
}
@Override
public String getName() {
return componentName;
}
@Override
public ComponentHealth check() {
final long now = System.currentTimeMillis();
final long lastRead = lastSuccessfulReadTimestamp.get();
final long timeSinceLastRead = now - lastRead;
final boolean isHealthy = timeSinceLastRead < maxTimeSinceLastReadMs;
final Instant lastReadInstant = Instant.ofEpochMilli(lastRead);
final HealthState state = isHealthy ? HealthState.UP : HealthState.DOWN;
final String message;
if (isHealthy) {
message = String.format("Last successful read %d seconds ago", timeSinceLastRead / 1000);
} else {
message = String.format("No successful read for %d seconds. Last error: %s",
timeSinceLastRead / 1000, lastError.get());
}
return new ComponentHealth(componentName, state, lastReadInstant, message);
}
}
@@ -2,22 +2,22 @@ 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;
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;
@@ -26,36 +26,95 @@ public class ModbusRtuTcpRepository implements Repository {
this.registryMappings = mappings;
}
private String normalizeRegistryValue(Short value) {
if (ZERO > value) {
return String.valueOf(-(value + HALF_SHORT_INT) / 10.0);
} else {
return String.valueOf((value / 10.0));
/**
* Normalizes a raw numeric value using the mapping's scale and legacy wrap
* handling.
* This is the single source of truth for value normalization.
*/
private Double normalizeValue(Number raw, RegistryMapping mapping) {
if (raw == null)
return null;
final double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
final Boolean signedLegacyWrap = mapping.getSignedLegacyWrap();
if (raw instanceof Short) {
short s = raw.shortValue();
// Handle legacy negative wrap for very negative 16-bit values
if ((signedLegacyWrap != null && signedLegacyWrap && s < -32000) || s < -32000) {
return -((s + 32768.0) * scale);
}
}
return raw.doubleValue() * scale;
}
@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)
));
// Filter out registries with skipPolling=true
final List<RegistryMapping> mappingsToRead = registryMappings.stream()
.filter(elem -> elem.getSkipPolling() == null || !elem.getSkipPolling())
.collect(java.util.stream.Collectors.toList());
mappingsToRead.forEach(elem -> {
final int unitId = elem.getUnitId() == null ? 1 : elem.getUnitId();
final int dataType = resolveDataType(elem);
requestBatchRead.addLocator(elem.getRegistry(),
BaseLocator.holdingRegister(unitId, elem.getRegistry(), dataType));
});
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();
}
).collect(Collectors.toList());
final java.util.List<Registry> results = new java.util.ArrayList<>();
for (RegistryMapping mapping : mappingsToRead) {
Double normalizedValue = null;
try {
// Try to get double value directly
final Double rawDouble = send.getDoubleValue(mapping.getRegistry());
normalizedValue = normalizeValue(rawDouble, mapping);
} catch (ClassCastException cce) {
// Handle non-Double types
try {
final Object raw = send.getValue(mapping.getRegistry());
LOGGER.debug("Batch read returned raw value (type {}): {} for registry {}",
raw == null ? "null" : raw.getClass().getSimpleName(), raw, mapping.getRegistry());
if (raw instanceof Number) {
normalizedValue = normalizeValue((Number) raw, mapping);
}
} catch (Exception ignored) {
// Fall through to individual read
}
}
// Fallback: read individually if batch failed
if (normalizedValue == null) {
try {
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED"
: mapping.getDataType();
ReadHoldingRegistersResponse response = getRegistry(unitId, mapping.getRegistry(),
isFourByte(dataType) ? 2 : 1);
final short[] rawData = response.getShortData();
normalizedValue = isFourByte(dataType)
? decodeValueFromShorts(rawData, dataType, mapping.getScale(),
mapping.getSignedLegacyWrap())
: normalizeValue(rawData[0], mapping);
} catch (Exception exInner) {
LOGGER.warn("Cannot read registry {} individually: {}", mapping.getRegistry(),
exInner.getMessage());
}
}
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), normalizedValue));
}
return results;
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("Cannot get all registries due to: ", e);
return Collections.emptyList();
}
}
@@ -63,22 +122,45 @@ public class ModbusRtuTcpRepository implements Repository {
@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();
final ReadHoldingRegistersResponse response = isFourByte(dataType)
? getRegistry(unitId, register, 2)
: getRegistry(unitId, register, 1);
final short[] rawData = response.getShortData();
LOGGER.info("Registry {}: raw short data = {}, scale = {}",
register, rawData[0], mapping.getScale());
final Double value;
if (isFourByte(dataType)) {
value = decodeValueFromShorts(rawData, dataType, mapping.getScale(),
mapping.getSignedLegacyWrap());
} else {
value = normalizeValue(rawData[0], mapping);
}
LOGGER.info("Registry {}: calculated value = {}", register, value);
return new Registry(register, mapping.getDesc(), value);
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
LOGGER.warn("Cannot get registry {}: {}", register, e.getMessage());
LOGGER.debug("Cannot get registry {} stack trace", register, e);
throw new RuntimeException("Cannot get registry " + register, e);
}
}
@NotNull
private ReadHoldingRegistersResponse getRegistry(int register) throws Exception {
ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(1, register, 1);
private ReadHoldingRegistersResponse getRegistry(int unitId, int register, int count) throws Exception {
// Try different addressing schemes for setpoints (registers >= 4100)
int protocolAddress = register;
if (register >= 4100) {
// Try without 4000 prefix (e.g. 4100 -> 100)
protocolAddress = register - 4000;
LOGGER.info("Registry {}: trying without 4000 prefix, using address {}", register, protocolAddress);
}
ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(unitId, protocolAddress, count);
ReadHoldingRegistersResponse response = (ReadHoldingRegistersResponse) connection.getConnection().send(request);
if (response.isException()) {
@@ -87,10 +169,127 @@ public class ModbusRtuTcpRepository implements Repository {
return response;
}
private boolean isFourByte(String dataType) {
return dataType != null && dataType.startsWith("FOUR_BYTE");
}
/**
* Decodes 4-byte data types from raw short array.
* Returns Double value (numeric) instead of formatted String.
*/
private Double decodeValueFromShorts(short[] data, String dataType, Double scaleObj, Boolean signedLegacyWrap) {
final double scale = scaleObj == null ? 0.1 : scaleObj;
if (data == null || data.length < 1)
return null;
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;
final String dt = dataType == null ? "" : dataType;
switch (dt) {
case "FOUR_BYTE_INT_SIGNED":
case "FOUR_BYTE_INT_SWAPPED":
return (double) combined * scale;
case "FOUR_BYTE_INT_UNSIGNED":
case "FOUR_BYTE_INT_UNSIGNED_SWAPPED":
final long unsigned = Integer.toUnsignedLong(combined);
return (double) unsigned * scale;
case "FOUR_BYTE_FLOAT":
case "FOUR_BYTE_FLOAT_SWAPPED":
final float f = Float.intBitsToFloat(combined);
return (double) f * scale;
default:
return (double) combined * scale;
}
} else {
// two-byte fallback
final short s = data[0];
if (signedLegacyWrap != null && signedLegacyWrap && s < -32000) {
return -(s + 32768.0) * scale;
}
return (double) 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);
}
}
}
@@ -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);
}
}
@@ -1,15 +1,86 @@
package home.automation.modbus;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.*;
@Getter
@Setter
@Builder
@AllArgsConstructor
@ToString
public class Registry {
private Integer registry;
private String desc;
private String value;
private Object value;
public Registry(Integer registry, String desc, Object value) {
this.registry = registry;
this.desc = desc;
this.value = value;
}
public Integer getRegistry() {
return registry;
}
public String getDesc() {
return desc;
}
/**
* Returns the raw value (Number or String for backwards compatibility).
*/
public Object getValue() {
return value;
}
/**
* Returns string representation of value (for JSON serialization
* compatibility).
*/
public String getValueAsString() {
if (value == null)
return null;
if (value instanceof Number) {
double d = ((Number) value).doubleValue();
// Format to reasonable precision
if (d == Math.floor(d) && d < Long.MAX_VALUE) {
return String.valueOf((long) d);
}
return String.valueOf(d);
}
return value.toString();
}
/**
* Returns value as double. Throws if value is null or not convertible.
*/
@JsonIgnore
public double asDouble() {
if (value == null) {
throw new IllegalStateException("Value is null for registry " + registry);
}
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
// Fallback: try parsing String
return Double.parseDouble(value.toString());
}
/**
* Returns value as double, or defaultValue if null/not parseable.
*/
@JsonIgnore
public double asDouble(double defaultValue) {
if (value == null)
return defaultValue;
try {
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
return Double.parseDouble(value.toString());
} catch (NumberFormatException e) {
return defaultValue;
}
}
public void setValue(Object v) {
this.value = v;
}
}
@@ -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;
}
+6 -50
View File
@@ -1,6 +1,10 @@
{
"address": "192.168.0.65",
"address": "192.168.0.150",
"port": 502,
"useMock": false,
"pollIntervalMs": 30000,
"windowSeconds": 300,
"influxSendIntervalSeconds": 300,
"influx_db": {
"address": "https://influxdb.mariuszpacek.pl",
"username": "",
@@ -27,65 +31,17 @@
"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"
"desc": "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"
},
{
"registry": 4116,
"desc": "Pompa CCW"
}
]
}
@@ -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,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).asDouble(), 0.001);
repo.write(1100, 0);
assertEquals(0.0, repo.get(1100).asDouble(), 0.001);
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);
try (TestModbusConnection conn = new TestModbusConnection(cfg, 2, mockMaster)) {
final ModbusMaster master = conn.getConnection();
assertEquals(mockMaster, master);
// attempts should be 3 (0,1 fail; 2 succeed)
assertEquals(3, conn.getAttempts());
}
}
@Test
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);
try (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();
try (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,80 @@
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.asDouble(), 0.001);
Registry r4065 = all.stream().filter(r -> r.getRegistry() == 4065).findFirst().orElse(null);
assertNotNull(r4065);
assertEquals(-5.0, r4065.asDouble(), 0.001);
Registry r4066 = all.stream().filter(r -> r.getRegistry() == 4066).findFirst().orElse(null);
assertNotNull(r4066);
assertEquals(20.4, r4066.asDouble(), 0.001);
}
}
@@ -0,0 +1,90 @@
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 Double invokeDecode(ModbusRtuTcpRepository repo, short[] data, String dataType, Double scale,
Boolean legacy) throws Exception {
Method m = ModbusRtuTcpRepository.class.getDeclaredMethod("decodeValueFromShorts", short[].class, String.class,
Double.class, Boolean.class);
m.setAccessible(true);
return (Double) 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);
Double decoded = invokeDecode(repo, new short[] { high, low }, "FOUR_BYTE_FLOAT", 1.0, null);
assertEquals(12.34, decoded, 0.01);
}
@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
Double decoded = invokeDecode(repo, new short[] { low, high }, "FOUR_BYTE_FLOAT_SWAPPED", 1.0, null);
assertEquals(-5.5, decoded, 0.01);
}
@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);
Double decoded = invokeDecode(repo, new short[] { high, low }, "FOUR_BYTE_INT_UNSIGNED", 1.0, null);
assertEquals((double) unsignedVal, 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);
Double decoded = invokeDecode(repo, new short[] { high, low }, "FOUR_BYTE_INT_SIGNED", 1.0, null);
assertEquals((double) signedVal, decoded, 0.0001);
}
@Test
void decodeTwoByteSignedLegacyWrap() throws Exception {
ModbusRtuTcpRepository repo = createRepo();
short s = (short) (-32760); // near legacy wrap sentinel
Double decoded = invokeDecode(repo, new short[] { s }, "TWO_BYTE_INT_SIGNED", 1.0, true);
// calculation: -(s + 32768) * scale -> -( -32760 + 32768) = -8
assertEquals(-8.0, decoded, 0.001);
}
@Test
void decodeTwoByteNormal() throws Exception {
ModbusRtuTcpRepository repo = createRepo();
short s = (short) (123);
Double decoded = invokeDecode(repo, new short[] { s }, "TWO_BYTE_INT_SIGNED", 0.1, false);
// 123 * 0.1 = 12.3
assertEquals(12.3, decoded, 0.001);
}
}
@@ -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;
@@ -18,49 +19,220 @@ import static java.lang.Short.parseShort;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ModbusRtuTcpRepositoryTests {
private ModbusRtuTcpRepository repository;
@Mock
private ModbusMaster modbusMaster;
@Mock
private ReadHoldingRegistersResponse response;
JsonFileBasedConfiguration configuration = new JsonFileBasedConfiguration();
private ModbusRtuTcpRepository repository;
@Mock
private ModbusMaster modbusMaster;
@Mock
private ReadHoldingRegistersResponse response;
JsonFileBasedConfiguration configuration = new JsonFileBasedConfiguration();
@BeforeEach
public void setup() {
final List<RegistryMapping> registry_mapping = configuration.getConfig().get().getRegistry_mapping();
this.repository = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), registry_mapping);
@BeforeEach
public void setup() {
final List<RegistryMapping> registry_mapping = configuration.getConfig().get().getRegistry_mapping();
this.repository = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), registry_mapping);
}
}
@Test
public void givenModbusRepositoryWhenGetRegistryValueIsNormalizedAndReturned() throws Exception {
when(response.isException()).thenReturn(false);
short[] returnValue = {parseShort("100")};
when(response.getShortData()).thenReturn(returnValue);
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
assert ("10.0".equals(repository.get(4201).getValue()));
assert ("Set Temp wew".equals(repository.get(4201).getDesc()));
}
@Test
public void givenModbusRepositoryWhenGetRegistryValueIsNormalizedAndReturned() throws Exception {
when(response.isException()).thenReturn(false);
short[] returnValue = { parseShort("100") };
when(response.getShortData()).thenReturn(returnValue);
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
Assertions.assertEquals(10.0, repository.get(4201).asDouble(), 0.001);
Assertions.assertEquals("Set Temp wew", repository.get(4201).getDesc());
}
@Test
public void givenModbusRepositoryWhenGetRegistryIsNegativeValueIsNormalizedAndReturned() throws Exception {
when(response.isException()).thenReturn(false);
short[] returnValue = {parseShort("-32668")};
when(response.getShortData()).thenReturn(returnValue);
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
assert ("-10.0".equals(repository.get(4201).getValue()));
assert ("Set Temp wew".equals(repository.get(4201).getDesc()));
}
@Test
public void givenModbusRepositoryWhenGetRegistryIsNegativeValueIsNormalizedAndReturned() throws Exception {
when(response.isException()).thenReturn(false);
short[] returnValue = { parseShort("-100") };
when(response.getShortData()).thenReturn(returnValue);
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
Assertions.assertEquals(-10.0, repository.get(4201).asDouble(), 0.001);
Assertions.assertEquals("Set Temp wew", repository.get(4201).getDesc());
}
@Test
public void givenModbusRepositoryWhenGetRegistryThenExecptionIsRised() throws Exception {
when(response.isException()).thenReturn(true);
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 givenModbusRepositoryWhenGetRegistryThenExecptionIsRised() throws Exception {
when(response.isException()).thenReturn(true);
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
Assertions.assertThrowsExactly(RuntimeException.class, () -> repository.get(4201));
}
@Test
public void givenBatchResultsReturnsShort_When_getAll_Then_valuesAreConverted() throws Exception {
// Create a repository with a single mapping to keep the test focused
RegistryMapping mapping = new RegistryMapping();
mapping.setRegistry(4201);
mapping.setDesc("Set Temp wew");
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
java.util.List.of(mapping));
// mock a BatchResults that throws ClassCast when getDoubleValue is invoked for
// any registry
@SuppressWarnings("unchecked")
com.serotonin.modbus4j.BatchResults<Integer> batchResults = (com.serotonin.modbus4j.BatchResults<Integer>) org.mockito.Mockito
.mock(com.serotonin.modbus4j.BatchResults.class);
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt()))
.thenThrow(new ClassCastException());
org.mockito.Mockito.when(batchResults.getValue(4201)).thenReturn(Short.valueOf((short) 100));
@SuppressWarnings("unchecked")
com.serotonin.modbus4j.BatchRead<Integer> anyBatchRead = (com.serotonin.modbus4j.BatchRead<Integer>) any(
com.serotonin.modbus4j.BatchRead.class);
org.mockito.Mockito.when(modbusMaster.send(anyBatchRead))
.thenReturn(batchResults);
final java.util.List<Registry> all = repoSingle.getAll();
// ensure registry 4201 is present with normalized value 10.0 (scale 0.1
// default)
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
org.junit.jupiter.api.Assertions.assertNotNull(r);
org.junit.jupiter.api.Assertions.assertEquals(10.0, r.asDouble(), 0.001);
}
@Test
public void givenMappingWithCustomScale_When_getAll_Then_valueIsScaled() throws Exception {
// Create mapping with custom scale 0.5
RegistryMapping mapping = new RegistryMapping();
mapping.setRegistry(4201);
mapping.setDesc("Set Temp wew");
mapping.setScale(0.5);
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
java.util.List.of(mapping));
@SuppressWarnings("unchecked")
com.serotonin.modbus4j.BatchResults<Integer> batchResults = (com.serotonin.modbus4j.BatchResults<Integer>) org.mockito.Mockito
.mock(com.serotonin.modbus4j.BatchResults.class);
// getDoubleValue returns 100.0 -> scaled to 50.0
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt()))
.thenReturn(100.0);
@SuppressWarnings("unchecked")
com.serotonin.modbus4j.BatchRead<Integer> anyBatchRead = (com.serotonin.modbus4j.BatchRead<Integer>) any(
com.serotonin.modbus4j.BatchRead.class);
org.mockito.Mockito.when(modbusMaster.send(anyBatchRead))
.thenReturn(batchResults);
final java.util.List<Registry> all = repoSingle.getAll();
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
org.junit.jupiter.api.Assertions.assertNotNull(r);
org.junit.jupiter.api.Assertions.assertEquals(50.0, r.asDouble(), 0.001);
}
@Test
public void givenMappingWithCustomUnitId_When_get_Then_requestUsesUnitId() throws Exception {
RegistryMapping mapping = new RegistryMapping();
mapping.setRegistry(4201);
mapping.setDesc("Set Temp wew");
mapping.setUnitId(2);
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
java.util.List.of(mapping));
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.asDouble(), 0.001);
}
@Test
public void givenMappingWithFourByteFloat_When_getAll_Then_valueIsDecodedFromTwoRegisters() throws Exception {
// mapping uses FOUR_BYTE_FLOAT (scale 1.0)
RegistryMapping mapping = new RegistryMapping();
mapping.setRegistry(5000);
mapping.setDesc("Example 4-byte float");
mapping.setDataType("FOUR_BYTE_FLOAT");
mapping.setScale(1.0);
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster),
java.util.List.of(mapping));
// Simulate BatchResults returning raw value as Number (Double)
@SuppressWarnings("unchecked")
com.serotonin.modbus4j.BatchResults<Integer> batchResults = (com.serotonin.modbus4j.BatchResults<Integer>) org.mockito.Mockito
.mock(com.serotonin.modbus4j.BatchResults.class);
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt()))
.thenThrow(new ClassCastException());
// Return Float wrapped as Double
org.mockito.Mockito.when(batchResults.getValue(5000)).thenReturn(Float.valueOf(12.34f));
@SuppressWarnings("unchecked")
com.serotonin.modbus4j.BatchRead<Integer> anyBatchRead = (com.serotonin.modbus4j.BatchRead<Integer>) any(
com.serotonin.modbus4j.BatchRead.class);
org.mockito.Mockito.when(modbusMaster.send(anyBatchRead))
.thenReturn(batchResults);
final java.util.List<Registry> all = repoSingle.getAll();
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 5000).findFirst().orElse(null);
org.junit.jupiter.api.Assertions.assertNotNull(r);
// value is float 12.34 * scale 1.0
org.junit.jupiter.api.Assertions.assertEquals(12.34, r.asDouble(), 0.01);
}
@Test
public void givenMappingWithFourByteFloat_When_get_Then_valueIsDecodedFromTwoRegisters() throws Exception {
// mapping uses FOUR_BYTE_FLOAT (scale 1.0)
RegistryMapping mapping = new RegistryMapping();
mapping.setRegistry(5000);
mapping.setDesc("Example 4-byte float");
mapping.setDataType("FOUR_BYTE_FLOAT");
mapping.setScale(1.0);
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(expected, r.asDouble(), 0.001);
}
@Test
public void givenMappingWithFourByteFloatSwapped_When_get_Then_valueIsDecodedFromTwoRegistersSwapped()
throws Exception {
// mapping uses FOUR_BYTE_FLOAT_SWAPPED (scale 1.0)
RegistryMapping mapping = new RegistryMapping();
mapping.setRegistry(5002);
mapping.setDesc("Example 4-byte float swapped");
mapping.setDataType("FOUR_BYTE_FLOAT_SWAPPED");
mapping.setScale(1.0);
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(expected, r2.asDouble(), 0.001);
}
}
@@ -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": {}
}