Compare commits

..

5 Commits

Author SHA1 Message Date
5b0f9013aa modbus polling Intervall vergrößert
Some checks failed
Build Docker Image (Podman) / build (push) Has been cancelled
2026-05-26 22:46:35 +02:00
9d33984c97 CONFIG_PATH für yaml Datei 2026-03-23 22:01:44 +01:00
89f02422e7 VERISON in Dockerfile abhandeln 2026-03-23 22:01:10 +01:00
58f5522442 Speichern von Holding und Coils ging nicht mehr 2026-03-23 21:13:04 +01:00
e6c60d0707 Ausführen /Debuggen in vscode nun mit Version 2026-03-23 20:48:56 +01:00
12 changed files with 128 additions and 187 deletions

View File

@ -12,10 +12,6 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set version in Cargo.toml
run: |
VERSION=$(cat VERSION)
sed -i "s/^version = \"__VERSION__\"/version = \"$VERSION\"/" Cargo.toml
- name: Build image with Podman - name: Build image with Podman
env: env:
BUILDAH_ISOLATION: chroot BUILDAH_ISOLATION: chroot

View File

@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
set -e
# Patch Cargo.toml version from VERSION file before build # Patch Cargo.toml version from VERSION file before build
VERSION=$(cat VERSION) VERSION=$(cat VERSION)
sed -i "s/^version = ".*"/version = \"$VERSION\"/" Cargo.toml sed -i 's/^version = ".*"/version = "'"$VERSION"'"/' Cargo.toml
exec cargo build exec cargo build

3
.vscode/cargo-reset-version.sh vendored Normal file
View File

@ -0,0 +1,3 @@
#!/bin/bash
set -e
sed -i 's/^version = ".*"/version = "__VERSION__"/' Cargo.toml

12
.vscode/cargo-run-version.sh vendored Normal file
View File

@ -0,0 +1,12 @@
#!/bin/bash
set -e
reset_version() {
sed -i 's/^version = ".*"/version = "__VERSION__"/' Cargo.toml
}
trap reset_version EXIT
VERSION=$(cat VERSION)
sed -i 's/^version = ".*"/version = "'"$VERSION"'"/' Cargo.toml
cargo run

3
.vscode/launch.json vendored
View File

@ -9,7 +9,8 @@
"args": [], "args": [],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"sourceLanguages": ["rust"], "sourceLanguages": ["rust"],
"preLaunchTask": "cargo build" "preLaunchTask": "cargo build",
"postDebugTask": "cargo reset version placeholder"
} }
] ]
} }

14
.vscode/tasks.json vendored
View File

@ -9,7 +9,19 @@
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
}, },
"problemMatcher": ["$rustc"] "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"]
} }
] ]
} }

View File

@ -6,11 +6,14 @@ WORKDIR /usr/src/app
COPY Cargo.toml ./ COPY Cargo.toml ./
COPY Cargo.lock ./ COPY Cargo.lock ./
COPY VERSION ./
COPY src ./src COPY src ./src
COPY templates ./templates COPY templates ./templates
COPY static ./static COPY static ./static
RUN cargo build --release RUN VERSION=$(cat VERSION) \
&& sed -i 's/^version = "__VERSION__"/version = "'"$VERSION"'"/' Cargo.toml \
&& cargo build --release
# Runtime Stage # Runtime Stage
FROM debian:bookworm-slim FROM debian:bookworm-slim

189
README.md
View File

@ -1,190 +1,103 @@
# Tabellen Webserver # Paramod
Ein einfacher Rust-Webserver, der eine editierbare 3x3-Tabelle bereitstellt und in einer JSON-Konfigurationsdatei persistiert. Rust-Webserver zur Anzeige und Bearbeitung von Modbus-Tabellen mit Persistierung in `paramod.yaml`.
## Projektstruktur
```
paramod/
├── src/
│ └── main.rs
├── templates/
│ ├── index.html
│ └── settings.html
├── static/
│ ├── style.css
│ ├── script.js
│ └── settings.js
├── Cargo.toml
├── Dockerfile
├── .gitignore
└── README.md
```
## Funktionen ## Funktionen
- **3 separate Tabellen** für verschiedene Sensor-Gruppen - Tabellen für `modbus_input_register`, `modbus_holding_register` und `modbus_coils`
- **Navigation** mit aktivem Status-Indikator - Web-UI zum Bearbeiten und Speichern der Tabellen
- **Header mit Logo** für professionelles Erscheinungsbild - Einstellungsseite für `default`, `modbus`, `mqtt` und `influxdb`
- **Zeilen hinzufügen/löschen** dynamisch zur Laufzeit - MQTT/Influx-Integration gemäß Konfiguration
- **Einstellungsseite** für MQTT und InfluxDB Konfiguration - Speicherung aller Änderungen in `paramod.yaml`
- Editierbare Textfelder (Bezeichnung, Adresse, Type, Faktor)
- Toggle-Schalter für Boolean-Werte (MQTT, InfluxDB)
- **Zentrale JSON-Persistierung** für alle Tabellen und Einstellungen
- REST-API für Daten-Management
- Docker-Unterstützung
- Responsive Design
## Lokale Entwicklung ## Lokale Entwicklung
### Voraussetzungen ### Voraussetzungen
- Rust (Version 1.75 oder höher) - Rust + Cargo
- Cargo
### Installation und Start ### Start
```bash ```bash
# Projekt erstellen
cargo new paramod
cd paramod
# Dependencies installieren und starten
cargo run cargo run
``` ```
Der Server läuft dann auf `http://localhost:8080` Server: `http://localhost:8080`
## Docker ## Docker (Podman)
### Container bauen ### Image bauen
```bash ```bash
podman build -t paramod . podman build -t paramod:latest .
``` ```
### Container starten ### Container starten
```bash ```bash
poddman run -p 8080:8080 -v $(pwd)/data:/app/data paramod podman run --rm -p 8080:8080 -v "$(pwd)/paramod.yaml:/app/paramod.yaml" paramod:latest
``` ```
Mit Volume-Mount bleibt die Konfigurationsdatei auch nach Container-Neustarts erhalten.
## Verwendung
1. Öffne `http://localhost:8080` im Browser
2. Navigiere zwischen den Tabellen über das Menü:
- **Tabelle 1, 2, 3**: Verschiedene Sensor-Gruppen
- **⚙️ Einstellungen**: MQTT und InfluxDB Konfiguration
3. In den Tabellen:
- ** Zeile hinzufügen**: Neue Sensor-Einträge erstellen
- **🗑️ Löschen**: Einzelne Zeilen entfernen
- **Felder bearbeiten**:
- Bezeichnung: Name des Sensors
- Adresse: IP-Adresse oder Identifier
- Type: Sensor-Typ (z.B. Temperatur, Luftfeuchtigkeit)
- Faktor: Numerischer Korrekturfaktor
- MQTT: Toggle-Schalter für MQTT-Aktivierung
- InfluxDB: Toggle-Schalter für InfluxDB-Aktivierung
4. **💾 Speichern**: Änderungen persistieren
5. Alle Daten werden zentral in `paramod.yaml` gespeichert
## API Endpoints ## API Endpoints
- `GET /` - Zeigt Tabelle 1 - `GET /`
- `GET /table/table2` - Zeigt Tabelle 2 - `GET /table/modbus_input_register`
- `GET /table/table3` - Zeigt Tabelle 3 - `GET /table/modbus_holding_register`
- `GET /settings` - Zeigt Einstellungsseite - `GET /table/modbus_coils`
- `POST /api/save` - Speichert eine Tabelle - `GET /settings`
- `POST /api/save-settings` - Speichert die Einstellungen - `GET /api/config`
- `GET /static/*` - Statische Dateien (CSS, JS) - `POST /api/save`
- `POST /api/save-settings`
### Beispiel API-Request (Tabelle speichern) ## API-Beispiel: Tabelle speichern
Beispiel für Holding-Register:
```bash ```bash
curl -X POST http://localhost:8080/api/save \ curl -X POST http://localhost:8080/api/save \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"table_id": "table1", "table_id": "modbus_holding_register",
"rows": [ "rows": [
{ {
"bezeichnung": "Sensor 1", "TVsoll": {
"adresse": "192.168.1.100", "addr": 2,
"type": "Temperatur", "type": "INT16",
"faktor": "1.0", "factor": 0.1,
"mqtt": true, "write": true,
"influxdb": false "mqtt": true,
"influxdb": true,
"comment": "Vorlauf Soll"
}
} }
] ]
}' }'
``` ```
### Beispiel API-Request (Einstellungen speichern) Beispiel für Coils:
```bash ```bash
curl -X POST http://localhost:8080/api/save-settings \ curl -X POST http://localhost:8080/api/save \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"mqtt_broker": "localhost", "table_id": "modbus_coils",
"mqtt_port": "1883", "rows": [
"influxdb_url": "http://localhost:8086", {
"influxdb_token": "your-token-here" "MgtSystem": {
"addr": 0,
"write": true,
"mqtt": true,
"influxdb": false,
"comment": "Leitsystem aktiv"
}
}
]
}' }'
``` ```
## Konfigurationsdatei ## Konfiguration
Die komplette Anwendungskonfiguration wird in `table_config.json` gespeichert: Die komplette Konfiguration liegt in `paramod.yaml`.
```json
{
"table1": [
{
"bezeichnung": "Temp Sensor 1",
"adresse": "192.168.1.100",
"type": "Temperatur",
"faktor": "1.0",
"mqtt": true,
"influxdb": false
},
{
"bezeichnung": "Temp Sensor 2",
"adresse": "192.168.1.101",
"type": "Temperatur",
"faktor": "1.0",
"mqtt": false,
"influxdb": true
}
],
"table2": [
{
"bezeichnung": "Humidity Sensor 1",
"adresse": "192.168.1.200",
"type": "Luftfeuchtigkeit",
"faktor": "0.5",
"mqtt": true,
"influxdb": true
}
],
"table3": [
{
"bezeichnung": "Pressure Sensor 1",
"adresse": "192.168.1.300",
"type": "Druck",
"faktor": "2.0",
"mqtt": true,
"influxdb": false
}
],
"settings": {
"mqtt_broker": "localhost",
"mqtt_port": "1883",
"influxdb_url": "http://localhost:8086",
"influxdb_token": ""
}
}
```
## Lizenz ## Lizenz

View File

@ -8,6 +8,7 @@ mqtt:
password: 97sm3pHNSMZ4M5qUj0x8 password: 97sm3pHNSMZ4M5qUj0x8
path: heizung/paradigma path: heizung/paradigma
leitsystem_path: heizung/leitsystem2 leitsystem_path: heizung/leitsystem2
set_write_interval_ms: 2000
influxdb: influxdb:
bucket: Paradigma bucket: Paradigma
org: skaville org: skaville

View File

@ -16,6 +16,10 @@ use crate::config::{AppConfig, ModbusValueMaps};
use crate::modbus_types::{ModbusInputRegisterConfig, ModbusHoldingRegisterConfig, ModbusCoilsConfig}; use crate::modbus_types::{ModbusInputRegisterConfig, ModbusHoldingRegisterConfig, ModbusCoilsConfig};
use crate::app_state::AppState; 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> { async fn index(data: web::Data<AppState>) -> Result<HttpResponse> {
let config = data.config.lock().unwrap(); let config = data.config.lock().unwrap();
let value_maps = data.value_maps.lock().unwrap(); let value_maps = data.value_maps.lock().unwrap();
@ -127,18 +131,10 @@ async fn settings_page(data: web::Data<AppState>) -> Result<HttpResponse> {
#[derive(Serialize, Deserialize, Clone)]
#[serde(untagged)]
enum SaveTableRows {
InputRegister(Vec<HashMap<String, ModbusInputRegisterConfig>>),
HoldingRegister(Vec<HashMap<String, ModbusHoldingRegisterConfig>>),
Coils(Vec<HashMap<String, ModbusCoilsConfig>>),
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct SaveTableRequest { struct SaveTableRequest {
table_id: String, table_id: String,
rows: SaveTableRows, rows: serde_json::Value,
} }
async fn save_table( async fn save_table(
@ -146,28 +142,31 @@ async fn save_table(
req: web::Json<SaveTableRequest>, req: web::Json<SaveTableRequest>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let mut config = data.config.lock().unwrap(); let mut config = data.config.lock().unwrap();
let conf_path = "paramod.yaml"; let conf_path = get_config_path();
match req.table_id.as_str() { match req.table_id.as_str() {
"modbus_input_register" => { "modbus_input_register" => {
if let SaveTableRows::InputRegister(rows) = req.rows.clone() { let rows: Vec<HashMap<String, ModbusInputRegisterConfig>> =
config.modbus_input_register = Some(rows); match serde_json::from_value(req.rows.clone()) {
} else { Ok(v) => v,
return Ok(HttpResponse::BadRequest().body("Falscher Typ für input_register")); Err(e) => return Ok(HttpResponse::BadRequest().body(format!("Falscher Typ für input_register: {}", e))),
} };
config.modbus_input_register = Some(rows);
} }
"modbus_holding_register" => { "modbus_holding_register" => {
if let SaveTableRows::HoldingRegister(rows) = req.rows.clone() { let rows: Vec<HashMap<String, ModbusHoldingRegisterConfig>> =
config.modbus_holding_register = Some(rows); match serde_json::from_value(req.rows.clone()) {
} else { Ok(v) => v,
return Ok(HttpResponse::BadRequest().body("Falscher Typ für holding_register")); Err(e) => return Ok(HttpResponse::BadRequest().body(format!("Falscher Typ für holding_register: {}", e))),
} };
config.modbus_holding_register = Some(rows);
} }
"modbus_coils" => { "modbus_coils" => {
if let SaveTableRows::Coils(rows) = req.rows.clone() { let rows: Vec<HashMap<String, ModbusCoilsConfig>> =
config.modbus_coils = Some(rows); match serde_json::from_value(req.rows.clone()) {
} else { Ok(v) => v,
return Ok(HttpResponse::BadRequest().body("Falscher Typ für coils")); 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")), _ => return Ok(HttpResponse::BadRequest().body("Invalid table_id")),
} }
@ -175,7 +174,7 @@ async fn save_table(
Ok(s) => s, Ok(s) => s,
Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Serialisierungsfehler: {}", e))), Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Serialisierungsfehler: {}", e))),
}; };
if let Err(e) = fs::write(conf_path, yaml_str) { if let Err(e) = fs::write(&conf_path, yaml_str) {
return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Schreiben: {}", e))); return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Schreiben: {}", e)));
} }
Ok(HttpResponse::Ok().body("success")) Ok(HttpResponse::Ok().body("success"))
@ -200,16 +199,16 @@ async fn save_settings(
// Value-Maps neu initialisieren // Value-Maps neu initialisieren
let mut value_maps = data.value_maps.lock().unwrap(); let mut value_maps = data.value_maps.lock().unwrap();
*value_maps = ModbusValueMaps::from_config(&new_config); *value_maps = ModbusValueMaps::from_config(&new_config);
let conf_path = "paramod.yaml"; let conf_path = get_config_path();
let yaml_str = match serde_yaml::to_string(&new_config) { let yaml_str = match serde_yaml::to_string(&new_config) {
Ok(s) => s, Ok(s) => s,
Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Serialisierungsfehler: {}", e))), Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Serialisierungsfehler: {}", e))),
}; };
if let Err(e) = fs::write(conf_path, yaml_str) { if let Err(e) = fs::write(&conf_path, yaml_str) {
return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Schreiben: {}", e))); return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Schreiben: {}", e)));
} }
// Reload config from file // Reload config from file
let conf_str = match std::fs::read_to_string(conf_path) { let conf_str = match std::fs::read_to_string(&conf_path) {
Ok(s) => s, Ok(s) => s,
Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Lesen: {}", e))), Err(e) => return Ok(HttpResponse::InternalServerError().body(format!("Fehler beim Lesen: {}", e))),
}; };
@ -231,9 +230,8 @@ async fn get_config(data: web::Data<AppState>) -> HttpResponse {
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
let config_path = get_config_path();
let config_path = "paramod.yaml"; let app_state = web::Data::new(AppState::load_from_conf(&config_path));
let app_state = web::Data::new(AppState::load_from_conf(config_path));
// Starte Modbus-Polling-Thread // Starte Modbus-Polling-Thread
{ {
@ -245,7 +243,7 @@ async fn main() -> std::io::Result<()> {
&config.modbus_holding_register, &config.modbus_holding_register,
&config.modbus_coils, &config.modbus_coils,
value_maps, value_maps,
std::time::Duration::from_secs(2), // Poll-Intervall std::time::Duration::from_secs(10), // Poll-Intervall
); );
} }

View File

@ -37,7 +37,7 @@ pub fn start_mqtt_thread(config: Arc<Mutex<AppConfig>>, values: Arc<Mutex<Modbus
if let Err(e) = client.subscribe(set_topic, QoS::AtLeastOnce) { if let Err(e) = client.subscribe(set_topic, QoS::AtLeastOnce) {
eprintln!("MQTT Subscribe fehlgeschlagen: {}", e); eprintln!("MQTT Subscribe fehlgeschlagen: {}", e);
} }
let leitsystem_state_topic = format!("{}/state", leitsystem_path.trim_end_matches('/')); let leitsystem_state_topic = format!("{}/active", leitsystem_path.trim_end_matches('/'));
if let Err(e) = client.subscribe(leitsystem_state_topic.clone(), QoS::AtLeastOnce) { if let Err(e) = client.subscribe(leitsystem_state_topic.clone(), QoS::AtLeastOnce) {
eprintln!("MQTT Subscribe Leitsystem fehlgeschlagen: {}", e); eprintln!("MQTT Subscribe Leitsystem fehlgeschlagen: {}", e);
} }

View File

@ -56,7 +56,7 @@ function addRow() {
<button class="delete-btn" onclick="deleteRow(this)">🗑</button> <button class="delete-btn" onclick="deleteRow(this)">🗑</button>
</td> </td>
`; `;
} else if (typeof tableId !== 'undefined' && tableId === 'modbus_holding_registers') { } else if (typeof tableId !== 'undefined' && tableId === 'modbus_holding_register') {
newRow.innerHTML = ` newRow.innerHTML = `
<td><input type='text' class='text-input' data-field='bezeichnung' value='' /></td> <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='adresse' value='' /></td>
@ -147,14 +147,14 @@ async function saveTable() {
influxdb: influxdb, influxdb: influxdb,
comment: comment comment: comment
}; };
} else if (typeof tableId !== 'undefined' && tableId === 'modbus_holding_registers') { } else if (typeof tableId !== 'undefined' && tableId === 'modbus_holding_register') {
const write = row.querySelector("input[data-field='write']")?.checked || false; const write = row.querySelector("input[data-field='write']")?.checked || false;
const rtype = row.querySelector("input[data-field='type']")?.value || null; 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 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; const comment = row.querySelector("input[data-field='comment']")?.value || null;
value = { value = {
addr: addr, addr: addr,
rtype: rtype, type: type,
factor: factor, factor: factor,
write: write, write: write,
mqtt: mqtt, mqtt: mqtt,
@ -162,12 +162,12 @@ async function saveTable() {
comment: comment comment: comment
}; };
} else { } else {
const rtype = row.querySelector("input[data-field='type']")?.value || null; 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 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; const comment = row.querySelector("input[data-field='comment']")?.value || null;
value = { value = {
addr: addr, addr: addr,
rtype: rtype, type: type,
factor: factor, factor: factor,
mqtt: mqtt, mqtt: mqtt,
influxdb: influxdb, influxdb: influxdb,
@ -196,8 +196,9 @@ async function saveTable() {
messageDiv.className = 'message success'; messageDiv.className = 'message success';
messageDiv.textContent = '✓ Erfolgreich gespeichert!'; messageDiv.textContent = '✓ Erfolgreich gespeichert!';
} else { } else {
const errorText = (await response.text()) || 'Unbekannter Fehler';
messageDiv.className = 'message error'; messageDiv.className = 'message error';
messageDiv.textContent = '✗ Fehler beim Speichern!'; messageDiv.textContent = `✗ Fehler beim Speichern: ${errorText}`;
} }
setTimeout(() => { setTimeout(() => {