primo caricamento
This commit is contained in:
commit
d30984adfe
15 changed files with 658 additions and 0 deletions
49
README.md
Normal file
49
README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Add-on Domotica Work
|
||||||
|
|
||||||
|
Negozio di add-on Home Assistant per la flotta. Un add-on qui dentro si installa
|
||||||
|
su qualunque impianto e si aggiorna su tutti alzando un numero di versione.
|
||||||
|
|
||||||
|
## Add-on disponibili
|
||||||
|
|
||||||
|
| Cartella | Nome | Cosa fa |
|
||||||
|
|---|---|---|
|
||||||
|
| `concentratore/` | Concentratore Modbus | Espone gli inverter ZCS al CCI e tiene rinfrescato il comando di modulazione |
|
||||||
|
|
||||||
|
## Registrare il repository su un impianto
|
||||||
|
|
||||||
|
Dall'interfaccia: **Impostazioni → Add-on → Negozio → ⋮ → Repository**, incollare
|
||||||
|
l'URL del repository.
|
||||||
|
|
||||||
|
Oppure dal fleet, senza aprire nulla:
|
||||||
|
|
||||||
|
```
|
||||||
|
ha_manage_addon(istanza="cliente-xxx", action="add_repository",
|
||||||
|
repository="https://git.domotica.work/domotica/addons")
|
||||||
|
ha_manage_addon(istanza="cliente-xxx", slug="<slug>", action="install")
|
||||||
|
ha_manage_addon(istanza="cliente-xxx", slug="<slug>", options={...})
|
||||||
|
ha_manage_addon(istanza="cliente-xxx", slug="<slug>", action="start")
|
||||||
|
```
|
||||||
|
|
||||||
|
Il Supervisor clona **senza credenziali**: il repository deve essere leggibile
|
||||||
|
in anonimo dagli impianti. Si chiude a livello di rete, non con un flag privato.
|
||||||
|
|
||||||
|
## Regole di casa
|
||||||
|
|
||||||
|
**Ogni modifica al codice alza `version` in `config.yaml`.** È l'unico
|
||||||
|
meccanismo che fa comparire "Aggiorna" sugli impianti. Se si modifica il codice
|
||||||
|
senza toccare la versione, gli impianti restano indietro e non lo segnala
|
||||||
|
nessuno.
|
||||||
|
|
||||||
|
**Niente valori cablati nel codice.** IP, slave, porte, tempi: tutto in
|
||||||
|
`options` con il relativo `schema`. È quello che permette allo stesso add-on di
|
||||||
|
girare su venti impianti diversi senza toccarne il sorgente. Nel repository non
|
||||||
|
deve finire niente di specifico di un cliente.
|
||||||
|
|
||||||
|
**Ogni add-on ha un `CHANGELOG.md`.** Home Assistant lo mostra nella schermata
|
||||||
|
di aggiornamento: è lì che si legge cosa cambia prima di premere il pulsante.
|
||||||
|
|
||||||
|
## Aggiungere un add-on nuovo
|
||||||
|
|
||||||
|
Copiare `docs/modello/` in una cartella nuova alla radice del repository,
|
||||||
|
scegliere uno `slug` univoco, riempire `config.yaml`. La cartella `docs/` non
|
||||||
|
viene letta dal Supervisor, quindi il modello non compare nel negozio.
|
||||||
24
concentratore/CHANGELOG.md
Normal file
24
concentratore/CHANGELOG.md
Normal 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
11
concentratore/Dockerfile
Normal 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
51
concentratore/README.md
Normal 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
3
concentratore/build.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
build_from:
|
||||||
|
aarch64: ghcr.io/home-assistant/aarch64-base:latest
|
||||||
|
amd64: ghcr.io/home-assistant/amd64-base:latest
|
||||||
409
concentratore/concentratore.py
Normal file
409
concentratore/concentratore.py
Normal 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
30
concentratore/config.yaml
Normal 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
25
concentratore/run.sh
Normal 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}
|
||||||
5
docs/modello/CHANGELOG.md
Normal file
5
docs/modello/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Modifiche
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
- Prima versione.
|
||||||
10
docs/modello/Dockerfile
Normal file
10
docs/modello/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
ARG BUILD_FROM
|
||||||
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3
|
||||||
|
|
||||||
|
COPY programma.py /programma.py
|
||||||
|
COPY run.sh /run.sh
|
||||||
|
RUN chmod a+x /run.sh
|
||||||
|
|
||||||
|
CMD [ "/run.sh" ]
|
||||||
7
docs/modello/README.md
Normal file
7
docs/modello/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Modello di add-on
|
||||||
|
|
||||||
|
Copiare questa cartella alla radice del repository, rinominarla, scegliere uno
|
||||||
|
`slug` univoco e riempire `config.yaml`.
|
||||||
|
|
||||||
|
Promemoria: niente valori di un singolo cliente nel codice, tutto in `options`
|
||||||
|
con il relativo `schema`, e ogni modifica successiva alza `version`.
|
||||||
3
docs/modello/build.yaml
Normal file
3
docs/modello/build.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
build_from:
|
||||||
|
aarch64: ghcr.io/home-assistant/aarch64-base:latest
|
||||||
|
amd64: ghcr.io/home-assistant/amd64-base:latest
|
||||||
20
docs/modello/config.yaml
Normal file
20
docs/modello/config.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
name: Nome dell'add-on
|
||||||
|
version: "1.0.0"
|
||||||
|
slug: slug_univoco
|
||||||
|
description: Una riga su cosa fa
|
||||||
|
url: https://domotica.work
|
||||||
|
arch:
|
||||||
|
- aarch64
|
||||||
|
- amd64
|
||||||
|
init: false
|
||||||
|
startup: services
|
||||||
|
boot: auto
|
||||||
|
# watchdog: "tcp://[HOST]:[PORT:0000]" # se l'add-on ascolta su una porta
|
||||||
|
# ports:
|
||||||
|
# 0000/tcp: 0000
|
||||||
|
# ports_description:
|
||||||
|
# 0000/tcp: A cosa serve questa porta
|
||||||
|
options:
|
||||||
|
esempio: valore
|
||||||
|
schema:
|
||||||
|
esempio: str
|
||||||
8
docs/modello/run.sh
Normal file
8
docs/modello/run.sh
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
# Legge le opzioni impostate dall'interfaccia dell'add-on e avvia il programma.
|
||||||
|
|
||||||
|
ESEMPIO=$(bashio::config 'esempio')
|
||||||
|
|
||||||
|
bashio::log.info "Avvio con esempio=${ESEMPIO}"
|
||||||
|
|
||||||
|
exec python3 /programma.py --esempio "${ESEMPIO}"
|
||||||
3
repository.yaml
Normal file
3
repository.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
name: Add-on Domotica Work
|
||||||
|
url: https://domotica.work
|
||||||
|
maintainer: Domotica Work <info@domotica.work>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue