Compare commits

..
61 Commits
Author SHA1 Message Date
eneuber 5b0f9013aa modbus polling Intervall vergrößert
Build Docker Image (Podman) / build (push) Has been cancelled
2026-05-26 22:46:35 +02:00
eneuber 9d33984c97 CONFIG_PATH für yaml Datei 2026-03-23 22:01:44 +01:00
eneuber 89f02422e7 VERISON in Dockerfile abhandeln 2026-03-23 22:01:10 +01:00
eneuber 58f5522442 Speichern von Holding und Coils ging nicht mehr 2026-03-23 21:13:04 +01:00
eneuber e6c60d0707 Ausführen /Debuggen in vscode nun mit Version 2026-03-23 20:48:56 +01:00
eneuber e96af3ac7e Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 33m16s
2026-03-20 10:55:02 +01:00
eneuber 87343e354b Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 32m55s
2026-03-19 22:01:25 +01:00
eneuber ca43d6b9a5 neueres rust verwenden
Build Docker Image (Podman) / build (push) Failing after 14m29s
2026-03-18 17:57:26 +01:00
eneuber 68140a0c45 Daten für InfluxDB ohne type 2026-03-18 17:28:21 +01:00
eneuber 3ed80f4b68 Schreibe MQTT.Set Werte, wenn das Leitsystem aktiv ist
Build Docker Image (Podman) / build (push) Failing after 15m52s
2026-03-13 22:56:06 +01:00
eneuber 497a0e48e3 write-Eigenschaft nun auch an holding-Register 2026-03-11 21:26:41 +01:00
eneuber 825547889c Anpassungen an der paramod.yaml 2026-03-11 17:59:05 +01:00
eneuber 923a35cad1 Lokal debuggen ermöglichen 2026-03-11 17:40:13 +01:00
eneuber 0c10ff4051 Seitenbezeichnung geändert 2026-03-07 21:24:12 +01:00
eneuber 451008b895 Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 20m44s
2026-02-22 20:42:31 +01:00
eneuber 2ca79a7b9f Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 5m52s
2026-02-22 20:29:27 +01:00
eneuber a78d49ef7e Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 1m8s
2026-02-22 20:21:32 +01:00
eneuber 56982cc26a Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 38s
2026-02-22 19:44:29 +01:00
eneuber 3168d21c5f Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 5m1s
2026-02-22 19:26:57 +01:00
eneuber 5b7b4fa57e Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 13m7s
2026-02-22 19:03:06 +01:00
eneuber ee2c69b4c2 Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 3m31s
2026-02-22 18:48:41 +01:00
eneuber cb7e8d19e4 Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 55s
2026-02-22 18:43:20 +01:00
eneuber 59de54e80a neue Checkout-Version beim Job
Build Docker Image (Podman) / build (push) Failing after 56s
2026-02-22 18:08:32 +01:00
eneuber 10bdc8fb5a Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 48s
2026-02-22 17:47:48 +01:00
eneuber c134a20da8 doch nicht privileged
Build Docker Image (Podman) / build (push) Failing after 49s
2026-02-21 22:34:19 +01:00
eneuber 0d696a44e4 Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 1m14s
2026-02-21 22:19:32 +01:00
eneuber 839fd24b4e Build-Job ist privileged
Build Docker Image (Podman) / build (push) Failing after 47s
2026-02-21 16:58:32 +01:00
eneuber 1c8dd77946 Pipeline tringgern
Build Docker Image (Podman) / build (push) Failing after 58s
2026-02-21 16:54:23 +01:00
eneuber 60823714a6 Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 1m18s
2026-02-20 00:13:12 +01:00
eneuber 4dd58ecaaa Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 26s
2026-02-20 00:06:05 +01:00
eneuber e15484c460 Benutze Podman im runner
Build Docker Image (Podman) / build (push) Failing after 55s
2026-02-19 23:57:23 +01:00
eneuber 4673ff3ff1 Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 2m0s
2026-02-19 23:21:18 +01:00
eneuber e15b1566f0 Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 47s
2026-02-18 23:20:05 +01:00
eneuber e75764da43 Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 48s
2026-02-18 22:18:20 +01:00
eneuber 41b88d9e3e Pipeline triggern
Build Docker Image (Podman) / build (push) Failing after 49s
2026-02-18 20:34:54 +01:00
eneuber 241a9ec240 Pipeline Anpassung
Build Docker Image (Podman) / build (push) Failing after 45s
2026-02-18 18:59:12 +01:00
eneuber 55e7260ee5 Änderungen am Trigger der Pipeline
Build Docker Image (Podman) / build (push) Failing after 2m19s
2026-02-18 18:46:07 +01:00
eneuber f47cd3a97b Pipeline umbenannt 2026-02-18 18:23:22 +01:00
eneuber 67b0e4ebd7 Anpassung Pipeline und Dockerfile 2026-02-17 23:20:46 +01:00
eneuber adac95129d Werte in die InfluxDB übermitteln 2026-02-17 21:34:47 +01:00
eneuber 58bacd2f6d Werte in den MQTT Broker übermitteln 2026-02-17 21:01:10 +01:00
eneuber 483d27750b RegisterConfig aufgetrennt 2026-02-17 19:56:33 +01:00
eneuber 698be221c0 Mqtt-Pfad wird jetzt mit angegeben 2026-02-16 23:08:42 +01:00
eneuber 79cbde6b4a Nachkommastellen einschränken 2026-02-16 22:55:52 +01:00
eneuber 7fb98b95f8 Nicht vorhandene Sensoren beachten 2026-02-16 22:50:00 +01:00
eneuber 459b97d95c Modbus Werte werden nun ausgelesen 2026-02-16 22:33:27 +01:00
eneuber c499f1b97c Warnung entfernt 2026-02-13 22:26:24 +01:00
eneuber e8d77ec18e endlich funktionierender Darkmodus 2026-02-13 22:21:29 +01:00
eneuber 4bc399b44f yaml ohne Quotes 2026-02-13 21:25:03 +01:00
eneuber b9bc0e52d3 Modbus-Table-Konfigurationen ausgelagert 2026-02-13 21:16:25 +01:00
eneuber 84eb4b2365 Tabellen erhalten, wenn Settings gespeichert werden 2026-01-18 19:27:05 +01:00
eneuber 49bd17d52b get_config realisiert, damti der darkmode aus der yaml geladen wird 2026-01-18 19:17:48 +01:00
eneuber 3fb6051e4a Darkmode eingeführt 2026-01-14 18:41:57 +01:00
eneuber 195df9f260 Warnungen entfernt 2026-01-12 20:35:09 +01:00
Eric Neuber 965a7aa225 Konfigurationen ausgelagert, modbus-Thread wird gerufen 2026-01-07 15:44:05 +01:00
Eric Neuber 7a8758d8cc Value Maps angelegt 2026-01-05 21:53:18 +01:00
Eric Neuber 09c41821c8 Konfiguration auf yaml umgestellt 2026-01-01 20:37:03 +01:00
Eric Neuber e74689c70f funktionierender Settings-Dialog 2025-12-29 17:58:56 +01:00
Eric Neuber bb2bb8d493 rust debuggen ermöglicht 2025-12-28 12:17:27 +01:00
Eric Neuber 220161a70a jetzt mit Header, 3 Tabellen 2025-11-20 20:12:25 +01:00
Eric Neuber f2417ec65d Erste Version 2025-11-20 20:11:48 +01:00
26 changed files with 5316 additions and 1 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
name: Build Docker Image (Podman)
on:
push:
branches: [ master ]
pull_request:
# workflow_call:
jobs:
build:
runs-on: podman-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build image with Podman
env:
BUILDAH_ISOLATION: chroot
run: |
VERSION=$(cat VERSION)
podman build --network=host -t paramod:latest -t git.skaville.rocks/eneuber/paramod:$VERSION .
- name: Login to Gitea Registry
run: echo ${{ secrets.REGISTRY_TOKEN }} | podman login -u ${{ secrets.USER }} --password-stdin git.skaville.rocks
- name: Push image with version
run: |
VERSION=$(cat VERSION)
podman push git.skaville.rocks/eneuber/paramod:$VERSION
- name: Push image latest
run: podman push git.skaville.rocks/eneuber/paramod:latest
+4
View File
@@ -0,0 +1,4 @@
/target
table_config.json
Cargo.lock
.DS_Store
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
set -e
# Patch Cargo.toml version from VERSION file before build
VERSION=$(cat VERSION)
sed -i 's/^version = ".*"/version = "'"$VERSION"'"/' Cargo.toml
exec cargo build
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
set -e
sed -i 's/^version = ".*"/version = "__VERSION__"/' Cargo.toml
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -e
reset_version() {
sed -i 's/^version = ".*"/version = "__VERSION__"/' Cargo.toml
}
trap reset_version EXIT
VERSION=$(cat VERSION)
sed -i 's/^version = ".*"/version = "'"$VERSION"'"/' Cargo.toml
cargo run
+16
View File
@@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug main.rs",
"type": "lldb",
"request": "launch",
"program": "${workspaceFolder}/target/debug/paramod",
"args": [],
"cwd": "${workspaceFolder}",
"sourceLanguages": ["rust"],
"preLaunchTask": "cargo build",
"postDebugTask": "cargo reset version placeholder"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "cargo build",
"type": "shell",
"command": ".vscode/cargo-build-version.sh",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": ["$codelldb-rustc"]
},
{
"label": "cargo reset version placeholder",
"type": "shell",
"command": "bash .vscode/cargo-reset-version.sh",
"problemMatcher": []
},
{
"label": "cargo run",
"type": "shell",
"command": "bash .vscode/cargo-run-version.sh",
"problemMatcher": ["$codelldb-rustc"]
}
]
}
Generated
+2958
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "paramod"
version = "__VERSION__"
edition = "2024"
[dependencies]
serde_yaml = "0.9"
actix-web = "4.4.0"
actix-files = "0.6.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tera = "1.19"
# Für TOML/INI-Konfiguration
config = "0.14"
toml = "0.8"
serde_derive = "1.0"
tokio-modbus = "0.7"
rumqttc = "0.23"
influxdb = "0.7"
chrono = { version = "0.4", features = ["serde"] }
+36
View File
@@ -0,0 +1,36 @@
# Build Stage
FROM rust:1.85 as builder
WORKDIR /usr/src/app
COPY Cargo.toml ./
COPY Cargo.lock ./
COPY VERSION ./
COPY src ./src
COPY templates ./templates
COPY static ./static
RUN VERSION=$(cat VERSION) \
&& sed -i 's/^version = "__VERSION__"/version = "'"$VERSION"'"/' Cargo.toml \
&& cargo build --release
# Runtime Stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /usr/src/app/target/release/paramod /app/paramod
COPY --from=builder /usr/src/app/templates /app/templates
COPY --from=builder /usr/src/app/static /app/static
COPY paramod.yaml /app/paramod.yaml
EXPOSE 8080
ENV CONFIG_PATH=/app/paramod.yaml
CMD ["/app/paramod"]
+103 -1
View File
@@ -1,2 +1,104 @@
# paramod-rust
# Paramod
Rust-Webserver zur Anzeige und Bearbeitung von Modbus-Tabellen mit Persistierung in `paramod.yaml`.
## Funktionen
- Tabellen für `modbus_input_register`, `modbus_holding_register` und `modbus_coils`
- Web-UI zum Bearbeiten und Speichern der Tabellen
- Einstellungsseite für `default`, `modbus`, `mqtt` und `influxdb`
- MQTT/Influx-Integration gemäß Konfiguration
- Speicherung aller Änderungen in `paramod.yaml`
## Lokale Entwicklung
### Voraussetzungen
- Rust + Cargo
### Start
```bash
cargo run
```
Server: `http://localhost:8080`
## Docker (Podman)
### Image bauen
```bash
podman build -t paramod:latest .
```
### Container starten
```bash
podman run --rm -p 8080:8080 -v "$(pwd)/paramod.yaml:/app/paramod.yaml" paramod:latest
```
## API Endpoints
- `GET /`
- `GET /table/modbus_input_register`
- `GET /table/modbus_holding_register`
- `GET /table/modbus_coils`
- `GET /settings`
- `GET /api/config`
- `POST /api/save`
- `POST /api/save-settings`
## API-Beispiel: Tabelle speichern
Beispiel für Holding-Register:
```bash
curl -X POST http://localhost:8080/api/save \
-H "Content-Type: application/json" \
-d '{
"table_id": "modbus_holding_register",
"rows": [
{
"TVsoll": {
"addr": 2,
"type": "INT16",
"factor": 0.1,
"write": true,
"mqtt": true,
"influxdb": true,
"comment": "Vorlauf Soll"
}
}
]
}'
```
Beispiel für Coils:
```bash
curl -X POST http://localhost:8080/api/save \
-H "Content-Type: application/json" \
-d '{
"table_id": "modbus_coils",
"rows": [
{
"MgtSystem": {
"addr": 0,
"write": true,
"mqtt": true,
"influxdb": false,
"comment": "Leitsystem aktiv"
}
}
]
}'
```
## Konfiguration
Die komplette Konfiguration liegt in `paramod.yaml`.
## Lizenz
MIT
+1
View File
@@ -0,0 +1 @@
0.3.0
+14
View File
@@ -0,0 +1,14 @@
# Heizungsteuerung einer Paradigma-Anlage über Modbus
Man kann an einer Paradigma Anlage über Modbus-TCP viele Daten abfragen und und setzen. Ziel dieses Projekts ist es, die Daten sowohl als MQTT-Daten bereitzustellen, als auch in eine InfluxDB einzupflegen.
Zusätzlich soll noch die Möglichkeit gegeben werden, über MQTT Werte zu setzen und damit die Steuerung zu übernehmen.
## Technische Details
### Konfiguration
Die Konfiguration soll sowohl über Webservice als auch über eine Konfigurationsdatei möglich sein. Änderungen an der Weboberfläche sollen direkten Einfluss haben und in der Konfigurationsdatei persistiert werden. Die Konfigurationsdatei soll beim Start genutzt werden. Die Konfiguration soll im yaml-Format vorliegen.
An der Weboberfläche soll es drei Tabellen geben, um die Modbus-Einstellungen für coils, input_register und holding_register vorzunehmen. Weiterhin soll es ein Einstellungsmenü geben, um den MQTT-Broker, den Modbus-Server, den InfluxDB-Server und allgemeine Einstellungen vorzunehmen.
In den Tabellen soll es jeweils eine Spalte mit dem aktuellen Wert geben. Dieser wird aus den Modbus-Werten auf Rust-Seite ausgelesen und wird je nach Type aus einer bzw. zwei Adressen berechnet und mit dem Faktor multipliziert. Das trifft für input_register und holding_register zu. Bei coils handelt es sich um 1-Bit Datenpunkte und damit Boolean.
+136
View File
@@ -0,0 +1,136 @@
default:
loglevel: DEBUG
darkmode: true
mqtt:
broker: 192.168.178.2
port: 1883
user: admin
password: 97sm3pHNSMZ4M5qUj0x8
path: heizung/paradigma
leitsystem_path: heizung/leitsystem2
set_write_interval_ms: 2000
influxdb:
bucket: Paradigma
org: skaville
token: i-sXFQbEkSC1XVzqFEaFwXwzasbsEIciVlK4SaAUOEvk0VjQPkD3fr8d7_3SPeyseTZkqj7ZMZU78b3n2F6_SQ==
url: http://192.168.178.2:8086
location: Radebeul
measurement: ParadigmaModbus
modbus:
host: 192.168.178.10
port: 502
max_coils_addr: 8
max_input_addr: 45
max_holding_addr: 61
modbus_coils:
- MgtSystem: {addr: 0, write: false, mqtt: true, influxdb: false, comment: Leitsystem aktiv}
- HK1pres: {addr: 1, write: false, mqtt: false, influxdb: false, comment: HK1 vorhanden}
- HK2pres: {addr: 2, write: false, mqtt: false, influxdb: false, comment: HK2 vorhanden}
- HK3pres: {addr: 3, write: false, mqtt: false, influxdb: false, comment: HK3 vorhanden}
- TWrelease: {addr: 4, write: true, mqtt: false, influxdb: false, comment: Trinkwassererwärmung freigegeben}
- TWlock: {addr: 5, write: true, mqtt: false, influxdb: false, comment: Trinkwassererwärmung gesperrt}
- Zrelease: {addr: 6, write: true, mqtt: false, influxdb: false, comment: Zirkulation freigegeben}
- Zlock: {addr: 7, write: true, mqtt: false, influxdb: false, comment: Zirkulation gesperrt}
- SHKpres: {addr: 8, write: false, mqtt: false, influxdb: false, comment: Schwimmbadheizkrei vorhanden}
modbus_input_register:
- TA: {addr: 0, type: INT16, factor: 0.1, mqtt: true, influxdb: true}
- TV: {addr: 1, type: INT16, factor: 0.1, mqtt: true, influxdb: true}
- TR: {addr: 2, type: INT16, factor: 0.1, mqtt: true, influxdb: true}
- TWO: {addr: 3, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TPO: {addr: 4, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TPU: {addr: 5, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TZR: {addr: 6, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TV2: {addr: 7, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TR2: {addr: 8, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- RI1: {addr: 9, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- RI2: {addr: 10, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TSA: {addr: 11, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- FATV: {addr: 12, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- FATR: {addr: 13, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TVKH: {addr: 14, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TRKH: {addr: 15, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TPOKH: {addr: 16, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TV3: {addr: 17, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TR3: {addr: 18, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TSB: {addr: 19, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TVSB: {addr: 20, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TRSB: {addr: 21, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TWE: {addr: 22, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TWA: {addr: 23, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TWS: {addr: 24, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TVSI: {addr: 25, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TK: {addr: 26, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- FATV1: {addr: 27, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- FATV2: {addr: 28, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- FATV3: {addr: 29, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- FATV4: {addr: 30, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TSE: {addr: 31, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TW: {addr: 32, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TSV: {addr: 33, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TW2: {addr: 34, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- S: {addr: 35, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TAM: {addr: 36, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TAM2: {addr: 37, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TSA1: {addr: 38, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TSA2: {addr: 39, type: INT16, factor: 0.1, mqtt: false, influxdb: false}
- TSP: {addr: 40, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TWW: {addr: 41, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- TKW: {addr: 42, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- VKW: {addr: 43, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
- VSPm: {addr: 44, type: INT16, factor: 0.1, mqtt: false, influxdb: true}
modbus_holding_register:
- nothing: {addr: 0, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- ErrLS: {addr: 1, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- TVsoll: {addr: 2, type: INT16, factor: 0.1, write: true, mqtt: true, influxdb: true}
- TV2soll: {addr: 3, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- TV3soll: {addr: 4, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- HK1soll: {addr: 5, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- HK2soll: {addr: 6, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- HK3soll: {addr: 7, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- TWWsoll: {addr: 8, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: true}
- TV1max: {addr: 9, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: true}
- TV2max: {addr: 10, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- TV3max: {addr: 11, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- ErrHR: {addr: 12, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- ErrSR: {addr: 13, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- ErrWE1_1: {addr: 14, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- ErrWE1_2: {addr: 15, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- ErrWE1_3: {addr: 16, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- ErrWE1_4: {addr: 17, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- ErrWE1_5: {addr: 18, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- KollLei: {addr: 19, type: UINT16, factor: 0.1, write: false, mqtt: false, influxdb: true}
- TagesS: {addr: 20, type: UINT16, factor: 0.1, write: false, mqtt: true, influxdb: true}
- GesS: {addr: 21, type: UINT32, factor: 0.1, write: false, mqtt: false, influxdb: true}
- GesWW: {addr: 23, type: UINT32, factor: 0.1, write: false, mqtt: false, influxdb: true}
- GesZ: {addr: 25, type: UINT32, factor: 0.1, write: false, mqtt: false, influxdb: true}
- HGesK1: {addr: 27, type: UINT32, factor: 1, write: false, mqtt: false, influxdb: true}
- StartK1: {addr: 29, type: UINT32, factor: 1, write: false, mqtt: false, influxdb: true}
- HGesPel: {addr: 31, type: UINT32, factor: 1, write: false, mqtt: false, influxdb: true}
- VGesPel: {addr: 33, type: UINT16, factor: 0.1, write: false, mqtt: false, influxdb: true}
- StatWW: {addr: 34, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- StatZ: {addr: 35, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- StatHK1: {addr: 36, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- StatHK2: {addr: 37, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- StatHK3: {addr: 38, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- StatS: {addr: 39, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- StatSB: {addr: 40, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- StatK1: {addr: 41, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- StatPel: {addr: 42, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- StatKH: {addr: 43, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- TPOsoll: {addr: 44, type: UINT16, factor: 0.1, write: false, mqtt: false, influxdb: true}
- FATVsoll: {addr: 45, type: UINT16, factor: 0.1, write: false, mqtt: false, influxdb: true}
- TSBsollHK: {addr: 46, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- TSBsollS: {addr: 47, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- BetrHK1: {addr: 48, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: true}
- BetrHK2: {addr: 49, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- BetrHK3: {addr: 50, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- BetrSB: {addr: 51, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- GesKKsoll: {addr: 52, type: INT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- KKsollWE1: {addr: 53, type: UINT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- KKsollWE2: {addr: 54, type: UINT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- KKsollWE3: {addr: 55, type: UINT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- KKsollWE4: {addr: 56, type: UINT16, factor: 0.1, write: false, mqtt: false, influxdb: false}
- ErrWE1: {addr: 57, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- ErrWE2: {addr: 58, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- ErrWE3: {addr: 59, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
- ErrWE4: {addr: 60, type: UINT16, factor: 1, write: false, mqtt: false, influxdb: false}
+33
View File
@@ -0,0 +1,33 @@
use std::sync::{Mutex, Arc};
use tera::Tera;
use crate::config::{AppConfig, ModbusValueMaps};
pub struct AppState {
pub config: Arc<Mutex<AppConfig>>,
pub value_maps: Arc<Mutex<ModbusValueMaps>>,
pub templates: Tera,
}
impl AppState {
pub fn load_from_conf(conf_path: &str) -> Self {
let conf_str = std::fs::read_to_string(conf_path).expect("Config-Datei konnte nicht gelesen werden");
let config: AppConfig = serde_yaml::from_str(&conf_str).expect("Config-Deserialisierung fehlgeschlagen");
let config = Arc::new(Mutex::new(config));
let value_maps = Arc::new(Mutex::new(ModbusValueMaps::from_config(&config.lock().unwrap())));
let tera = match Tera::new("templates/**/*") {
Ok(t) => t,
Err(e) => {
println!("Template parsing error: {}", e);
std::process::exit(1);
}
};
AppState {
config,
value_maps,
templates: tera,
}
}
}
+93
View File
@@ -0,0 +1,93 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::modbus_types::{ModbusCoilsConfig, ModbusInputRegisterConfig, ModbusHoldingRegisterConfig};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DefaultConfig {
pub loglevel: Option<String>,
pub darkmode: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ModbusConfig {
pub host: String,
pub port: u16,
pub max_coils_addr: Option<u16>,
pub max_input_addr: Option<u16>,
pub max_holding_addr: Option<u16>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MqttConfig {
pub broker: String,
pub port: u16,
pub user: Option<String>,
pub password: Option<String>,
pub path: Option<String>,
pub leitsystem_path: Option<String>,
pub set_write_interval_ms: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct InfluxConfig {
pub bucket: String,
pub org: String,
pub token: String,
pub url: String,
pub location: Option<String>,
pub measurement: Option<String>,
}
// ModbusRegisterConfig und ModbusCoilsConfig werden jetzt aus modbus_types.rs importiert
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppConfig {
pub default: DefaultConfig,
pub mqtt: MqttConfig,
pub influxdb: InfluxConfig,
pub modbus: ModbusConfig,
pub modbus_coils: Option<Vec<HashMap<String, ModbusCoilsConfig>>>,
pub modbus_input_register: Option<Vec<HashMap<String, ModbusInputRegisterConfig>>>,
pub modbus_holding_register: Option<Vec<HashMap<String, ModbusHoldingRegisterConfig>>>,
}
#[derive(Debug, Default, Clone)]
pub struct ModbusValueMaps {
pub modbus_coils_values: HashMap<String, Option<f64>>,
pub modbus_input_register_values: HashMap<String, Option<f64>>,
pub modbus_holding_register_values: HashMap<String, Option<f64>>,
}
impl ModbusValueMaps {
pub fn from_config(config: &AppConfig) -> Self {
let mut coils = HashMap::new();
if let Some(ref vec) = config.modbus_coils {
for map in vec {
for key in map.keys() {
coils.entry(key.clone()).or_insert(None);
}
}
}
let mut input = HashMap::new();
if let Some(ref vec) = config.modbus_input_register {
for map in vec {
for key in map.keys() {
input.entry(key.clone()).or_insert(None);
}
}
}
let mut holding = HashMap::new();
if let Some(ref vec) = config.modbus_holding_register {
for map in vec {
for key in map.keys() {
holding.entry(key.clone()).or_insert(None);
}
}
}
ModbusValueMaps {
modbus_coils_values: coils,
modbus_input_register_values: input,
modbus_holding_register_values: holding,
}
}
}
+112
View File
@@ -0,0 +1,112 @@
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use crate::config::{AppConfig, ModbusValueMaps};
use influxdb::{Client, WriteQuery, Timestamp};
use chrono::Utc;
use tokio::runtime::Runtime;
pub fn start_influx_thread(config: Arc<Mutex<AppConfig>>, values: Arc<Mutex<ModbusValueMaps>>) {
let influx_config = {
let cfg = config.lock().unwrap();
cfg.influxdb.clone()
};
let url = influx_config.url.clone();
let bucket = influx_config.bucket.clone();
let _org = influx_config.org.clone();
let token = influx_config.token.clone();
let measurement = influx_config.measurement.clone().unwrap_or_else(|| "modbus".to_string());
let location = influx_config.location.clone().unwrap_or_else(|| "default".to_string());
thread::spawn(move || {
let client = Client::new(url, bucket).with_token(token);
let rt = Runtime::new().unwrap();
loop {
let mut points = Vec::new();
{
let values = values.lock().unwrap();
// Input Register
for (name, val) in &values.modbus_input_register_values {
if let Some(v) = val {
if should_write(&config, name, "input_register") {
let point = WriteQuery::new(Timestamp::from(Utc::now()), measurement.clone())
.add_field(name, *v)
// .add_tag("type", "input_register")
.add_tag("location", location.as_str())
.to_owned();
points.push(point);
}
}
}
// Holding Register
for (name, val) in &values.modbus_holding_register_values {
if let Some(v) = val {
if should_write(&config, name, "holding_register") {
let point = WriteQuery::new(Timestamp::from(Utc::now()), measurement.clone())
.add_field(name, *v)
// .add_tag("type", "holding_register")
.add_tag("location", location.as_str())
.to_owned();
points.push(point);
}
}
}
// Coils
for (name, val) in &values.modbus_coils_values {
if let Some(v) = val {
if should_write(&config, name, "coils") {
let point = WriteQuery::new(Timestamp::from(Utc::now()), measurement.clone())
.add_field(name, *v)
// .add_tag("type", "coils")
.add_tag("location", location.as_str())
.to_owned();
points.push(point);
}
}
}
}
if !points.is_empty() {
let res = rt.block_on(client.query(points));
if let Err(e) = res {
eprintln!("Influx write error: {}", e);
}
}
thread::sleep(Duration::from_secs(10));
}
});
}
fn should_write(config: &Arc<Mutex<AppConfig>>, name: &str, reg_type: &str) -> bool {
let cfg = config.lock().unwrap();
match reg_type {
"input_register" => {
if let Some(regs) = &cfg.modbus_input_register {
for map in regs {
if let Some(reg) = map.get(name) {
return reg.influxdb.unwrap_or(false);
}
}
}
}
"holding_register" => {
if let Some(regs) = &cfg.modbus_holding_register {
for map in regs {
if let Some(reg) = map.get(name) {
return reg.influxdb.unwrap_or(false);
}
}
}
}
"coils" => {
if let Some(coils) = &cfg.modbus_coils {
for map in coils {
if let Some(coil) = map.get(name) {
return coil.influxdb.unwrap_or(false);
}
}
}
}
_ => {}
}
false
}
+278
View File
@@ -0,0 +1,278 @@
use actix_web::{web, App, HttpResponse, HttpServer, Result};
use actix_files as actix_fs;
use std::sync::{Arc};
use tera::Context;
use std::fs;
use serde_yaml;
use std::collections::HashMap;
use serde::{Serialize, Deserialize};
mod config;
mod modbus;
mod app_state;
mod mqtt;
mod influx;
pub mod modbus_types;
use crate::config::{AppConfig, ModbusValueMaps};
use crate::modbus_types::{ModbusInputRegisterConfig, ModbusHoldingRegisterConfig, ModbusCoilsConfig};
use crate::app_state::AppState;
fn get_config_path() -> String {
std::env::var("CONFIG_PATH").unwrap_or_else(|_| "paramod.yaml".to_string())
}
async fn index(data: web::Data<AppState>) -> Result<HttpResponse> {
let config = data.config.lock().unwrap();
let value_maps = data.value_maps.lock().unwrap();
let mut context = Context::new();
let mut rows = config.modbus_input_register.clone().unwrap_or_default();
rows.sort_by(|a, b| {
let addr_a = a.values().next().map(|v| v.addr).unwrap_or(0);
let addr_b = b.values().next().map(|v| v.addr).unwrap_or(0);
addr_a.cmp(&addr_b)
});
context.insert("rows", &rows);
context.insert("table_id", "modbus_input_register");
context.insert("active_page", "modbus_input_register");
context.insert("value_map", &value_maps.modbus_input_register_values);
let html = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(html))
}
async fn table_page(data: web::Data<AppState>, path: web::Path<String>) -> Result<HttpResponse> {
let table_id = path.into_inner();
let config = data.config.lock().unwrap();
let value_maps = data.value_maps.lock().unwrap();
let mut context = Context::new();
match table_id.as_str() {
"modbus_input_register" => {
let mut rows = config.modbus_input_register.clone().unwrap_or_default();
rows.sort_by(|a, b| {
let addr_a = a.values().next().map(|v| v.addr).unwrap_or(0);
let addr_b = b.values().next().map(|v| v.addr).unwrap_or(0);
addr_a.cmp(&addr_b)
});
context.insert("rows", &rows);
context.insert("table_id", &table_id);
context.insert("active_page", &table_id);
context.insert("value_map", &value_maps.modbus_input_register_values);
let html = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(html))
}
"modbus_holding_register" => {
let mut rows = config.modbus_holding_register.clone().unwrap_or_default();
rows.sort_by(|a, b| {
let addr_a = a.values().next().map(|v| v.addr).unwrap_or(0);
let addr_b = b.values().next().map(|v| v.addr).unwrap_or(0);
addr_a.cmp(&addr_b)
});
context.insert("rows", &rows);
context.insert("table_id", &table_id);
context.insert("active_page", &table_id);
context.insert("value_map", &value_maps.modbus_holding_register_values);
let html = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(html))
}
"modbus_coils" => {
let mut rows = config.modbus_coils.clone().unwrap_or_default();
rows.sort_by(|a, b| {
let addr_a = a.values().next().map(|v| v.addr).unwrap_or(0);
let addr_b = b.values().next().map(|v| v.addr).unwrap_or(0);
addr_a.cmp(&addr_b)
});
context.insert("rows", &rows);
context.insert("table_id", &table_id);
context.insert("active_page", &table_id);
context.insert("value_map", &value_maps.modbus_coils_values);
let html = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(html))
}
_ => Ok(HttpResponse::NotFound().body("Table not found")),
}
}
async fn settings_page(data: web::Data<AppState>) -> Result<HttpResponse> {
let config = data.config.lock().unwrap();
let mut context = Context::new();
context.insert("default", &config.default);
context.insert("mqtt", &config.mqtt);
context.insert("influxdb", &config.influxdb);
context.insert("modbus", &config.modbus);
context.insert("modbus_coils", &config.modbus_coils);
context.insert("modbus_input_register", &config.modbus_input_register);
context.insert("modbus_holding_register", &config.modbus_holding_register);
context.insert("active_page", "settings");
let html = data.templates.render("settings.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok()
.content_type("text/html")
.insert_header(("Cache-Control", "no-store, must-revalidate"))
.body(html))
}
#[derive(Serialize, Deserialize)]
struct SaveTableRequest {
table_id: String,
rows: serde_json::Value,
}
async fn save_table(
data: web::Data<AppState>,
req: web::Json<SaveTableRequest>,
) -> Result<HttpResponse> {
let mut config = data.config.lock().unwrap();
let conf_path = get_config_path();
match req.table_id.as_str() {
"modbus_input_register" => {
let rows: Vec<HashMap<String, ModbusInputRegisterConfig>> =
match serde_json::from_value(req.rows.clone()) {
Ok(v) => v,
Err(e) => return Ok(HttpResponse::BadRequest().body(format!("Falscher Typ für input_register: {}", e))),
};
config.modbus_input_register = Some(rows);
}
"modbus_holding_register" => {
let rows: Vec<HashMap<String, ModbusHoldingRegisterConfig>> =
match serde_json::from_value(req.rows.clone()) {
Ok(v) => v,
Err(e) => return Ok(HttpResponse::BadRequest().body(format!("Falscher Typ für holding_register: {}", e))),
};
config.modbus_holding_register = Some(rows);
}
"modbus_coils" => {
let rows: Vec<HashMap<String, ModbusCoilsConfig>> =
match serde_json::from_value(req.rows.clone()) {
Ok(v) => v,
Err(e) => return Ok(HttpResponse::BadRequest().body(format!("Falscher Typ für coils: {}", e))),
};
config.modbus_coils = Some(rows);
}
_ => return Ok(HttpResponse::BadRequest().body("Invalid table_id")),
}
let yaml_str = match serde_yaml::to_string(&*config) {
Ok(s) => s,
Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Serialisierungsfehler: {}", e))),
};
if let Err(e) = fs::write(&conf_path, yaml_str) {
return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Schreiben: {}", e)));
}
Ok(HttpResponse::Ok().body("success"))
}
async fn save_settings(
data: web::Data<AppState>,
settings: web::Json<AppConfig>,
) -> Result<HttpResponse> {
let mut config = data.config.lock().unwrap();
let mut new_config = settings.into_inner();
// Tabellenwerte erhalten, falls sie im Request null sind
if new_config.modbus_coils.is_none() {
new_config.modbus_coils = config.modbus_coils.clone();
}
if new_config.modbus_input_register.is_none() {
new_config.modbus_input_register = config.modbus_input_register.clone();
}
if new_config.modbus_holding_register.is_none() {
new_config.modbus_holding_register = config.modbus_holding_register.clone();
}
// Value-Maps neu initialisieren
let mut value_maps = data.value_maps.lock().unwrap();
*value_maps = ModbusValueMaps::from_config(&new_config);
let conf_path = get_config_path();
let yaml_str = match serde_yaml::to_string(&new_config) {
Ok(s) => s,
Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Serialisierungsfehler: {}", e))),
};
if let Err(e) = fs::write(&conf_path, yaml_str) {
return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Schreiben: {}", e)));
}
// Reload config from file
let conf_str = match std::fs::read_to_string(&conf_path) {
Ok(s) => s,
Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Lesen: {}", e))),
};
let updated_config: AppConfig = match serde_yaml::from_str(&conf_str) {
Ok(cfg) => cfg,
Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Deserialisierungsfehler: {}", e))),
};
// darkmode-Konvertierung: String zu bool
// (YAML kann bool als string interpretieren)
*config = updated_config;
Ok(HttpResponse::Ok().body("success"))
}
async fn get_config(data: web::Data<AppState>) -> HttpResponse {
let config = data.config.lock().unwrap();
HttpResponse::Ok().json(&*config)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let config_path = get_config_path();
let app_state = web::Data::new(AppState::load_from_conf(&config_path));
// Starte Modbus-Polling-Thread
{
let config = app_state.config.lock().unwrap().clone();
let value_maps = Arc::clone(&app_state.value_maps);
modbus::start_modbus_polling_thread(
&config.modbus,
&config.modbus_input_register,
&config.modbus_holding_register,
&config.modbus_coils,
value_maps,
std::time::Duration::from_secs(10), // Poll-Intervall
);
}
// Starte MQTT-Thread
{
let value_maps = Arc::clone(&app_state.value_maps);
mqtt::start_mqtt_thread(Arc::clone(&app_state.config), value_maps);
}
// Starte InfluxDB-Thread
{
let value_maps = Arc::clone(&app_state.value_maps);
influx::start_influx_thread(Arc::clone(&app_state.config), value_maps);
}
println!("Server läuft auf http://0.0.0.0:8080");
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.route("/", web::get().to(index))
.route("/table/{id}", web::get().to(table_page))
.route("/settings", web::get().to(settings_page))
.route("/api/save", web::post().to(save_table))
.route("/api/save-settings", web::post().to(save_settings))
.route("/api/config", web::get().to(get_config))
.service(actix_fs::Files::new("/static", "./static"))
})
.bind("0.0.0.0:8080")?
.run()
.await
}
+227
View File
@@ -0,0 +1,227 @@
use crate::config::{ModbusValueMaps, ModbusConfig};
use crate::modbus_types::{ModbusInputRegisterConfig, ModbusHoldingRegisterConfig, ModbusCoilsConfig};
use crate::config::AppConfig;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tokio_modbus::prelude::*;
use tokio_modbus::client::tcp;
use std::net::SocketAddr;
pub fn write_value_by_name(config: &AppConfig, name: &str, payload: &str) -> Result<(), String> {
let addr: SocketAddr = format!("{}:{}", config.modbus.host, config.modbus.port)
.parse()
.map_err(|e| format!("Ungültige Modbus-Adresse: {}", e))?;
if let Some(ref coils) = config.modbus_coils {
for map in coils {
if let Some(coil) = map.get(name) {
if !coil.write.unwrap_or(false) {
return Err(format!("Schreiben für '{}' nicht erlaubt", name));
}
let bool_value = parse_bool_payload(payload)?;
let rt = tokio::runtime::Runtime::new()
.map_err(|e| format!("Tokio Runtime Fehler: {}", e))?;
rt.block_on(async {
let mut client = tcp::connect_slave(addr, 1u8.into())
.await
.map_err(|e| format!("Modbus Verbindung fehlgeschlagen: {}", e))?;
client
.write_single_coil(coil.addr, bool_value)
.await
.map_err(|e| format!("Coil schreiben fehlgeschlagen: {}", e))
})?;
return Ok(());
}
}
}
if let Some(ref holding_registers) = config.modbus_holding_register {
for map in holding_registers {
if let Some(reg) = map.get(name) {
if !reg.write.unwrap_or(false) {
return Err(format!("Schreiben für '{}' nicht erlaubt", name));
}
let numeric_value = payload
.trim()
.parse::<f64>()
.map_err(|e| format!("Ungültiger Zahlenwert '{}': {}", payload, e))?;
let factor = reg.factor.unwrap_or(1.0);
let raw = if factor == 0.0 {
numeric_value
} else {
numeric_value / factor
};
let rt = tokio::runtime::Runtime::new()
.map_err(|e| format!("Tokio Runtime Fehler: {}", e))?;
return rt.block_on(async {
let mut client = tcp::connect_slave(addr, 1u8.into())
.await
.map_err(|e| format!("Modbus Verbindung fehlgeschlagen: {}", e))?;
match reg.r#type.as_deref().unwrap_or("UINT16") {
"INT16" => {
let int_value = raw.round();
if int_value < i16::MIN as f64 || int_value > i16::MAX as f64 {
return Err(format!("Wert außerhalb INT16-Bereich für '{}': {}", name, numeric_value));
}
let register = int_value as i16 as u16;
client
.write_multiple_registers(reg.addr, &[register])
.await
.map_err(|e| format!("Holding Register schreiben fehlgeschlagen: {}", e))?;
}
"UINT32" => {
let int_value = raw.round();
if int_value < 0.0 || int_value > u32::MAX as f64 {
return Err(format!("Wert außerhalb UINT32-Bereich für '{}': {}", name, numeric_value));
}
let val = int_value as u32;
let high = ((val >> 16) & 0xFFFF) as u16;
let low = (val & 0xFFFF) as u16;
client
.write_multiple_registers(reg.addr, &[high, low])
.await
.map_err(|e| format!("Holding Register (UINT32) schreiben fehlgeschlagen: {}", e))?;
}
_ => {
let int_value = raw.round();
if int_value < 0.0 || int_value > u16::MAX as f64 {
return Err(format!("Wert außerhalb UINT16-Bereich für '{}': {}", name, numeric_value));
}
client
.write_multiple_registers(reg.addr, &[int_value as u16])
.await
.map_err(|e| format!("Holding Register schreiben fehlgeschlagen: {}", e))?;
}
}
Ok(())
});
}
}
}
Err(format!("Kein schreibbares Modbus-Mapping für '{}' gefunden", name))
}
fn parse_bool_payload(payload: &str) -> Result<bool, String> {
match payload.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "on" => Ok(true),
"0" | "false" | "off" => Ok(false),
_ => Err(format!("Ungültiger bool-Wert '{}', erwartet 0/1 oder true/false", payload)),
}
}
/// Startet einen Thread, der zyklisch Modbus-Register abfragt und ModbusValueMaps aktualisiert
pub fn start_modbus_polling_thread(
modbus_config: &ModbusConfig,
input_registers: &Option<Vec<HashMap<String, ModbusInputRegisterConfig>>>,
holding_registers: &Option<Vec<HashMap<String, ModbusHoldingRegisterConfig>>>,
coils: &Option<Vec<HashMap<String, ModbusCoilsConfig>>>,
value_maps: Arc<Mutex<ModbusValueMaps>>,
poll_interval: Duration,
) {
let host = modbus_config.host.clone();
let port = modbus_config.port;
let input_registers = input_registers.clone();
let holding_registers = holding_registers.clone();
let coils = coils.clone();
thread::spawn(move || {
let addr: SocketAddr = format!("{}:{}", host, port).parse().unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
loop {
// Verbindungsaufbau
let connect_result = rt.block_on(async {
tcp::connect_slave(addr, 1u8.into()).await
});
match connect_result {
Ok(mut client) => {
// Werte abfragen
// Input Register
if let Some(ref vec) = input_registers {
for map in vec {
for (key, reg) in map {
let addr = reg.addr;
let typ = reg.r#type.as_deref().unwrap_or("");
let factor = reg.factor.unwrap_or(1.0);
let value: Option<f64> = rt.block_on(async {
match typ {
"INT16" => client.read_input_registers(addr, 1).await.ok().map(|v| {
let raw = v[0] as i16;
if raw == -32768 || raw == 32767 { None } else { Some(((raw as f64 * factor) * 10.0).round() / 10.0) }
}).flatten(),
"UINT16" => client.read_input_registers(addr, 1).await.ok().map(|v| {
if v[0] == 0xFFFF { None } else { Some(((v[0] as f64 * factor) * 10.0).round() / 10.0) }
}).flatten(),
"UINT32" => client.read_input_registers(addr, 2).await.ok().map(|v| {
let raw = (v[0] as u32) << 16 | (v[1] as u32);
if raw == 0xFFFFFFFF { None } else { Some(((raw as f64 * factor) * 10.0).round() / 10.0) }
}).flatten(),
_ => Some(0.0),
}
});
if let Ok(mut maps) = value_maps.lock() {
maps.modbus_input_register_values.insert(key.clone(), value);
}
}
}
}
// Holding Register
if let Some(ref vec) = holding_registers {
for map in vec {
for (key, reg) in map {
let addr = reg.addr;
let typ = reg.r#type.as_deref().unwrap_or("");
let factor = reg.factor.unwrap_or(1.0);
let value: Option<f64> = rt.block_on(async {
match typ {
"INT16" => client.read_holding_registers(addr, 1).await.ok().map(|v| {
let raw = v[0] as i16;
if raw == -32768 || raw == 32767 { None } else { Some(((raw as f64 * factor) * 10.0).round() / 10.0) }
}).flatten(),
"UINT16" => client.read_holding_registers(addr, 1).await.ok().map(|v| {
if v[0] == 0xFFFF { None } else { Some(((v[0] as f64 * factor) * 10.0).round() / 10.0) }
}).flatten(),
"UINT32" => client.read_holding_registers(addr, 2).await.ok().map(|v| {
let raw = (v[0] as u32) << 16 | (v[1] as u32);
if raw == 0xFFFFFFFF { None } else { Some(((raw as f64 * factor) * 10.0).round() / 10.0) }
}).flatten(),
_ => Some(0.0),
}
});
if let Ok(mut maps) = value_maps.lock() {
maps.modbus_holding_register_values.insert(key.clone(), value);
}
}
}
}
// Coils
if let Some(ref vec) = coils {
for map in vec {
for (key, reg) in map {
let addr = reg.addr;
let value = rt.block_on(async {
client.read_coils(addr, 1).await.ok().map(|v| if v[0] { 1.0 } else { 0.0 })
}).unwrap_or(0.0);
if let Ok(mut maps) = value_maps.lock() {
maps.modbus_coils_values.insert(key.clone(), Some(value));
}
}
}
}
},
Err(_) => {
eprintln!("Modbus: Verbindung fehlgeschlagen, retry in {:?}", poll_interval);
thread::sleep(poll_interval);
continue;
}
}
thread::sleep(poll_interval);
}
});
}
+31
View File
@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ModbusCoilsConfig {
pub addr: u16,
pub write: Option<bool>,
pub mqtt: Option<bool>,
pub influxdb: Option<bool>,
pub comment: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ModbusInputRegisterConfig {
pub addr: u16,
pub r#type: Option<String>,
pub factor: Option<f64>,
pub mqtt: Option<bool>,
pub influxdb: Option<bool>,
pub comment: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ModbusHoldingRegisterConfig {
pub addr: u16,
pub r#type: Option<String>,
pub factor: Option<f64>,
pub write: Option<bool>,
pub mqtt: Option<bool>,
pub influxdb: Option<bool>,
pub comment: Option<String>,
}
+227
View File
@@ -0,0 +1,227 @@
use std::sync::{Arc, Mutex};
use std::process;
use std::collections::HashMap;
use std::thread;
use std::time::{Duration, Instant};
use crate::config::{AppConfig, ModbusValueMaps};
use crate::modbus;
use rumqttc::{Event, MqttOptions, Packet, Client, QoS, RecvTimeoutError};
pub fn start_mqtt_thread(config: Arc<Mutex<AppConfig>>, values: Arc<Mutex<ModbusValueMaps>>) {
let mqtt_config = {
let cfg = config.lock().unwrap();
cfg.mqtt.clone()
};
let broker = if mqtt_config.broker.is_empty() { "localhost".to_string() } else { mqtt_config.broker.clone() };
let port = if mqtt_config.port == 0 { 1883 } else { mqtt_config.port };
let path = if let Some(ref p) = mqtt_config.path { p.clone() } else { "paramod/values".to_string() };
let leitsystem_path = mqtt_config
.leitsystem_path
.clone()
.unwrap_or_else(|| format!("{}/leitsystem", path));
let set_write_interval = Duration::from_millis(mqtt_config.set_write_interval_ms.unwrap_or(2000).max(100));
let user = mqtt_config.user.clone().unwrap_or_default();
let password = mqtt_config.password.clone().unwrap_or_default();
let user_is_empty = user.is_empty();
thread::spawn(move || {
let client_id = format!("paramod-client-{}", process::id());
let mut mqttoptions = MqttOptions::new(client_id, broker, port);
mqttoptions.set_keep_alive(Duration::from_secs(5));
if !user_is_empty {
mqttoptions.set_credentials(user, password);
}
let (mut client, mut connection) = Client::new(mqttoptions, 10);
let set_topic = format!("{}/+/set", path);
if let Err(e) = client.subscribe(set_topic, QoS::AtLeastOnce) {
eprintln!("MQTT Subscribe fehlgeschlagen: {}", e);
}
let leitsystem_state_topic = format!("{}/active", leitsystem_path.trim_end_matches('/'));
if let Err(e) = client.subscribe(leitsystem_state_topic.clone(), QoS::AtLeastOnce) {
eprintln!("MQTT Subscribe Leitsystem fehlgeschlagen: {}", e);
}
let publish_interval = Duration::from_secs(5);
let mut last_publish = Instant::now() - publish_interval;
let mut last_set_write = Instant::now() - set_write_interval;
let mut pending_set_values: HashMap<String, String> = HashMap::new();
let mut leitsystem_enabled = false;
loop {
if last_publish.elapsed() >= publish_interval {
{
let values = values.lock().unwrap();
// Input Register
for (name, val) in &values.modbus_input_register_values {
if let Some(v) = val {
if should_publish(&config, name, "input_register") {
let topic = format!("{}/{}/state", path, name);
let payload = format!("{}", v);
let _ = client.publish(topic, QoS::AtLeastOnce, false, payload);
}
}
}
// Holding Register
for (name, val) in &values.modbus_holding_register_values {
if let Some(v) = val {
if should_publish(&config, name, "holding_register") {
let topic = format!("{}/{}/state", path, name);
let payload = format!("{}", v);
let _ = client.publish(topic, QoS::AtLeastOnce, false, payload);
}
}
}
// Coils
for (name, val) in &values.modbus_coils_values {
if let Some(v) = val {
if should_publish(&config, name, "coils") {
let topic = format!("{}/{}/state", path, name);
let payload = format!("{}", v);
let _ = client.publish(topic, QoS::AtLeastOnce, false, payload);
}
}
}
}
let state_payload = if leitsystem_enabled { "ON" } else { "OFF" };
let _ = client.publish(
leitsystem_state_topic.clone(),
QoS::AtLeastOnce,
true,
state_payload,
);
last_publish = Instant::now();
}
if last_set_write.elapsed() >= set_write_interval {
let cfg = {
let lock = config.lock().unwrap();
lock.clone()
};
if leitsystem_enabled {
for (name, payload) in &pending_set_values {
if let Err(e) = modbus::write_value_by_name(&cfg, name, payload) {
eprintln!("MQTT set -> Modbus Fehler ({}): {}", name, e);
} else {
mirror_set_value(&values, &cfg, name, payload);
}
}
}
last_set_write = Instant::now();
}
match connection.recv_timeout(Duration::from_millis(200)) {
Ok(Ok(Event::Incoming(Packet::Publish(publish)))) => {
if publish.topic == leitsystem_state_topic {
let payload = String::from_utf8_lossy(&publish.payload).trim().to_string();
match parse_boolish(&payload) {
Some(v) => leitsystem_enabled = v,
None => eprintln!("Ungültiger Leitsystem state '{}', erwartet ON/OFF", payload),
}
continue;
}
if let Ok(name) = extract_name_from_set_topic(&path, &publish.topic) {
let payload = String::from_utf8_lossy(&publish.payload).trim().to_string();
pending_set_values.insert(name.clone(), payload.clone());
}
}
Ok(Ok(_)) => {}
Ok(Err(e)) => {
eprintln!("MQTT Connection Fehler: {}", e);
eprintln!("Hinweis: Häufige Ursache ist eine zweite MQTT-Session mit derselben Client-ID.");
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
eprintln!("MQTT Verbindung getrennt (request channel geschlossen)");
thread::sleep(Duration::from_millis(500));
}
}
}
});
}
fn parse_boolish(payload: &str) -> Option<bool> {
match payload.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "on" => Some(true),
"0" | "false" | "off" => Some(false),
_ => None,
}
}
fn mirror_set_value(values: &Arc<Mutex<ModbusValueMaps>>, cfg: &AppConfig, name: &str, payload: &str) {
if let Ok(mut maps) = values.lock() {
if let Some(ref coils) = cfg.modbus_coils {
for map in coils {
if map.contains_key(name) {
if let Some(v) = parse_boolish(payload) {
maps.modbus_coils_values.insert(name.to_string(), Some(if v { 1.0 } else { 0.0 }));
}
return;
}
}
}
if let Some(ref holding) = cfg.modbus_holding_register {
for map in holding {
if map.contains_key(name) {
if let Ok(v) = payload.parse::<f64>() {
maps.modbus_holding_register_values.insert(name.to_string(), Some(v));
}
return;
}
}
}
}
}
fn extract_name_from_set_topic(base_path: &str, topic: &str) -> Result<String, String> {
let prefix = format!("{}/", base_path.trim_end_matches('/'));
if !topic.starts_with(&prefix) || !topic.ends_with("/set") {
return Err("Topic passt nicht zum /set-Schema".to_string());
}
let without_prefix = &topic[prefix.len()..];
let name = without_prefix.strip_suffix("/set").unwrap_or_default();
if name.is_empty() || name.contains('/') {
return Err("Ungültiger Variablenname im Topic".to_string());
}
Ok(name.to_string())
}
fn should_publish(config: &Arc<Mutex<AppConfig>>, name: &str, reg_type: &str) -> bool {
let cfg = config.lock().unwrap();
match reg_type {
"input_register" => {
if let Some(regs) = &cfg.modbus_input_register {
for map in regs {
if let Some(reg) = map.get(name) {
return reg.mqtt.unwrap_or(false);
}
}
}
}
"holding_register" => {
if let Some(regs) = &cfg.modbus_holding_register {
for map in regs {
if let Some(reg) = map.get(name) {
return reg.mqtt.unwrap_or(false);
}
}
}
}
"coils" => {
if let Some(coils) = &cfg.modbus_coils {
for map in coils {
if let Some(coil) = map.get(name) {
return coil.mqtt.unwrap_or(false);
}
}
}
}
_ => {}
}
false
}
+212
View File
@@ -0,0 +1,212 @@
// Sortiere die Tabelle nach Adresse (addr) beim Laden der Seite
document.addEventListener('DOMContentLoaded', function() {
// Darkmode-Status aus Konfiguration übernehmen
fetch('/api/config')
.then(response => response.json())
.then(config => {
if (config && config.default && config.default.darkmode === true) {
document.body.classList.add('darkmode');
} else {
document.body.classList.remove('darkmode');
}
});
const tableBody = document.getElementById('tableBody');
if (tableBody) {
// Extrahiere alle Zeilen und deren addr
const rows = Array.from(tableBody.querySelectorAll('tr'));
rows.sort((a, b) => {
const addrA = parseInt(a.querySelector("input[data-field='addr']")?.value || '0', 10);
const addrB = parseInt(b.querySelector("input[data-field='addr']")?.value || '0', 10);
return addrA - addrB;
});
// Füge sortierte Zeilen wieder ein
rows.forEach(row => tableBody.appendChild(row));
}
});
function addRow() {
const tableBody = document.getElementById('tableBody');
const rowCount = tableBody.querySelectorAll('tr').length;
const newRow = document.createElement('tr');
newRow.setAttribute('data-row', rowCount);
if (typeof tableId !== 'undefined' && tableId === 'modbus_coils') {
newRow.innerHTML = `
<td><input type='text' class='text-input' data-field='bezeichnung' value='' /></td>
<td><input type='number' class='text-input' data-field='adresse' value='' /></td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='write' />
<span class='slider'></span>
</label>
</td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='mqtt' />
<span class='slider'></span>
</label>
</td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='influxdb' />
<span class='slider'></span>
</label>
</td>
<td><input type='text' class='text-input' data-field='comment' value='' /></td>
<td>
<button class="delete-btn" onclick="deleteRow(this)">🗑️</button>
</td>
`;
} else if (typeof tableId !== 'undefined' && tableId === 'modbus_holding_register') {
newRow.innerHTML = `
<td><input type='text' class='text-input' data-field='bezeichnung' value='' /></td>
<td><input type='text' class='text-input' data-field='adresse' value='' /></td>
<td><input type='text' class='text-input' data-field='type' value='' /></td>
<td><input type='text' class='text-input' data-field='faktor' value='1.0' /></td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='write' />
<span class='slider'></span>
</label>
</td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='mqtt' />
<span class='slider'></span>
</label>
</td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='influxdb' />
<span class='slider'></span>
</label>
</td>
<td>
<button class="delete-btn" onclick="deleteRow(this)">🗑️</button>
</td>
`;
} else {
newRow.innerHTML = `
<td><input type='text' class='text-input' data-field='bezeichnung' value='' /></td>
<td><input type='text' class='text-input' data-field='adresse' value='' /></td>
<td><input type='text' class='text-input' data-field='type' value='' /></td>
<td><input type='text' class='text-input' data-field='faktor' value='1.0' /></td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='mqtt' />
<span class='slider'></span>
</label>
</td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='influxdb' />
<span class='slider'></span>
</label>
</td>
<td>
<button class="delete-btn" onclick="deleteRow(this)">🗑️</button>
</td>
`;
}
tableBody.appendChild(newRow);
}
function deleteRow(button) {
const row = button.closest('tr');
if (confirm('Möchten Sie diese Zeile wirklich löschen?')) {
row.remove();
updateRowIndices();
}
}
function updateRowIndices() {
const rows = document.querySelectorAll('#tableBody tr');
rows.forEach((row, index) => {
row.setAttribute('data-row', index);
});
}
async function saveTable() {
const rows = [];
const tableRows = document.querySelectorAll('#tableBody tr');
tableRows.forEach((row) => {
const key = row.querySelector("input[data-field='bezeichnung']")?.value.trim() || row.querySelector("input[data-field='key']")?.value.trim();
if (!key) return; // leere Zeilen überspringen
const addr = parseInt(row.querySelector("input[data-field='addr']")?.value || row.querySelector("input[data-field='adresse']")?.value || '0', 10);
const mqtt = row.querySelector("input[data-field='mqtt']")?.checked || false;
const influxdb = row.querySelector("input[data-field='influxdb']")?.checked || false;
let value = {};
if (typeof tableId !== 'undefined' && tableId === 'modbus_coils') {
const write = row.querySelector("input[data-field='write']")?.checked || false;
const comment = row.querySelector("input[data-field='comment']")?.value || null;
value = {
addr: addr,
write: write,
mqtt: mqtt,
influxdb: influxdb,
comment: comment
};
} else if (typeof tableId !== 'undefined' && tableId === 'modbus_holding_register') {
const write = row.querySelector("input[data-field='write']")?.checked || false;
const type = row.querySelector("input[data-field='type']")?.value || null;
const factor = parseFloat(row.querySelector("input[data-field='factor']")?.value || row.querySelector("input[data-field='faktor']")?.value || '1.0');
const comment = row.querySelector("input[data-field='comment']")?.value || null;
value = {
addr: addr,
type: type,
factor: factor,
write: write,
mqtt: mqtt,
influxdb: influxdb,
comment: comment
};
} else {
const type = row.querySelector("input[data-field='type']")?.value || null;
const factor = parseFloat(row.querySelector("input[data-field='factor']")?.value || row.querySelector("input[data-field='faktor']")?.value || '1.0');
const comment = row.querySelector("input[data-field='comment']")?.value || null;
value = {
addr: addr,
type: type,
factor: factor,
mqtt: mqtt,
influxdb: influxdb,
comment: comment
};
}
const obj = {};
obj[key] = value;
rows.push(obj);
});
try {
const response = await fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
table_id: tableId,
rows: rows
})
});
const messageDiv = document.getElementById('message');
if (response.ok) {
messageDiv.className = 'message success';
messageDiv.textContent = '✓ Erfolgreich gespeichert!';
} else {
const errorText = (await response.text()) || 'Unbekannter Fehler';
messageDiv.className = 'message error';
messageDiv.textContent = `✗ Fehler beim Speichern: ${errorText}`;
}
setTimeout(() => {
messageDiv.style.display = 'none';
}, 3000);
} catch (error) {
const messageDiv = document.getElementById('message');
messageDiv.className = 'message error';
messageDiv.textContent = '✗ Verbindungsfehler!';
}
}
+108
View File
@@ -0,0 +1,108 @@
// Setze den Wert des Darkmode-Schalters beim Laden der Seite, sobald das Element existiert
document.addEventListener('DOMContentLoaded', function() {
fetch('/api/config')
.then(response => response.json())
.then(config => {
function setSwitch() {
const el = document.getElementById('darkmode_switch');
if (el) {
if (config && config.default && (config.default.darkmode === true || config.default.darkmode === 'true')) {
el.checked = true;
document.body.classList.add('darkmode');
} else {
el.checked = false;
document.body.classList.remove('darkmode');
}
// Event Listener für Umschalten
el.addEventListener('change', function() {
if (el.checked) {
document.body.classList.add('darkmode');
} else {
document.body.classList.remove('darkmode');
}
});
} else {
setTimeout(setSwitch, 50);
}
}
setSwitch();
});
});
async function saveSettings() {
// Default (für [default])
const darkmode = document.getElementById('darkmode_switch')?.checked ?? null;
const defaultConfig = {
loglevel: document.getElementById('loglevel')?.value || null,
darkmode: darkmode
};
// Modbus
const modbus = {
host: document.getElementById('modbus_host').value,
port: parseInt(document.getElementById('modbus_port').value, 10),
max_coils_addr: parseInt(document.getElementById('modbus_max_coils_addr').value, 10) || null,
max_input_addr: parseInt(document.getElementById('modbus_max_input_addr').value, 10) || null,
max_holding_addr: parseInt(document.getElementById('modbus_max_holding_addr').value, 10) || null,
modbus_coils: null,
modbus_input_register: null,
modbus_holding_register: null,
};
// MQTT
const mqtt = {
broker: document.getElementById('mqtt_broker').value,
port: parseInt(document.getElementById('mqtt_port').value, 10),
user: document.getElementById('mqtt_user').value || null,
password: document.getElementById('mqtt_password').value || null,
path: document.getElementById('mqtt_path')?.value || null,
leitsystem_path: document.getElementById('mqtt_leitsystem_path')?.value || null,
set_write_interval_ms: parseInt(document.getElementById('mqtt_set_write_interval_ms')?.value, 10) || 2000
};
// InfluxDB
const influxdb = {
url: document.getElementById('influxdb_url').value,
token: document.getElementById('influxdb_token').value,
bucket: document.getElementById('influxdb_bucket').value,
org: document.getElementById('influxdb_org').value,
location: document.getElementById('influxdb_location').value || null,
measurement: document.getElementById('influxdb_measurement').value || null
};
const settings = {
default: defaultConfig,
modbus,
mqtt,
influxdb
};
try {
const response = await fetch('/api/save-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings)
});
const messageDiv = document.getElementById('message');
if (response.ok) {
messageDiv.className = 'message success';
messageDiv.textContent = '✓ Einstellungen erfolgreich gespeichert!';
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
messageDiv.className = 'message error';
messageDiv.textContent = '✗ Fehler beim Speichern der Einstellungen!';
setTimeout(() => {
messageDiv.style.display = 'none';
}, 3000);
}
} catch (error) {
const messageDiv = document.getElementById('message');
messageDiv.className = 'message error';
messageDiv.textContent = '✗ Verbindungsfehler!';
}
}
+367
View File
@@ -0,0 +1,367 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--background: #fff;
--background-alt: #f9f9f9;
--container-bg: #fff;
--header-bg: #fff;
--primary: #667eea;
--primary-dark: #764ba2;
--text: #111;
--text-light: #333;
--nav-link: #666;
--nav-link-hover: #f0f0f0;
--table-header: #667eea;
--table-header-border: #5568d3;
--table-row-hover: #f8f9ff;
--input-border: #e0e0e0;
--input-focus: #667eea;
--button-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--button-gradient-alt: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
--delete-btn: #f44336;
--delete-btn-hover: #da190b;
--success-bg: #d4edda;
--success-text: #155724;
--success-border: #c3e6cb;
--error-bg: #f8d7da;
--error-text: #721c24;
--error-border: #f5c6cb;
}
body.darkmode {
--background: #181a1b;
--background-alt: #23272b;
--container-bg: #23272b;
--header-bg: #23272b;
--primary: #3399ff;
--primary-dark: #1a2a3a;
--text: #eee;
--text-light: #bbb;
--nav-link: #bbb;
--nav-link-hover: #222;
--table-header: #222e3a;
--table-header-border: #3399ff;
--table-row-hover: #23272b;
--input-border: #444;
--input-focus: #3399ff;
--button-gradient: linear-gradient(135deg, #3399ff 0%, #1a2a3a 100%);
--button-gradient-alt: linear-gradient(135deg, #388e3c 0%, #1b5e20 100%);
--delete-btn: #b71c1c;
--delete-btn-hover: #7f0000;
--success-bg: #234d2c;
--success-text: #b9f6ca;
--success-border: #388e3c;
--error-bg: #4a2323;
--error-text: #ff8a80;
--error-border: #b71c1c;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--background);
min-height: 100vh;
padding-top: 80px;
color: var(--text);
}
/* Header Styles */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--header-bg);
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-text {
font-size: 40px;
font-weight: 800;
color: var(--text-light);
}
.nav {
display: flex;
gap: 5px;
}
.nav-link {
padding: 10px 20px;
text-decoration: none;
color: var(--nav-link);
border-radius: 6px;
transition: all 0.3s;
font-weight: 500;
}
.nav-link:hover {
background-color: var(--nav-link-hover);
color: var(--text-light);
}
.nav-link.active {
background: var(--button-gradient);
color: #fff;
}
/* Container */
.container {
max-width: 1400px;
margin: 30px auto;
padding: 30px;
background-color: var(--container-bg);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h1 {
color: var(--text-light);
text-align: center;
margin-bottom: 30px;
font-size: 28px;
}
h2 {
color: var(--text-light);
margin-bottom: 20px;
font-size: 20px;
border-bottom: 2px solid var(--primary);
padding-bottom: 10px;
}
/* Table Styles */
.table-wrapper {
overflow-x: auto;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
background-color: var(--container-bg);
}
th {
background-color: var(--table-header);
color: #fff;
padding: 15px;
text-align: left;
font-weight: 600;
border: 1px solid var(--table-header-border);
}
td {
border: 1px solid var(--input-border);
padding: 12px;
}
tr:hover {
background-color: var(--table-row-hover);
}
.text-input {
width: 100%;
padding: 8px 12px;
border: 2px solid var(--input-border);
border-radius: 6px;
box-sizing: border-box;
font-size: 14px;
transition: border-color 0.3s;
}
.text-input:focus {
outline: none;
border-color: var(--input-focus);
}
/* Toggle Switch */
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4CAF50;
}
input:focus + .slider {
box-shadow: 0 0 1px #4CAF50;
}
input:checked + .slider:before {
transform: translateX(26px);
}
/* Buttons */
.button-group {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 20px;
}
.save-btn, .add-btn {
padding: 14px 30px;
background: var(--button-gradient);
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
.save-btn:hover, .add-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.save-btn:active, .add-btn:active {
transform: translateY(0);
}
.add-btn {
background: var(--button-gradient-alt);
}
.delete-btn {
padding: 8px 12px;
background-color: var(--delete-btn);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.delete-btn:hover {
background-color: var(--delete-btn-hover);
}
/* Messages */
.message {
text-align: center;
padding: 12px;
margin: 20px 0;
border-radius: 8px;
display: none;
font-weight: 500;
}
.message.success {
background-color: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success-border);
display: block;
}
.message.error {
background-color: var(--error-bg);
color: var(--error-text);
border: 1px solid var(--error-border);
display: block;
}
/* Settings Page */
.settings-section {
margin-bottom: 40px;
padding: 20px;
background-color: var(--background-alt);
border-radius: 8px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-light);
font-weight: 500;
}
.form-group .text-input {
max-width: 600px;
}
/* Responsive */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 15px;
}
.nav {
flex-wrap: wrap;
justify-content: center;
}
.container {
padding: 15px;
margin: 15px;
}
.button-group {
flex-direction: column;
}
.save-btn, .add-btn {
width: 100%;
}
}
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Konfiguration</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="header">
<div class="header-content">
<div class="logo">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#667eea"/>
<path d="M12 20L18 26L28 14" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="logo-text">Paramod</span>
</div>
<nav class="nav">
<a href="/table/modbus_input_register" class="nav-link {% if active_page == 'modbus_input_register' %}active{% endif %}">Input Register</a>
<a href="/table/modbus_holding_register" class="nav-link {% if active_page == 'modbus_holding_register' %}active{% endif %}">Holding Register</a>
<a href="/table/modbus_coils" class="nav-link {% if active_page == 'modbus_coils' %}active{% endif %}">Coils</a>
<a href="/settings" class="nav-link {% if active_page == 'settings' %}active{% endif %}">⚙️ Einstellungen</a>
</nav>
</div>
</header>
<div class="container">
<h1>🔧 Konfiguration - {{ table_id | upper }}</h1>
<div id="message" class="message"></div>
<div class="table-wrapper">
<table id="sensorTable">
<thead>
<tr>
<th>Bezeichnung</th>
<th>Wert</th>
<th>Adresse</th>
{% if table_id == "modbus_coils" %}
<th>Write</th>
{% elif table_id == "modbus_holding_registers" or table_id == "modbus_holding_register" %}
<th>Type</th>
<th>Faktor</th>
<th>Write</th>
{% else %}
<th>Type</th>
<th>Faktor</th>
{% endif %}
<th>MQTT</th>
<th>InfluxDB</th>
{% if table_id == "modbus_coils" %}
<th>Kommentar</th>
{% endif %}
<th>Aktionen</th>
</tr>
</thead>
<tbody id="tableBody">
{% for entry in rows %}
{% for key, row in entry %}
<tr data-row="{{ loop.index0 }}">
<td><input type='text' class='text-input' data-field='key' value='{{ key }}' /></td>
<td>{{ value_map[key] | default(value="-") }}</td>
<td><input type='number' class='text-input' data-field='addr' value='{{ row.addr }}' /></td>
{% if table_id == "modbus_coils" %}
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='write' {% if row.write %}checked{% endif %} />
<span class='slider'></span>
</label>
</td>
{% elif table_id == "modbus_holding_registers" or table_id == "modbus_holding_register" %}
<td><input type='text' class='text-input' data-field='type' value='{{ row.type | default(value="") }}' /></td>
<td><input type='text' class='text-input' data-field='factor' value='{{ row.factor | default(value="") }}' /></td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='write' {% if row.write %}checked{% endif %} />
<span class='slider'></span>
</label>
</td>
{% else %}
<td><input type='text' class='text-input' data-field='type' value='{{ row.type | default(value="") }}' /></td>
<td><input type='text' class='text-input' data-field='factor' value='{{ row.factor | default(value="") }}' /></td>
{% endif %}
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='mqtt' {% if row.mqtt %}checked{% endif %} />
<span class='slider'></span>
</label>
</td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-field='influxdb' {% if row.influxdb %}checked{% endif %} />
<span class='slider'></span>
</label>
</td>
{% if table_id == "modbus_coils" %}
<td><input type='text' class='text-input' data-field='comment' value='{{ row.comment | default(value="") }}' /></td>
{% endif %}
<td>
<button class="delete-btn" onclick="deleteRow(this)">🗑️</button>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
<div class="button-group">
<button class="add-btn" onclick="addRow()">➕ Zeile hinzufügen</button>
<button class="save-btn" onclick="saveTable()">💾 Speichern</button>
</div>
</div>
<script>
const tableId = "{{ table_id }}";
</script>
<script src="/static/script.js"></script>
</body>
</html>
+143
View File
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einstellungen</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body{% if default.darkmode == true or default.darkmode == "true" %} class="darkmode"{% endif %}>
<header class="header">
<div class="header-content">
<div class="logo">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#667eea"/>
<path d="M12 20L18 26L28 14" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="logo-text">Paramod</span>
</div>
<nav class="nav">
<a href="/table/modbus_input_register" class="nav-link {% if active_page == 'modbus_input_register' %}active{% endif %}">Input Register</a>
<a href="/table/modbus_holding_register" class="nav-link {% if active_page == 'modbus_holding_register' %}active{% endif %}">Holding Register</a>
<a href="/table/modbus_coils" class="nav-link {% if active_page == 'modbus_coils' %}active{% endif %}">Coils</a>
<a href="/settings" class="nav-link {% if active_page == 'settings' %}active{% endif %}">⚙️ Einstellungen</a>
</nav>
</div>
</header>
<div class="container">
<h1>⚙️ Einstellungen</h1>
<div id="message" class="message"></div>
<div class="settings-section">
<h2>Allgemein</h2>
<div class="form-group">
<label for="loglevel">Loglevel:</label>
<select id="loglevel" class="text-input">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARN">WARN</option>
<option value="ERROR">ERROR</option>
</select>
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 12px;">
<label for="darkmode_switch">Darkmode:</label>
<label class="switch">
<input type="checkbox" id="darkmode_switch">
<span class="slider"></span>
</label>
</div>
</div>
<div class="settings-section">
<h2>Modbus Konfiguration</h2>
<div class="form-group">
<label for="modbus_host">Host:</label>
<input type="text" id="modbus_host" class="text-input" value="{{ modbus.host }}" />
</div>
<div class="form-group">
<label for="modbus_port">Port:</label>
<input type="text" id="modbus_port" class="text-input" value="{{ modbus.port }}" />
</div>
<div class="form-group">
<label for="modbus_max_coils_addr">Max Coils Addr:</label>
<input type="text" id="modbus_max_coils_addr" class="text-input" value="{{ modbus.max_coils_addr | default(value="") }}" />
</div>
<div class="form-group">
<label for="modbus_max_input_addr">Max Input Addr:</label>
<input type="text" id="modbus_max_input_addr" class="text-input" value="{{ modbus.max_input_addr | default(value="") }}" />
</div>
<div class="form-group">
<label for="modbus_max_holding_addr">Max Holding Addr:</label>
<input type="text" id="modbus_max_holding_addr" class="text-input" value="{{ modbus.max_holding_addr | default(value="") }}" />
</div>
</div>
<div class="settings-section">
<h2>MQTT Konfiguration</h2>
<div class="form-group">
<label for="mqtt_broker">MQTT Broker:</label>
<input type="text" id="mqtt_broker" class="text-input" value="{{ mqtt.broker }}" />
</div>
<div class="form-group">
<label for="mqtt_port">MQTT Port:</label>
<input type="text" id="mqtt_port" class="text-input" value="{{ mqtt.port }}" />
</div>
<div class="form-group">
<label for="mqtt_user">MQTT User:</label>
<input type="text" id="mqtt_user" class="text-input" value="{{ mqtt.user | default(value="") }}" />
</div>
<div class="form-group">
<label for="mqtt_password">MQTT Passwort:</label>
<input type="password" id="mqtt_password" class="text-input" value="{{ mqtt.password | default(value="") }}" />
</div>
<div class="form-group">
<label for="mqtt_path">MQTT Pfad:</label>
<input type="text" id="mqtt_path" class="text-input" value="{{ mqtt.path | default(value="") }}" />
</div>
<div class="form-group">
<label for="mqtt_leitsystem_path">Leitsystem Pfad:</label>
<input type="text" id="mqtt_leitsystem_path" class="text-input" value="{{ mqtt.leitsystem_path | default(value="") }}" />
</div>
<div class="form-group">
<label for="mqtt_set_write_interval_ms">Set-Sendeintervall (ms):</label>
<input type="number" min="100" id="mqtt_set_write_interval_ms" class="text-input" value="{{ mqtt.set_write_interval_ms | default(value="2000") }}" />
</div>
</div>
<div class="settings-section">
<h2>InfluxDB Konfiguration</h2>
<div class="form-group">
<label for="influxdb_url">InfluxDB URL:</label>
<input type="text" id="influxdb_url" class="text-input" value="{{ influxdb.url }}" />
</div>
<div class="form-group">
<label for="influxdb_token">InfluxDB Token:</label>
<input type="password" id="influxdb_token" class="text-input" value="{{ influxdb.token }}" />
</div>
<div class="form-group">
<label for="influxdb_bucket">Bucket:</label>
<input type="text" id="influxdb_bucket" class="text-input" value="{{ influxdb.bucket }}" />
</div>
<div class="form-group">
<label for="influxdb_org">Org:</label>
<input type="text" id="influxdb_org" class="text-input" value="{{ influxdb.org }}" />
</div>
<div class="form-group">
<label for="influxdb_location">Location:</label>
<input type="text" id="influxdb_location" class="text-input" value="{{ influxdb.location | default(value="") }}" />
</div>
<div class="form-group">
<label for="influxdb_measurement">Measurement:</label>
<input type="text" id="influxdb_measurement" class="text-input" value="{{ influxdb.measurement | default(value="") }}" />
</div>
</div>
<button class="save-btn" onclick="saveSettings()">💾 Einstellungen speichern</button>
</div>
<script src="/static/settings.js"></script>
</body>
</html>