Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,141 @@
|
||||
# Gitea Actions: quick notes 🔧
|
||||
|
||||
## What this adds
|
||||
- `.gitea/workflows/ci.yml` — runs backend Maven tests and builds the frontend on every `push`.
|
||||
|
||||
## Requirements (quick checklist)
|
||||
1. **Enable Actions** for this repository: Repository Settings → Actions → Enable Repository Actions.
|
||||
2. **Runner (act_runner)**: you must register at least one runner so jobs can execute.
|
||||
- Prefer running the runner on a separate machine from the Gitea server.
|
||||
- You can use the `gitea/act_runner` docker image or the binaries from Gitea downloads.
|
||||
|
||||
### Example runner registration (local machine)
|
||||
1. On your host, download or pull the runner image: `docker pull gitea/act_runner:latest`.
|
||||
2. Start a container for registration only (refer to docs for token and instance):
|
||||
- Get a token from Gitea UI: **Settings** → **Actions** → **Runners** → **Generate token** (repo/org/instance level).
|
||||
- Run the register command (example using container; adjust instance and token):
|
||||
```bash
|
||||
docker run --rm -it -v $PWD:/data gitea/act_runner register --instance https://your.gitea.host --token <TOKEN>
|
||||
```
|
||||
3. Start the runner daemon (on the machine where you registered):
|
||||
```bash
|
||||
./act_runner daemon
|
||||
# or use the docker image as a long-lived container after registration
|
||||
```
|
||||
|
||||
## Secrets & env
|
||||
- If tests or builds need credentials, add them to **Settings → Secrets** at repo/org/instance level and reference via `${{ secrets.MY_SECRET }}`.
|
||||
|
||||
## Notes & tips
|
||||
- The workflow uses `actions/setup-java` and `actions/setup-node` which fetch from public actions. If your runner has restricted network access, adjust steps to install local toolchains instead.
|
||||
- If you want to run integration tests or deploy artifacts, tell me which target host or container you'll use and I can add deploy steps.
|
||||
|
||||
---
|
||||
|
||||
## Deploying the runner to all workers (recommended for docker-executor)
|
||||
If you want the runner to run on every worker node, use the provided `gitea-act-runner-stack.yml` which runs the service in `mode: global` with a placement constraint for workers:
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
mode: global
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == worker
|
||||
```
|
||||
|
||||
Notes:
|
||||
- This will start one runner instance on each worker node. If you need fewer runners, switch to `mode: replicated` and use `replicas` with a node label constraint (e.g., `node.labels.runner == true`).
|
||||
- Mounting `/var/run/docker.sock` gives the runner the ability to run job containers (docker-executor) but is a security-sensitive choice — only run this on trusted worker nodes.
|
||||
|
||||
### Quick deploy
|
||||
```bash
|
||||
# from a manager node
|
||||
docker stack deploy -c gitea-act-runner-stack.yml act-runner
|
||||
```
|
||||
|
||||
### Optional: label nodes instead of using node.role
|
||||
If you prefer to run on a subset of workers, add a label to desired nodes and update the `placement.constraints`:
|
||||
|
||||
```bash
|
||||
docker node update --label-add runner=true <node-id-or-hostname>
|
||||
```
|
||||
|
||||
Then use:
|
||||
|
||||
```yaml
|
||||
placement:
|
||||
constraints:
|
||||
- node.labels.runner == true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registration examples & troubleshooting 🔍
|
||||
If you see errors like `instance address is empty`, it means the runner registration command wasn't given an instance URL (via `--instance` or `GITEA_INSTANCE_URL`). Use one of these approaches depending on your environment.
|
||||
|
||||
1) One-shot CLI (internal Swarm network)
|
||||
```bash
|
||||
docker run --rm -it --network gitea -v act_runner-data:/data gitea/act_runner register \
|
||||
--instance http://gitea_server:3000 --token <TOKEN> --no-interactive --name swarm-runner
|
||||
```
|
||||
Use the internal service name (`gitea_server:3000`) when registering from inside the Swarm `gitea` network.
|
||||
|
||||
2) One-shot (public/Traefik URL)
|
||||
```bash
|
||||
docker run --rm -it -v act_runner-data:/data gitea/act_runner register \
|
||||
--instance https://git.mariuszpacek.pl --token <TOKEN> --no-interactive --name swarm-runner
|
||||
```
|
||||
If you use an HTTPS URL, ensure the container trusts the cert (or use the internal HTTP address if available).
|
||||
|
||||
3) Auto-register & run with env vars (recommended for simple Docker usage)
|
||||
This will register (if needed) and start the runner in one step:
|
||||
```bash
|
||||
docker run -d --name gitea-runner \
|
||||
-v act_runner-data:/data -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e GITEA_INSTANCE_URL=https://git.mariuszpacek.pl \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
||||
-e GITEA_RUNNER_NAME=swarm-runner \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
4) Portainer notes
|
||||
- For a temporary registration container, set image `gitea/act_runner:latest`, **Command** to:
|
||||
`register --instance http://gitea_server:3000 --token <TOKEN> --no-interactive`
|
||||
- Or set environment variables `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN` and start the container (detached) to auto-register.
|
||||
|
||||
5) Debugging checks
|
||||
- Test reachability from the registrator container:
|
||||
```bash
|
||||
docker run --rm --network gitea curlimages/curl curl -sS http://gitea_server:3000/api/v1/version
|
||||
```
|
||||
- Verify `.runner` written to the volume:
|
||||
```bash
|
||||
docker run --rm -v act_runner-data:/data alpine sh -c "ls -la /data && cat /data/.runner"
|
||||
```
|
||||
- Check logs of the register container / service for precise errors.
|
||||
|
||||
If the error persists, paste the exact register command you ran and its container logs and I'll inspect them.
|
||||
|
||||
---
|
||||
|
||||
## Portainer: secure register + deploy (recommended)
|
||||
Use a one-shot *registration stack* (deploy it, wait for logs, then remove it) and then deploy the long-running daemon stack that uses a Docker secret for the registration token.
|
||||
|
||||
1) **Create a secret** in Portainer (or via CLI):
|
||||
- Portainer: *Secrets → Add secret* → name: `registration_token` → value: `<your_token>`
|
||||
- CLI: `echo "<your_token>" | docker secret create registration_token -`
|
||||
|
||||
2) **Register (one-shot)** — Stack: `.gitea/act-runner-register-stack.yml`
|
||||
- Deploy in Portainer → set stack variables:
|
||||
- `INSTANCE_URL` = `http://gitea_server:3000` (or `https://git.mariuszpacek.pl`)
|
||||
- `REGISTRATION_TOKEN` = *your token* (or use a temporary secret and modify command to read it)
|
||||
- Check service logs for success, then remove this stack.
|
||||
|
||||
3) **Deploy daemon (global on workers)** — Stack: `.gitea/act-runner-deploy-stack.yml`
|
||||
- Ensure secret `registration_token` exists (see step 1).
|
||||
- Deploy stack and set `INSTANCE_URL` and `RUNNER_NAME` as environment variables in Portainer.
|
||||
- Runner will start as a global service on workers and use `/run/secrets/registration_token` (no token stored in repo).
|
||||
|
||||
Notes:
|
||||
- **Do not commit tokens to the repo.** Use Docker secrets or Portainer's secret store.
|
||||
- If you prefer a quick test, the register stack accepts `REGISTRATION_TOKEN` as an env var (less secure). After successful registration, remove the env token and rely on the Docker secret for the daemon stack.
|
||||
@@ -0,0 +1,65 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests (Maven)
|
||||
runs-on: ['ubuntu-latest']
|
||||
container:
|
||||
image: maven:3.9-jdk-21
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show runner info
|
||||
run: |
|
||||
echo "Runner name: ${{ runner.name }}"
|
||||
echo "Runner labels: ${{ toJSON(runner.labels) }}"
|
||||
|
||||
- name: Cache Maven
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Run Maven tests
|
||||
run: mvn -B test
|
||||
|
||||
frontend:
|
||||
name: Frontend build (node)
|
||||
runs-on: ['ubuntu-latest']
|
||||
container:
|
||||
image: node:18-bullseye
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show runner info
|
||||
run: |
|
||||
echo "Runner name: ${{ runner.name }}"
|
||||
echo "Runner labels: ${{ toJSON(runner.labels) }}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install & Build
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
+43
-1
@@ -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,41 @@
|
||||
# home-pump
|
||||
# Home Pump Monitor
|
||||
|
||||
A Java application that reads data from a heat pump via Modbus TCP and sends metrics to InfluxDB.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads Modbus holding registers from a heat pump
|
||||
- Caches readings for 60 seconds to reduce load
|
||||
- Sends temperature and pump status data to InfluxDB every minute
|
||||
- Configurable via JSON file
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `src/main/resources/registryMapping.json` to configure:
|
||||
|
||||
- Modbus TCP connection (address, port)
|
||||
- InfluxDB connection (address, username, password)
|
||||
- List of registers to read with descriptions
|
||||
|
||||
Additional mapping fields (optional):
|
||||
|
||||
- `useMock` (Boolean, top-level): When `true`, the application runs with a local `MockRepository` instead of attempting to connect to a Modbus device. Useful for local development when a device is not available (default: false).
|
||||
|
||||
- `unitId` (Integer): Modbus slave/unit id for the register (default: 1)
|
||||
- `scale` (Double): Multiply raw device value by this to obtain display value (default: 0.1)
|
||||
- `dataType` (String): Data type used for the locator (e.g. `TWO_BYTE_INT_SIGNED`). Supported values: `TWO_BYTE_INT_SIGNED`, `TWO_BYTE_INT_UNSIGNED`, `FOUR_BYTE_INT_SIGNED`, `FOUR_BYTE_INT_UNSIGNED`, `FOUR_BYTE_FLOAT` (the latter reads two adjacent registers and interprets as IEEE754 float). For devices with non-standard word order, the following swapped variants are supported: `FOUR_BYTE_INT_SWAPPED`, `FOUR_BYTE_INT_UNSIGNED_SWAPPED`, `FOUR_BYTE_FLOAT_SWAPPED` (word order low/high).
|
||||
- `signedLegacyWrap` (Boolean): When true, apply legacy negative wrap decoding for values near -32768 (default: true)
|
||||
|
||||
## Build and Run
|
||||
|
||||
```bash
|
||||
mvn clean package
|
||||
java -jar target/home-pump-1.0.0.jar
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21
|
||||
- Modbus TCP accessible heat pump
|
||||
- InfluxDB instance
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Home Pump Frontend
|
||||
|
||||
This frontend is a small React + TypeScript app (Vite) that connects to the backend via REST and SSE.
|
||||
|
||||
Development:
|
||||
|
||||
1. cd frontend
|
||||
2. npm install
|
||||
3. npm run dev
|
||||
|
||||
The dev server proxies `/api` and `/sse` to the backend. Override the backend URL/port used by the dev server with `VITE_BACKEND_URL` in a `.env` file (for example see `.env.example`). Default: `http://localhost:7000`.
|
||||
|
||||
Dev mode tip: If your development environment does not have a Modbus device available, set `"useMock": true` in `src/main/resources/registryMapping.json` (or your deployed configuration). The backend will use a `MockRepository` that serves realistic fake values and accepts writes.
|
||||
|
||||
Build for production:
|
||||
|
||||
1. npm run build
|
||||
2. Serve the `dist/` folder by any static server, or copy it into the backend `src/main/resources/static/` for integrated deployment.
|
||||
|
||||
Integrated deployment (option B):
|
||||
- After `npm run build`, copy `frontend/dist` into `src/main/resources/static` (or configure your CI to do that) so that the backend serves the static frontend files from the JAR.
|
||||
|
||||
Integrated deployment (option B):
|
||||
- After `npm run build`, copy `frontend/dist` into `src/main/resources/static` (or configure your CI to do that) so that the backend serves the static frontend files from the JAR.
|
||||
|
||||
SSE endpoint: `/sse/live` (server sends arrays of points: [{registry,ts,value}, ...])
|
||||
|
||||
API endpoints used by the frontend:
|
||||
- GET /api/registries
|
||||
- GET /api/live
|
||||
- POST /api/registry/:id/write { value }
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Home Pump Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3262
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "home-pump-frontend",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^6.0.22",
|
||||
"@mantine/notifications": "^6.0.22",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"axios": "1.4.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"recharts": "2.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-dom": "18.2.6",
|
||||
"@vitejs/plugin-react": "5.1.0",
|
||||
"typescript": "5.4.2",
|
||||
"vite": "5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { AppShell, MantineProvider } from '@mantine/core'
|
||||
import Dashboard from './components/Dashboard'
|
||||
import HeaderBar from './components/HeaderBar'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppShell
|
||||
padding="md"
|
||||
header={<HeaderBar />}
|
||||
styles={(theme) => ({
|
||||
main: { background: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[0] }
|
||||
})}
|
||||
>
|
||||
<Dashboard />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Button, TextInput, Grid, Select } from '@mantine/core'
|
||||
|
||||
type Reg = { registry: number; desc?: string }
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const [registries, setRegistries] = useState<Reg[]>([])
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const [value, setValue] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/registries').then(r => setRegistries(r.data || []))
|
||||
}, [])
|
||||
|
||||
const onWrite = async () => {
|
||||
if (!selected) return
|
||||
await axios.post(`/api/registry/${selected}/write`, { value: Number(value) })
|
||||
setValue('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Select
|
||||
label="Registry"
|
||||
data={registries.map(r => ({ value: String(r.registry), label: `${r.registry} — ${r.desc || ''}` }))}
|
||||
value={selected ? String(selected) : undefined}
|
||||
onChange={v => setSelected(v ? Number(v) : null)}
|
||||
searchable
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<TextInput label="Value" value={value} onChange={(e) => setValue(e.currentTarget.value)} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<Button onClick={onWrite} style={{ marginTop: 24 }}>
|
||||
Write
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Card, Stack, Group, Text, NumberInput, Switch, Button, Grid } from '@mantine/core'
|
||||
import axios from 'axios'
|
||||
import { showNotification } from '@mantine/notifications'
|
||||
|
||||
const iconFor = (desc: string | undefined) => {
|
||||
if (!desc) return '⚙️'
|
||||
const d = desc.toLowerCase()
|
||||
if (d.includes('temp') || d.includes('temp')) return '🌡️'
|
||||
if (d.includes('grzalka') || d.includes('grz')) return '🔥'
|
||||
if (d.includes('pompa')) return '💧'
|
||||
if (d.includes('sprez') || d.includes('spręż')) return '🧊'
|
||||
return '⚙️'
|
||||
}
|
||||
|
||||
export default function ControlsPanel({ mappings, buffer }: { mappings: any[], buffer: Record<number, any[]> }) {
|
||||
const setpoints = mappings.filter(m => m.controlType === 'setpoint')
|
||||
const toggles = mappings.filter(m => m.controlType === 'toggle')
|
||||
const others = mappings.filter(m => !m.controlType)
|
||||
|
||||
return (
|
||||
<Stack spacing="sm">
|
||||
{setpoints.length > 0 && (
|
||||
<div>
|
||||
<Text weight={700} mb={6}>Nastawy</Text>
|
||||
<Grid>
|
||||
{setpoints.map(m => (
|
||||
<Grid.Col xs={12} sm={6} md={4} key={m.registry}>
|
||||
<ControlRow mapping={m} samples={buffer[m.registry] || []} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toggles.length > 0 && (
|
||||
<div>
|
||||
<Text weight={700} mb={6}>Aktuatory</Text>
|
||||
<Grid>
|
||||
{toggles.map(m => (
|
||||
<Grid.Col xs={12} sm={6} md={4} key={m.registry}>
|
||||
<ControlRow mapping={m} samples={buffer[m.registry] || []} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{others.length > 0 && (
|
||||
<div>
|
||||
<Text weight={700} mb={6}>Inne</Text>
|
||||
<Grid>
|
||||
{others.map(m => (
|
||||
<Grid.Col xs={12} sm={6} md={4} key={m.registry}>
|
||||
<ControlRow mapping={m} samples={buffer[m.registry] || []} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function precisionFromStep(step: number) {
|
||||
const s = String(step)
|
||||
if (!s.includes('.')) return 0
|
||||
return s.split('.')[1].length
|
||||
}
|
||||
|
||||
function ControlRow({ mapping, samples, compact = false }: { mapping: any, samples: any[], compact?: boolean }) {
|
||||
const [value, setValue] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [flash, setFlash] = useState(false)
|
||||
|
||||
const lastSample = samples.length ? samples[samples.length - 1] : null
|
||||
const last = lastSample ? lastSample.value : null
|
||||
const lastTs = lastSample ? new Date(lastSample.ts).toLocaleTimeString() : null
|
||||
const cardPadding = compact ? 'sm' : 'md'
|
||||
|
||||
useEffect(() => {
|
||||
if (last !== null && (value === null || isNaN(value))) {
|
||||
const num = Number(last)
|
||||
if (!isNaN(num)) setValue(num)
|
||||
}
|
||||
}, [last])
|
||||
|
||||
const flashOnce = () => {
|
||||
setFlash(true)
|
||||
setTimeout(() => setFlash(false), 1200)
|
||||
}
|
||||
|
||||
const onWrite = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const v = value !== null ? value : (last !== null ? Number(last) : null)
|
||||
if (v === null || isNaN(v)) {
|
||||
showNotification({ title: 'Błąd', message: 'Brak wartości do zapisu', color: 'red' })
|
||||
return
|
||||
}
|
||||
await axios.post(`/api/registry/${mapping.registry}/write`, { value: v })
|
||||
showNotification({ title: 'Zapisano', message: `${mapping.desc}: ${v} ${mapping.unit || ''}`, color: 'teal' })
|
||||
flashOnce()
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.response?.data || e?.message || 'Nieznany błąd')
|
||||
showNotification({ title: 'Błąd', message: msg, color: 'red' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.controlType === 'setpoint') {
|
||||
const step = mapping.step || 0.1
|
||||
const precision = precisionFromStep(step)
|
||||
const inc = () => setValue(prev => {
|
||||
const base = prev !== null ? prev : (last !== null ? Number(last) : (mapping.min ?? 0))
|
||||
return Number((base + step).toFixed(precision))
|
||||
})
|
||||
const dec = () => setValue(prev => {
|
||||
const base = prev !== null ? prev : (last !== null ? Number(last) : (mapping.min ?? 0))
|
||||
return Number((base - step).toFixed(precision))
|
||||
})
|
||||
|
||||
return (
|
||||
<Card shadow="xs" p={cardPadding} style={{ transition: 'box-shadow 200ms, transform 200ms', boxShadow: flash ? '0 0 0 6px rgba(16, 185, 129, 0.12)' : undefined, transform: flash ? 'translateY(-2px)' : undefined }}>
|
||||
<Group position="apart" align="center" spacing="md">
|
||||
<Group>
|
||||
<Text>{iconFor(mapping.desc)} </Text>
|
||||
<div>
|
||||
<Text weight={600}>{mapping.desc} <small>({mapping.registry})</small></Text>
|
||||
<Text size="xs" color="dimmed">{last !== null ? `${last} ${mapping.unit || ''}` : '—'} {lastTs && <small>· {lastTs}</small>} </Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group spacing="xs">
|
||||
<Button size="xs" variant="outline" onClick={dec}>-</Button>
|
||||
<NumberInput
|
||||
value={value === null ? undefined : value}
|
||||
onChange={(v: any) => setValue(v === undefined ? null : v)}
|
||||
step={step}
|
||||
min={mapping.min}
|
||||
max={mapping.max}
|
||||
precision={precision}
|
||||
placeholder={mapping.min !== undefined ? mapping.min : undefined}
|
||||
size="xs"
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<Button size="xs" variant="outline" onClick={inc}>+</Button>
|
||||
<Button loading={loading} onClick={onWrite} size="xs">Zastosuj</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (mapping.controlType === 'toggle') {
|
||||
const cur = last !== null ? (Number(last) !== 0) : false
|
||||
const [state, setState] = useState<boolean>(cur)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// keep toggle state in sync with incoming samples
|
||||
setState(last !== null ? (Number(last) !== 0) : false)
|
||||
}, [last])
|
||||
|
||||
const onToggle = async (v: boolean) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await axios.post(`/api/registry/${mapping.registry}/write`, { value: v ? 1 : 0 })
|
||||
setState(v)
|
||||
const msg = `${mapping.desc}: ${v ? 'Włączono' : 'Wyłączono'}`
|
||||
showNotification({ title: 'Zapisano', message: msg, color: 'teal' })
|
||||
flashOnce()
|
||||
} catch (e: any) {
|
||||
const err = String(e?.response?.data || e?.message || 'Nieznany błąd')
|
||||
showNotification({ title: 'Błąd', message: err, color: 'red' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card shadow="xs" style={{ transition: 'box-shadow 200ms, transform 200ms', boxShadow: flash ? '0 0 0 6px rgba(16, 185, 129, 0.12)' : undefined, transform: flash ? 'translateY(-2px)' : undefined }}>
|
||||
<Group position="apart" align="center">
|
||||
<Group>
|
||||
<Text>{iconFor(mapping.desc)} </Text>
|
||||
<div>
|
||||
<Text weight={600}>{mapping.desc} <small>({mapping.registry})</small></Text>
|
||||
<Text size="sm">Stan: <span style={{ color: state ? 'green' : 'gray', fontWeight: 700 }}>{state ? 'Włączony' : 'Wyłączony'}</span> {lastTs && <small>· {lastTs}</small>}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Switch checked={state} onChange={(e) => onToggle(e.currentTarget.checked)} disabled={busy} color={state ? 'teal' : 'gray'} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card shadow="xs">
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<Text>{iconFor(mapping.desc)} </Text>
|
||||
<div>
|
||||
<Text weight={600}>{mapping.desc} <small>({mapping.registry})</small></Text>
|
||||
<Text size="sm">Aktualnie: {last !== null ? `${last} ${mapping.unit || ''}` : '—'} {lastTs && <small>· {lastTs}</small>}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import SseService, { Point } from '../services/sseService'
|
||||
import LiveChart from './LiveChart'
|
||||
import ControlsPanel from './ControlsPanel'
|
||||
import SummaryPanel from './SummaryPanel'
|
||||
import axios from 'axios'
|
||||
import { Card, Grid, Select, Text, Divider, Group, Button, Tooltip, ActionIcon, MultiSelect, Loader } from '@mantine/core'
|
||||
|
||||
|
||||
export default function Dashboard() {
|
||||
const [buffer, setBuffer] = useState<Record<number, { ts: number; value: number }[]>>({})
|
||||
const [registries, setRegistries] = useState<any[]>([])
|
||||
const [mappings, setMappings] = useState<any[]>([])
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const [connected, setConnected] = useState<boolean>(true)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
// suppress offline banner until timestamp (ms) to avoid flicker during a manual refresh
|
||||
const [suppressOfflineUntil, setSuppressOfflineUntil] = useState<number>(0)
|
||||
const sse = useRef(new SseService('/sse/live'))
|
||||
|
||||
useEffect(() => {
|
||||
// initial snapshots
|
||||
axios.get('/api/live').then(r => setBuffer(r.data || {}))
|
||||
axios.get('/api/registries').then(r => setRegistries(r.data || []))
|
||||
axios.get('/api/mappings').then(r => setMappings((r.data && r.data.registry_mapping) || []))
|
||||
|
||||
const svc = sse.current
|
||||
const unsub = svc.subscribe((pts) => {
|
||||
setBuffer(prev => {
|
||||
const next = { ...prev }
|
||||
pts.forEach(p => {
|
||||
next[p.registry] = (next[p.registry] || []).concat({ ts: p.ts, value: p.value }).slice(-5000)
|
||||
})
|
||||
return next
|
||||
})
|
||||
})
|
||||
|
||||
const unsubStatus = svc.subscribeStatus((s) => {
|
||||
setConnected(s)
|
||||
})
|
||||
svc.start()
|
||||
return () => { unsub(); try { unsubStatus(); } catch (e) {} ; svc.close() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// set default selected to first setpoint mapping if none
|
||||
if (!selected && mappings.length > 0) {
|
||||
const setpoint = mappings.find(m => m.controlType === 'setpoint')
|
||||
if (setpoint) setSelected(setpoint.registry)
|
||||
}
|
||||
}, [mappings])
|
||||
|
||||
const data = selected && buffer[selected] ? buffer[selected] : []
|
||||
|
||||
// prepare series list for LiveChart
|
||||
const seriesList = mappings.map(m => ({ registry: m.registry, label: m.desc || String(m.registry), samples: buffer[m.registry] || [] }))
|
||||
|
||||
// initialize visible from localStorage if present; otherwise set defaults after mappings load
|
||||
const [visible, setVisible] = useState<Set<number>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('visibleSeries')
|
||||
if (stored) {
|
||||
const arr = JSON.parse(stored)
|
||||
return new Set<number>((arr || []).map((v: any) => Number(v)))
|
||||
}
|
||||
} catch (e) {}
|
||||
return new Set<number>()
|
||||
})
|
||||
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(new Date())
|
||||
|
||||
// when mappings load and user hasn't chosen series, set sensible defaults (up to 3 setpoints)
|
||||
useEffect(() => {
|
||||
if (visible.size === 0 && mappings.length > 0) {
|
||||
const setpoints = mappings.filter(m => m.controlType === 'setpoint')
|
||||
const defaultRegs = (setpoints.length ? setpoints.slice(0,3) : mappings.slice(0,3)).map(m => m.registry)
|
||||
setVisible(new Set<number>(defaultRegs))
|
||||
}
|
||||
}, [mappings])
|
||||
|
||||
// persist visible set to localStorage
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('visibleSeries', JSON.stringify(Array.from(visible))) } catch (e) {}
|
||||
}, [visible])
|
||||
|
||||
const toggleVisible = (id: number) => {
|
||||
setVisible(prev => {
|
||||
const copy = new Set(prev)
|
||||
if (copy.has(id)) copy.delete(id); else copy.add(id)
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
{(!connected && Date.now() > suppressOfflineUntil) && <div style={{ marginBottom: 8 }}><Card shadow="xs" style={{ background: '#fff6f6', border: '1px solid #f1c0c0' }}><Text color="red" weight={700}>Offline — backend Modbus niedostępny lub rozłączone SSE</Text></Card></div>}
|
||||
|
||||
<SummaryPanel registries={registries} />
|
||||
|
||||
<Grid mt="md">
|
||||
<Grid.Col xs={12} md={8}>
|
||||
<Card shadow="sm">
|
||||
<Text weight={500} mb={8}>Wykres na żywo</Text>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<MultiSelect
|
||||
data={mappings.map(m => ({ value: String(m.registry), label: `${m.registry} — ${m.desc || ''}` }))}
|
||||
placeholder="Wybierz serie do wyświetlenia"
|
||||
searchable
|
||||
clearable
|
||||
value={Array.from(visible).map(String)}
|
||||
onChange={(vals) => {
|
||||
const s = new Set<number>();
|
||||
(vals || []).forEach(v => s.add(Number(v)))
|
||||
setVisible(s)
|
||||
}}
|
||||
style={{ minWidth: 240 }}
|
||||
/>
|
||||
<Tooltip label="Odśwież dane">
|
||||
<ActionIcon size="sm" onClick={async () => {
|
||||
setIsRefreshing(true)
|
||||
setSuppressOfflineUntil(Date.now() + 1500) // hide offline banner for next 1.5s
|
||||
try {
|
||||
const [live, regs, maps] = await Promise.all([
|
||||
axios.get('/api/live'),
|
||||
axios.get('/api/registries'),
|
||||
axios.get('/api/mappings')
|
||||
])
|
||||
setBuffer(live.data || {})
|
||||
setRegistries(regs.data || [])
|
||||
setMappings((maps.data && maps.data.registry_mapping) || [])
|
||||
setLastRefreshed(new Date())
|
||||
} catch (e: any) {
|
||||
throw e
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}}>
|
||||
{isRefreshing ? <Loader size={16} /> : '🔄'}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<LiveChart series={seriesList} visible={visible} onToggle={(id) => toggleVisible(id)} />
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" mt="md">
|
||||
<Text weight={500} mb={8}>Sterowanie</Text>
|
||||
<ControlsPanel mappings={mappings} buffer={buffer} />
|
||||
</Card>
|
||||
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col xs={12} md={4}>
|
||||
<Card shadow="sm">
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={500}>System</Text>
|
||||
<Group>
|
||||
<Text size="xs" color="dimmed">Ostatnie odświeżenie: {lastRefreshed ? lastRefreshed.toLocaleTimeString() : '—'}</Text>
|
||||
<Tooltip label="Odśwież dane">
|
||||
<ActionIcon size="sm" onClick={() => { axios.get('/api/live').then(r => setBuffer(r.data || {})); axios.get('/api/registries').then(r => setRegistries(r.data || [])); axios.get('/api/mappings').then(r => setMappings((r.data && r.data.registry_mapping) || [])); setLastRefreshed(new Date()) }}>
|
||||
🔄
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" color="dimmed">Mapa rejestrów: {mappings.length}</Text>
|
||||
<Text size="sm" color="dimmed">Rejestry: {registries.length}</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { Header, Group, Title, Image, useMantineTheme } from '@mantine/core'
|
||||
|
||||
const Logo = () => (
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="6" width="22" height="12" rx="2" fill="#FFB454" />
|
||||
<path d="M6 9v6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M12 9v6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M18 9v6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="18" cy="6" r="2" fill="#7AE582" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function HeaderBar() {
|
||||
const theme = useMantineTheme()
|
||||
return (
|
||||
<Header height={64} p="sm" style={{ borderBottom: '1px solid rgba(0,0,0,0.04)', background: theme.white }}>
|
||||
<Group position="apart" align="center" style={{ height: '100%' }}>
|
||||
<Group align="center">
|
||||
<Logo />
|
||||
<Title order={3} style={{ marginLeft: 8 }}>Panel Pompy</Title>
|
||||
</Group>
|
||||
</Group>
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer, Legend } from 'recharts'
|
||||
|
||||
type Sample = { ts: number; value: number }
|
||||
|
||||
export default function LiveChart({ series, visible, onToggle }: { series: { registry: number; label: string; samples: Sample[] }[], visible: Set<number>, onToggle?: (id: number) => void }) {
|
||||
// Build unified time axis from all samples
|
||||
const tsSet = new Set<number>()
|
||||
series.forEach(s => s.samples.forEach(p => tsSet.add(p.ts)))
|
||||
const tsArr = Array.from(tsSet).sort()
|
||||
|
||||
const data = tsArr.map(ts => {
|
||||
const point: any = { t: new Date(ts).toLocaleTimeString() }
|
||||
series.forEach(s => {
|
||||
const found = s.samples.find(p => p.ts === ts)
|
||||
point[String(s.registry)] = found ? found.value : null
|
||||
})
|
||||
return point
|
||||
})
|
||||
|
||||
const colors = ["#4c6ef5", "#20c997", "#ffd43b", "#ff6b6b", "#15aabf", "#f06595", "#845ef7"]
|
||||
|
||||
const handleLegendClick = (e: any) => {
|
||||
if (!e || !e.dataKey) return
|
||||
const id = Number(e.dataKey)
|
||||
if (onToggle) onToggle(id)
|
||||
}
|
||||
|
||||
const CustomLegend = (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{payload && payload.map((p: any, i: number) => {
|
||||
const id = Number(p.dataKey)
|
||||
const active = visible.has(id)
|
||||
const color = colors[i % colors.length]
|
||||
return (
|
||||
<div key={p.dataKey} onClick={() => handleLegendClick(p)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: active ? 1 : 0.35, transition: 'opacity 220ms' }}>
|
||||
<svg width="12" height="12" style={{ marginRight: 6 }}>
|
||||
<rect width="12" height="12" fill={color} rx="2" />
|
||||
</svg>
|
||||
<span style={{ fontSize: 12 }}>{p.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 360 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="t" minTickGap={20} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend content={CustomLegend} />
|
||||
{series.map((s, idx) => (
|
||||
<Line key={s.registry} type="monotone" dataKey={String(s.registry)} name={s.label} stroke={colors[idx % colors.length]} strokeOpacity={visible.has(s.registry) ? 1 : 0.15} strokeWidth={2} dot={false} isAnimationActive={false} style={{ transition: 'stroke-opacity 300ms' }} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Card, Text, Group, Badge, ScrollArea, Button } from '@mantine/core'
|
||||
import eventService, { AppEvent } from '../services/eventService'
|
||||
|
||||
export default function LogsPanel() {
|
||||
const [events, setEvents] = useState<AppEvent[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = eventService.subscribe((evs) => setEvents(evs))
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
const clear = () => {
|
||||
eventService.clear()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card shadow="sm">
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={500}>Logi i zdarzenia</Text>
|
||||
<Button size="xs" variant="outline" onClick={clear}>Wyczyść</Button>
|
||||
</Group>
|
||||
<ScrollArea style={{ height: 220, marginTop: 8 }}>
|
||||
{events.length === 0 && <Text size="sm" color="dimmed">Brak zdarzeń</Text>}
|
||||
{events.map(ev => (
|
||||
<div key={ev.id} style={{ padding: '8px 0', borderBottom: '1px solid rgba(0,0,0,0.04)' }}>
|
||||
<Group position="apart">
|
||||
<div>
|
||||
<Text size="sm" weight={600}>{ev.title || (ev.type === 'system' ? 'System' : ev.type === 'error' ? 'Błąd' : 'Informacja')}</Text>
|
||||
<Text size="xs" color="dimmed">{new Date(ev.ts).toLocaleTimeString()}</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Badge color={ev.type === 'error' ? 'red' : ev.type === 'success' ? 'teal' : 'gray'}>{ev.type}</Badge>
|
||||
<Text size="sm">{ev.message}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { Group, Card, Text, Badge, Grid } from '@mantine/core'
|
||||
|
||||
export default function SummaryPanel({ registries }: { registries: any[] }) {
|
||||
const find = (id: number) => registries.find((r: any) => r.registry === id)
|
||||
const tExt = find(4065)
|
||||
const tIn = find(4070)
|
||||
const setCo = find(4202)
|
||||
const pump = find(4117)
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col xs={12} sm={4}>
|
||||
<Card shadow="sm" p="md">
|
||||
<Text size="xs" color="dimmed">Na zewnątrz</Text>
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={700} size="xl">{tExt ? tExt.value : '—'}°C</Text>
|
||||
<Badge color="teal">Sprawne</Badge>
|
||||
</Group>
|
||||
<Text size="xs" color="dimmed" mt={6}>{pump ? (Number(pump.value) ? 'Pompa włączona' : 'Pompa wyłączona') : ''}</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} sm={4}>
|
||||
<Card shadow="sm" p="md">
|
||||
<Text size="xs" color="dimmed">Wewnątrz</Text>
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={700} size="xl">{tIn ? tIn.value : '—'}°C</Text>
|
||||
<Badge color="yellow">Stabilnie</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col xs={12} sm={4}>
|
||||
<Card shadow="sm" p="md">
|
||||
<Text size="xs" color="dimmed">Nastawa (CO)</Text>
|
||||
<Group position="apart" align="center">
|
||||
<Text weight={700} size="xl">{setCo ? setCo.value : '—'}°C</Text>
|
||||
<Badge color="blue">Nastawa</Badge>
|
||||
</Group>
|
||||
<Text size="xs" color="dimmed" mt={6}>Nastawa centralnego ogrzewania</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
:root{
|
||||
--accent: #ffb454;
|
||||
--accent-2: #7ae582;
|
||||
--bg: linear-gradient(180deg, #f7fbfc 0%, #ffffff 100%);
|
||||
}
|
||||
html, body, #root { height: 100%; margin: 0; font-family: Inter, system-ui, sans-serif; background: radial-gradient(circle at 10% 10%, rgba(12,18,32,1) 0%, rgba(6,12,22,1) 40%, rgba(3,6,10,1) 100%); color: #e6eef6 }
|
||||
|
||||
/* subtle card lift */
|
||||
.mantine-Card-root {
|
||||
transition: box-shadow 120ms ease, transform 120ms ease;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.mantine-Card-root:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 30px rgba(2,6,23,0.6);
|
||||
}
|
||||
|
||||
.mantine-Header-root {
|
||||
background: linear-gradient(90deg, rgba(20,30,48,1) 0%, rgba(12,18,32,1) 100%);
|
||||
color: #f1f6fb;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.mantine-Title-root {
|
||||
color: #e6eef6;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider withGlobalStyles withNormalizeCSS>
|
||||
<Notifications />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
export type EventType = 'info' | 'success' | 'error' | 'system'
|
||||
|
||||
export type AppEvent = {
|
||||
id: string
|
||||
ts: number
|
||||
type: EventType
|
||||
title?: string
|
||||
message: string
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
type Listener = (ev: AppEvent[]) => void
|
||||
|
||||
const MAX_EVENTS = 100
|
||||
let events: AppEvent[] = []
|
||||
let listeners: Listener[] = []
|
||||
|
||||
function pushEvent(e: Omit<AppEvent, 'id' | 'ts'>) {
|
||||
const ev: AppEvent = { ...e, id: String(Math.random()).slice(2), ts: Date.now() }
|
||||
events = [ev, ...events].slice(0, MAX_EVENTS)
|
||||
listeners.forEach(l => l(events))
|
||||
}
|
||||
|
||||
function subscribe(fn: Listener) {
|
||||
listeners.push(fn)
|
||||
// initial snapshot
|
||||
fn(events)
|
||||
return () => { listeners = listeners.filter(l => l !== fn) }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
events = []
|
||||
listeners.forEach(l => l(events))
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return events.slice()
|
||||
}
|
||||
|
||||
export default { pushEvent, subscribe, clear, snapshot }
|
||||
@@ -0,0 +1,99 @@
|
||||
export type Point = { registry: number; ts: number; value: number }
|
||||
|
||||
type Listener = (points: Point[]) => void
|
||||
type StatusListener = (connected: boolean) => void
|
||||
|
||||
export default class SseService {
|
||||
private source: EventSource | null = null
|
||||
private listeners: Listener[] = []
|
||||
private statusListeners: StatusListener[] = []
|
||||
private connected = false
|
||||
private url: string
|
||||
private reconnectMs = 5000 // start with 5s backoff
|
||||
private reconnectScheduled = false
|
||||
|
||||
// Debounce offline status to avoid quick flicker in the UI when SSE reconnects rapidly
|
||||
private offlineDelayMs = 3000
|
||||
private offlineTimeout: number | null = null
|
||||
|
||||
constructor(url = '/sse/live') {
|
||||
this.url = url
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.source) return
|
||||
try {
|
||||
this.source = new EventSource(this.url)
|
||||
this.source.onmessage = (ev) => {
|
||||
try {
|
||||
const pts = JSON.parse(ev.data) as Point[]
|
||||
this.listeners.forEach(l => l(pts))
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.source.onopen = () => { this.reconnectMs = 5000; this.setConnected(true); this.reconnectScheduled = false }
|
||||
this.source.onerror = () => {
|
||||
this.setConnected(false)
|
||||
// Schedule a reconnect with exponential backoff + jitter; avoid scheduling multiple
|
||||
if (this.reconnectScheduled) return
|
||||
this.reconnectScheduled = true
|
||||
const jitter = Math.floor(Math.random() * 1000)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.close()
|
||||
this.start()
|
||||
} finally {
|
||||
this.reconnectMs = Math.min(60000, this.reconnectMs * 2)
|
||||
this.reconnectScheduled = false
|
||||
}
|
||||
}, this.reconnectMs + jitter)
|
||||
}
|
||||
} catch (e) {
|
||||
setTimeout(() => this.start(), this.reconnectMs)
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.source) {
|
||||
try { this.source.close() } catch (e) {}
|
||||
this.source = null
|
||||
this.setConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
private setConnected(v: boolean) {
|
||||
// When marking disconnected, wait offlineDelayMs before notifying so brief blips don't show the banner
|
||||
if (!v) {
|
||||
if (this.offlineTimeout != null) return // already waiting
|
||||
this.offlineTimeout = window.setTimeout(() => {
|
||||
this.offlineTimeout = null
|
||||
this.connected = false
|
||||
this.statusListeners.forEach(l => { try { l(false) } catch (e) {} })
|
||||
}, this.offlineDelayMs)
|
||||
return
|
||||
}
|
||||
|
||||
// on connect, cancel any pending offline timer and notify immediately
|
||||
if (this.offlineTimeout != null) {
|
||||
clearTimeout(this.offlineTimeout)
|
||||
this.offlineTimeout = null
|
||||
}
|
||||
if (!this.connected) {
|
||||
this.connected = true
|
||||
this.statusListeners.forEach(l => { try { l(true) } catch (e) {} })
|
||||
}
|
||||
}
|
||||
|
||||
subscribeStatus(fn: StatusListener) {
|
||||
this.statusListeners.push(fn)
|
||||
return () => { this.statusListeners = this.statusListeners.filter(l => l !== fn) }
|
||||
}
|
||||
|
||||
isConnected() { return this.connected }
|
||||
|
||||
subscribe(fn: Listener) {
|
||||
this.listeners.push(fn)
|
||||
return () => { this.listeners = this.listeners.filter(l => l !== fn) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM"],
|
||||
"types": [],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// Proxy API requests to backend (default http://localhost:7000). Override with VITE_BACKEND_URL in .env
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Vite exposes env variables prefixed with VITE_ to the client. Allow setting backend target via VITE_BACKEND_URL
|
||||
const backend = process.env.VITE_BACKEND_URL || 'http://localhost:7000'
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': backend,
|
||||
'/sse': backend
|
||||
}
|
||||
},
|
||||
define: {
|
||||
__VITE_BACKEND_URL__: JSON.stringify(backend)
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3,58 +3,76 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.example</groupId>
|
||||
<artifactId>test.com</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<groupId>com.home</groupId>
|
||||
<artifactId>home-pump</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.11.1</version>
|
||||
<version>2.20.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>4.2.0</version>
|
||||
<version>5.8.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>1.7.32</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>30.1.1-jre</version>
|
||||
<version>33.5.0-jre</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.infiniteautomation</groupId>
|
||||
<artifactId>modbus4j</artifactId>
|
||||
<version>3.0.3</version>
|
||||
<version>3.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.influxdb</groupId>
|
||||
<artifactId>influxdb-java</artifactId>
|
||||
<version>2.22</version>
|
||||
<version>2.25</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.8.2</version>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.24</version>
|
||||
<version>1.18.28</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>5.6.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- default: exclude integration tests -->
|
||||
<test.excluded.tags>integration</test.excluded.tags>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>with-integration-tests</id>
|
||||
<activation>
|
||||
<activeByDefault>false</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<test.excluded.tags></test.excluded.tags>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
<repositories>
|
||||
<repository>
|
||||
<releases>
|
||||
@@ -84,20 +102,53 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.0.0-M7</version>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<excludedTags>${test.excluded.tags}</excludedTags>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<addClasspath>true</addClasspath>
|
||||
<mainClass>home.automation.Main</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.10.1</version>
|
||||
<version>3.8.1</version>
|
||||
<configuration>
|
||||
<release>11</release>
|
||||
<source>11</source>
|
||||
<release>21</release>
|
||||
<source>21</source>
|
||||
<encoding>UTF-8</encoding>
|
||||
<showWarnings>true</showWarnings>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.24</version>
|
||||
<version>1.18.30</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.mariuszpacek.pl",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "External temperature"
|
||||
},
|
||||
{
|
||||
"registry": 4070,
|
||||
"desc": "Temp in"
|
||||
},
|
||||
{
|
||||
"registry": 4071,
|
||||
"desc": "Temp out"
|
||||
},
|
||||
{
|
||||
"registry": 4072,
|
||||
"desc": "Temp pc out"
|
||||
},
|
||||
{
|
||||
"registry": 4067,
|
||||
"desc": "Temp co"
|
||||
},
|
||||
{
|
||||
"registry": 4202,
|
||||
"desc": "Set Temp co"
|
||||
},
|
||||
{
|
||||
"registry": 4069,
|
||||
"desc": "Temp cwu"
|
||||
},
|
||||
{
|
||||
"registry": 4203,
|
||||
"desc": "Set Temp cwu"
|
||||
},
|
||||
{
|
||||
"registry": 4066,
|
||||
"desc": "Tem wew"
|
||||
},
|
||||
{
|
||||
"registry": 4201,
|
||||
"desc": "Set Temp wew"
|
||||
},
|
||||
{
|
||||
"registry": 4199,
|
||||
"desc": "Temp zsr"
|
||||
},
|
||||
{
|
||||
"registry": 4117,
|
||||
"desc": "Pompa Dz"
|
||||
},
|
||||
{
|
||||
"registry": 4113,
|
||||
"desc": "Pompa BUF"
|
||||
},
|
||||
{
|
||||
"registry": 4112,
|
||||
"desc": "Pompa CO"
|
||||
},
|
||||
{
|
||||
"registry": 4118,
|
||||
"desc": "Pompa CWU"
|
||||
},
|
||||
{
|
||||
"registry": 4114,
|
||||
"desc": "Sprezarka"
|
||||
},
|
||||
{
|
||||
"registry": 4115,
|
||||
"desc": "GrzalkaCWU"
|
||||
},
|
||||
{
|
||||
"registry": 4119,
|
||||
"desc": "WyCHL"
|
||||
},
|
||||
{
|
||||
"registry": 4116,
|
||||
"desc": "Pompa CCW"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
const http = require('http');
|
||||
const data = JSON.stringify({value: 60});
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 7000,
|
||||
path: '/api/registry/4202/write',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data)
|
||||
}
|
||||
};
|
||||
const req = http.request(options, (res) => {
|
||||
console.log('STATUS', res.statusCode);
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => console.log('BODY', chunk));
|
||||
res.on('end', () => {
|
||||
// fetch registries
|
||||
http.get('http://localhost:7000/api/registries', (res2) => {
|
||||
console.log('REG STATUS', res2.statusCode);
|
||||
let body = '';
|
||||
res2.setEncoding('utf8');
|
||||
res2.on('data', c => body += c);
|
||||
res2.on('end', () => console.log('REG BODY', body));
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => console.error('REQ ERR', e));
|
||||
req.write(data);
|
||||
req.end();
|
||||
@@ -1,13 +1,14 @@
|
||||
package home.automation;
|
||||
|
||||
import home.automation.configuration.JsonFileBasedConfiguration;
|
||||
import home.automation.configuration.RegistryConfiguration;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import home.automation.influxdb.InfluxConnection;
|
||||
import home.automation.influxdb.InfluxMetricModbusSender;
|
||||
import home.automation.modbus.ModbusConnection;
|
||||
import home.automation.modbus.ModbusRtuTcpRepository;
|
||||
import home.automation.modbus.ModbusService;
|
||||
import lombok.*;
|
||||
import home.automation.live.ModbusPoller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -20,23 +21,38 @@ public class Main {
|
||||
|
||||
final JsonFileBasedConfiguration jsonFileBasedConfiguration = new JsonFileBasedConfiguration();
|
||||
|
||||
final List<RegistryMapping> registryMappings = jsonFileBasedConfiguration
|
||||
final RegistryConfiguration config = jsonFileBasedConfiguration
|
||||
.getConfig()
|
||||
.orElseThrow(() -> new Exception("Missing configuration file"))
|
||||
.getRegistry_mapping();
|
||||
.orElseThrow(() -> new Exception("Missing configuration file"));
|
||||
|
||||
final InfluxConnection connection = new InfluxConnection(
|
||||
jsonFileBasedConfiguration.getConfig()
|
||||
.get()
|
||||
.getInflux_db()
|
||||
final List<RegistryMapping> registryMappings = config.getRegistry_mapping();
|
||||
|
||||
final InfluxConnection connection = new InfluxConnection(config.getInflux_db());
|
||||
|
||||
final var useMock = config.getUseMock() != null && config.getUseMock();
|
||||
ModbusService service;
|
||||
if (useMock) {
|
||||
service = new ModbusService(new home.automation.modbus.MockRepository(registryMappings));
|
||||
} else {
|
||||
service = new ModbusService(
|
||||
new ModbusRtuTcpRepository(
|
||||
new ModbusConnection(jsonFileBasedConfiguration), registryMappings)
|
||||
);
|
||||
}
|
||||
|
||||
// Start live poller for realtime dashboard
|
||||
final ModbusPoller poller = new ModbusPoller(service, registryMappings, config.getPollIntervalMs(), config.getWindowSeconds());
|
||||
ScheduledExecutorService liveScheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
liveScheduler.scheduleAtFixedRate(
|
||||
poller::pollOnce,
|
||||
0,
|
||||
config.getPollIntervalMs(),
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
|
||||
ModbusService service = new ModbusService(
|
||||
new ModbusRtuTcpRepository(
|
||||
new ModbusConnection(
|
||||
new JsonFileBasedConfiguration()
|
||||
), registryMappings)
|
||||
);
|
||||
// Start web server (REST + WebSocket)
|
||||
final home.automation.web.WebServer webServer = new home.automation.web.WebServer(service, poller, 7000);
|
||||
webServer.start();
|
||||
|
||||
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
scheduler.scheduleAtFixedRate(
|
||||
|
||||
@@ -21,6 +21,37 @@ public class JsonFileBasedConfiguration implements ConfigurationStrategy {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException("Cannot read the file");
|
||||
}
|
||||
return create(jsonConfiguration);
|
||||
final java.util.Optional<RegistryConfiguration> opt = create(jsonConfiguration);
|
||||
opt.ifPresent(cfg -> {
|
||||
// Basic validation and sensible defaults
|
||||
if (cfg.getAddress() == null) {
|
||||
throw new RuntimeException("Configuration missing 'address' field");
|
||||
}
|
||||
if (cfg.getPort() == null) {
|
||||
cfg.setPort(502);
|
||||
}
|
||||
if (cfg.getRegistry_mapping() == null || cfg.getRegistry_mapping().isEmpty()) {
|
||||
throw new RuntimeException("Configuration contains no registry mappings");
|
||||
}
|
||||
cfg.getRegistry_mapping().forEach(m -> {
|
||||
if (m.getUnitId() == null) m.setUnitId(1);
|
||||
if (m.getScale() == null) m.setScale(0.1);
|
||||
if (m.getDataType() == null) m.setDataType("TWO_BYTE_INT_SIGNED");
|
||||
if (m.getSignedLegacyWrap() == null) m.setSignedLegacyWrap(true);
|
||||
// control metadata defaults (no control by default)
|
||||
if (m.getControlType() == null) m.setControlType(null);
|
||||
if (m.getMin() == null) m.setMin(null);
|
||||
if (m.getMax() == null) m.setMax(null);
|
||||
if (m.getStep() == null) m.setStep(null);
|
||||
if (m.getUnit() == null) m.setUnit(null);
|
||||
});
|
||||
|
||||
// Live polling defaults
|
||||
if (cfg.getPollIntervalMs() == null) cfg.setPollIntervalMs(2000);
|
||||
if (cfg.getWindowSeconds() == null) cfg.setWindowSeconds(300);
|
||||
// Mock mode default
|
||||
if (cfg.getUseMock() == null) cfg.setUseMock(false);
|
||||
});
|
||||
return opt;
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,35 @@ public class RegistryConfiguration {
|
||||
private Integer port;
|
||||
private InfluxDBConfiguration influx_db;
|
||||
private List<RegistryMapping> registry_mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll interval in milliseconds for live polling. Defaults to 2000.
|
||||
*/
|
||||
private Integer pollIntervalMs;
|
||||
|
||||
/**
|
||||
* Window size in seconds for live rolling buffer. Defaults to 300 (5 minutes).
|
||||
*/
|
||||
private Integer windowSeconds;
|
||||
|
||||
/**
|
||||
* Connection timeout in milliseconds for Modbus TCP master. Defaults to 5000.
|
||||
*/
|
||||
private Integer timeoutMs;
|
||||
|
||||
/**
|
||||
* Maximum number of retries to attempt when establishing a Modbus connection. Defaults to 2.
|
||||
*/
|
||||
private Integer maxRetries;
|
||||
|
||||
/**
|
||||
* Base backoff in milliseconds between retries (exponential backoff applied). Defaults to 1000.
|
||||
*/
|
||||
private Integer retryBackoffMs;
|
||||
|
||||
/**
|
||||
* Use a mock repository instead of a real Modbus connection. Useful for local development
|
||||
* and environments without device access. Defaults to false.
|
||||
*/
|
||||
private Boolean useMock;
|
||||
}
|
||||
@@ -10,5 +10,54 @@ import lombok.ToString;
|
||||
public class RegistryMapping {
|
||||
private Integer registry;
|
||||
private String desc;
|
||||
}
|
||||
|
||||
/** Unit id (Modbus slave id) to use for this mapping. Defaults to 1 when missing. */
|
||||
private Integer unitId;
|
||||
|
||||
/** Scaling factor applied to the raw device value (e.g. 0.1). Defaults to 0.1 when missing. */
|
||||
private Double scale;
|
||||
|
||||
/** Data type name used when creating the locator. E.g. "TWO_BYTE_INT_SIGNED". Defaults to TWO_BYTE_INT_SIGNED. */
|
||||
private String dataType;
|
||||
|
||||
/** Whether to apply legacy signed wrap decoding for values near -32768. Defaults to true. */
|
||||
private Boolean signedLegacyWrap;
|
||||
|
||||
// Explicit getters and setters in case Lombok annotation processing isn't available.
|
||||
public Integer getRegistry() { return registry; }
|
||||
public void setRegistry(Integer registry) { this.registry = registry; }
|
||||
|
||||
public String getDesc() { return desc; }
|
||||
public void setDesc(String desc) { this.desc = desc; }
|
||||
|
||||
public Integer getUnitId() { return unitId; }
|
||||
public void setUnitId(Integer unitId) { this.unitId = unitId; }
|
||||
|
||||
public Double getScale() { return scale; }
|
||||
public void setScale(Double scale) { this.scale = scale; }
|
||||
|
||||
public String getDataType() { return dataType; }
|
||||
public void setDataType(String dataType) { this.dataType = dataType; }
|
||||
|
||||
public Boolean getSignedLegacyWrap() { return signedLegacyWrap; }
|
||||
public void setSignedLegacyWrap(Boolean signedLegacyWrap) { this.signedLegacyWrap = signedLegacyWrap; }
|
||||
|
||||
// Optional UI control metadata
|
||||
private String controlType;
|
||||
private Double min;
|
||||
private Double max;
|
||||
private Double step;
|
||||
private String unit;
|
||||
|
||||
public String getControlType() { return controlType; }
|
||||
public void setControlType(String controlType) { this.controlType = controlType; }
|
||||
public Double getMin() { return min; }
|
||||
public void setMin(Double min) { this.min = min; }
|
||||
public Double getMax() { return max; }
|
||||
public void setMax(Double max) { this.max = max; }
|
||||
public Double getStep() { return step; }
|
||||
public void setStep(Double step) { this.step = step; }
|
||||
public String getUnit() { return unit; }
|
||||
public void setUnit(String unit) { this.unit = unit; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package home.automation.influxdb;
|
||||
|
||||
import home.automation.modbus.ModbusService;
|
||||
import home.automation.modbus.Registry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.influxdb.InfluxDB;
|
||||
import org.influxdb.dto.Point;
|
||||
import org.influxdb.dto.Query;
|
||||
@@ -10,6 +12,7 @@ import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnable {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(InfluxMetricModbusSender.class);
|
||||
private final ModbusService modbusService;
|
||||
private final InfluxDB influxConnection;
|
||||
|
||||
@@ -26,7 +29,7 @@ public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnabl
|
||||
|
||||
@Override
|
||||
public boolean send(List<Registry> metricsToSend) {
|
||||
modbusService.readAll().forEach(registry ->
|
||||
metricsToSend.forEach(registry ->
|
||||
influxConnection.write(
|
||||
Point.measurement("POMPA")
|
||||
.addField(registry.getDesc(), Double.valueOf(registry.getValue()))
|
||||
@@ -40,7 +43,15 @@ public class InfluxMetricModbusSender implements MetricSender<Registry>, Runnabl
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
final List<Registry> registries = modbusService.readAll();
|
||||
send(registries);
|
||||
try {
|
||||
final List<Registry> registries = modbusService.readAll();
|
||||
if (!registries.isEmpty()) {
|
||||
send(registries);
|
||||
} else {
|
||||
LOGGER.warn("No registries read from Modbus");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Error in scheduled Modbus reading", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package home.automation.live;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class LiveRegistryBuffer {
|
||||
public static class Sample {
|
||||
public final long ts;
|
||||
public final double value;
|
||||
|
||||
public Sample(long ts, double value) {
|
||||
this.ts = ts;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CircularBuffer {
|
||||
private final Deque<Sample> buffer;
|
||||
private final int capacity;
|
||||
|
||||
CircularBuffer(int capacity) {
|
||||
this.capacity = Math.max(1, capacity);
|
||||
this.buffer = new ArrayDeque<>(this.capacity);
|
||||
}
|
||||
|
||||
synchronized void add(Sample s) {
|
||||
buffer.addLast(s);
|
||||
if (buffer.size() > capacity) buffer.removeFirst();
|
||||
}
|
||||
|
||||
synchronized List<Sample> snapshot() {
|
||||
return new ArrayList<>(buffer);
|
||||
}
|
||||
|
||||
synchronized int size() { return buffer.size(); }
|
||||
}
|
||||
|
||||
private final Map<Integer, CircularBuffer> map = new ConcurrentHashMap<>();
|
||||
private final int capacity;
|
||||
|
||||
public LiveRegistryBuffer(int capacity) {
|
||||
this.capacity = Math.max(1, capacity);
|
||||
}
|
||||
|
||||
public void addSample(int registry, long ts, double value) {
|
||||
map.computeIfAbsent(registry, r -> new CircularBuffer(capacity)).add(new Sample(ts, value));
|
||||
}
|
||||
|
||||
public List<Sample> getSamples(int registry) {
|
||||
CircularBuffer cb = map.get(registry);
|
||||
return cb == null ? Collections.emptyList() : cb.snapshot();
|
||||
}
|
||||
|
||||
public int size(int registry) {
|
||||
CircularBuffer cb = map.get(registry);
|
||||
return cb == null ? 0 : cb.size();
|
||||
}
|
||||
|
||||
public Map<Integer, List<Sample>> snapshotAll() {
|
||||
Map<Integer, List<Sample>> snap = new HashMap<>();
|
||||
map.forEach((k, v) -> snap.put(k, v.snapshot()));
|
||||
return snap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package home.automation.live;
|
||||
|
||||
import home.automation.modbus.ModbusService;
|
||||
import home.automation.modbus.Registry;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class ModbusPoller {
|
||||
private final ModbusService service;
|
||||
private final LiveRegistryBuffer buffer;
|
||||
private final List<RegistryMapping> mappings;
|
||||
|
||||
public static class Point {
|
||||
public final int registry;
|
||||
public final long ts;
|
||||
public final double value;
|
||||
|
||||
public Point(int registry, long ts, double value) {
|
||||
this.registry = registry;
|
||||
this.ts = ts;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private final List<Consumer<List<Point>>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
public ModbusPoller(ModbusService service, List<RegistryMapping> mappings, int pollIntervalMs, int windowSeconds) {
|
||||
this.service = Objects.requireNonNull(service);
|
||||
this.mappings = Objects.requireNonNull(mappings);
|
||||
final int capacity = Math.max(1, (windowSeconds * 1000) / Math.max(1, pollIntervalMs));
|
||||
this.buffer = new LiveRegistryBuffer(capacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single poll. Public for testing and manual triggering.
|
||||
*/
|
||||
public void pollOnce() {
|
||||
final List<Registry> regs = service.readAll();
|
||||
final long ts = System.currentTimeMillis();
|
||||
if (regs == null) return;
|
||||
final List<Point> points = new java.util.ArrayList<>();
|
||||
for (Registry r : regs) {
|
||||
if (r == null || r.getValue() == null) continue;
|
||||
try {
|
||||
final double v = Double.parseDouble(r.getValue());
|
||||
buffer.addSample(r.getRegistry(), ts, v);
|
||||
points.add(new Point(r.getRegistry(), ts, v));
|
||||
} catch (NumberFormatException nfe) {
|
||||
// ignore non-numeric values for live buffer
|
||||
}
|
||||
}
|
||||
if (!points.isEmpty()) {
|
||||
listeners.forEach(l -> {
|
||||
try {
|
||||
l.accept(points);
|
||||
} catch (Exception e) {
|
||||
// ignore listener errors
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public LiveRegistryBuffer getBuffer() {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public void addListener(Consumer<List<Point>> l) { listeners.add(l); }
|
||||
|
||||
public void removeListener(Consumer<List<Point>> l) { listeners.remove(l); }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Simple in-memory mock repository for development and testing when no Modbus device is available.
|
||||
*/
|
||||
public class MockRepository implements Repository {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(MockRepository.class);
|
||||
private final Map<Integer, Registry> state = new HashMap<>();
|
||||
|
||||
public MockRepository(List<RegistryMapping> mappings) {
|
||||
final Random r = new Random(42);
|
||||
for (RegistryMapping m : mappings) {
|
||||
final double initial;
|
||||
if ("setpoint".equals(m.getControlType())) {
|
||||
final double min = m.getMin() == null ? 0.0 : m.getMin();
|
||||
final double max = m.getMax() == null ? (min + 10.0) : m.getMax();
|
||||
initial = Math.round(((min + max) / 2.0) / (m.getStep() == null ? 1.0 : m.getStep())) * (m.getStep() == null ? 1.0 : m.getStep());
|
||||
} else if ("toggle".equals(m.getControlType())) {
|
||||
initial = r.nextBoolean() ? 1.0 : 0.0;
|
||||
} else {
|
||||
// sensor-like default using mapping scale
|
||||
final double scale = m.getScale() == null ? 0.1 : m.getScale();
|
||||
initial = Math.round((20.0 / scale)); // will be converted below
|
||||
}
|
||||
final String val = String.valueOf(initial);
|
||||
state.put(m.getRegistry(), new Registry(m.getRegistry(), m.getDesc(), val));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Registry> getAll() {
|
||||
return state.values().stream().collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Registry get(int register) {
|
||||
return state.get(register);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
final Registry r = state.get(register);
|
||||
if (r == null) throw new Exception("Unknown registry " + register);
|
||||
// no explicit range validation here; callers may expect IllegalArgumentException when invalid
|
||||
state.put(register, new Registry(register, r.getDesc(), Double.toString(scaledValue)));
|
||||
LOGGER.info("Mock write {} = {}", register, scaledValue);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,18 @@ package home.automation.modbus;
|
||||
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static home.automation.modbus.Registry.builder;
|
||||
import static java.util.Collections.singletonList;
|
||||
|
||||
@Slf4j
|
||||
public class ModbusCachedWrapper implements Repository {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ModbusCachedWrapper.class);
|
||||
|
||||
private final Cache<Integer, Registry> repositoryCache;
|
||||
private final Cache<Integer, List<Registry>> repositoryCacheAllElements;
|
||||
private final Repository repository;
|
||||
@@ -33,8 +34,8 @@ public class ModbusCachedWrapper implements Repository {
|
||||
try {
|
||||
return repositoryCacheAllElements.get(ALL_ELEMENTS, repository::getAll);
|
||||
} catch (ExecutionException e) {
|
||||
log.error("Cannot get all keys due to: ", e);
|
||||
return singletonList(builder().registry(0).desc("").value("").build());
|
||||
LOGGER.error("Cannot get all keys due to: ", e);
|
||||
return singletonList(new Registry(0, "", ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +44,21 @@ public class ModbusCachedWrapper implements Repository {
|
||||
try {
|
||||
return repositoryCache.get(register, () -> repository.get(register));
|
||||
} catch (ExecutionException e) {
|
||||
log.error("Cannot get" + register + "due to: ", e);
|
||||
return builder().registry(0).desc("").value("").build();
|
||||
LOGGER.error("Cannot get {} due to: ", register, e);
|
||||
return new Registry(0, "", "");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
try {
|
||||
repository.write(register, scaledValue);
|
||||
// invalidate cache for this register and the all-elements cache so subsequent reads are fresh
|
||||
repositoryCache.invalidate(register);
|
||||
repositoryCacheAllElements.invalidate(ALL_ELEMENTS);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Cannot write to {} due to: ", register, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,16 @@ import java.util.Objects;
|
||||
|
||||
import static java.util.Objects.isNull;
|
||||
|
||||
public class ModbusConnection implements Connection {
|
||||
public class ModbusConnection implements Connection, AutoCloseable {
|
||||
private final ConfigurationStrategy config;
|
||||
private volatile ModbusMaster connection;
|
||||
|
||||
public ModbusConnection(ConfigurationStrategy config) {
|
||||
this.config = config;
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
if (Objects.nonNull(connection)) {
|
||||
connection.destroy();
|
||||
try {
|
||||
this.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -29,19 +30,58 @@ public class ModbusConnection implements Connection {
|
||||
.orElseThrow(() -> new RuntimeException("Missing configuration"));
|
||||
}
|
||||
|
||||
private int getTimeoutMsFromConfig() {
|
||||
return config.getConfig().map(cfg -> cfg.getTimeoutMs() == null ? 5000 : cfg.getTimeoutMs()).orElse(5000);
|
||||
}
|
||||
|
||||
private int getMaxRetriesFromConfig() {
|
||||
return config.getConfig().map(cfg -> cfg.getMaxRetries() == null ? 2 : cfg.getMaxRetries()).orElse(2);
|
||||
}
|
||||
|
||||
private int getRetryBackoffMsFromConfig() {
|
||||
return config.getConfig().map(cfg -> cfg.getRetryBackoffMs() == null ? 1000 : cfg.getRetryBackoffMs()).orElse(1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
synchronized public ModbusMaster getConnection() throws Exception {
|
||||
if (isNull(connection) || !connection.isConnected()) {
|
||||
if (Objects.nonNull(connection)) {
|
||||
connection.destroy();
|
||||
try {
|
||||
connection.destroy();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
final Pair<InetAddress, Integer> portAndAddressFromConfig = getPortAndAddressFromConfig(config);
|
||||
this.connection = initConnection(portAndAddressFromConfig.getFirst(), portAndAddressFromConfig.getSecond());
|
||||
|
||||
int maxRetries = getMaxRetriesFromConfig();
|
||||
int attempt = 0;
|
||||
Exception lastEx = null;
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
this.connection = initConnection(portAndAddressFromConfig.getFirst(), portAndAddressFromConfig.getSecond());
|
||||
return connection;
|
||||
} catch (Exception e) {
|
||||
lastEx = e;
|
||||
attempt++;
|
||||
if (attempt > maxRetries) {
|
||||
throw lastEx;
|
||||
}
|
||||
final int backoff = getRetryBackoffMsFromConfig();
|
||||
final long sleepMs = backoff * (1L << (attempt - 1)); // exponential backoff
|
||||
try {
|
||||
Thread.sleep(sleepMs);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw ie;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("Failed to establish connection");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
private ModbusMaster initConnection(InetAddress address, int port) throws Exception {
|
||||
protected ModbusMaster initConnection(InetAddress address, int port) throws Exception {
|
||||
final String ip = address.getHostAddress();
|
||||
IpParameters ipParameters = new IpParameters();
|
||||
ipParameters.setHost(ip);
|
||||
@@ -49,7 +89,41 @@ public class ModbusConnection implements Connection {
|
||||
ModbusFactory factory = new ModbusFactory();
|
||||
|
||||
final ModbusMaster tcpMaster = factory.createTcpMaster(ipParameters, true);
|
||||
// set a sensible timeout so a stuck connection doesn't block indefinitely
|
||||
tcpMaster.setTimeout(getTimeoutMsFromConfig());
|
||||
tcpMaster.init();
|
||||
return tcpMaster;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured remote endpoint (address and port) for diagnostics.
|
||||
*/
|
||||
public Pair<InetAddress, Integer> getRemoteEndpoint() {
|
||||
return getPortAndAddressFromConfig(config);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
if (Objects.nonNull(connection)) {
|
||||
try {
|
||||
connection.destroy();
|
||||
} finally {
|
||||
connection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A small test helper to supply configuration in tests that need it
|
||||
public static class TestConfiguration implements ConfigurationStrategy {
|
||||
private final home.automation.configuration.RegistryConfiguration cfg;
|
||||
|
||||
public TestConfiguration(home.automation.configuration.RegistryConfiguration cfg) {
|
||||
this.cfg = cfg;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<home.automation.configuration.RegistryConfiguration> getConfig() {
|
||||
return java.util.Optional.of(cfg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,25 @@ package home.automation.modbus;
|
||||
|
||||
import com.serotonin.modbus4j.BatchRead;
|
||||
import com.serotonin.modbus4j.BatchResults;
|
||||
import com.serotonin.modbus4j.code.DataType;
|
||||
import com.serotonin.modbus4j.locator.BaseLocator;
|
||||
import com.serotonin.modbus4j.msg.ReadHoldingRegistersRequest;
|
||||
import com.serotonin.modbus4j.msg.ReadHoldingRegistersResponse;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.serotonin.modbus4j.code.DataType.TWO_BYTE_INT_SIGNED;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
public class ModbusRtuTcpRepository implements Repository {
|
||||
|
||||
private static final int ZERO = 0;
|
||||
private static final int HALF_SHORT_INT = 32768;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ModbusRtuTcpRepository.class);
|
||||
|
||||
private final Connection connection;
|
||||
private final List<RegistryMapping> registryMappings;
|
||||
|
||||
@@ -27,58 +30,117 @@ public class ModbusRtuTcpRepository implements Repository {
|
||||
}
|
||||
|
||||
private String normalizeRegistryValue(Short value) {
|
||||
if (ZERO > value) {
|
||||
return String.valueOf(-(value + HALF_SHORT_INT) / 10.0);
|
||||
} else {
|
||||
return String.valueOf((value / 10.0));
|
||||
// Some devices encode small negative temperatures using an unsigned wrap (values near -32768).
|
||||
// If the raw short is very negative (device-specific sentinel), decode using legacy wrap rule.
|
||||
if (value < -32000) {
|
||||
return String.valueOf(-(value + 32768) / 10.0);
|
||||
}
|
||||
// Otherwise treat as regular signed value
|
||||
return String.valueOf(value / 10.0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Registry> getAll() {
|
||||
BatchRead<Integer> requestBatchRead = new BatchRead<>();
|
||||
registryMappings.forEach(elem -> requestBatchRead.addLocator(
|
||||
elem.getRegistry(),
|
||||
BaseLocator.holdingRegister(1, elem.getRegistry(), TWO_BYTE_INT_SIGNED)
|
||||
));
|
||||
registryMappings.forEach(elem -> {
|
||||
final int unitId = elem.getUnitId() == null ? 1 : elem.getUnitId();
|
||||
final int dataType = resolveDataType(elem);
|
||||
requestBatchRead.addLocator(elem.getRegistry(), BaseLocator.holdingRegister(unitId, elem.getRegistry(), dataType));
|
||||
});
|
||||
|
||||
try {
|
||||
final BatchResults<Integer> send = connection.getConnection().send(requestBatchRead);
|
||||
return registryMappings.stream()
|
||||
.map(mapping -> {
|
||||
final Double doubleValue = send.getDoubleValue(mapping.getRegistry());
|
||||
return Registry.builder()
|
||||
.registry(mapping.getRegistry())
|
||||
.value(doubleValue.toString())
|
||||
.desc(mapping.getDesc())
|
||||
.build();
|
||||
final java.util.List<Registry> results = new java.util.ArrayList<>();
|
||||
for (RegistryMapping mapping : registryMappings) {
|
||||
try {
|
||||
// Prefer convenience method but be defensive against ClassCastException
|
||||
final double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
|
||||
final Double doubleValue = send.getDoubleValue(mapping.getRegistry()) * scale;
|
||||
final String formattedValue = ("FOUR_BYTE_FLOAT".equals(mapping.getDataType()))
|
||||
? BigDecimal.valueOf(doubleValue).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
|
||||
: Double.toString(doubleValue);
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), formattedValue));
|
||||
continue;
|
||||
} catch (ClassCastException cce) {
|
||||
// Some implementations return Short/Integer instead of Double. Try to handle generically.
|
||||
try {
|
||||
final Object raw = send.getValue(mapping.getRegistry());
|
||||
LOGGER.debug("Batch read returned raw value (type {}): {} for registry {}", raw == null ? "null" : raw.getClass().getSimpleName(), raw, mapping.getRegistry());
|
||||
final double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
|
||||
if (raw instanceof Number) {
|
||||
// preserve legacy negative decoding for signed 16-bit values when requested
|
||||
if (raw instanceof Short) {
|
||||
final short s = (Short) raw;
|
||||
if (mapping.getSignedLegacyWrap() != null && mapping.getSignedLegacyWrap() && s < -32000) {
|
||||
final double v = -((s + 32768.0) * scale);
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), Double.toString(v)));
|
||||
continue;
|
||||
} else {
|
||||
final double v = ((Number) raw).doubleValue() * scale;
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), Double.toString(v)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
).collect(Collectors.toList());
|
||||
|
||||
final double v = ((Number) raw).doubleValue() * scale;
|
||||
final String valStr = ("FOUR_BYTE_FLOAT".equals(mapping.getDataType()))
|
||||
? BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
|
||||
: Double.toString(v);
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), valStr));
|
||||
continue;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fall through to per-register read
|
||||
}
|
||||
|
||||
// Fallback: read individually using mapping's unitId and decode with mapping's settings
|
||||
try {
|
||||
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
|
||||
ReadHoldingRegistersResponse response = getRegistry(unitId, mapping.getRegistry(), isFourByte(mapping.getDataType()) ? 2 : 1);
|
||||
final String value = normalizeRegistryValue(response.getShortData()[0]);
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), value));
|
||||
continue;
|
||||
} catch (Exception exInner) {
|
||||
LOGGER.warn("Cannot read registry {} individually after batch type mismatch: {}", mapping.getRegistry(), exInner.getMessage());
|
||||
results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), null));
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("Cannot get all registries due to: ", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Registry get(int register) {
|
||||
try {
|
||||
ReadHoldingRegistersResponse response = getRegistry(register);
|
||||
final String value = normalizeRegistryValue(response.getShortData()[0]);
|
||||
final RegistryMapping mapping = findRegistryMapping(register);
|
||||
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
|
||||
final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
|
||||
|
||||
return Registry.builder().registry(register)
|
||||
.value(value)
|
||||
.desc(mapping.getDesc())
|
||||
.build();
|
||||
ReadHoldingRegistersResponse response = isFourByte(dataType)
|
||||
? getRegistry(unitId, register, 2)
|
||||
: getRegistry(unitId, register, 1);
|
||||
|
||||
final String value;
|
||||
if (isFourByte(dataType)) {
|
||||
value = decodeValueFromShorts(response.getShortData(), dataType, mapping.getScale(), mapping.getSignedLegacyWrap());
|
||||
} else {
|
||||
value = normalizeRegistryValue(response.getShortData()[0]);
|
||||
}
|
||||
|
||||
return new Registry(register, mapping.getDesc(), value);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Cannot get registry {} due to: ", register, e);
|
||||
throw new RuntimeException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private ReadHoldingRegistersResponse getRegistry(int register) throws Exception {
|
||||
ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(1, register, 1);
|
||||
private ReadHoldingRegistersResponse getRegistry(int unitId, int register, int count) throws Exception {
|
||||
// Keep addressing consistent with the legacy implementation (do not subtract 1)
|
||||
ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(unitId, register, count);
|
||||
ReadHoldingRegistersResponse response = (ReadHoldingRegistersResponse) connection.getConnection().send(request);
|
||||
|
||||
if (response.isException()) {
|
||||
@@ -87,10 +149,125 @@ public class ModbusRtuTcpRepository implements Repository {
|
||||
return response;
|
||||
}
|
||||
|
||||
private boolean isFourByte(String dataType) {
|
||||
return dataType != null && dataType.startsWith("FOUR_BYTE");
|
||||
}
|
||||
|
||||
private String decodeValueFromShorts(short[] data, String dataType, Double scaleObj, Boolean signedLegacyWrap) {
|
||||
final double scale = scaleObj == null ? 0.1 : scaleObj;
|
||||
if (data == null || data.length < 1) return null;
|
||||
try {
|
||||
if (isFourByte(dataType)) {
|
||||
if (data.length < 2) return null;
|
||||
final boolean swapped = dataType != null && dataType.endsWith("_SWAPPED");
|
||||
final int high = swapped ? (data[1] & 0xFFFF) : (data[0] & 0xFFFF);
|
||||
final int low = swapped ? (data[0] & 0xFFFF) : (data[1] & 0xFFFF);
|
||||
final int combined = (high << 16) | low;
|
||||
switch (dataType) {
|
||||
case "FOUR_BYTE_INT_SIGNED":
|
||||
case "FOUR_BYTE_INT_SWAPPED":
|
||||
final int signedInt = combined;
|
||||
return String.valueOf(signedInt * scale);
|
||||
case "FOUR_BYTE_INT_UNSIGNED":
|
||||
case "FOUR_BYTE_INT_UNSIGNED_SWAPPED":
|
||||
final long unsigned = Integer.toUnsignedLong(combined);
|
||||
return String.valueOf(unsigned * scale);
|
||||
case "FOUR_BYTE_FLOAT":
|
||||
case "FOUR_BYTE_FLOAT_SWAPPED":
|
||||
final float f = Float.intBitsToFloat(combined);
|
||||
final double scaledFloat = f * scale;
|
||||
// Round float results to 2 decimals for human-friendly output
|
||||
return BigDecimal.valueOf(scaledFloat).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
|
||||
default:
|
||||
return String.valueOf(combined * scale);
|
||||
}
|
||||
} else {
|
||||
// two-byte fallback
|
||||
final short s = data[0];
|
||||
if (signedLegacyWrap != null && signedLegacyWrap && s < -32000) {
|
||||
return String.valueOf(-(s + 32768) * scale);
|
||||
}
|
||||
return String.valueOf(s * scale);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to decode raw registers {} as {}: {}", java.util.Arrays.toString(data), dataType, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private RegistryMapping findRegistryMapping(int register) throws Exception {
|
||||
return registryMappings.stream()
|
||||
.filter(reg -> reg.getRegistry().equals(register))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new Exception("Missing registry mapping in configuration"));
|
||||
}
|
||||
|
||||
private int resolveDataType(RegistryMapping mapping) {
|
||||
final String dt = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
|
||||
switch (dt) {
|
||||
case "TWO_BYTE_INT_UNSIGNED":
|
||||
return DataType.TWO_BYTE_INT_UNSIGNED;
|
||||
case "FOUR_BYTE_INT_SIGNED":
|
||||
case "FOUR_BYTE_INT_SWAPPED":
|
||||
return DataType.FOUR_BYTE_INT_SIGNED;
|
||||
case "FOUR_BYTE_INT_UNSIGNED":
|
||||
case "FOUR_BYTE_INT_UNSIGNED_SWAPPED":
|
||||
return DataType.FOUR_BYTE_INT_UNSIGNED;
|
||||
case "FOUR_BYTE_FLOAT":
|
||||
case "FOUR_BYTE_FLOAT_SWAPPED":
|
||||
try {
|
||||
return DataType.FOUR_BYTE_FLOAT; // if available in modbus4j
|
||||
} catch (NoSuchFieldError e) {
|
||||
return DataType.FOUR_BYTE_INT_SIGNED; // fallback
|
||||
}
|
||||
case "TWO_BYTE_INT_SIGNED":
|
||||
default:
|
||||
return DataType.TWO_BYTE_INT_SIGNED;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
final RegistryMapping mapping = findRegistryMapping(register);
|
||||
final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
|
||||
final Double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
|
||||
|
||||
// Validate range using mapping metadata (values are in scaled units)
|
||||
final Double min = mapping.getMin();
|
||||
final Double max = mapping.getMax();
|
||||
if (min != null && scaledValue < min) {
|
||||
throw new IllegalArgumentException("Value " + scaledValue + " below minimum " + min + " for registry " + register);
|
||||
}
|
||||
if (max != null && scaledValue > max) {
|
||||
throw new IllegalArgumentException("Value " + scaledValue + " above maximum " + max + " for registry " + register);
|
||||
}
|
||||
|
||||
if (isFourByte(dataType)) {
|
||||
throw new UnsupportedOperationException("Writes for 4-byte data types are not implemented in this repository");
|
||||
}
|
||||
final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
|
||||
final long raw = Math.round(scaledValue / scale);
|
||||
// Constrain raw to 16-bit signed range
|
||||
final short rawShort = (short) ((int) raw);
|
||||
try {
|
||||
final com.serotonin.modbus4j.msg.WriteRegisterRequest req = new com.serotonin.modbus4j.msg.WriteRegisterRequest(unitId, register, rawShort);
|
||||
final com.serotonin.modbus4j.msg.ModbusResponse resp = (com.serotonin.modbus4j.msg.ModbusResponse) connection.getConnection().send(req);
|
||||
if (resp != null && resp.isException()) {
|
||||
throw new Exception(resp.getExceptionMessage());
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// pass through validation error
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new Exception("Write failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeRegistryValue(short value, Double scaleObj, Boolean signedLegacyWrap) {
|
||||
final double scale = scaleObj == null ? 0.1 : scaleObj;
|
||||
if (signedLegacyWrap != null && signedLegacyWrap && value < -32000) {
|
||||
return String.valueOf(-(value + 32768) * scale);
|
||||
}
|
||||
return String.valueOf(value * scale);
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,8 @@ public class ModbusService {
|
||||
public Registry get(int register) {
|
||||
return repository.get(register);
|
||||
}
|
||||
}
|
||||
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
repository.write(register, scaledValue);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,21 @@ package home.automation.modbus;
|
||||
import lombok.*;
|
||||
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@ToString
|
||||
public class Registry {
|
||||
private Integer registry;
|
||||
private String desc;
|
||||
private String value;
|
||||
|
||||
public Registry(Integer registry, String desc, String value) {
|
||||
this.registry = registry;
|
||||
this.desc = desc;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public Integer getRegistry() { return registry; }
|
||||
public String getDesc() { return desc; }
|
||||
public String getValue() { return value; }
|
||||
|
||||
public void setValue(String v) { this.value = v; }
|
||||
}
|
||||
@@ -6,4 +6,11 @@ public interface Repository {
|
||||
List<Registry> getAll();
|
||||
|
||||
Registry get(int register);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a scaled value to a registry. The repository is responsible for
|
||||
* converting the scaled value to device raw register(s) and performing
|
||||
* the Modbus write.
|
||||
*/
|
||||
void write(int register, double scaledValue) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package home.automation.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.sse.SseClient;
|
||||
import home.automation.live.ModbusPoller;
|
||||
import home.automation.live.LiveRegistryBuffer;
|
||||
import home.automation.modbus.ModbusService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class WebServer {
|
||||
private final ModbusService service;
|
||||
private final ModbusPoller poller;
|
||||
private final int port;
|
||||
private Javalin app;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(WebServer.class);
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final Set<SseClient> sessions = ConcurrentHashMap.newKeySet();
|
||||
private java.util.concurrent.ScheduledExecutorService heartbeatScheduler;
|
||||
|
||||
public WebServer(ModbusService service, ModbusPoller poller, int port) {
|
||||
this.service = service;
|
||||
this.poller = poller;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
app = Javalin.create();
|
||||
|
||||
// REST snapshot of registries
|
||||
app.get("/api/registries", ctx -> ctx.json(service.readAll()));
|
||||
|
||||
// REST snapshot of live buffer
|
||||
app.get("/api/live", ctx -> {
|
||||
final LiveRegistryBuffer buf = poller.getBuffer();
|
||||
ctx.json(buf.snapshotAll());
|
||||
});
|
||||
|
||||
// Provide full mapping configuration (including optional UI metadata)
|
||||
app.get("/api/mappings", ctx -> {
|
||||
final var is = Thread.currentThread().getContextClassLoader().getResourceAsStream("registryMapping.json");
|
||||
if (is == null) {
|
||||
ctx.status(500).result("Configuration missing");
|
||||
return;
|
||||
}
|
||||
final Object parsed = mapper.readValue(is, Object.class);
|
||||
ctx.json(parsed);
|
||||
});
|
||||
|
||||
// Write registry
|
||||
app.post("/api/registry/{id}/write", ctx -> {
|
||||
final int id = Integer.parseInt(ctx.pathParam("id"));
|
||||
final Map<String, Object> body = mapper.readValue(ctx.body(), Map.class);
|
||||
if (!body.containsKey("value")) {
|
||||
ctx.status(400).result("Missing 'value' in body");
|
||||
return;
|
||||
}
|
||||
final Object valObj = body.get("value");
|
||||
LOGGER.info("Write request for registry {} with body {}", id, body);
|
||||
try {
|
||||
if (valObj == null) {
|
||||
ctx.status(400).result("Missing 'value' in body");
|
||||
return;
|
||||
}
|
||||
final double val;
|
||||
if (valObj instanceof Number) {
|
||||
val = ((Number) valObj).doubleValue();
|
||||
} else {
|
||||
// try parse string
|
||||
try {
|
||||
val = Double.parseDouble(valObj.toString());
|
||||
} catch (NumberFormatException nfe) {
|
||||
ctx.status(400).result("Invalid 'value' in body");
|
||||
return;
|
||||
}
|
||||
}
|
||||
service.write(id, val);
|
||||
ctx.status(204);
|
||||
} catch (IllegalArgumentException iae) {
|
||||
ctx.status(400).result(iae.getMessage());
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Write to registry {} failed: {}", id, e.getMessage());
|
||||
// If the failure is due to device connectivity, return 503 so frontend can show offline state
|
||||
if (isDeviceUnavailable(e)) {
|
||||
ctx.status(503).result("Modbus device unavailable: " + e.getMessage());
|
||||
} else {
|
||||
ctx.status(500).result(e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to detect device unavailability via cause types or messages
|
||||
// Keep this package-private and simple: check for known exception types in cause chain or indicative messages
|
||||
// (e.g., SocketTimeoutException, ModbusInitException, or text like "timed out" or "Failed to establish connection")
|
||||
final java.util.function.Predicate<Throwable> isDeviceUnavailablePredicate = t -> {
|
||||
Throwable cur = t;
|
||||
while (cur != null) {
|
||||
final String cn = cur.getClass().getName();
|
||||
if (cn.contains("SocketTimeoutException") || cn.contains("ModbusInitException") || (cur.getMessage() != null && (cur.getMessage().toLowerCase().contains("connect timed out") || cur.getMessage().toLowerCase().contains("failed to establish connection")))) {
|
||||
return true;
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// SSE for live points
|
||||
app.sse("/sse/live", client -> {
|
||||
// register connected client; we'll remove clients if sending fails
|
||||
sessions.add(client);
|
||||
// ensure removal when client closes
|
||||
client.onClose(() -> {
|
||||
sessions.remove(client);
|
||||
LOGGER.info("SSE client closed and removed");
|
||||
});
|
||||
});
|
||||
|
||||
// Schedule heartbeat pings to keep connections alive and detect dead clients
|
||||
heartbeatScheduler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
heartbeatScheduler.scheduleAtFixedRate(() -> {
|
||||
sessions.forEach(s -> {
|
||||
try {
|
||||
s.sendEvent("ping", "");
|
||||
} catch (Exception e) {
|
||||
// remove failing client
|
||||
sessions.remove(s);
|
||||
LOGGER.info("Removed SSE client due to send failure: {}", e.getMessage());
|
||||
}
|
||||
});
|
||||
}, 10, 10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
|
||||
// Subscribe poller events and broadcast via SSE to connected clients
|
||||
poller.addListener(points -> {
|
||||
try {
|
||||
final String msg = mapper.writeValueAsString(points.stream().map(p -> Map.of("registry", p.registry, "ts", p.ts, "value", p.value)).collect(Collectors.toList()));
|
||||
sessions.forEach(s -> {
|
||||
try {
|
||||
s.sendEvent("update", msg);
|
||||
} catch (Exception e) {
|
||||
sessions.remove(s);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// ignore serialization errors
|
||||
}
|
||||
});
|
||||
|
||||
app.start(port);
|
||||
}
|
||||
|
||||
private boolean isDeviceUnavailable(Throwable t) {
|
||||
Throwable cur = t;
|
||||
while (cur != null) {
|
||||
final String cn = cur.getClass().getName();
|
||||
if (cn.contains("SocketTimeoutException") || cn.contains("ModbusInitException") || (cur.getMessage() != null && (cur.getMessage().toLowerCase().contains("connect timed out") || cur.getMessage().toLowerCase().contains("failed to establish connection")))) {
|
||||
return true;
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (heartbeatScheduler != null) {
|
||||
try { heartbeatScheduler.shutdownNow(); } catch (Exception ignored) {}
|
||||
}
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"address": "192.168.0.65",
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"useMock": true,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.mariuszpacek.pl",
|
||||
"username": "",
|
||||
@@ -9,83 +10,110 @@
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "External temperature"
|
||||
"desc": "Temperatura zewnętrzna",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4070,
|
||||
"desc": "Temp in"
|
||||
"desc": "Temperatura wewnętrzna",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4071,
|
||||
"desc": "Temp out"
|
||||
"desc": "Temperatura wyjściowa"
|
||||
},
|
||||
{
|
||||
"registry": 4072,
|
||||
"desc": "Temp pc out"
|
||||
"desc": "Temperatura PC (wyjście)"
|
||||
},
|
||||
{
|
||||
"registry": 4067,
|
||||
"desc": "Temp co"
|
||||
"desc": "Temperatura CO"
|
||||
},
|
||||
{
|
||||
"registry": 4202,
|
||||
"desc": "Set Temp co"
|
||||
"desc": "Nastawa CO",
|
||||
"controlType": "setpoint",
|
||||
"min": 5.0,
|
||||
"max": 90.0,
|
||||
"step": 0.1,
|
||||
"unit": "°C"
|
||||
},
|
||||
{
|
||||
"registry": 4069,
|
||||
"desc": "Temp cwu"
|
||||
"desc": "Temperatura CWU"
|
||||
},
|
||||
{
|
||||
"registry": 4203,
|
||||
"desc": "Set Temp cwu"
|
||||
"desc": "Nastawa CWU",
|
||||
"controlType": "setpoint",
|
||||
"min": 20.0,
|
||||
"max": 80.0,
|
||||
"step": 0.1,
|
||||
"unit": "°C"
|
||||
},
|
||||
{
|
||||
"registry": 4066,
|
||||
"desc": "Tem wew"
|
||||
"desc": "Temperatura wewnętrzna"
|
||||
},
|
||||
{
|
||||
"registry": 4201,
|
||||
"desc": "Set Temp wew"
|
||||
"desc": "Nastawa wewnętrzna",
|
||||
"controlType": "setpoint",
|
||||
"min": 5.0,
|
||||
"max": 30.0,
|
||||
"step": 0.1,
|
||||
"unit": "°C"
|
||||
},
|
||||
{
|
||||
"registry": 4199,
|
||||
"desc": "Temp zsr"
|
||||
"desc": "Temperatura ZSR"
|
||||
},
|
||||
{
|
||||
"registry": 4117,
|
||||
"desc": "Pompa Dz"
|
||||
"desc": "Pompa Dz",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4113,
|
||||
"desc": "Pompa BUF"
|
||||
"desc": "Pompa BUF",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4112,
|
||||
"desc": "Pompa CO"
|
||||
"desc": "Pompa CO",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4118,
|
||||
"desc": "Pompa CWU"
|
||||
"desc": "Pompa CWU",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4114,
|
||||
"desc": "Sprezarka"
|
||||
"desc": "Sprężarka",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4115,
|
||||
"desc": "GrzalkaCWU"
|
||||
"desc": "Grzałka CWU",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4119,
|
||||
"desc": "WyCHL"
|
||||
"desc": "WyCHL",
|
||||
"controlType": "toggle"
|
||||
},
|
||||
{
|
||||
"registry": 4116,
|
||||
"desc": "Pompa CCW"
|
||||
},
|
||||
{
|
||||
"registry": 4116,
|
||||
"desc": "Pompa CCW"
|
||||
"desc": "Pompa CCW",
|
||||
"controlType": "toggle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,5 +11,14 @@ class JsonFileBasedConfigurationTests {
|
||||
ConfigurationStrategy configuration = new JsonFileBasedConfiguration();
|
||||
final Optional<RegistryConfiguration> config = configuration.getConfig();
|
||||
assert (config.isPresent());
|
||||
final RegistryConfiguration cfg = config.get();
|
||||
// config defaults should be applied
|
||||
assert (cfg.getPort() != null && cfg.getPort() == 502);
|
||||
assert (!cfg.getRegistry_mapping().isEmpty());
|
||||
final RegistryMapping first = cfg.getRegistry_mapping().get(0);
|
||||
assert (first.getUnitId() != null && first.getUnitId() == 1);
|
||||
assert (first.getScale() != null && first.getScale() == 0.1);
|
||||
assert (first.getDataType() != null && "TWO_BYTE_INT_SIGNED".equals(first.getDataType()));
|
||||
assert (first.getSignedLegacyWrap() != null && first.getSignedLegacyWrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package home.automation.live;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LiveRegistryBufferTest {
|
||||
|
||||
@Test
|
||||
void testBufferWrap() {
|
||||
LiveRegistryBuffer buf = new LiveRegistryBuffer(3);
|
||||
buf.addSample(100, 1L, 1.0);
|
||||
buf.addSample(100, 2L, 2.0);
|
||||
buf.addSample(100, 3L, 3.0);
|
||||
assertEquals(3, buf.size(100));
|
||||
buf.addSample(100, 4L, 4.0);
|
||||
List<LiveRegistryBuffer.Sample> s = buf.getSamples(100);
|
||||
assertEquals(3, s.size());
|
||||
assertEquals(2.0, s.get(0).value);
|
||||
assertEquals(3.0, s.get(1).value);
|
||||
assertEquals(4.0, s.get(2).value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package home.automation.live;
|
||||
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import home.automation.modbus.ModbusService;
|
||||
import home.automation.modbus.Registry;
|
||||
import home.automation.modbus.Repository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ModbusPollerTest {
|
||||
|
||||
@Test
|
||||
void pollOnceAddsSamples() {
|
||||
Repository repo = new Repository() {
|
||||
@Override
|
||||
public List<Registry> getAll() {
|
||||
return List.of(new Registry(5000, "t", "1.23"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Registry get(int register) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
// not used in this test
|
||||
}
|
||||
};
|
||||
|
||||
ModbusService service = new ModbusService(repo);
|
||||
List<RegistryMapping> mappings = List.of(new RegistryMapping() {{
|
||||
setRegistry(5000);
|
||||
setDesc("t");
|
||||
setUnitId(1);
|
||||
setScale(1.0);
|
||||
setDataType("FOUR_BYTE_FLOAT");
|
||||
setSignedLegacyWrap(false);
|
||||
}});
|
||||
|
||||
ModbusPoller poller = new ModbusPoller(service, mappings, 2000, 300);
|
||||
poller.pollOnce();
|
||||
|
||||
List<LiveRegistryBuffer.Sample> samples = poller.getBuffer().getSamples(5000);
|
||||
assertEquals(1, samples.size());
|
||||
assertEquals(1.23, samples.get(0).value, 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MockRepositoryTest {
|
||||
|
||||
@Test
|
||||
void getAllAndWriteBehavior() throws Exception {
|
||||
RegistryMapping sp = new RegistryMapping();
|
||||
sp.setRegistry(1000);
|
||||
sp.setDesc("Setpoint");
|
||||
sp.setControlType("setpoint");
|
||||
sp.setMin(10.0);
|
||||
sp.setMax(30.0);
|
||||
sp.setStep(0.5);
|
||||
|
||||
RegistryMapping tog = new RegistryMapping();
|
||||
tog.setRegistry(1100);
|
||||
tog.setDesc("Toggle");
|
||||
tog.setControlType("toggle");
|
||||
|
||||
MockRepository repo = new MockRepository(List.of(sp, tog));
|
||||
|
||||
assertNotNull(repo.getAll());
|
||||
assertNotNull(repo.get(1000));
|
||||
assertNotNull(repo.get(1100));
|
||||
|
||||
repo.write(1000, 25.5);
|
||||
assertEquals("25.5", repo.get(1000).getValue());
|
||||
|
||||
repo.write(1100, 0);
|
||||
assertEquals("0.0", repo.get(1100).getValue());
|
||||
|
||||
assertThrows(Exception.class, () -> repo.write(9999, 1.0));
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static java.lang.Thread.sleep;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
class ModbusCachedWrapperTests {
|
||||
|
||||
public static final int MAX_TESTS = 10;
|
||||
private final RepositoryInMemory repository = new RepositoryInMemory();
|
||||
private final ModbusCachedWrapper cachedRepository = new ModbusCachedWrapper(repository, Duration.ofSeconds(1));
|
||||
|
||||
@Test
|
||||
public void givenRepositoryInMemoryAndCacheDurationOf1Seconds_WhenGetRegistryFromCachedRepository2times_ThenValuesAreTheSame() {
|
||||
final Registry firstTry = cachedRepository.get(4065);
|
||||
final Registry secondTry = cachedRepository.get(4065);
|
||||
assert (firstTry.getValue().equals(secondTry.getValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenRepositoryInMemoryAndCacheDurationOf1Seconds_WhenGetRegistryFromCachedRepository2timesWithDelayMoreThan1Sec_ThenValuesAreTheDifferent() throws InterruptedException {
|
||||
final Registry firstTry = cachedRepository.get(4065);
|
||||
final long currentTimeMillis = System.currentTimeMillis();
|
||||
final long desireTimeoutInMs = 1000;
|
||||
while (desireTimeoutInMs > System.currentTimeMillis() - currentTimeMillis) {
|
||||
sleep(510);
|
||||
}
|
||||
final Registry secondTry = cachedRepository.get(4065);
|
||||
assert (!firstTry.getValue().equals(secondTry.getValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenRepositoryInMemoryAndCacheDurationOf3Seconds_WhenGetRegistryFromCachedRepositoryMultipleTimesWithIn3SecondDelay_ThenAllValuesAreTheSame() throws InterruptedException {
|
||||
final Registry firstTry = cachedRepository.get(4065);
|
||||
final long currentTimeMillis = System.currentTimeMillis();
|
||||
final long desireTimeoutInMs = 500;
|
||||
int count = 0;
|
||||
while (desireTimeoutInMs > System.currentTimeMillis() - currentTimeMillis || count == MAX_TESTS) {
|
||||
count++;
|
||||
final Registry secondTry = cachedRepository.get(4065);
|
||||
assert (firstTry.getValue().equals(secondTry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenRepositoryInMemoryAndCacheDurationOf3Seconds_WhenGetAllFromCachedRepository2times_ThenValuesAreTheSame() {
|
||||
final List<Registry> firstTry = cachedRepository.getAll();
|
||||
final List<Registry> secondTry = cachedRepository.getAll();
|
||||
assertEquals(firstTry, secondTry);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenRepositoryInMemoryAndCacheDurationOf3Seconds_WhenGetAllFromCachedRepository2timesWith4SecondDelay_ThenValuesAreTheDifferent() throws InterruptedException {
|
||||
final List<Registry> firstTry = cachedRepository.getAll();
|
||||
final long currentTimeMillis = System.currentTimeMillis();
|
||||
final long desireTimeoutInMs = 1000;
|
||||
while (desireTimeoutInMs > System.currentTimeMillis() - currentTimeMillis) {
|
||||
sleep(510);
|
||||
}
|
||||
final List<Registry> secondTry = cachedRepository.getAll();
|
||||
assertNotEquals(firstTry, secondTry);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenRepositoryInMemoryAndCacheDurationOf3Seconds_WhenGetAllFromCachedRepositoryMultipleTimesWithIn3SecondDelay_ThenAllValuesAreTheSame() throws InterruptedException {
|
||||
final List<Registry> firstTry = cachedRepository.getAll();
|
||||
final long currentTimeMillis = System.currentTimeMillis();
|
||||
final long desireTimeoutInMs = 500;
|
||||
int count = 0;
|
||||
while (desireTimeoutInMs > System.currentTimeMillis() - currentTimeMillis || count == MAX_TESTS) {
|
||||
count++;
|
||||
final List<Registry> secondTry = cachedRepository.getAll();
|
||||
assertEquals(firstTry, secondTry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import com.serotonin.modbus4j.ModbusMaster;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import home.automation.configuration.ConfigurationStrategy;
|
||||
|
||||
import java.net.InetAddress;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class ModbusConnectionCloseTests {
|
||||
|
||||
static class TestModbusConnection extends ModbusConnection {
|
||||
private final ModbusMaster toReturn;
|
||||
|
||||
public TestModbusConnection(ConfigurationStrategy cfg, ModbusMaster toReturn) {
|
||||
super(cfg);
|
||||
this.toReturn = toReturn;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModbusMaster initConnection(java.net.InetAddress address, int port) throws Exception {
|
||||
return toReturn;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void givenActiveConnection_whenClose_thenDestroyCalled() throws Exception {
|
||||
home.automation.configuration.RegistryConfiguration rc = new home.automation.configuration.RegistryConfiguration();
|
||||
rc.setAddress(InetAddress.getLoopbackAddress());
|
||||
rc.setPort(502);
|
||||
ModbusConnection.TestConfiguration cfg = new ModbusConnection.TestConfiguration(rc);
|
||||
|
||||
ModbusMaster mockMaster = Mockito.mock(ModbusMaster.class);
|
||||
TestModbusConnection conn = new TestModbusConnection(cfg, mockMaster);
|
||||
|
||||
ModbusMaster m = conn.getConnection();
|
||||
assertEquals(mockMaster, m);
|
||||
|
||||
conn.close();
|
||||
|
||||
verify(mockMaster, times(1)).destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
void givenClosed_thenGetConnectionReconnects() throws Exception {
|
||||
home.automation.configuration.RegistryConfiguration rc = new home.automation.configuration.RegistryConfiguration();
|
||||
rc.setAddress(InetAddress.getLoopbackAddress());
|
||||
rc.setPort(502);
|
||||
ModbusConnection.TestConfiguration cfg = new ModbusConnection.TestConfiguration(rc);
|
||||
|
||||
ModbusMaster first = Mockito.mock(ModbusMaster.class);
|
||||
ModbusMaster second = Mockito.mock(ModbusMaster.class);
|
||||
|
||||
TestModbusConnection conn = new TestModbusConnection(cfg, first) {
|
||||
private int count = 0;
|
||||
|
||||
@Override
|
||||
protected ModbusMaster initConnection(java.net.InetAddress address, int port) throws Exception {
|
||||
count++;
|
||||
return count == 1 ? first : second;
|
||||
}
|
||||
};
|
||||
|
||||
ModbusMaster m1 = conn.getConnection();
|
||||
assertEquals(first, m1);
|
||||
conn.close();
|
||||
ModbusMaster m2 = conn.getConnection();
|
||||
assertEquals(second, m2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import com.serotonin.modbus4j.ModbusMaster;
|
||||
import home.automation.configuration.ConfigurationStrategy;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ModbusConnectionRetryTests {
|
||||
|
||||
static class TestModbusConnection extends ModbusConnection {
|
||||
private final int failTimes;
|
||||
private final AtomicInteger counter = new AtomicInteger(0);
|
||||
private final ModbusMaster toReturn;
|
||||
|
||||
public TestModbusConnection(ConfigurationStrategy cfg, int failTimes, ModbusMaster toReturn) {
|
||||
super(cfg);
|
||||
this.failTimes = failTimes;
|
||||
this.toReturn = toReturn;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModbusMaster initConnection(InetAddress address, int port) throws Exception {
|
||||
final int c = counter.getAndIncrement();
|
||||
if (c < failTimes) {
|
||||
throw new Exception("simulated failure");
|
||||
}
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
public int getAttempts() {
|
||||
return counter.get();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void givenRetriesConfigured_whenInitFails_thenRetriesAndSucceeds() throws Exception {
|
||||
// configuration with maxRetries=2 (so it will attempt up to 3 times)
|
||||
home.automation.configuration.RegistryConfiguration rc = new home.automation.configuration.RegistryConfiguration();
|
||||
rc.setAddress(InetAddress.getLoopbackAddress());
|
||||
rc.setPort(502);
|
||||
rc.setMaxRetries(2);
|
||||
rc.setRetryBackoffMs(1);
|
||||
ModbusConnection.TestConfiguration cfg = new ModbusConnection.TestConfiguration(rc);
|
||||
|
||||
ModbusMaster mockMaster = Mockito.mock(ModbusMaster.class);
|
||||
TestModbusConnection conn = new TestModbusConnection(cfg, 2, mockMaster);
|
||||
|
||||
final ModbusMaster master = conn.getConnection();
|
||||
assertEquals(mockMaster, master);
|
||||
// attempts should be 3 (0,1 fail; 2 succeed)
|
||||
assertEquals(3, conn.getAttempts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void givenRetriesExhausted_thenExceptionIsThrown() throws Exception {
|
||||
home.automation.configuration.RegistryConfiguration rc = new home.automation.configuration.RegistryConfiguration();
|
||||
rc.setAddress(InetAddress.getLoopbackAddress());
|
||||
rc.setPort(502);
|
||||
rc.setMaxRetries(1); // allows two attempts
|
||||
rc.setRetryBackoffMs(1);
|
||||
ModbusConnection.TestConfiguration cfg = new ModbusConnection.TestConfiguration(rc);
|
||||
|
||||
ModbusMaster mockMaster = Mockito.mock(ModbusMaster.class);
|
||||
TestModbusConnection conn = new TestModbusConnection(cfg, 5, mockMaster);
|
||||
|
||||
assertThrows(Exception.class, conn::getConnection);
|
||||
// attempts should be 2
|
||||
assertEquals(2, conn.getAttempts());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import home.automation.configuration.ConfigurationStrategy;
|
||||
import home.automation.configuration.JsonFileBasedConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.net.InetAddress;
|
||||
|
||||
class ModbusConnectionTests {
|
||||
|
||||
@Test
|
||||
void GivenJsonConfiguration_When_getRemoteEndpoint_Then_returnConfiguredAddressAndPort() throws Exception {
|
||||
ConfigurationStrategy conf = new JsonFileBasedConfiguration();
|
||||
ModbusConnection connection = new ModbusConnection(conf);
|
||||
|
||||
var endpoint = connection.getRemoteEndpoint();
|
||||
InetAddress addr = endpoint.getFirst();
|
||||
Integer port = endpoint.getSecond();
|
||||
|
||||
var confVal = conf.getConfig().orElseThrow();
|
||||
assertEquals(confVal.getAddress().getHostAddress(), addr.getHostAddress());
|
||||
assertEquals(confVal.getPort(), port);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import home.automation.configuration.ConfigurationStrategy;
|
||||
import home.automation.configuration.RegistryConfiguration;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import home.automation.modbus.mock.MockModbusExtension;
|
||||
import home.automation.modbus.mock.MockModbusServer;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
@ExtendWith(MockModbusExtension.class)
|
||||
@Tag("integration")
|
||||
class ModbusIntegrationMockTests {
|
||||
|
||||
static class TestConfiguration implements ConfigurationStrategy {
|
||||
private final RegistryConfiguration cfg;
|
||||
|
||||
TestConfiguration(RegistryConfiguration cfg) {
|
||||
this.cfg = cfg;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RegistryConfiguration> getConfig() {
|
||||
return Optional.of(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void givenMockServer_When_readingRegisters_Then_normalizedValuesMatchLegacy(MockModbusServer server) throws Exception {
|
||||
// set some registers used in tests (values are raw device shorts)
|
||||
// 4201 -> 100 => normalized 10.0
|
||||
server.setRegister(4201, (short) 100);
|
||||
// 4065 -> -5.0 in legacy encoding: raw = -32768 + (5*10) = -32718
|
||||
server.setRegister(4065, (short) -32718);
|
||||
// 4066 -> 204 => 20.4
|
||||
server.setRegister(4066, (short) 204);
|
||||
|
||||
RegistryConfiguration rc = new RegistryConfiguration();
|
||||
rc.setAddress(InetAddress.getLoopbackAddress());
|
||||
rc.setPort(server.getPort());
|
||||
|
||||
// minimal registry mappings for the test
|
||||
RegistryMapping m1 = new RegistryMapping(); m1.setRegistry(4201); m1.setDesc("Set Temp wew");
|
||||
RegistryMapping m2 = new RegistryMapping(); m2.setRegistry(4065); m2.setDesc("External temperature");
|
||||
RegistryMapping m3 = new RegistryMapping(); m3.setRegistry(4066); m3.setDesc("Tem wew");
|
||||
|
||||
ModbusConnection connection = new ModbusConnection(new TestConfiguration(rc));
|
||||
ModbusRtuTcpRepository repo = new ModbusRtuTcpRepository(connection, List.of(m1, m2, m3));
|
||||
|
||||
List<Registry> all = repo.getAll();
|
||||
assertNotNull(all);
|
||||
|
||||
Registry r4201 = all.stream().filter(r -> r.getRegistry() == 4201).findFirst().orElse(null);
|
||||
assertNotNull(r4201);
|
||||
assertEquals("10.0", r4201.getValue());
|
||||
|
||||
Registry r4065 = all.stream().filter(r -> r.getRegistry() == 4065).findFirst().orElse(null);
|
||||
assertNotNull(r4065);
|
||||
assertEquals("-5.0", r4065.getValue());
|
||||
|
||||
Registry r4066 = all.stream().filter(r -> r.getRegistry() == 4066).findFirst().orElse(null);
|
||||
assertNotNull(r4066);
|
||||
assertEquals("20.4", r4066.getValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ModbusParserTests {
|
||||
|
||||
private ModbusRtuTcpRepository createRepo() {
|
||||
return new ModbusRtuTcpRepository(new Connection() {
|
||||
@Override
|
||||
public com.serotonin.modbus4j.ModbusMaster getConnection() {
|
||||
throw new UnsupportedOperationException("not used in parser tests");
|
||||
}
|
||||
}, java.util.Collections.emptyList());
|
||||
}
|
||||
|
||||
private String invokeDecode(ModbusRtuTcpRepository repo, short[] data, String dataType, Double scale, Boolean legacy) throws Exception {
|
||||
Method m = ModbusRtuTcpRepository.class.getDeclaredMethod("decodeValueFromShorts", short[].class, String.class, Double.class, Boolean.class);
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(repo, (Object) data, dataType, scale, legacy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeFourByteFloat() throws Exception {
|
||||
ModbusRtuTcpRepository repo = createRepo();
|
||||
float value = 12.34f;
|
||||
int bits = Float.floatToIntBits(value);
|
||||
short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
short low = (short) (bits & 0xFFFF);
|
||||
String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_FLOAT", 1.0, null);
|
||||
assertEquals("12.34", decoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeFourByteFloatSwapped() throws Exception {
|
||||
ModbusRtuTcpRepository repo = createRepo();
|
||||
float value = -5.5f;
|
||||
int bits = Float.floatToIntBits(value);
|
||||
short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
short low = (short) (bits & 0xFFFF);
|
||||
// swapped: pass low first, then high and use SWAPPED dataType
|
||||
String decoded = invokeDecode(repo, new short[]{low, high}, "FOUR_BYTE_FLOAT_SWAPPED", 1.0, null);
|
||||
// rounding to 2 decimals expected by implementation
|
||||
assertEquals("-5.5", decoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeFourByteUnsignedInt() throws Exception {
|
||||
ModbusRtuTcpRepository repo = createRepo();
|
||||
long unsignedVal = 3000000000L; // > 2^31
|
||||
int bits = (int) unsignedVal; // low 32 bits
|
||||
short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
short low = (short) (bits & 0xFFFF);
|
||||
String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_INT_UNSIGNED", 1.0, null);
|
||||
assertEquals((double) unsignedVal, Double.parseDouble(decoded), 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeFourByteSignedInt() throws Exception {
|
||||
ModbusRtuTcpRepository repo = createRepo();
|
||||
int signedVal = -12345678;
|
||||
int bits = signedVal;
|
||||
short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
short low = (short) (bits & 0xFFFF);
|
||||
String decoded = invokeDecode(repo, new short[]{high, low}, "FOUR_BYTE_INT_SIGNED", 1.0, null);
|
||||
assertEquals((double) signedVal, Double.parseDouble(decoded), 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeTwoByteSignedLegacyWrap() throws Exception {
|
||||
ModbusRtuTcpRepository repo = createRepo();
|
||||
short s = (short) (-32760); // near legacy wrap sentinel
|
||||
String decoded = invokeDecode(repo, new short[]{s}, "TWO_BYTE_INT_SIGNED", 1.0, true);
|
||||
// calculation: -(s + 32768) * scale -> -( -32760 + 32768) = -8
|
||||
assertEquals("-8.0", decoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeTwoByteNormal() throws Exception {
|
||||
ModbusRtuTcpRepository repo = createRepo();
|
||||
short s = (short) (123);
|
||||
String decoded = invokeDecode(repo, new short[]{s}, "TWO_BYTE_INT_SIGNED", 0.1, false);
|
||||
// 123 * 0.1 = 12.3
|
||||
assertEquals("12.3", decoded);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package home.automation.modbus;
|
||||
|
||||
import com.serotonin.modbus4j.ModbusMaster;
|
||||
import com.serotonin.modbus4j.msg.ModbusRequest;
|
||||
import com.serotonin.modbus4j.msg.ReadHoldingRegistersRequest;
|
||||
import com.serotonin.modbus4j.msg.ReadHoldingRegistersResponse;
|
||||
import home.automation.configuration.JsonFileBasedConfiguration;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
@@ -49,7 +50,7 @@ class ModbusRtuTcpRepositoryTests {
|
||||
@Test
|
||||
public void givenModbusRepositoryWhenGetRegistryIsNegativeValueIsNormalizedAndReturned() throws Exception {
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = {parseShort("-32668")};
|
||||
short[] returnValue = {parseShort("-100")};
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
assert ("-10.0".equals(repository.get(4201).getValue()));
|
||||
@@ -59,8 +60,149 @@ class ModbusRtuTcpRepositoryTests {
|
||||
@Test
|
||||
public void givenModbusRepositoryWhenGetRegistryThenExecptionIsRised() throws Exception {
|
||||
when(response.isException()).thenReturn(true);
|
||||
short[] returnValue = {parseShort("-32668")};
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
Assertions.assertThrowsExactly(RuntimeException.class,() -> "-10.0".equals(repository.get(4201).getValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenBatchResultsReturnsShort_When_getAll_Then_valuesAreConverted() throws Exception {
|
||||
// Create a repository with a single mapping to keep the test focused
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
|
||||
// mock a BatchResults that throws ClassCast when getDoubleValue is invoked for any registry
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenThrow(new ClassCastException());
|
||||
org.mockito.Mockito.when(batchResults.getValue(4201)).thenReturn(Short.valueOf((short)100));
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
|
||||
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
// ensure registry 4201 is present with normalized value 10.0 (scale 0.1 default)
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("10.0", r.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithCustomScale_When_getAll_Then_valueIsScaled() throws Exception {
|
||||
// Create mapping with custom scale 0.5
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
mapping.setScale(0.5);
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
// getDoubleValue returns 100.0 -> scaled to 50.0
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenReturn(100.0);
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
|
||||
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("50.0", r.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithCustomUnitId_When_get_Then_requestUsesUnitId() throws Exception {
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(4201);
|
||||
mapping.setDesc("Set Temp wew");
|
||||
mapping.setUnitId(2);
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = {parseShort("100")};
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(ModbusRequest.class))).thenAnswer(invocation -> {
|
||||
final Object arg = invocation.getArgument(0);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(arg instanceof ReadHoldingRegistersRequest);
|
||||
ReadHoldingRegistersRequest req = (ReadHoldingRegistersRequest) arg;
|
||||
org.junit.jupiter.api.Assertions.assertEquals(2, req.getSlaveId());
|
||||
return response;
|
||||
});
|
||||
|
||||
final Registry r = repoSingle.get(4201);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("10.0", r.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloat_When_getAll_Then_valueIsDecodedFromTwoRegisters() throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5000);
|
||||
mapping.setDesc("Example 4-byte float");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT");
|
||||
mapping.setScale(1.0);
|
||||
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
|
||||
// Simulate BatchResults returning raw value as Number (Double)
|
||||
com.serotonin.modbus4j.BatchResults<Integer> batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
|
||||
org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenThrow(new ClassCastException());
|
||||
// Return Float wrapped as Double
|
||||
org.mockito.Mockito.when(batchResults.getValue(5000)).thenReturn(Float.valueOf(12.34f));
|
||||
org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
|
||||
|
||||
final java.util.List<Registry> all = repoSingle.getAll();
|
||||
final Registry r = all.stream().filter(rr -> rr.getRegistry() == 5000).findFirst().orElse(null);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(r);
|
||||
// value is float 12.34 * scale 1.0
|
||||
org.junit.jupiter.api.Assertions.assertEquals("12.34", r.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloat_When_get_Then_valueIsDecodedFromTwoRegisters() throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5000);
|
||||
mapping.setDesc("Example 4-byte float");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT");
|
||||
mapping.setScale(1.0);
|
||||
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
|
||||
// Prepare ReadHoldingRegistersResponse that carries two 16-bit words representing 12.34f
|
||||
final float expected = 12.34f;
|
||||
final int bits = Float.floatToIntBits(expected);
|
||||
final short high = (short) ((bits >>> 16) & 0xFFFF);
|
||||
final short low = (short) (bits & 0xFFFF);
|
||||
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue = {high, low};
|
||||
when(response.getShortData()).thenReturn(returnValue);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
|
||||
final Registry r = repoSingle.get(5000);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(String.valueOf(expected), r.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenMappingWithFourByteFloatSwapped_When_get_Then_valueIsDecodedFromTwoRegistersSwapped() throws Exception {
|
||||
// mapping uses FOUR_BYTE_FLOAT_SWAPPED (scale 1.0)
|
||||
RegistryMapping mapping = new RegistryMapping();
|
||||
mapping.setRegistry(5002);
|
||||
mapping.setDesc("Example 4-byte float swapped");
|
||||
mapping.setDataType("FOUR_BYTE_FLOAT_SWAPPED");
|
||||
mapping.setScale(1.0);
|
||||
|
||||
final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
|
||||
|
||||
final float expected = 12.34f;
|
||||
final int bits2 = Float.floatToIntBits(expected);
|
||||
final short high2 = (short) ((bits2 >>> 16) & 0xFFFF);
|
||||
final short low2 = (short) (bits2 & 0xFFFF);
|
||||
|
||||
// swapped word order: low, high
|
||||
when(response.isException()).thenReturn(false);
|
||||
short[] returnValue2 = {low2, high2};
|
||||
when(response.getShortData()).thenReturn(returnValue2);
|
||||
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
|
||||
|
||||
final Registry r2 = repoSingle.get(5002);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(String.valueOf(expected), r2.getValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import com.serotonin.modbus4j.ModbusMaster;
|
||||
import com.serotonin.modbus4j.msg.WriteRegisterRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ModbusRtuTcpRepositoryWriteTest {
|
||||
|
||||
@Test
|
||||
void writeConvertsAndSendsWriteRequest() throws Exception {
|
||||
ModbusMaster master = mock(ModbusMaster.class);
|
||||
// return a non-exceptional response so write() doesn't NPE
|
||||
com.serotonin.modbus4j.msg.ModbusResponse okResp = mock(com.serotonin.modbus4j.msg.ModbusResponse.class);
|
||||
when(okResp.isException()).thenReturn(false);
|
||||
when(master.send(any(WriteRegisterRequest.class))).thenReturn(okResp);
|
||||
|
||||
final RegistryMapping m = new RegistryMapping();
|
||||
m.setRegistry(5000);
|
||||
m.setScale(0.1);
|
||||
m.setDataType("TWO_BYTE_INT_SIGNED");
|
||||
m.setUnitId(1);
|
||||
|
||||
final ModbusRtuTcpRepository repo = new ModbusRtuTcpRepository(new Connection() { @Override public com.serotonin.modbus4j.ModbusMaster getConnection() { return master; } }, List.of(m));
|
||||
|
||||
// write scaled value 12.3 -> raw 123
|
||||
repo.write(5000, 12.3);
|
||||
|
||||
ArgumentCaptor<WriteRegisterRequest> cap = ArgumentCaptor.forClass(WriteRegisterRequest.class);
|
||||
verify(master, times(1)).send(cap.capture());
|
||||
WriteRegisterRequest req = cap.getValue();
|
||||
assertNotNull(req);
|
||||
assertTrue(req instanceof WriteRegisterRequest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package home.automation.modbus;
|
||||
|
||||
import com.serotonin.modbus4j.ModbusMaster;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import home.automation.configuration.RegistryMapping;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
class ModbusRtuTcpRepositoryWriteValidationTest {
|
||||
|
||||
@Test
|
||||
void writeOutsideRangeIsRejected() throws Exception {
|
||||
ModbusMaster master = mock(ModbusMaster.class);
|
||||
// no need to stub send(); validation should reject before any Modbus calls
|
||||
|
||||
final RegistryMapping m = new RegistryMapping();
|
||||
m.setRegistry(6000);
|
||||
m.setScale(0.1);
|
||||
m.setUnitId(1);
|
||||
m.setControlType("setpoint");
|
||||
m.setMin(0.0);
|
||||
m.setMax(50.0);
|
||||
|
||||
final ModbusRtuTcpRepository repo = new ModbusRtuTcpRepository(new Connection() { @Override public ModbusMaster getConnection() { return master; } }, List.of(m));
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> repo.write(6000, 60.0));
|
||||
assertTrue(ex.getMessage().contains("above maximum"));
|
||||
|
||||
verify(master, never()).send(any(com.serotonin.modbus4j.msg.ModbusRequest.class));
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,11 @@ public class RepositoryInMemory implements Repository {
|
||||
|
||||
private Registry generateRandomResponse(int registry) {
|
||||
final Random random = new Random();
|
||||
return Registry.builder()
|
||||
.registry(registry)
|
||||
.value(Double.toString(random.nextDouble()))
|
||||
.desc("TEST").build();
|
||||
return new Registry(registry, "TEST", Double.toString(random.nextDouble()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int register, double scaledValue) throws Exception {
|
||||
// no-op for tests
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package home.automation.modbus.mock;
|
||||
|
||||
import org.junit.jupiter.api.extension.*;
|
||||
|
||||
import java.lang.reflect.Parameter;
|
||||
|
||||
/**
|
||||
* JUnit extension that starts a MockModbusServer before all tests and stops it afterwards.
|
||||
* It also resolves parameters of type MockModbusServer for test methods.
|
||||
*/
|
||||
public class MockModbusExtension implements BeforeAllCallback, AfterAllCallback, ParameterResolver {
|
||||
private MockModbusServer server;
|
||||
|
||||
@Override
|
||||
public void beforeAll(ExtensionContext context) throws Exception {
|
||||
server = new MockModbusServer(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterAll(ExtensionContext context) throws Exception {
|
||||
if (server != null) server.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
|
||||
Parameter parameter = parameterContext.getParameter();
|
||||
return parameter.getType() == MockModbusServer.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
|
||||
return server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package home.automation.modbus.mock;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Extremely small Modbus-TCP mock server for tests.
|
||||
* Supports Read Holding Registers (function code 3) only.
|
||||
*/
|
||||
public class MockModbusServer implements AutoCloseable {
|
||||
private final ServerSocket serverSocket;
|
||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
private final Map<Integer, Short> registers = new ConcurrentHashMap<>();
|
||||
private volatile boolean running = true;
|
||||
|
||||
public MockModbusServer(int port) throws Exception {
|
||||
this.serverSocket = new ServerSocket(port);
|
||||
startAcceptLoop();
|
||||
}
|
||||
|
||||
public MockModbusServer() throws Exception {
|
||||
this(0);
|
||||
}
|
||||
|
||||
private void startAcceptLoop() {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
while (running && !serverSocket.isClosed()) {
|
||||
Socket s = serverSocket.accept();
|
||||
executor.submit(() -> handleConnection(s));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleConnection(Socket s) {
|
||||
try (Socket socket = s; InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream()) {
|
||||
while (running && !socket.isClosed()) {
|
||||
// Read MBAP header (7 bytes)
|
||||
byte[] header = in.readNBytes(7);
|
||||
if (header.length < 7) break;
|
||||
int transactionId = ((header[0] & 0xff) << 8) | (header[1] & 0xff);
|
||||
int protocolId = ((header[2] & 0xff) << 8) | (header[3] & 0xff);
|
||||
int length = ((header[4] & 0xff) << 8) | (header[5] & 0xff);
|
||||
byte unitId = header[6];
|
||||
|
||||
int pduLen = length - 1; // length includes unit id
|
||||
byte[] pdu = in.readNBytes(pduLen);
|
||||
if (pdu.length < pduLen) break;
|
||||
byte func = pdu[0];
|
||||
|
||||
if (func == 3) { // Read Holding Registers
|
||||
int startAddr = ((pdu[1] & 0xff) << 8) | (pdu[2] & 0xff);
|
||||
int qty = ((pdu[3] & 0xff) << 8) | (pdu[4] & 0xff);
|
||||
byte[] respPdu = new byte[2 + qty * 2];
|
||||
respPdu[0] = 3;
|
||||
respPdu[1] = (byte) (qty * 2);
|
||||
for (int i = 0; i < qty; i++) {
|
||||
short v = registers.getOrDefault(startAddr + i, (short) 0);
|
||||
respPdu[2 + i * 2] = (byte) ((v >> 8) & 0xff);
|
||||
respPdu[2 + i * 2 + 1] = (byte) (v & 0xff);
|
||||
}
|
||||
int respLen = respPdu.length + 1;
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
baos.write((byte) ((transactionId >> 8) & 0xff));
|
||||
baos.write((byte) (transactionId & 0xff));
|
||||
baos.write((byte) ((protocolId >> 8) & 0xff));
|
||||
baos.write((byte) (protocolId & 0xff));
|
||||
baos.write((byte) ((respLen >> 8) & 0xff));
|
||||
baos.write((byte) (respLen & 0xff));
|
||||
baos.write(unitId);
|
||||
baos.write(respPdu);
|
||||
out.write(baos.toByteArray());
|
||||
out.flush();
|
||||
} else {
|
||||
// return exception (illegal function)
|
||||
byte[] respPdu = new byte[]{(byte) (func | 0x80), 0x01};
|
||||
int respLen = respPdu.length + 1;
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
baos.write((byte) ((transactionId >> 8) & 0xff));
|
||||
baos.write((byte) (transactionId & 0xff));
|
||||
baos.write(0);
|
||||
baos.write(0);
|
||||
baos.write((byte) ((respLen >> 8) & 0xff));
|
||||
baos.write((byte) (respLen & 0xff));
|
||||
baos.write(unitId);
|
||||
baos.write(respPdu);
|
||||
out.write(baos.toByteArray());
|
||||
out.flush();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// ignore client disconnects/etc
|
||||
}
|
||||
}
|
||||
|
||||
public void setRegister(int register, short value) {
|
||||
registers.put(register, value);
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
running = false;
|
||||
try {
|
||||
serverSocket.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -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