Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd739e16c0 | ||
|
|
e345063720 | ||
|
|
ecc4f19076 | ||
|
|
2664e7e52f | ||
|
|
f2cb8d664d | ||
|
|
1b725ec9a7 | ||
|
|
254c09b771 | ||
|
|
da604c1945 | ||
|
|
c59c26bbe0 | ||
|
|
40a8602b46 | ||
|
|
6fab319fbb | ||
|
|
9d140a5880 | ||
|
|
4060e40d9e | ||
|
|
50527a363c | ||
|
|
399b6200fa | ||
|
|
0c224e16a0 | ||
|
|
f0b9546fdc | ||
|
|
d77f9e643e | ||
|
|
8065af3fec | ||
|
|
452a58c154 | ||
|
|
7398be4151 | ||
|
|
ac94218045 | ||
|
|
d07e2812d8 | ||
|
|
d902523b11 | ||
|
|
cc263f474c | ||
|
|
84608f14fe | ||
|
|
19afd28304 | ||
|
|
e3b060b3cc | ||
|
|
4ea9a9396d | ||
|
|
6ffbfc7848 | ||
|
|
b5f095b117 | ||
|
|
1c7e4fc751 | ||
|
|
9f4a2f2dc4 | ||
|
|
a7a9559494 | ||
|
|
0f538b60a9 | ||
|
|
176d62967d | ||
|
|
d334449e56 | ||
|
|
b187abc55f | ||
|
|
07bfd79e9a | ||
|
|
4a84ae041f | ||
|
|
46030746eb | ||
|
|
9f17db0959 | ||
|
|
8fea494dbd | ||
|
|
0322440476 | ||
|
|
c370d9e95e | ||
|
|
4fd78bbf08 | ||
|
|
d4cb077594 | ||
|
|
99d1687f86 | ||
|
|
ac0b9edf78 | ||
|
|
0db5d4931c | ||
|
|
1be34071bc | ||
|
|
828f1f8c0c | ||
|
|
7228906b34 | ||
|
|
eec2f2e990 | ||
|
|
291d81fd26 | ||
|
|
7ce15e1fcd | ||
|
|
0ad26402d0 | ||
|
|
5d01f9491f | ||
|
|
c4ccca5bf7 | ||
|
|
d43842a31a | ||
|
|
266b093114 | ||
|
|
5902e27182 | ||
|
|
6364a3e8e7 | ||
|
|
6b2a689b58 | ||
|
|
02b44a5ea0 | ||
|
|
5095c83986 | ||
|
|
f801e98554 | ||
|
|
af0139a799 | ||
|
|
f02529eadf | ||
|
|
962ee435ff | ||
|
|
ec4f58bc31 | ||
|
|
1e0ae84929 | ||
|
|
ab916501fc | ||
|
|
c7cbb3ea05 | ||
|
|
fbb976445e |
No files matched your search
+18
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
Generated
-8
@@ -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/
|
||||
Generated
-7
@@ -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>
|
||||
Generated
-5
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
-14
@@ -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
@@ -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>
|
||||
Generated
-30
@@ -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>
|
||||
Generated
-14
@@ -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>
|
||||
Generated
-2
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4" />
|
||||
Generated
-124
@@ -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
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM amazoncorretto:11.0.17
|
||||
COPY target/test*.jar app/home-pump.jar
|
||||
|
||||
CMD ["java", "-jar", "app/home-pump.jar"]
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
# Health check and restart script for home-pump service
|
||||
# Run this via cron every 2 minutes: */2 * * * * /home/pi/home-pump/scripts/healthcheck-monitor.sh
|
||||
|
||||
SERVICE_NAME="home-pump.service"
|
||||
HEALTH_ENDPOINT="http://localhost:8080/health"
|
||||
LOG_FILE="/var/log/home-pump-monitor.log"
|
||||
MAX_RESTART_ATTEMPTS=3
|
||||
RESTART_WINDOW_SECONDS=600
|
||||
|
||||
RESTART_COUNT_FILE="/tmp/home-pump-restart-count"
|
||||
RESTART_TIMESTAMP_FILE="/tmp/home-pump-restart-timestamp"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Function to check if service is running
|
||||
is_service_running() {
|
||||
systemctl is-active --quiet "$SERVICE_NAME"
|
||||
}
|
||||
|
||||
# Function to get restart count in the current window
|
||||
get_restart_count() {
|
||||
if [ -f "$RESTART_COUNT_FILE" ]; then
|
||||
cat "$RESTART_COUNT_FILE"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get last restart timestamp
|
||||
get_last_restart_time() {
|
||||
if [ -f "$RESTART_TIMESTAMP_FILE" ]; then
|
||||
cat "$RESTART_TIMESTAMP_FILE"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to reset restart counter if window expired
|
||||
reset_counter_if_expired() {
|
||||
local last_restart=$(get_last_restart_time)
|
||||
local now=$(date +%s)
|
||||
local diff=$((now - last_restart))
|
||||
|
||||
if [ "$diff" -gt "$RESTART_WINDOW_SECONDS" ]; then
|
||||
echo "0" > "$RESTART_COUNT_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to increment restart counter
|
||||
increment_restart_count() {
|
||||
local count=$(get_restart_count)
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$RESTART_COUNT_FILE"
|
||||
date +%s > "$RESTART_TIMESTAMP_FILE"
|
||||
}
|
||||
|
||||
# Check if service is running
|
||||
if ! is_service_running; then
|
||||
log "ERROR: Service $SERVICE_NAME is not running. Attempting to start..."
|
||||
sudo systemctl start "$SERVICE_NAME"
|
||||
sleep 5
|
||||
if is_service_running; then
|
||||
log "SUCCESS: Service restarted successfully"
|
||||
else
|
||||
log "CRITICAL: Failed to start service $SERVICE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check health endpoint
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
log "WARNING: Health check failed with HTTP code: $HTTP_CODE"
|
||||
|
||||
# Reset counter if restart window expired
|
||||
reset_counter_if_expired
|
||||
|
||||
# Check if we can restart
|
||||
restart_count=$(get_restart_count)
|
||||
|
||||
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||
log "CRITICAL: Maximum restart attempts ($MAX_RESTART_ATTEMPTS) reached in the last $RESTART_WINDOW_SECONDS seconds. Manual intervention required."
|
||||
# Send alert (could be email, Pushover, etc.)
|
||||
# Example: echo "Home pump service needs manual intervention" | mail -s "Critical: Home Pump Alert" admin@example.com
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log "Attempting to restart service (attempt $((restart_count + 1))/$MAX_RESTART_ATTEMPTS)..."
|
||||
increment_restart_count
|
||||
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
sleep 10
|
||||
|
||||
# Verify restart
|
||||
if is_service_running; then
|
||||
NEW_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
|
||||
if [ "$NEW_HTTP_CODE" == "200" ]; then
|
||||
log "SUCCESS: Service restarted and health check passed"
|
||||
else
|
||||
log "WARNING: Service restarted but health check still failing (HTTP $NEW_HTTP_CODE)"
|
||||
fi
|
||||
else
|
||||
log "ERROR: Failed to restart service"
|
||||
fi
|
||||
else
|
||||
# Health check passed, reset counter
|
||||
echo "0" > "$RESTART_COUNT_FILE"
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# DEPRECATED: This script is no longer used
|
||||
#
|
||||
# Previously this script was used with cron to monitor and restart the service.
|
||||
# Now systemd handles all restart logic automatically.
|
||||
#
|
||||
# The application monitors health and exits with status 1 when failures exceed threshold,
|
||||
# causing systemd to automatically restart the service (Restart=on-failure).
|
||||
#
|
||||
# This approach is simpler and doesn't require external monitoring.
|
||||
# The service unit (home-pump.service) handles all restart logic.
|
||||
|
||||
echo "This script is deprecated. Systemd automat handles service restarts."
|
||||
exit 0
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Home Pump Automation Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/home-pump
|
||||
ExecStart=/usr/bin/java -jar /home/pi/home-pump/target/home-pump-1.0.0.jar
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Resource limits
|
||||
MemoryLimit=512M
|
||||
CPUQuota=50%
|
||||
|
||||
# Security
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# Installation script for home-pump service on Raspberry Pi/Ubuntu
|
||||
|
||||
set -e
|
||||
|
||||
echo "Home Pump Service Installation Script"
|
||||
echo "======================================"
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="/home/pi/home-pump"
|
||||
SERVICE_FILE="scripts/home-pump.service"
|
||||
JAR_FILE="target/home-pump-1.0.0.jar"
|
||||
|
||||
# Check if project directory exists
|
||||
if [ ! -d "$PROJECT_DIR" ]; then
|
||||
echo "Error: Project directory $PROJECT_DIR not found"
|
||||
echo "Please adjust PROJECT_DIR in this script or create the directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check if JAR file exists
|
||||
if [ ! -f "$JAR_FILE" ]; then
|
||||
echo "Error: JAR file not found at $JAR_FILE"
|
||||
echo "Please build the project first using: mvn clean package"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install systemd service
|
||||
echo "Installing systemd service..."
|
||||
cp "$SERVICE_FILE" /etc/systemd/system/home-pump.service
|
||||
systemctl daemon-reload
|
||||
echo "✓ Systemd service installed"
|
||||
|
||||
# Enable service to start on boot
|
||||
systemctl enable home-pump.service
|
||||
echo "✓ Service enabled to start on boot"
|
||||
|
||||
# Start the service
|
||||
systemctl start home-pump.service
|
||||
echo "✓ Service started"
|
||||
|
||||
# Wait a bit and check status
|
||||
sleep 3
|
||||
if systemctl is-active --quiet home-pump.service; then
|
||||
echo ""
|
||||
echo "✓✓✓ Installation completed successfully! ✓✓✓"
|
||||
echo ""
|
||||
echo "Service status:"
|
||||
systemctl status home-pump.service --no-pager -l
|
||||
echo ""
|
||||
echo "How it works:"
|
||||
echo " - Application monitors health internally"
|
||||
echo " - On consecutive failures (default 3), app exits with error code"
|
||||
echo " - Systemd automatically restarts the service (Restart=on-failure)"
|
||||
echo " - No external monitoring script needed"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " - View logs: journalctl -u home-pump.service -f"
|
||||
echo " - Check status: systemctl status home-pump.service"
|
||||
echo " - Restart service: sudo systemctl restart home-pump.service"
|
||||
echo " - Stop service: sudo systemctl stop home-pump.service"
|
||||
echo " - Health check: curl http://localhost:8080/health"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠ Warning: Service installed but not running properly"
|
||||
echo "Check logs with: journalctl -u home-pump.service -n 50"
|
||||
fi
|
||||
@@ -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();
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Quick test script for local development
|
||||
|
||||
echo "Testing health endpoint..."
|
||||
curl -v http://localhost:8080/health
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "Expected response when healthy:"
|
||||
echo '{"status":"UP","lastSuccessfulRead":"2026-02-06T19:30:45Z","message":"Last successful read 15 seconds ago"}'
|
||||
|
||||
echo ""
|
||||
echo "Expected response when unhealthy (503):"
|
||||
echo '{"status":"DOWN","lastSuccessfulRead":"2026-02-06T19:20:45Z","message":"No successful read for 620 seconds. Last error: Connection timeout"}'
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
Reference in new issue
Block a user