HAOS Add-on: MVP + NuttX Binary + .gitignore
- haos-addon/: vollständiges HA Add-on (config.yaml, Dockerfile, build.yaml) - Python Backend: pymodbus Modbus TCP → paho-mqtt MQTT Discovery - Unterstützte Modelle: MIC 1500/2000 TL-X, SPH 5000 TL3, MOD 6000 TL3 - Web UI: Wechselrichter-Auswahl, Modbus/MQTT-Konfig, Live-Sensor-Grid (dark theme) - MQTT HA Discovery für alle Sensoren mit device_class, state_class, icon - ShineLAN-X/releases/nuttx-mbusd-shinelanx.bin: NuttX Firmware (ohne DFU, 0x08000000) - .gitignore: Logs, MQTT-JSON, shinelanx-modbus/ ausgeschlossen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
*.log
|
||||
*.txt
|
||||
*.json
|
||||
!haos-addon/**/*.json
|
||||
shinelanx-modbus/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -63,16 +63,65 @@ extern "C" void USBD_reenumerate(void) {
|
||||
|
||||
HardwareSerial DebugSerial(PA10, PA9);
|
||||
|
||||
// USBSerial::flush() wartet endlos wenn der Wechselrichter den IN-Endpoint nicht pollt.
|
||||
// Dieser Wrapper ersetzt flush() durch eine No-Op.
|
||||
// USB-Diagnose: was der Wechselrichter per Control-Transfer schickt
|
||||
extern __IO bool dtrState;
|
||||
extern __IO bool rtsState;
|
||||
struct { uint32_t baud; uint8_t format; uint8_t parity; uint8_t bits; }
|
||||
g_lineCoding = {115200, 0, 0, 8};
|
||||
volatile bool g_lineCodingUpdated = false;
|
||||
|
||||
// Wird vom USB-Stack aufgerufen wenn Wechselrichter SET_LINE_CODING schickt
|
||||
// usbd_cdc_if.c kann nicht geändert werden → wir lesen linecoding-Struct direkt
|
||||
extern "C" {
|
||||
typedef struct { uint32_t bitrate; uint8_t format; uint8_t paritytype; uint8_t datatype; }
|
||||
USBD_CDC_LineCodingTypeDef;
|
||||
extern USBD_CDC_LineCodingTypeDef linecoding;
|
||||
}
|
||||
|
||||
// CDC SERIAL_STATE Notification — NuttX sendet dies beim Öffnen von /dev/ttyACM0
|
||||
// signalisiert dem Wechselrichter DCD+DSR=1 → startet Modbus-Server im Inverter
|
||||
#include "usbd_core.h"
|
||||
extern USBD_HandleTypeDef hUSBD_Device_CDC;
|
||||
|
||||
#define CDC_SERIAL_STATE_EP 0x82U
|
||||
|
||||
void sendSerialStateNotification() {
|
||||
static uint8_t notif[10] = {
|
||||
0xA1, 0x20, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03, 0x00
|
||||
};
|
||||
USBD_LL_Transmit(&hUSBD_Device_CDC, CDC_SERIAL_STATE_EP, notif, sizeof(notif));
|
||||
}
|
||||
|
||||
// ModbusMaster schreibt Byte-für-Byte. Würden wir sofort senden, käme Byte 1 als
|
||||
// eigenes 1-Byte USB-Paket an — der Wechselrichter behandelt jedes Short Packet
|
||||
// als Frame-Ende und sieht keinen gültigen Modbus-Frame.
|
||||
// Lösung: Bytes puffern, dann in flush() als ein einziges USB-Paket senden.
|
||||
class ModbusStream : public Stream {
|
||||
public:
|
||||
uint8_t txBuf[16]; uint8_t txLen = 0;
|
||||
uint8_t rxBuf[32]; uint8_t rxLen = 0;
|
||||
uint32_t rxTotal = 0;
|
||||
void reset() { txLen = 0; rxLen = 0; }
|
||||
|
||||
int available() override { return SerialUSB.available(); }
|
||||
int read() override { return SerialUSB.read(); }
|
||||
int read() override {
|
||||
int b = SerialUSB.read();
|
||||
if (b >= 0) { rxTotal++; if (rxLen < sizeof(rxBuf)) rxBuf[rxLen++] = (uint8_t)b; }
|
||||
return b;
|
||||
}
|
||||
int peek() override { return SerialUSB.peek(); }
|
||||
size_t write(uint8_t b) override { return SerialUSB.write(b); }
|
||||
size_t write(const uint8_t* buf, size_t size) override { return SerialUSB.write(buf, size); }
|
||||
void flush() override {}
|
||||
size_t write(uint8_t b) override {
|
||||
if (txLen < sizeof(txBuf)) txBuf[txLen++] = b;
|
||||
return 1;
|
||||
}
|
||||
size_t write(const uint8_t* buf, size_t size) override {
|
||||
for (size_t i = 0; i < size && txLen < sizeof(txBuf); i++) txBuf[txLen++] = buf[i];
|
||||
return size;
|
||||
}
|
||||
void flush() override {
|
||||
if (txLen > 0) SerialUSB.write(txBuf, txLen);
|
||||
// txLen nicht zurücksetzen — Debug-Code liest txBuf danach noch
|
||||
}
|
||||
using Print::write;
|
||||
} modbusSerial;
|
||||
|
||||
@@ -153,6 +202,7 @@ EthernetServer webServer(80);
|
||||
uint32_t g_okTotal = 0;
|
||||
uint32_t g_failTotal = 0;
|
||||
uint8_t g_lastErr = 0;
|
||||
uint32_t g_rawTotal = 0;
|
||||
|
||||
// ============================================================
|
||||
// Web-Server
|
||||
@@ -524,6 +574,12 @@ void setup() {
|
||||
SerialUSB.begin(MODBUS_BAUD);
|
||||
delay(10000);
|
||||
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET);
|
||||
// Warte bis Wechselrichter USB-CDC konfiguriert hat
|
||||
delay(3000);
|
||||
// SERIAL_STATE(DCD=1,DSR=1) senden — NuttX tut dies beim Öffnen von ttyACM0
|
||||
// signalisiert dem Wechselrichter dass das Device bereit ist
|
||||
sendSerialStateNotification();
|
||||
delay(500);
|
||||
modbus.begin(MODBUS_ADDR, modbusSerial);
|
||||
|
||||
DebugSerial.println("Setup done.");
|
||||
@@ -533,6 +589,7 @@ void setup() {
|
||||
// Loop
|
||||
// ============================================================
|
||||
unsigned long lastUpdate = 0;
|
||||
static bool g_usbInfoPublished = false;
|
||||
|
||||
void loop() {
|
||||
handleWebRequest();
|
||||
@@ -546,65 +603,110 @@ void loop() {
|
||||
mqtt.loop();
|
||||
handleWebRequest();
|
||||
|
||||
// usbinfo: initial publish + DTR-Änderungen
|
||||
{
|
||||
static bool lastDtr = false;
|
||||
if (!g_usbInfoPublished || (bool)dtrState != lastDtr) {
|
||||
g_usbInfoPublished = true;
|
||||
lastDtr = (bool)dtrState;
|
||||
char info[80];
|
||||
snprintf(info, sizeof(info), "baud=%lu fmt=%u par=%u bits=%u dtr=%d rts=%d",
|
||||
(unsigned long)linecoding.bitrate, linecoding.format,
|
||||
linecoding.paritytype, linecoding.datatype,
|
||||
(int)dtrState, (int)rtsState);
|
||||
mqtt.publish("growatt/shinelan/usbinfo", info, true);
|
||||
}
|
||||
}
|
||||
|
||||
// SERIAL_STATE(DCD=1,DSR=1) alle 5s senden bis DTR vom Wechselrichter gesetzt wird
|
||||
{
|
||||
static uint32_t lastNotif = 0;
|
||||
if (!dtrState && millis() - lastNotif > 5000) {
|
||||
lastNotif = millis();
|
||||
sendSerialStateNotification();
|
||||
}
|
||||
}
|
||||
|
||||
// Raw-RX-Drain: jedes Byte das außerhalb von Modbus-Fenstern ankommt fangen
|
||||
// Beantwortet: sendet der Inverter JEMALS irgendwas (spontan oder verspätet)?
|
||||
{
|
||||
static uint8_t rawBuf[32] = {};
|
||||
static uint8_t rawLen = 0;
|
||||
static uint32_t rawPublish = 0;
|
||||
while (SerialUSB.available()) {
|
||||
uint8_t b = (uint8_t)SerialUSB.read();
|
||||
g_rawTotal++;
|
||||
if (rawLen < sizeof(rawBuf)) rawBuf[rawLen++] = b;
|
||||
}
|
||||
if (rawLen > 0 && millis() - rawPublish > 1000) {
|
||||
rawPublish = millis();
|
||||
char msg[80];
|
||||
size_t p = snprintf(msg, sizeof(msg), "raw=%lu: ", g_rawTotal);
|
||||
for (uint8_t i = 0; i < rawLen && p < 74; i++)
|
||||
p += snprintf(msg + p, sizeof(msg) - p, "%02X", rawBuf[i]);
|
||||
mqtt.publish("growatt/shinelan/rawbytes", msg);
|
||||
rawLen = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (millis() - lastUpdate < cfg.updateMs) return;
|
||||
lastUpdate = millis();
|
||||
|
||||
ledSet(LED_BLUE, true);
|
||||
|
||||
uint8_t avStart = SerialUSB.availableForWrite();
|
||||
bool usbReady = (avStart >= 8);
|
||||
uint8_t okCount = 0, failCount = 0;
|
||||
uint32_t okCnt = 0, failCnt = 0;
|
||||
uint8_t lastErr = 0;
|
||||
uint16_t lastErrReg = 0;
|
||||
|
||||
for (uint8_t i = 0; i < SENSOR_COUNT; i++) {
|
||||
mqtt.loop();
|
||||
handleWebRequest();
|
||||
|
||||
const Sensor& s = SENSORS[i];
|
||||
modbusSerial.reset();
|
||||
|
||||
if (!usbReady) {
|
||||
failCount++; lastErr = 0xFE; lastErrReg = s.address;
|
||||
continue;
|
||||
}
|
||||
uint8_t res = modbus.readInputRegisters(s.address, s.isDword ? 2 : 1);
|
||||
|
||||
uint8_t result;
|
||||
uint32_t raw = 0;
|
||||
|
||||
if (s.isDword) {
|
||||
result = modbus.readInputRegisters(s.address, 2);
|
||||
if (result == ModbusMaster::ku8MBSuccess)
|
||||
raw = ((uint32_t)modbus.getResponseBuffer(0) << 16) | modbus.getResponseBuffer(1);
|
||||
} else {
|
||||
result = modbus.readInputRegisters(s.address, 1);
|
||||
if (result == ModbusMaster::ku8MBSuccess)
|
||||
raw = modbus.getResponseBuffer(0);
|
||||
}
|
||||
|
||||
if (result != ModbusMaster::ku8MBSuccess) {
|
||||
failCount++; lastErr = result; lastErrReg = s.address;
|
||||
continue;
|
||||
}
|
||||
|
||||
okCount++;
|
||||
float value = raw * s.scale;
|
||||
char valueStr[16];
|
||||
dtostrf(value, 1, (s.scale < 0.1f) ? 2 : 1, valueStr);
|
||||
|
||||
char stateTopic[64];
|
||||
snprintf(stateTopic, sizeof(stateTopic), "growatt/shinelan/%s", s.id);
|
||||
mqtt.publish(stateTopic, valueStr);
|
||||
}
|
||||
|
||||
g_okTotal += okCount;
|
||||
g_failTotal += failCount;
|
||||
if (lastErr) g_lastErr = lastErr;
|
||||
|
||||
char dbg[64];
|
||||
snprintf(dbg, sizeof(dbg), "ok=%u fail=%u err=0x%02X@%u avS=%d avE=%d rx=%d",
|
||||
okCount, failCount, lastErr, lastErrReg,
|
||||
avStart, SerialUSB.availableForWrite(), SerialUSB.available());
|
||||
char dbg[80];
|
||||
size_t pos = snprintf(dbg, sizeof(dbg), "ok=%lu fail=%lu err=0x%02X@%u avS=%d dtr=%d raw=%lu rx=%u tx=",
|
||||
g_okTotal + okCnt, g_failTotal + failCnt,
|
||||
lastErr, lastErrReg,
|
||||
SerialUSB.availableForWrite(),
|
||||
(int)dtrState,
|
||||
g_rawTotal,
|
||||
modbusSerial.rxLen);
|
||||
for (uint8_t j = 0; j < modbusSerial.txLen && pos < 74; j++)
|
||||
pos += snprintf(dbg + pos, sizeof(dbg) - pos, "%02X", modbusSerial.txBuf[j]);
|
||||
pos += snprintf(dbg + pos, sizeof(dbg) - pos, " rx=");
|
||||
for (uint8_t j = 0; j < modbusSerial.rxLen && pos < 78; j++)
|
||||
pos += snprintf(dbg + pos, sizeof(dbg) - pos, "%02X", modbusSerial.rxBuf[j]);
|
||||
mqtt.publish("growatt/shinelan/debug", dbg);
|
||||
|
||||
delay(10); // mbusd -R10: 10ms Pause zwischen Requests
|
||||
|
||||
if (res != modbus.ku8MBSuccess) {
|
||||
failCnt++;
|
||||
lastErr = res;
|
||||
lastErrReg = s.address;
|
||||
continue;
|
||||
}
|
||||
|
||||
okCnt++;
|
||||
float val;
|
||||
if (s.isDword) {
|
||||
uint32_t raw = ((uint32_t)modbus.getResponseBuffer(0) << 16)
|
||||
| modbus.getResponseBuffer(1);
|
||||
val = raw * s.scale;
|
||||
} else {
|
||||
val = modbus.getResponseBuffer(0) * s.scale;
|
||||
}
|
||||
|
||||
char topic[64], payload[24];
|
||||
snprintf(topic, sizeof(topic), "growatt/shinelan/%s", s.id);
|
||||
snprintf(payload, sizeof(payload), "%.1f", (double)val);
|
||||
mqtt.publish(topic, payload);
|
||||
}
|
||||
|
||||
g_okTotal += okCnt;
|
||||
g_failTotal += failCnt;
|
||||
g_lastErr = lastErr;
|
||||
|
||||
ledSet(LED_BLUE, false);
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
ARG BUILD_FROM
|
||||
FROM ${BUILD_FROM}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY src/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY src/ .
|
||||
|
||||
COPY run.sh /run.sh
|
||||
RUN chmod +x /run.sh
|
||||
|
||||
CMD ["/run.sh"]
|
||||
@@ -0,0 +1,7 @@
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.11-alpine3.18
|
||||
amd64: ghcr.io/home-assistant/amd64-base-python:3.11-alpine3.18
|
||||
armv7: ghcr.io/home-assistant/armv7-base-python:3.11-alpine3.18
|
||||
armhf: ghcr.io/home-assistant/armhf-base-python:3.11-alpine3.18
|
||||
i386: ghcr.io/home-assistant/i386-base-python:3.11-alpine3.18
|
||||
squash: false
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Growatt ShineLAN-X
|
||||
version: "1.0.0"
|
||||
slug: growatt-shinelan-x
|
||||
description: Growatt Wechselrichter via ShineLAN-X (NuttX Modbus TCP) → MQTT Discovery + Web UI
|
||||
url: https://gitea.bitfire.work/retr0/Growatt-Wechselrichter-HAOS
|
||||
arch:
|
||||
- armhf
|
||||
- armv7
|
||||
- aarch64
|
||||
- amd64
|
||||
- i386
|
||||
startup: application
|
||||
boot: auto
|
||||
init: false
|
||||
ingress: true
|
||||
ingress_port: 8099
|
||||
panel_icon: mdi:solar-power
|
||||
panel_title: ShineLAN-X
|
||||
options:
|
||||
modbus_ip: "10.10.20.190"
|
||||
modbus_port: 502
|
||||
modbus_address: 1
|
||||
inverter_model: "MIC_1500_TL_X"
|
||||
mqtt_broker: "core-mosquitto"
|
||||
mqtt_port: 1883
|
||||
mqtt_user: ""
|
||||
mqtt_pass: ""
|
||||
mqtt_topic_prefix: "growatt/shinelanx"
|
||||
update_interval: 30
|
||||
schema:
|
||||
modbus_ip: str
|
||||
modbus_port: int
|
||||
modbus_address: int
|
||||
inverter_model: "MIC_1500_TL_X|MIC_2000_TL_X|SPH_5000_TL3|MOD_6000_TL3"
|
||||
mqtt_broker: str
|
||||
mqtt_port: int
|
||||
mqtt_user: str
|
||||
mqtt_pass: str
|
||||
mqtt_topic_prefix: str
|
||||
update_interval: int
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
|
||||
export MODBUS_IP=$(bashio::config 'modbus_ip')
|
||||
export MODBUS_PORT=$(bashio::config 'modbus_port')
|
||||
export MODBUS_ADDRESS=$(bashio::config 'modbus_address')
|
||||
export INVERTER_MODEL=$(bashio::config 'inverter_model')
|
||||
export MQTT_BROKER=$(bashio::config 'mqtt_broker')
|
||||
export MQTT_PORT=$(bashio::config 'mqtt_port')
|
||||
export MQTT_USER=$(bashio::config 'mqtt_user')
|
||||
export MQTT_PASS=$(bashio::config 'mqtt_pass')
|
||||
export MQTT_TOPIC_PREFIX=$(bashio::config 'mqtt_topic_prefix')
|
||||
export UPDATE_INTERVAL=$(bashio::config 'update_interval')
|
||||
export INGRESS_PORT=8099
|
||||
|
||||
bashio::log.info "Starte Growatt ShineLAN-X Add-on..."
|
||||
bashio::log.info "Modbus: ${MODBUS_IP}:${MODBUS_PORT} Slave=${MODBUS_ADDRESS}"
|
||||
bashio::log.info "Wechselrichter: ${INVERTER_MODEL}"
|
||||
bashio::log.info "MQTT: ${MQTT_BROKER}:${MQTT_PORT}"
|
||||
|
||||
exec python3 /app/main.py
|
||||
@@ -0,0 +1,127 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sensor:
|
||||
id: str
|
||||
name: str
|
||||
reg: int
|
||||
count: int # 1 = uint16, 2 = uint32 (high word first)
|
||||
scale: float
|
||||
unit: str
|
||||
device_class: Optional[str]
|
||||
state_class: str
|
||||
icon: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inverter:
|
||||
id: str
|
||||
name: str
|
||||
manufacturer: str
|
||||
sensors: List[Sensor]
|
||||
# Register ranges to batch-read as (start, length) tuples (FC04 input registers)
|
||||
read_ranges: List[tuple]
|
||||
|
||||
|
||||
def _mic_sensors() -> List[Sensor]:
|
||||
return [
|
||||
Sensor("pv_voltage", "PV Spannung", 3, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv_current", "PV Strom", 4, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv_power", "PV Leistung", 5, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"),
|
||||
Sensor("grid_frequency", "Netz-Frequenz", 37, 1, 0.01, "Hz", "frequency", "measurement", "mdi:sine-wave"),
|
||||
Sensor("grid_voltage", "Netz-Spannung", 38, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"),
|
||||
Sensor("grid_current", "Netz-Strom", 39, 1, 0.1, "A", "current", "measurement", "mdi:flash"),
|
||||
Sensor("ac_power", "AC Ausgangsleistung", 40, 2, 0.1, "W", "power", "measurement", "mdi:flash"),
|
||||
Sensor("energy_today", "Energie Heute", 53, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"),
|
||||
Sensor("energy_total", "Energie Gesamt", 55, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"),
|
||||
Sensor("inverter_temp", "Wechselrichter Temp.", 93, 1, 0.1, "°C", "temperature", "measurement", "mdi:thermometer"),
|
||||
]
|
||||
|
||||
|
||||
def _sph_sensors() -> List[Sensor]:
|
||||
return [
|
||||
Sensor("pv1_voltage", "PV1 Spannung", 3, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv1_current", "PV1 Strom", 4, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv1_power", "PV1 Leistung", 5, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv2_voltage", "PV2 Spannung", 7, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv2_current", "PV2 Strom", 8, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv2_power", "PV2 Leistung", 9, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"),
|
||||
Sensor("ac_power_total", "AC Gesamtleistung", 35, 2, 0.1, "W", "power", "measurement", "mdi:flash"),
|
||||
Sensor("grid_frequency", "Netz-Frequenz", 37, 1, 0.01, "Hz", "frequency", "measurement", "mdi:sine-wave"),
|
||||
Sensor("grid_voltage_l1", "Netz-Spannung L1", 38, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"),
|
||||
Sensor("grid_current_l1", "Netz-Strom L1", 39, 1, 0.1, "A", "current", "measurement", "mdi:flash"),
|
||||
Sensor("grid_voltage_l2", "Netz-Spannung L2", 42, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"),
|
||||
Sensor("grid_current_l2", "Netz-Strom L2", 43, 1, 0.1, "A", "current", "measurement", "mdi:flash"),
|
||||
Sensor("grid_voltage_l3", "Netz-Spannung L3", 46, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"),
|
||||
Sensor("grid_current_l3", "Netz-Strom L3", 47, 1, 0.1, "A", "current", "measurement", "mdi:flash"),
|
||||
Sensor("energy_today", "Energie Heute", 53, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"),
|
||||
Sensor("energy_total", "Energie Gesamt", 55, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"),
|
||||
Sensor("inverter_temp", "Wechselrichter Temp.", 93, 1, 0.1, "°C", "temperature", "measurement", "mdi:thermometer"),
|
||||
Sensor("bat_discharge_power", "Batterie Entladeleistung", 1009, 2, 0.1, "W", "power", "measurement", "mdi:battery-minus"),
|
||||
Sensor("bat_charge_power", "Batterie Ladeleistung", 1011, 2, 0.1, "W", "power", "measurement", "mdi:battery-plus"),
|
||||
Sensor("bat_voltage", "Batterie Spannung", 1013, 1, 0.1, "V", "voltage", "measurement", "mdi:battery"),
|
||||
Sensor("bat_soc", "Batterie Ladezustand", 1014, 1, 1.0, "%", "battery", "measurement", "mdi:battery"),
|
||||
Sensor("bat_temperature", "Batterie Temp.", 1040, 1, 0.1, "°C", "temperature", "measurement", "mdi:thermometer"),
|
||||
Sensor("power_to_grid", "Einspeisung", 1021, 2, 0.1, "W", "power", "measurement", "mdi:transmission-tower-export"),
|
||||
Sensor("power_to_user", "Netzbezug", 1029, 2, 0.1, "W", "power", "measurement", "mdi:transmission-tower-import"),
|
||||
Sensor("energy_import_total", "Netzbezug Gesamt", 1046, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:transmission-tower-import"),
|
||||
Sensor("energy_export_total", "Einspeisung Gesamt", 1050, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:transmission-tower-export"),
|
||||
Sensor("bat_discharge_total", "Batterie Entladung Ges.",1054, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:battery-minus"),
|
||||
Sensor("bat_charge_total", "Batterie Ladung Ges.", 1058, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:battery-plus"),
|
||||
]
|
||||
|
||||
|
||||
def _mod_sensors() -> List[Sensor]:
|
||||
return [
|
||||
Sensor("pv1_voltage", "PV1 Spannung", 3, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv1_current", "PV1 Strom", 4, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv1_power", "PV1 Leistung", 5, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv2_voltage", "PV2 Spannung", 7, 1, 0.1, "V", "voltage", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv2_current", "PV2 Strom", 8, 1, 0.1, "A", "current", "measurement", "mdi:solar-panel"),
|
||||
Sensor("pv2_power", "PV2 Leistung", 9, 2, 0.1, "W", "power", "measurement", "mdi:solar-panel"),
|
||||
Sensor("ac_power_total", "AC Gesamtleistung", 35, 2, 0.1, "W", "power", "measurement", "mdi:flash"),
|
||||
Sensor("grid_frequency", "Netz-Frequenz", 37, 1, 0.01, "Hz", "frequency", "measurement", "mdi:sine-wave"),
|
||||
Sensor("grid_voltage_l1","Netz-Spannung L1", 38, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"),
|
||||
Sensor("grid_current_l1","Netz-Strom L1", 39, 1, 0.1, "A", "current", "measurement", "mdi:flash"),
|
||||
Sensor("grid_voltage_l2","Netz-Spannung L2", 42, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"),
|
||||
Sensor("grid_current_l2","Netz-Strom L2", 43, 1, 0.1, "A", "current", "measurement", "mdi:flash"),
|
||||
Sensor("grid_voltage_l3","Netz-Spannung L3", 46, 1, 0.1, "V", "voltage", "measurement", "mdi:flash"),
|
||||
Sensor("grid_current_l3","Netz-Strom L3", 47, 1, 0.1, "A", "current", "measurement", "mdi:flash"),
|
||||
Sensor("energy_today", "Energie Heute", 53, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"),
|
||||
Sensor("energy_total", "Energie Gesamt", 55, 2, 0.1, "kWh", "energy", "total_increasing", "mdi:solar-power"),
|
||||
Sensor("inverter_temp", "Wechselrichter Temp.",93, 1, 0.1, "°C", "temperature","measurement", "mdi:thermometer"),
|
||||
]
|
||||
|
||||
|
||||
INVERTERS = {
|
||||
"MIC_1500_TL_X": Inverter(
|
||||
id="MIC_1500_TL_X",
|
||||
name="Growatt MIC 1500 TL-X",
|
||||
manufacturer="Growatt",
|
||||
sensors=_mic_sensors(),
|
||||
read_ranges=[(3, 91), (93, 1)], # regs 3-93
|
||||
),
|
||||
"MIC_2000_TL_X": Inverter(
|
||||
id="MIC_2000_TL_X",
|
||||
name="Growatt MIC 2000 TL-X",
|
||||
manufacturer="Growatt",
|
||||
sensors=_mic_sensors(),
|
||||
read_ranges=[(3, 91), (93, 1)],
|
||||
),
|
||||
"SPH_5000_TL3": Inverter(
|
||||
id="SPH_5000_TL3",
|
||||
name="Growatt SPH 5000 TL3-BH-UP",
|
||||
manufacturer="Growatt",
|
||||
sensors=_sph_sensors(),
|
||||
read_ranges=[(3, 91), (93, 1), (1009, 52)], # regs 3-93 + 1009-1060
|
||||
),
|
||||
"MOD_6000_TL3": Inverter(
|
||||
id="MOD_6000_TL3",
|
||||
name="Growatt MOD 6000 TL3-XH",
|
||||
manufacturer="Growatt",
|
||||
sensors=_mod_sensors(),
|
||||
read_ranges=[(3, 91), (93, 1)],
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
|
||||
from inverters import INVERTERS, Inverter
|
||||
from modbus_client import ModbusReader
|
||||
from mqtt_publisher import MqttPublisher
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = "/data/config.json"
|
||||
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
||||
|
||||
app = Flask(__name__, static_folder=WEB_DIR)
|
||||
|
||||
|
||||
class State:
|
||||
lock = threading.Lock()
|
||||
config: Dict[str, Any] = {}
|
||||
last_values: Dict[str, float] = {}
|
||||
last_update: Optional[float] = None
|
||||
modbus_ok: bool = False
|
||||
mqtt_ok: bool = False
|
||||
poll_count: int = 0
|
||||
error_count: int = 0
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
cfg: Dict[str, Any] = {
|
||||
"modbus_ip": os.environ.get("MODBUS_IP", "10.10.20.190"),
|
||||
"modbus_port": int(os.environ.get("MODBUS_PORT", "502")),
|
||||
"modbus_address": int(os.environ.get("MODBUS_ADDRESS", "1")),
|
||||
"inverter_model": os.environ.get("INVERTER_MODEL", "MIC_1500_TL_X"),
|
||||
"mqtt_broker": os.environ.get("MQTT_BROKER", "core-mosquitto"),
|
||||
"mqtt_port": int(os.environ.get("MQTT_PORT", "1883")),
|
||||
"mqtt_user": os.environ.get("MQTT_USER", ""),
|
||||
"mqtt_pass": os.environ.get("MQTT_PASS", ""),
|
||||
"mqtt_topic_prefix": os.environ.get("MQTT_TOPIC_PREFIX", "growatt/shinelanx"),
|
||||
"update_interval": int(os.environ.get("UPDATE_INTERVAL", "30")),
|
||||
}
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH) as f:
|
||||
saved = json.load(f)
|
||||
cfg.update(saved)
|
||||
except Exception as e:
|
||||
log.warning("Config-Datei konnte nicht gelesen werden: %s", e)
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(cfg: Dict[str, Any]):
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
|
||||
|
||||
_reader: Optional[ModbusReader] = None
|
||||
_publisher: Optional[MqttPublisher] = None
|
||||
_poll_thread: Optional[threading.Thread] = None
|
||||
_stop_event = threading.Event()
|
||||
|
||||
|
||||
def _build_reader(cfg: Dict[str, Any]) -> ModbusReader:
|
||||
return ModbusReader(
|
||||
host=cfg["modbus_ip"],
|
||||
port=cfg["modbus_port"],
|
||||
slave=cfg["modbus_address"],
|
||||
)
|
||||
|
||||
|
||||
def _build_publisher(cfg: Dict[str, Any]) -> MqttPublisher:
|
||||
return MqttPublisher(
|
||||
broker=cfg["mqtt_broker"],
|
||||
port=cfg["mqtt_port"],
|
||||
user=cfg["mqtt_user"],
|
||||
password=cfg["mqtt_pass"],
|
||||
topic_prefix=cfg["mqtt_topic_prefix"],
|
||||
)
|
||||
|
||||
|
||||
def _poll_loop(cfg: Dict[str, Any]):
|
||||
global _reader, _publisher
|
||||
|
||||
inverter_id = cfg.get("inverter_model", "MIC_1500_TL_X")
|
||||
inverter: Inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"])
|
||||
interval = max(5, int(cfg.get("update_interval", 30)))
|
||||
|
||||
_reader = _build_reader(cfg)
|
||||
_publisher = _build_publisher(cfg)
|
||||
_publisher.connect()
|
||||
time.sleep(2)
|
||||
_publisher.setup_inverter(inverter)
|
||||
|
||||
log.info("Poll-Loop gestartet: %s alle %ds", inverter.name, interval)
|
||||
|
||||
while not _stop_event.is_set():
|
||||
t_start = time.time()
|
||||
values = _reader.read(inverter)
|
||||
with State.lock:
|
||||
if values is not None:
|
||||
State.last_values = values
|
||||
State.last_update = time.time()
|
||||
State.modbus_ok = True
|
||||
State.poll_count += 1
|
||||
_publisher.publish_data(values)
|
||||
_publisher.publish_status("online")
|
||||
else:
|
||||
State.modbus_ok = False
|
||||
State.error_count += 1
|
||||
_publisher.publish_status("offline")
|
||||
State.mqtt_ok = _publisher.connected
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
wait = max(0.0, interval - elapsed)
|
||||
_stop_event.wait(wait)
|
||||
|
||||
_reader.close()
|
||||
_publisher.publish_status("offline")
|
||||
_publisher.disconnect()
|
||||
log.info("Poll-Loop beendet")
|
||||
|
||||
|
||||
def start_poll_thread(cfg: Dict[str, Any]):
|
||||
global _poll_thread
|
||||
_stop_event.clear()
|
||||
_poll_thread = threading.Thread(target=_poll_loop, args=(cfg,), daemon=True, name="poll")
|
||||
_poll_thread.start()
|
||||
|
||||
|
||||
def stop_poll_thread():
|
||||
_stop_event.set()
|
||||
if _poll_thread:
|
||||
_poll_thread.join(timeout=15)
|
||||
|
||||
|
||||
# ── REST API ──────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/config")
|
||||
def api_get_config():
|
||||
cfg = State.config.copy()
|
||||
cfg.pop("mqtt_pass", None) # Passwort nie zurückgeben
|
||||
return jsonify(cfg)
|
||||
|
||||
|
||||
@app.post("/api/config")
|
||||
def api_save_config():
|
||||
data = request.get_json(force=True) or {}
|
||||
with State.lock:
|
||||
# Passwort nur überschreiben wenn mitgesendet und nicht leer
|
||||
if not data.get("mqtt_pass"):
|
||||
data["mqtt_pass"] = State.config.get("mqtt_pass", "")
|
||||
State.config.update(data)
|
||||
save_config(State.config)
|
||||
cfg_snapshot = State.config.copy()
|
||||
|
||||
stop_poll_thread()
|
||||
start_poll_thread(cfg_snapshot)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/data")
|
||||
def api_get_data():
|
||||
with State.lock:
|
||||
inverter_id = State.config.get("inverter_model", "MIC_1500_TL_X")
|
||||
inverter = INVERTERS.get(inverter_id, INVERTERS["MIC_1500_TL_X"])
|
||||
sensors_meta = [
|
||||
{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"unit": s.unit,
|
||||
"icon": s.icon,
|
||||
"device_class": s.device_class,
|
||||
}
|
||||
for s in inverter.sensors
|
||||
]
|
||||
return jsonify({
|
||||
"values": State.last_values,
|
||||
"sensors": sensors_meta,
|
||||
"last_update": State.last_update,
|
||||
"modbus_ok": State.modbus_ok,
|
||||
"mqtt_ok": State.mqtt_ok,
|
||||
"poll_count": State.poll_count,
|
||||
"error_count": State.error_count,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/inverters")
|
||||
def api_get_inverters():
|
||||
return jsonify({
|
||||
k: {"id": v.id, "name": v.name, "sensor_count": len(v.sensors)}
|
||||
for k, v in INVERTERS.items()
|
||||
})
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return send_from_directory(WEB_DIR, "index.html")
|
||||
|
||||
|
||||
@app.get("/<path:filename>")
|
||||
def static_files(filename):
|
||||
return send_from_directory(WEB_DIR, filename)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cfg = load_config()
|
||||
with State.lock:
|
||||
State.config = cfg
|
||||
start_poll_thread(cfg)
|
||||
port = int(os.environ.get("INGRESS_PORT", "8099"))
|
||||
log.info("Web UI startet auf Port %d", port)
|
||||
app.run(host="0.0.0.0", port=port, threaded=True)
|
||||
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
from pymodbus.exceptions import ModbusException
|
||||
|
||||
from inverters import Inverter, Sensor
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModbusReader:
|
||||
def __init__(self, host: str, port: int, slave: int, timeout: float = 10.0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.slave = slave
|
||||
self.timeout = timeout
|
||||
self._client: Optional[ModbusTcpClient] = None
|
||||
|
||||
def _connect(self) -> bool:
|
||||
if self._client and self._client.connected:
|
||||
return True
|
||||
self._client = ModbusTcpClient(self.host, port=self.port, timeout=self.timeout)
|
||||
if not self._client.connect():
|
||||
log.error("Modbus TCP Verbindung fehlgeschlagen: %s:%d", self.host, self.port)
|
||||
self._client = None
|
||||
return False
|
||||
log.info("Modbus TCP verbunden: %s:%d", self.host, self.port)
|
||||
return True
|
||||
|
||||
def _disconnect(self):
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def read(self, inverter: Inverter) -> Optional[Dict[str, float]]:
|
||||
if not self._connect():
|
||||
return None
|
||||
|
||||
# Batch-read aller Register-Bereiche
|
||||
reg_cache: Dict[int, int] = {}
|
||||
for start, length in inverter.read_ranges:
|
||||
try:
|
||||
result = self._client.read_input_registers(start, length, slave=self.slave)
|
||||
if result.isError():
|
||||
log.error("FC04 Fehler bei Reg %d+%d: %s", start, length, result)
|
||||
self._disconnect()
|
||||
return None
|
||||
for i, val in enumerate(result.registers):
|
||||
reg_cache[start + i] = val
|
||||
except ModbusException as e:
|
||||
log.error("Modbus Ausnahme: %s", e)
|
||||
self._disconnect()
|
||||
return None
|
||||
|
||||
return _extract_sensors(inverter.sensors, reg_cache)
|
||||
|
||||
def close(self):
|
||||
self._disconnect()
|
||||
|
||||
|
||||
def _extract_sensors(sensors: list, regs: Dict[int, int]) -> Dict[str, float]:
|
||||
values: Dict[str, float] = {}
|
||||
for s in sensors:
|
||||
if s.reg not in regs:
|
||||
log.warning("Register %d fehlt in Antwort (%s)", s.reg, s.id)
|
||||
continue
|
||||
if s.count == 2:
|
||||
if s.reg + 1 not in regs:
|
||||
log.warning("Register %d (high word) fehlt (%s)", s.reg + 1, s.id)
|
||||
continue
|
||||
raw = (regs[s.reg] << 16) | regs[s.reg + 1]
|
||||
else:
|
||||
raw = regs[s.reg]
|
||||
values[s.id] = round(raw * s.scale, 3)
|
||||
return values
|
||||
@@ -0,0 +1,94 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from inverters import Inverter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEVICE_ID = "growatt_shinelanx"
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
def __init__(self, broker: str, port: int, user: str, password: str, topic_prefix: str):
|
||||
self.topic_prefix = topic_prefix.rstrip("/")
|
||||
self._client = mqtt.Client(client_id=DEVICE_ID, clean_session=True)
|
||||
if user:
|
||||
self._client.username_pw_set(user, password)
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
self._broker = broker
|
||||
self._port = port
|
||||
self._connected = False
|
||||
self._inverter: Optional[Inverter] = None
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
if rc == 0:
|
||||
self._connected = True
|
||||
log.info("MQTT verbunden: %s:%d", self._broker, self._port)
|
||||
if self._inverter:
|
||||
self._publish_discovery(self._inverter)
|
||||
else:
|
||||
log.error("MQTT Verbindungsfehler rc=%d", rc)
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
self._connected = False
|
||||
log.warning("MQTT getrennt rc=%d", rc)
|
||||
|
||||
def connect(self):
|
||||
try:
|
||||
self._client.connect_async(self._broker, self._port, keepalive=60)
|
||||
self._client.loop_start()
|
||||
except Exception as e:
|
||||
log.error("MQTT connect fehlgeschlagen: %s", e)
|
||||
|
||||
def disconnect(self):
|
||||
self._client.loop_stop()
|
||||
self._client.disconnect()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def setup_inverter(self, inverter: Inverter):
|
||||
self._inverter = inverter
|
||||
if self._connected:
|
||||
self._publish_discovery(inverter)
|
||||
|
||||
def _publish_discovery(self, inverter: Inverter):
|
||||
device_payload = {
|
||||
"identifiers": [DEVICE_ID],
|
||||
"name": "Growatt ShineLAN-X",
|
||||
"manufacturer": inverter.manufacturer,
|
||||
"model": inverter.name,
|
||||
}
|
||||
for sensor in inverter.sensors:
|
||||
config = {
|
||||
"name": sensor.name,
|
||||
"unique_id": f"{DEVICE_ID}_{sensor.id}",
|
||||
"state_topic": f"{self.topic_prefix}/state",
|
||||
"value_template": f"{{{{ value_json.{sensor.id} }}}}",
|
||||
"unit_of_measurement": sensor.unit,
|
||||
"state_class": sensor.state_class,
|
||||
"icon": sensor.icon,
|
||||
"device": device_payload,
|
||||
}
|
||||
if sensor.device_class:
|
||||
config["device_class"] = sensor.device_class
|
||||
|
||||
topic = f"homeassistant/sensor/{DEVICE_ID}/{sensor.id}/config"
|
||||
self._client.publish(topic, json.dumps(config), retain=True, qos=1)
|
||||
log.info("MQTT Discovery für %d Sensoren veröffentlicht", len(inverter.sensors))
|
||||
|
||||
def publish_data(self, values: Dict[str, float]):
|
||||
if not self._connected:
|
||||
log.warning("MQTT nicht verbunden, Daten verworfen")
|
||||
return
|
||||
payload = json.dumps(values)
|
||||
self._client.publish(f"{self.topic_prefix}/state", payload, retain=True, qos=0)
|
||||
|
||||
def publish_status(self, status: str):
|
||||
self._client.publish(f"{self.topic_prefix}/status", status, retain=True, qos=1)
|
||||
@@ -0,0 +1,570 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Growatt ShineLAN-X</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--surface2: #21262d;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-dim: #8b949e;
|
||||
--accent: #f0c040;
|
||||
--green: #3fb950;
|
||||
--red: #f85149;
|
||||
--blue: #58a6ff;
|
||||
--orange: #ffa657;
|
||||
--purple: #bc8cff;
|
||||
--radius: 10px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
header svg { flex-shrink: 0; }
|
||||
header h1 { font-size: 16px; font-weight: 600; }
|
||||
header .subtitle { font-size: 12px; color: var(--text-dim); }
|
||||
.status-pill {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pill.ok { color: var(--green); border-color: var(--green); }
|
||||
.pill.err { color: var(--red); border-color: var(--red); }
|
||||
.dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tab {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.tab.active { color: var(--accent); border-color: var(--accent); }
|
||||
.tab:hover { color: var(--text); }
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; }
|
||||
|
||||
/* ── Sensor Grid ── */
|
||||
.sensor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.sensor-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.sensor-card:hover { border-color: var(--text-dim); }
|
||||
.sensor-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sensor-name {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sensor-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
}
|
||||
.sensor-unit {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-left: 3px;
|
||||
}
|
||||
/* Color coding by device class */
|
||||
.dc-power .sensor-value { color: var(--accent); }
|
||||
.dc-voltage .sensor-value { color: var(--blue); }
|
||||
.dc-current .sensor-value { color: var(--orange); }
|
||||
.dc-energy .sensor-value { color: var(--green); }
|
||||
.dc-temperature .sensor-value { color: var(--red); }
|
||||
.dc-battery .sensor-value { color: var(--purple); }
|
||||
.dc-frequency .sensor-value { color: var(--text); }
|
||||
|
||||
/* ── Config Form ── */
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 640px) { .config-grid { grid-template-columns: 1fr; } }
|
||||
.config-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
}
|
||||
.config-section h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.field input:focus, .field select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.field select option { background: var(--surface2); }
|
||||
.inverter-select-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.inverter-card {
|
||||
padding: 12px;
|
||||
background: var(--surface2);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s;
|
||||
text-align: center;
|
||||
}
|
||||
.inverter-card:hover { border-color: var(--text-dim); }
|
||||
.inverter-card.selected { border-color: var(--accent); background: rgba(240,192,64,0.08); }
|
||||
.inverter-card .inv-name { font-weight: 600; font-size: 13px; margin-bottom: 3px; }
|
||||
.inverter-card .inv-sensors { font-size: 11px; color: var(--text-dim); }
|
||||
.save-btn {
|
||||
margin-top: 20px;
|
||||
padding: 10px 24px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.85; }
|
||||
.save-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: 12px 20px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
z-index: 999;
|
||||
transform: translateY(80px);
|
||||
opacity: 0;
|
||||
transition: transform .3s, opacity .3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
.toast.ok { border-color: var(--green); color: var(--green); }
|
||||
.toast.err { border-color: var(--red); color: var(--red); }
|
||||
|
||||
/* ── Info row ── */
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.info-chip {
|
||||
padding: 6px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.info-chip span { color: var(--text); font-weight: 600; }
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.no-data p { margin-top: 8px; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="5" fill="#f0c040"/>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" stroke="#f0c040" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h1>Growatt ShineLAN-X</h1>
|
||||
<div class="subtitle" id="subtitle">Lade...</div>
|
||||
</div>
|
||||
<div class="status-pill">
|
||||
<div class="pill" id="pill-modbus"><div class="dot"></div>Modbus</div>
|
||||
<div class="pill" id="pill-mqtt"><div class="dot"></div>MQTT</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('live')">Live-Daten</div>
|
||||
<div class="tab" onclick="switchTab('config')">Konfiguration</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Panel -->
|
||||
<div class="panel active" id="panel-live">
|
||||
<div class="info-row" id="info-row"></div>
|
||||
<div class="sensor-grid" id="sensor-grid">
|
||||
<div class="no-data">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#8b949e" stroke-width="1.5">
|
||||
<path d="M12 22V12M12 12L8 16M12 12L16 16"/>
|
||||
<path d="M20 16.5A4.5 4.5 0 0 0 12 8a6 6 0 1 0-8 5.66"/>
|
||||
</svg>
|
||||
<p>Warte auf erste Messung...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config Panel -->
|
||||
<div class="panel" id="panel-config">
|
||||
<form id="config-form" onsubmit="saveConfig(event)">
|
||||
<div class="config-section" style="margin-bottom:20px">
|
||||
<h3>Wechselrichter-Modell</h3>
|
||||
<div class="inverter-select-grid" id="inverter-grid"></div>
|
||||
<input type="hidden" id="cfg-inverter" name="inverter_model">
|
||||
</div>
|
||||
|
||||
<div class="config-grid">
|
||||
<div class="config-section">
|
||||
<h3>Modbus TCP</h3>
|
||||
<div class="field">
|
||||
<label>IP-Adresse des ShineLAN-X</label>
|
||||
<input type="text" id="cfg-modbus-ip" placeholder="10.10.20.190" pattern="\d+\.\d+\.\d+\.\d+">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Port</label>
|
||||
<input type="number" id="cfg-modbus-port" placeholder="502" min="1" max="65535">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Modbus Slave-Adresse</label>
|
||||
<input type="number" id="cfg-modbus-addr" placeholder="1" min="1" max="247">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Abfrageintervall (Sekunden)</label>
|
||||
<input type="number" id="cfg-interval" placeholder="30" min="5" max="3600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3>MQTT</h3>
|
||||
<div class="field">
|
||||
<label>Broker</label>
|
||||
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto oder IP">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Port</label>
|
||||
<input type="number" id="cfg-mqtt-port" placeholder="1883" min="1" max="65535">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Benutzername</label>
|
||||
<input type="text" id="cfg-mqtt-user" placeholder="optional" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Passwort</label>
|
||||
<input type="password" id="cfg-mqtt-pass" placeholder="leer lassen = unverändert" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Topic-Präfix</label>
|
||||
<input type="text" id="cfg-mqtt-prefix" placeholder="growatt/shinelanx">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="save-btn" id="save-btn">Speichern & Neu starten</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const ICON_MAP = {
|
||||
"mdi:solar-panel": "☀️",
|
||||
"mdi:flash": "⚡",
|
||||
"mdi:sine-wave": "〜",
|
||||
"mdi:solar-power": "🔋",
|
||||
"mdi:thermometer": "🌡️",
|
||||
"mdi:battery": "🔋",
|
||||
"mdi:battery-minus": "🪫",
|
||||
"mdi:battery-plus": "⚡",
|
||||
"mdi:transmission-tower-export": "📤",
|
||||
"mdi:transmission-tower-import": "📥",
|
||||
};
|
||||
|
||||
let currentConfig = {};
|
||||
let inverterList = {};
|
||||
let refreshTimer = null;
|
||||
|
||||
async function fetchJSON(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
const el = document.getElementById("toast");
|
||||
el.textContent = msg;
|
||||
el.className = `toast show ${type}`;
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => el.className = "toast", 3000);
|
||||
}
|
||||
|
||||
// ── Tab switching ──
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll(".tab").forEach((t, i) => {
|
||||
t.classList.toggle("active", ["live", "config"][i] === name);
|
||||
});
|
||||
document.querySelectorAll(".panel").forEach((p, i) => {
|
||||
p.classList.toggle("active", ["panel-live", "panel-config"][i] === `panel-${name}`);
|
||||
});
|
||||
if (name === "live") startRefresh();
|
||||
else stopRefresh();
|
||||
}
|
||||
|
||||
// ── Live data ──
|
||||
async function refreshData() {
|
||||
try {
|
||||
const d = await fetchJSON("/api/data");
|
||||
updateStatus(d.modbus_ok, d.mqtt_ok);
|
||||
updateSubtitle(d);
|
||||
updateInfoRow(d);
|
||||
updateGrid(d);
|
||||
} catch (e) {
|
||||
updateStatus(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(modbus, mqtt) {
|
||||
const pm = document.getElementById("pill-modbus");
|
||||
const pq = document.getElementById("pill-mqtt");
|
||||
pm.className = `pill ${modbus ? "ok" : "err"}`;
|
||||
pq.className = `pill ${mqtt ? "ok" : "err"}`;
|
||||
}
|
||||
|
||||
function updateSubtitle(d) {
|
||||
const inv = currentConfig.inverter_model || "";
|
||||
const name = (inverterList[inv] || {}).name || inv;
|
||||
document.getElementById("subtitle").textContent = name;
|
||||
}
|
||||
|
||||
function updateInfoRow(d) {
|
||||
if (!d.last_update) return;
|
||||
const ago = Math.round(Date.now() / 1000 - d.last_update);
|
||||
const ip = currentConfig.modbus_ip || "";
|
||||
document.getElementById("info-row").innerHTML = `
|
||||
<div class="info-chip">Letzte Messung <span>${ago}s</span> vor</div>
|
||||
<div class="info-chip">Messungen <span>${d.poll_count}</span></div>
|
||||
<div class="info-chip">Fehler <span>${d.error_count}</span></div>
|
||||
<div class="info-chip">ShineLAN-X <span>${ip}:${currentConfig.modbus_port || 502}</span></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateGrid(d) {
|
||||
const grid = document.getElementById("sensor-grid");
|
||||
if (!d.last_update || !d.sensors || d.sensors.length === 0) return;
|
||||
|
||||
grid.innerHTML = d.sensors.map(s => {
|
||||
const val = d.values[s.id];
|
||||
const display = val !== undefined ? formatVal(val) : "—";
|
||||
const dcClass = s.device_class ? `dc-${s.device_class}` : "";
|
||||
const icon = ICON_MAP[s.icon] || "📊";
|
||||
return `<div class="sensor-card ${dcClass}">
|
||||
<div class="sensor-icon">${icon}</div>
|
||||
<div class="sensor-name">${s.name}</div>
|
||||
<div class="sensor-value">${display}<span class="sensor-unit">${s.unit}</span></div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function formatVal(v) {
|
||||
if (v >= 1000) return (v / 1000).toFixed(2).replace(".", ",") + "k";
|
||||
if (v % 1 === 0) return v.toString();
|
||||
return v.toFixed(v < 10 ? 2 : 1).replace(".", ",");
|
||||
}
|
||||
|
||||
function startRefresh() {
|
||||
stopRefresh();
|
||||
refreshData();
|
||||
refreshTimer = setInterval(refreshData, 5000);
|
||||
}
|
||||
|
||||
function stopRefresh() {
|
||||
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
||||
}
|
||||
|
||||
// ── Config ──
|
||||
async function loadConfig() {
|
||||
try {
|
||||
currentConfig = await fetchJSON("/api/config");
|
||||
fillForm(currentConfig);
|
||||
} catch (e) {
|
||||
showToast("Konfiguration konnte nicht geladen werden", "err");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInverters() {
|
||||
try {
|
||||
inverterList = await fetchJSON("/api/inverters");
|
||||
buildInverterGrid(inverterList, currentConfig.inverter_model);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function fillForm(cfg) {
|
||||
document.getElementById("cfg-modbus-ip").value = cfg.modbus_ip || "";
|
||||
document.getElementById("cfg-modbus-port").value = cfg.modbus_port || 502;
|
||||
document.getElementById("cfg-modbus-addr").value = cfg.modbus_address || 1;
|
||||
document.getElementById("cfg-interval").value = cfg.update_interval || 30;
|
||||
document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || "";
|
||||
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
|
||||
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
|
||||
document.getElementById("cfg-mqtt-prefix").value = cfg.mqtt_topic_prefix || "growatt/shinelanx";
|
||||
document.getElementById("cfg-inverter").value = cfg.inverter_model || "MIC_1500_TL_X";
|
||||
}
|
||||
|
||||
function buildInverterGrid(list, selected) {
|
||||
const grid = document.getElementById("inverter-grid");
|
||||
grid.innerHTML = Object.values(list).map(inv => `
|
||||
<div class="inverter-card ${inv.id === selected ? "selected" : ""}"
|
||||
onclick="selectInverter('${inv.id}', this)">
|
||||
<div class="inv-name">${inv.name}</div>
|
||||
<div class="inv-sensors">${inv.sensor_count} Sensoren</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function selectInverter(id, el) {
|
||||
document.querySelectorAll(".inverter-card").forEach(c => c.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
document.getElementById("cfg-inverter").value = id;
|
||||
}
|
||||
|
||||
async function saveConfig(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("save-btn");
|
||||
btn.disabled = true;
|
||||
|
||||
const body = {
|
||||
modbus_ip: document.getElementById("cfg-modbus-ip").value.trim(),
|
||||
modbus_port: parseInt(document.getElementById("cfg-modbus-port").value),
|
||||
modbus_address: parseInt(document.getElementById("cfg-modbus-addr").value),
|
||||
update_interval: parseInt(document.getElementById("cfg-interval").value),
|
||||
mqtt_broker: document.getElementById("cfg-mqtt-broker").value.trim(),
|
||||
mqtt_port: parseInt(document.getElementById("cfg-mqtt-port").value),
|
||||
mqtt_user: document.getElementById("cfg-mqtt-user").value,
|
||||
mqtt_pass: document.getElementById("cfg-mqtt-pass").value,
|
||||
mqtt_topic_prefix: document.getElementById("cfg-mqtt-prefix").value.trim(),
|
||||
inverter_model: document.getElementById("cfg-inverter").value,
|
||||
};
|
||||
|
||||
try {
|
||||
await fetchJSON("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
currentConfig = { ...currentConfig, ...body };
|
||||
showToast("Gespeichert! Neustart...", "ok");
|
||||
setTimeout(loadConfig, 2000);
|
||||
} catch (e) {
|
||||
showToast("Fehler beim Speichern", "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
(async () => {
|
||||
await loadConfig();
|
||||
await loadInverters();
|
||||
startRefresh();
|
||||
})();
|
||||
|
||||
// Cleanup on unload
|
||||
window.addEventListener("beforeunload", stopRefresh);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user