Compare commits
61
Commits
7e133d55de
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0f9013aa | ||
|
|
9d33984c97 | ||
|
|
89f02422e7 | ||
|
|
58f5522442 | ||
|
|
e6c60d0707 | ||
|
|
e96af3ac7e | ||
|
|
87343e354b | ||
|
|
ca43d6b9a5 | ||
|
|
68140a0c45 | ||
|
|
3ed80f4b68 | ||
|
|
497a0e48e3 | ||
|
|
825547889c | ||
|
|
923a35cad1 | ||
|
|
0c10ff4051 | ||
|
|
451008b895 | ||
|
|
2ca79a7b9f | ||
|
|
a78d49ef7e | ||
|
|
56982cc26a | ||
|
|
3168d21c5f | ||
|
|
5b7b4fa57e | ||
|
|
ee2c69b4c2 | ||
|
|
cb7e8d19e4 | ||
|
|
59de54e80a | ||
|
|
10bdc8fb5a | ||
|
|
c134a20da8 | ||
|
|
0d696a44e4 | ||
|
|
839fd24b4e | ||
|
|
1c8dd77946 | ||
|
|
60823714a6 | ||
|
|
4dd58ecaaa | ||
|
|
e15484c460 | ||
|
|
4673ff3ff1 | ||
|
|
e15b1566f0 | ||
|
|
e75764da43 | ||
|
|
41b88d9e3e | ||
|
|
241a9ec240 | ||
|
|
55e7260ee5 | ||
|
|
f47cd3a97b | ||
|
|
67b0e4ebd7 | ||
|
|
adac95129d | ||
|
|
58bacd2f6d | ||
|
|
483d27750b | ||
|
|
698be221c0 | ||
|
|
79cbde6b4a | ||
|
|
7fb98b95f8 | ||
|
|
459b97d95c | ||
|
|
c499f1b97c | ||
|
|
e8d77ec18e | ||
|
|
4bc399b44f | ||
|
|
b9bc0e52d3 | ||
|
|
84eb4b2365 | ||
|
|
49bd17d52b | ||
|
|
3fb6051e4a | ||
|
|
195df9f260 | ||
|
|
965a7aa225 | ||
|
|
7a8758d8cc | ||
|
|
09c41821c8 | ||
|
|
e74689c70f | ||
|
|
bb2bb8d493 | ||
|
|
220161a70a | ||
|
|
f2417ec65d |
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
table_config.json
|
||||||
|
Cargo.lock
|
||||||
|
.DS_Store
|
||||||
+6
@@ -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
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
sed -i 's/^version = ".*"/version = "__VERSION__"/' Cargo.toml
|
||||||
Vendored
+12
@@ -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
|
||||||
Vendored
+16
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+27
@@ -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
File diff suppressed because it is too large.
Load diff
+23
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
+14
@@ -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
@@ -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}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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!';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user