primo caricamento

This commit is contained in:
andrea castagnino 2026-08-27 12:28:06 +02:00
commit d30984adfe
15 changed files with 658 additions and 0 deletions

View file

@ -0,0 +1,24 @@
# Modifiche
## 1.0.2
- **Corretta una corsa che poteva far perdere un comando.** La conferma
registrata riguardava i valori presenti al ritorno della scrittura upstream,
non quelli effettivamente inviati. Un setpoint che arrivava dal CCI durante
quei ~300 ms veniva dichiarato confermato (registro 8192 a 1) senza essere
mai stato spedito agli inverter.
- **Il motore non muore piu' in silenzio.** Un'eccezione imprevista nel ciclo
uccideva il thread; il concentratore continuava a rispondere al CCI con dati
congelati e il watchdog non se ne accorgeva, perche' controlla solo che la
porta 5020 sia aperta.
- **La potenza di un inverter muto viene azzerata** dopo un periodo di grazia
(5 volte il refresh, minimo 10 s), invece di restare congelata all'ultimo
valore e continuare a sommarsi nel totale d'impianto (registro 8195).
- **I comandi in attesa vengono propagati prima delle letture**, cosi' un
setpoint nuovo non aspetta un giro di scansione completo.
- Connessione upstream chiusa e riaperta dopo quattro risposte spaiate, per non
restare disallineati sul flusso.
## 1.0.1
- Prima versione confezionata come add-on.

11
concentratore/Dockerfile Normal file
View file

@ -0,0 +1,11 @@
ARG BUILD_FROM
FROM $BUILD_FROM
# Serve solo Python: il concentratore non usa librerie esterne
RUN apk add --no-cache python3
COPY concentratore.py /concentratore.py
COPY run.sh /run.sh
RUN chmod a+x /run.sh
CMD [ "/run.sh" ]

51
concentratore/README.md Normal file
View file

@ -0,0 +1,51 @@
# Concentratore Modbus
Si presenta al CCI come se fosse il gateway degli inverter: stessi slave ID,
stessi registri. Risponde alle scritture in poche decine di millisecondi invece
di ~310 ms, propaga il comando agli inverter veri e lo tiene rinfrescato, perche'
i 50KTL-V3 lasciano decadere il limite se nessuno lo riscrive.
## Opzioni
| Opzione | Cosa e' |
|---|---|
| `gateway` | IP del Waveshare a cui sono appesi gli inverter |
| `gateway_porta` | Porta Modbus TCP del Waveshare, di norma 502 |
| `slaves` | Slave ID degli inverter, separati da virgola |
| `refresh` | Ogni quanti secondi riscrivere il setpoint, in secondi |
| `timeout` | Timeout delle transazioni verso il gateway |
| `log` | Traccia a video ogni transazione |
Il CCI si collega alla **porta 5020** dell'impianto.
## Registri
Uguali a quelli degli inverter ZCS serie V3:
| Registro | Hex | Cosa | |
|---|---|---|---|
| 4357 | 0x1105 | abilitazione controllo remoto | R/W |
| 4358 | 0x1106 | setpoint in decimi di percento | R/W |
| 1157 | 0x0485 | potenza attiva, decine di W | R |
Aggiunti da noi, **da dichiarare ad AiLux**:
| Registro | Hex | Cosa |
|---|---|---|
| 8192 | 0x2000 | 1 = comando confermato su questo inverter |
| 8193 | 0x2001 | decimi di secondo dall'ultima conferma (65535 = mai) |
| 8194 | 0x2002 | quanti inverter hanno il comando confermato |
| 8195 | 0x2003 | potenza totale d'impianto, decine di W |
## Prova
```
mbpoll -a 1 -t 4 -p 5020 -r 4358 <ip-impianto> 1 200
mbpoll -a 1 -t 4 -p 5020 -r 8192 -c 4 -1 <ip-impianto>
```
## Attenzione
Fermando l'add-on gli inverter **restano con l'ultimo comando ricevuto**, e il
limite decade da solo dopo qualche secondo. Non e' un modo per togliere il
limite in sicurezza.

3
concentratore/build.yaml Normal file
View file

@ -0,0 +1,3 @@
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base:latest
amd64: ghcr.io/home-assistant/amd64-base:latest

View file

@ -0,0 +1,409 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CONCENTRATORE Modbus - si presenta al CCI come se fosse il gateway degli inverter.
Il CCI (o mbpoll, o Modula) si collega qui con gli STESSI slave ID e gli STESSI
registri che userebbe sul Waveshare. Il concentratore:
- risponde SUBITO alle scritture (poche decine di ms invece di ~310 ms)
- propaga il comando agli inverter veri e lo tiene RINFRESCATO, perche' i
50KTL-V3 lasciano decadere il limite se nessuno lo riscrive
- tiene in memoria le letture di potenza e le serve istantaneamente
- dichiara in due registri di stato se il comando e' stato davvero recepito
Nessuna libreria da installare: solo la libreria standard di Python 3.
Avvio:
python3 concentratore.py --gateway 192.168.4.240 --slaves 1,2,3,4,5
(porta 5020 di default: sotto la 1024 servirebbero i permessi di root)
Poi si prova puntandoci contro mbpoll, cambiando SOLO l'indirizzo:
mbpoll -a 1 -t 4 -p 5020 -r 4359 -c 1 -1 127.0.0.1
mbpoll -a 1 -t 4 -p 5020 -r 4358 127.0.0.1 1 200
REGISTRI ESPOSTI (identici a quelli degli inverter ZCS serie V3):
4357 0x1105 abilitazione controllo remoto lettura/scrittura
4358 0x1106 setpoint in decimi di percento lettura/scrittura
1157 0x0485 potenza attiva (decine di W) sola lettura
REGISTRI DI STATO (aggiunti da noi - vanno dichiarati ad AiLux):
8192 0x2000 1 = comando confermato su questo inverter, 0 = non confermato
8193 0x2001 decimi di secondo dall'ultima conferma (65535 = mai)
8194 0x2002 quanti inverter su N hanno il comando confermato
8195 0x2003 potenza totale d'impianto in decine di W
NOTE DI VERSIONE
1.0.2 - corretta la corsa fra CCI e motore: la conferma ora riguarda i
valori davvero inviati, non quelli presenti al ritorno della
scrittura. Prima un comando che arrivava durante la transazione
upstream veniva dichiarato confermato senza essere mai spedito.
- il thread motore non muore piu' in silenzio su un'eccezione
imprevista: logga e riprende. Il watchdog dell'add-on controlla
solo che la porta sia aperta, quindi non avrebbe visto nulla.
- la potenza di un inverter che smette di rispondere viene azzerata
dopo un periodo di grazia, invece di restare congelata per sempre
e continuare a sommarsi nel registro 8195.
- i comandi in attesa vengono propagati prima delle letture, cosi'
un setpoint nuovo non aspetta un giro di scansione completo.
"""
import argparse
import socket
import socketserver
import struct
import threading
import time
REG_ENABLE = 4357 # 0x1105
REG_SETPOINT = 4358 # 0x1106
REG_POWER = 1157 # 0x0485
REG_ST_OK = 8192 # 0x2000
REG_ST_ETA = 8193 # 0x2001
REG_ST_CONTA = 8194 # 0x2002
REG_ST_TOT = 8195 # 0x2003
cfg = {}
stato = {}
_stato_lock = threading.Lock()
# ------------------------------------------------------------------ upstream
class Upstream:
"""Connessione persistente verso il Waveshare. Una transazione alla volta."""
def __init__(self, ip, porta, timeout):
self.ip, self.porta, self.timeout = ip, porta, timeout
self.sock = None
self.tid = 0
self.lock = threading.Lock()
def _apri(self):
if self.sock:
return
self.sock = socket.create_connection((self.ip, self.porta), timeout=self.timeout)
self.sock.settimeout(self.timeout)
def _chiudi(self):
if self.sock:
try:
self.sock.close()
except OSError:
pass
self.sock = None
def txn(self, unit, pdu):
"""Restituisce il PDU di risposta, oppure None. Verifica id e slave."""
with self.lock:
for tentativo in range(2):
try:
self._apri()
self.tid = (self.tid + 1) % 65000
mio = self.tid
self.sock.sendall(struct.pack('>HHHB', mio, 0, len(pdu) + 1, unit) + pdu)
for _ in range(4):
testa = self._leggi(6)
t, proto, ln = struct.unpack('>HHH', testa)
corpo = self._leggi(ln)
if t == mio and corpo[0] == unit:
return corpo[1:]
# quattro risposte spaiate: il flusso e' disallineato,
# meglio ripartire da una connessione pulita
self._chiudi()
return None
except (OSError, struct.error):
self._chiudi()
if tentativo == 0:
time.sleep(0.1)
return None
def _leggi(self, n):
buf = b''
while len(buf) < n:
c = self.sock.recv(n - len(buf))
if not c:
raise OSError('connessione chiusa')
buf += c
return buf
def leggi_reg(self, unit, addr, quanti=1):
r = self.txn(unit, struct.pack('>BHH', 3, addr, quanti))
if not r or r[0] != 3:
return None
n = r[1]
if len(r) < 2 + n:
return None
return list(struct.unpack('>%dH' % quanti, r[2:2 + quanti * 2]))
def scrivi_reg(self, unit, addr, valori):
dati = b''.join(struct.pack('>H', v & 0xFFFF) for v in valori)
r = self.txn(unit, struct.pack('>BHHB', 16, addr, len(valori), len(dati)) + dati)
return bool(r) and r[0] == 16
# ------------------------------------------------------------------ motore
def da_propagare(u):
"""C'e' un comando da mandare a questo inverter, adesso?"""
with _stato_lock:
s = stato[u]
if s['sporco']:
return True
# il rinfresco serve solo mentre il limite e' attivo: se il controllo
# e' disabilitato basta scrivere una volta al cambiamento
return bool(s['enable']) and (time.time() - s['ultimo_invio']) >= cfg['refresh']
def propaga(up, u):
"""Manda enable+setpoint a un inverter e registra cosa e' stato inviato."""
with _stato_lock:
s = stato[u]
inviato = (s['enable'], s['setpoint'])
ok = up.scrivi_reg(u, REG_ENABLE, list(inviato))
with _stato_lock:
s = stato[u]
s['ultimo_invio'] = time.time()
if ok:
s['confermato'] = inviato
s['ultima_conferma'] = time.time()
# se nel frattempo il CCI ha scritto qualcosa di diverso, il lavoro
# non e' finito: si resta sporchi e si riparte al giro successivo
if (s['enable'], s['setpoint']) == inviato:
s['sporco'] = False
s['errori'] = 0
else:
s['errori'] += 1
errori = s['errori']
if cfg['verboso']:
print(' -> inv%d enable=%d sp=%d %s'
% (u, inviato[0], inviato[1], 'ok' if ok else 'PERSA (x%d)' % errori))
def aggiorna_potenza(up, u):
"""Legge la potenza e la invecchia se l'inverter smette di rispondere."""
regs = up.leggi_reg(u, REG_POWER, 1)
with _stato_lock:
s = stato[u]
if regs:
s['potenza'] = regs[0]
s['letta'] = time.time()
elif s['letta'] and (time.time() - s['letta']) > cfg['scadenza']:
# meglio dichiarare zero che lasciare il CCI a regolare su una
# potenza fantasma di un inverter che non c'e' piu'
s['potenza'] = 0
s['letta'] = None
if cfg['verboso']:
print(' !! inv%d muto da oltre %.0f s: potenza azzerata'
% (u, cfg['scadenza']))
def ciclo(up):
inizio = time.time()
# prima i comandi in attesa, tutti: un setpoint nuovo non deve aspettare
# che finisca il giro delle letture
for u in cfg['slaves']:
if da_propagare(u):
propaga(up, u)
# poi le letture, ricontrollando fra una e l'altra se e' arrivato altro
for u in cfg['slaves']:
if da_propagare(u):
propaga(up, u)
aggiorna_potenza(up, u)
durata = time.time() - inizio
if durata < 0.2:
time.sleep(0.2 - durata)
def motore(up):
"""Propaga i comandi agli inverter e aggiorna le letture. Gira da solo.
Qualunque eccezione imprevista viene loggata e il ciclo riprende: se questo
thread morisse, il concentratore continuerebbe a rispondere al CCI con dati
congelati e nessuno se ne accorgerebbe (il watchdog guarda solo la porta).
"""
while True:
try:
ciclo(up)
except Exception as e:
print('MOTORE: eccezione imprevista, riprendo -> %r' % e)
time.sleep(1.0)
# ------------------------------------------------------------------ registri
def leggi_virtuale(unit, addr, quanti):
"""Serve le letture dalla memoria, senza toccare il bus."""
fuori = []
with _stato_lock:
s = stato.get(unit)
if not s:
return None
totale = sum(x['potenza'] for x in stato.values())
confermati = sum(1 for x in stato.values()
if x['confermato'] == (x['enable'], x['setpoint']))
for i in range(quanti):
a = addr + i
if a == REG_ENABLE:
fuori.append(s['enable'])
elif a == REG_SETPOINT:
fuori.append(s['setpoint'])
elif a == REG_POWER:
fuori.append(s['potenza'])
elif a == REG_ST_OK:
fuori.append(1 if s['confermato'] == (s['enable'], s['setpoint']) else 0)
elif a == REG_ST_ETA:
if not s['ultima_conferma']:
fuori.append(65535)
else:
fuori.append(min(65534, int((time.time() - s['ultima_conferma']) * 10)))
elif a == REG_ST_CONTA:
fuori.append(confermati)
elif a == REG_ST_TOT:
fuori.append(min(65535, totale))
else:
return None
return fuori
def scrivi_virtuale(unit, addr, valori):
"""Accetta il comando, risponde subito, lascia al motore il lavoro sporco."""
with _stato_lock:
s = stato.get(unit)
if not s:
return False
for i, v in enumerate(valori):
a = addr + i
if a == REG_ENABLE:
s['enable'] = v & 0xFFFF
elif a == REG_SETPOINT:
s['setpoint'] = max(0, min(1000, v & 0xFFFF))
else:
return False
s['sporco'] = True
return True
# ------------------------------------------------------------------ server
class Handler(socketserver.BaseRequestHandler):
def handle(self):
c = self.request
c.settimeout(120)
try:
while True:
testa = self._leggi(c, 6)
if not testa:
return
tid, proto, ln = struct.unpack('>HHH', testa)
corpo = self._leggi(c, ln)
if not corpo or proto != 0:
return
unit, fc = corpo[0], corpo[1]
t0 = time.time()
pdu = self._servi(unit, fc, corpo[2:])
c.sendall(struct.pack('>HHHB', tid, 0, len(pdu) + 1, unit) + pdu)
if cfg['verboso']:
print('CCI inv%d fc%-3d risposto in %5.1f ms'
% (unit, fc, 1000 * (time.time() - t0)))
except (OSError, struct.error):
pass
def _leggi(self, c, n):
buf = b''
while len(buf) < n:
try:
d = c.recv(n - len(buf))
except socket.timeout:
return None
if not d:
return None
buf += d
return buf
def _servi(self, unit, fc, dati):
try:
if fc in (3, 4):
addr, quanti = struct.unpack('>HH', dati[:4])
v = leggi_virtuale(unit, addr, quanti)
if v is None:
return struct.pack('>BB', fc | 0x80, 2)
return struct.pack('>BB', fc, quanti * 2) + b''.join(
struct.pack('>H', x & 0xFFFF) for x in v)
if fc == 6:
addr, val = struct.unpack('>HH', dati[:4])
if not scrivi_virtuale(unit, addr, [val]):
return struct.pack('>BB', 0x86, 2)
return struct.pack('>BHH', 6, addr, val)
if fc == 16:
addr, quanti = struct.unpack('>HH', dati[:4])
nbyte = dati[4]
valori = list(struct.unpack('>%dH' % quanti, dati[5:5 + nbyte]))
if not scrivi_virtuale(unit, addr, valori):
return struct.pack('>BB', 0x90, 2)
return struct.pack('>BHH', 16, addr, quanti)
except (struct.error, IndexError):
return struct.pack('>BB', fc | 0x80, 3)
return struct.pack('>BB', fc | 0x80, 1)
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
# ------------------------------------------------------------------ avvio
def main():
ap = argparse.ArgumentParser(description='Concentratore Modbus per inverter ZCS')
ap.add_argument('--gateway', default='192.168.4.240', help='IP del Waveshare')
ap.add_argument('--gateway-porta', type=int, default=502)
ap.add_argument('--slaves', default='1,2,3,4,5')
ap.add_argument('--host', default='0.0.0.0', help='su quale interfaccia ascoltare')
ap.add_argument('--porta', type=int, default=5020, help='porta di ascolto per il CCI')
ap.add_argument('--refresh', type=float, default=2.0,
help='ogni quanti secondi riscrivere il setpoint agli inverter')
ap.add_argument('--timeout', type=float, default=1.5)
ap.add_argument('--zitto', action='store_true', help='niente log a schermo')
a = ap.parse_args()
slaves = [int(x) for x in a.slaves.split(',') if x.strip()]
cfg.update(slaves=slaves,
refresh=a.refresh,
verboso=not a.zitto,
# dopo quanto una lettura di potenza va considerata morta:
# abbondante rispetto al giro di scansione, ma non infinita
scadenza=max(10.0, 5 * a.refresh))
for u in cfg['slaves']:
stato[u] = dict(enable=0, setpoint=1000, sporco=False,
confermato=None, ultimo_invio=0.0, ultima_conferma=None,
potenza=0, letta=None, errori=0)
up = Upstream(a.gateway, a.gateway_porta, a.timeout)
threading.Thread(target=motore, args=(up,), daemon=True).start()
srv = Server((a.host, a.porta), Handler)
print('CONCENTRATORE in ascolto su %s:%d' % (a.host, a.porta))
print('inverter a valle: %s via %s:%d' % (cfg['slaves'], a.gateway, a.gateway_porta))
print('rinfresco del setpoint ogni %.1f s (solo mentre il limite e\' attivo)' % a.refresh)
print('potenza azzerata dopo %.0f s di silenzio di un inverter' % cfg['scadenza'])
print('Ctrl+C per chiudere.\n')
try:
srv.serve_forever()
except KeyboardInterrupt:
print('\nchiuso. Attenzione: gli inverter restano con l\'ultimo comando dato.')
if __name__ == '__main__':
main()

30
concentratore/config.yaml Normal file
View file

@ -0,0 +1,30 @@
name: Concentratore Modbus
version: "1.0.2"
slug: concentratore
description: Espone gli inverter al CCI e mantiene rinfrescato il comando di modulazione
url: https://www.diametra.it
arch:
- aarch64
- amd64
init: false
startup: services
boot: auto
watchdog: "tcp://[HOST]:[PORT:5020]"
ports:
5020/tcp: 5020
ports_description:
5020/tcp: Porta Modbus TCP a cui si collega il CCI
options:
gateway: 192.168.4.240
gateway_porta: 502
slaves: "1,2,3,4,5"
refresh: 2.0
timeout: 1.5
log: true
schema:
gateway: str
gateway_porta: port
slaves: str
refresh: float(0.5,30.0)
timeout: float(0.2,10.0)
log: bool

25
concentratore/run.sh Normal file
View file

@ -0,0 +1,25 @@
#!/usr/bin/with-contenv bashio
# Legge le opzioni impostate dall'interfaccia dell'add-on e avvia il concentratore.
GATEWAY=$(bashio::config 'gateway')
PORTA=$(bashio::config 'gateway_porta')
SLAVES=$(bashio::config 'slaves')
REFRESH=$(bashio::config 'refresh')
TIMEOUT=$(bashio::config 'timeout')
ZITTO=""
if ! bashio::config.true 'log'; then
ZITTO="--zitto"
fi
bashio::log.info "Gateway ${GATEWAY}:${PORTA} - inverter ${SLAVES} - rinfresco ${REFRESH}s"
exec python3 /concentratore.py \
--gateway "${GATEWAY}" \
--gateway-porta "${PORTA}" \
--slaves "${SLAVES}" \
--refresh "${REFRESH}" \
--timeout "${TIMEOUT}" \
--host 0.0.0.0 \
--porta 5020 \
${ZITTO}