Compare commits
3
Commits
153b0abdd0
..
v1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c82b38e4 | ||
|
|
4566d53ebf | ||
|
|
918e5a91a5 |
@@ -7,14 +7,18 @@ Ce dépôt contient l’add-on Home Assistant `arkteos-proxy-addon`.
|
|||||||
Son rôle est de maintenir une connexion TCP unique vers une PAC Arkteos et de
|
Son rôle est de maintenir une connexion TCP unique vers une PAC Arkteos et de
|
||||||
mettre ce flux à disposition de plusieurs clients TCP.
|
mettre ce flux à disposition de plusieurs clients TCP.
|
||||||
|
|
||||||
Gitea est le dépôt principal :
|
Gitea est le dépôt principal de développement interne via `origin` :
|
||||||
|
|
||||||
- https://gitea.i-host.fr/raph666/arkteos-proxy-addon
|
- https://gitea.i-host.fr/raph666/arkteos-proxy-addon
|
||||||
|
|
||||||
GitHub est uniquement un miroir secondaire :
|
GitHub est le miroir public via `github` et l’URL de distribution de l’add-on :
|
||||||
|
|
||||||
- https://github.com/raph666/arkteos-proxy-addon
|
- https://github.com/raph666/arkteos-proxy-addon
|
||||||
|
|
||||||
|
Les fichiers publics destinés aux utilisateurs, notamment `README.md`,
|
||||||
|
`config.yaml` et `repository.yaml`, doivent utiliser l’URL GitHub, sauf demande
|
||||||
|
contraire. L’URL Gitea reste réservée à la documentation interne.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Le proxy comporte deux côtés distincts :
|
Le proxy comporte deux côtés distincts :
|
||||||
|
|||||||
@@ -4,6 +4,34 @@ Toutes les modifications importantes de ce projet sont documentées ici.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.6] - 2026-07-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Ajout d’un délai maximal pour l’établissement de la connexion à la PAC.
|
||||||
|
- Détection d’une connexion PAC silencieuse malgré une socket toujours ouverte.
|
||||||
|
- Fermeture propre du serveur, des clients et de la connexion PAC lors de l’arrêt de l’add-on.
|
||||||
|
- Validation plus stricte de la configuration au démarrage.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Ajout de tests pour les timeouts, la reconnexion, l’arrêt gracieux et les configurations invalides.
|
||||||
|
|
||||||
|
## [1.0.5] - 2026-07-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Mise à jour de la description de l’add-on pour retirer la référence obsolète à Node-RED.
|
||||||
|
- Clarification du fonctionnement multi-clients et de la lecture seule par défaut.
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-07-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Utilisation de GitHub comme URL publique d’installation et de documentation.
|
||||||
|
- Suppression des références Gitea dans les fichiers destinés aux utilisateurs.
|
||||||
|
- Gitea reste le dépôt principal de développement interne.
|
||||||
|
|
||||||
## [1.0.3] - 2026-07-22
|
## [1.0.3] - 2026-07-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
# Arkteos Proxy — Home Assistant Addon
|
# Arkteos Proxy — Home Assistant Addon
|
||||||
|
|
||||||
Proxy TCP pour PAC Arkteos REG3 (Zuran 4, Baguio 4, etc.).
|
Proxy TCP pour PAC Arkteos REG3 (Zuran 4, Baguio 4, etc.). Il maintient une connexion unique vers la PAC et partage son flux avec plusieurs clients TCP locaux. Les écritures client sont désactivées par défaut.
|
||||||
|
|
||||||
Permet les connexions simultanées de Node-RED et de l'application mobile Arkteos, en maintenant une connexion TCP unique vers la PAC et en distribuant son flux binaire aux clients connectés. Les écritures des clients vers la PAC sont désactivées par défaut.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Dans Home Assistant : **Paramètres → Modules complémentaires → trois points → Dépôts**
|
1. Dans Home Assistant : **Paramètres → Modules complémentaires → trois points → Dépôts**
|
||||||
2. Ajoute l'URL de ce repo : `https://gitea.i-host.fr/raph666/arkteos-proxy-addon`
|
2. Ajoute l'URL de ce repo : `https://github.com/raph666/arkteos-proxy-addon`
|
||||||
3. Installe l'addon **Arkteos Proxy**
|
3. Installe l'addon **Arkteos Proxy**
|
||||||
4. Configure l'IP de ta PAC dans les options
|
4. Configure l'IP de ta PAC dans les options
|
||||||
5. Configure le port exposé dans la section **Network** (défaut : 9641) et clique **Save**
|
5. Configure le port exposé dans la section **Network** (défaut : 9641) et clique **Save**
|
||||||
6. Démarre l'addon
|
6. Démarre l'addon
|
||||||
|
|
||||||
## Dépôts
|
|
||||||
|
|
||||||
Le dépôt principal est disponible sur Gitea : `https://gitea.i-host.fr/raph666/arkteos-proxy-addon`.
|
|
||||||
GitHub est conservé comme miroir secondaire : `https://github.com/raph666/arkteos-proxy-addon`.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Option | Description | Défaut |
|
| Option | Description | Défaut |
|
||||||
@@ -33,11 +26,19 @@ Le proxy se connecte à la PAC avec `pac_host` et `pac_port`. Il écoute les cli
|
|||||||
|
|
||||||
Les données reçues de la PAC sont distribuées à tous les clients connectés. Par défaut, les données reçues d’un client sont ignorées : le client reste connecté et peut continuer à recevoir le flux de la PAC.
|
Les données reçues de la PAC sont distribuées à tous les clients connectés. Par défaut, les données reçues d’un client sont ignorées : le client reste connecté et peut continuer à recevoir le flux de la PAC.
|
||||||
|
|
||||||
Active `allow_client_writes` uniquement si un client de confiance doit écrire vers la PAC. Dans ce mode bidirectionnel, les données client sont relayées telles quelles, sans filtrage ni validation ; les écritures vers la PAC sont sérialisées. L’intégration Home Assistant Arkteos n’a pas besoin de ces écritures.
|
Active `allow_client_writes` uniquement si un client de confiance doit écrire vers la PAC. Dans ce mode bidirectionnel, les données client sont relayées telles quelles, sans filtrage ni validation ; les écritures vers la PAC sont sérialisées.
|
||||||
|
|
||||||
Un keepalive composé d’un octet nul est envoyé toutes les 300 secondes à la PAC afin de maintenir la connexion. Le code n’établit pas le rôle exact de cet octet pour la PAC.
|
Un keepalive composé d’un octet nul est envoyé toutes les 300 secondes à la PAC afin de maintenir la connexion. Le code n’établit pas le rôle exact de cet octet pour la PAC.
|
||||||
|
|
||||||
L’intégration Home Assistant Arkteos est conçue pour un usage en lecture seule. Le proxy applique également ce comportement tant que `allow_client_writes` reste désactivée.
|
## Robustesse de la connexion
|
||||||
|
|
||||||
|
L’établissement de la connexion vers la PAC est limité dans le temps. Le proxy se reconnecte automatiquement après une coupure ou lorsqu’aucun octet n’est reçu de la PAC pendant une période prolongée, même si la socket reste ouverte.
|
||||||
|
|
||||||
|
Lors d’un arrêt, d’une mise à jour ou d’un redémarrage de l’add-on, le proxy arrête les reconnexions et ferme proprement le serveur, les clients et la connexion PAC. Ces mécanismes ne garantissent pas la disponibilité de la PAC ou du réseau.
|
||||||
|
|
||||||
|
## Clients TCP
|
||||||
|
|
||||||
|
Les clients TCP utilisent le port du proxy pour recevoir le flux binaire de la PAC. Aucun client particulier n’est requis pour le fonctionnement de l’add-on.
|
||||||
|
|
||||||
## Sécurité réseau
|
## Sécurité réseau
|
||||||
|
|
||||||
@@ -45,126 +46,6 @@ Ne pas exposer le port du proxy sur Internet. Limite l’accès au réseau local
|
|||||||
|
|
||||||
Le proxy ne met en œuvre ni authentification, ni filtrage protocolaire, ni contrôle d’accès. Il ne doit donc pas être considéré comme une barrière de contrôle d’accès, particulièrement en mode bidirectionnel.
|
Le proxy ne met en œuvre ni authentification, ni filtrage protocolaire, ni contrôle d’accès. Il ne doit donc pas être considéré comme une barrière de contrôle d’accès, particulièrement en mode bidirectionnel.
|
||||||
|
|
||||||
## Intégration Node-RED
|
|
||||||
|
|
||||||
### Import
|
|
||||||
|
|
||||||
1. Dans Node-RED : menu hamburger → **Import** → colle le contenu du fichier JSON
|
|
||||||
2. Double-clique sur le nœud **PAC via proxy** et remplace `<IP_HA>` par l'IP de ton Home Assistant
|
|
||||||
3. Double-clique sur le nœud **MQTT publish** → icône crayon → onglet **Security** → renseigne le login et mot de passe Mosquitto
|
|
||||||
4. Clique **Deploy**
|
|
||||||
|
|
||||||
### Fonctionnement
|
|
||||||
|
|
||||||
Le flow se connecte en streaming permanent au proxy. Chaque trame reçue est parsée, filtrée puis publiée sur MQTT.
|
|
||||||
|
|
||||||
**Filtrage des valeurs aberrantes**
|
|
||||||
|
|
||||||
Les valeurs hors plage physiquement plausible sont ignorées et loguées dans la console Node-RED :
|
|
||||||
|
|
||||||
| Valeur | Plage valide |
|
|
||||||
|---|---|
|
|
||||||
| Température extérieure | -50°C à 150°C |
|
|
||||||
| Températures circuit primaire | -10°C à 90°C |
|
|
||||||
| Consigne départ eau primaire | -10°C à 90°C |
|
|
||||||
| Températures ECS | 0°C à 95°C |
|
|
||||||
| Consigne ECS | 0°C à 80°C |
|
|
||||||
| Température intérieure zone 1 | -10°C à 50°C |
|
|
||||||
| Consigne zone 1 | 5°C à 35°C |
|
|
||||||
| Puissances | 0 à 50 000 W |
|
|
||||||
| Pression eau primaire | 0 à 10 bar |
|
|
||||||
| Fréquence compresseur actuelle | 0 à 200 Hz |
|
|
||||||
| Fréquence compresseur cible | 0 à 200 Hz |
|
|
||||||
| Vitesse ventilateur | 0 à 3 000 rpm |
|
|
||||||
| Voltage DC | 0 à 1 000 V |
|
|
||||||
| Débit eau primaire | 0 à 10 000 L/h |
|
|
||||||
| Circulateur primaire | 0% à 100% |
|
|
||||||
| Statut frigo | 0 à 3 |
|
|
||||||
| Statut PAC | 0 à 9 |
|
|
||||||
| Signal RF sonde | -128 à 127 dBm |
|
|
||||||
| Nombre de dégivrages | 0 à 99 999 |
|
|
||||||
| Temps fonctionnement compresseur | 0 à 999 999 h |
|
|
||||||
| Nb cycles compresseur | 0 à 9 999 999 |
|
|
||||||
| Temps mise sous tension | 0 à 999 999 h |
|
|
||||||
|
|
||||||
**Throttling**
|
|
||||||
|
|
||||||
Une valeur n'est publiée que si elle a changé au-delà d'un seuil défini, avec un rafraîchissement forcé à intervalle maximum :
|
|
||||||
|
|
||||||
| Valeur | Seuil | Intervalle max |
|
|
||||||
|---|---|---|
|
|
||||||
| Température extérieure | 0.5°C | 60s |
|
|
||||||
| Températures circuit primaire | 0.5°C | 60s |
|
|
||||||
| Consigne départ eau primaire | 0.5°C | 60s |
|
|
||||||
| Températures ECS | 0.5°C | 60s |
|
|
||||||
| Consigne ECS | 0.5°C | 60s |
|
|
||||||
| Température intérieure zone 1 | 0.5°C | 60s |
|
|
||||||
| Consigne zone 1 | 0.5°C | 60s |
|
|
||||||
| Puissance produite | 10 W | 30s |
|
|
||||||
| Puissance consommée | 10 W | 30s |
|
|
||||||
| Pression eau primaire | 0.1 bar | 60s |
|
|
||||||
| Fréquence compresseur actuelle | 1 Hz | 30s |
|
|
||||||
| Fréquence compresseur cible | 1 Hz | 30s |
|
|
||||||
| Vitesse ventilateur | 10 rpm | 30s |
|
|
||||||
| Voltage DC | 5 V | 60s |
|
|
||||||
| Débit eau primaire | 1 L/h | 60s |
|
|
||||||
| Circulateur primaire | 1% | 60s |
|
|
||||||
| Modèle PAC | changement | 1h |
|
|
||||||
| Statut frigo | changement | 60s |
|
|
||||||
| Statut PAC | changement | 60s |
|
|
||||||
| Erreurs | changement | 60s |
|
|
||||||
| Nombre de dégivrages | 1 | 5 min |
|
|
||||||
| Nb cycles compresseur | 100 | 5 min |
|
|
||||||
| Temps fonctionnement compresseur | 1 h | 5 min |
|
|
||||||
| Temps mise sous tension | 1 h | 5 min |
|
|
||||||
| Signal RF sonde | 1 dBm | 5 min |
|
|
||||||
|
|
||||||
**Statut de connexion**
|
|
||||||
|
|
||||||
Un `binary_sensor` HA indique si la PAC est connectée. Il passe à `OFF` si aucune trame n'est reçue depuis plus de 30 secondes.
|
|
||||||
|
|
||||||
### Entités créées dans HA
|
|
||||||
|
|
||||||
Le flow publie automatiquement via MQTT Discovery dans un appareil **PAC Arkteos Zuran 4** :
|
|
||||||
|
|
||||||
**Statut**
|
|
||||||
- PAC connectée (binary_sensor)
|
|
||||||
|
|
||||||
**Groupe frigorifique**
|
|
||||||
- Température extérieure (°C)
|
|
||||||
- Nombre de dégivrages
|
|
||||||
- Temps fonctionnement compresseur (h)
|
|
||||||
- Nombre de cycles compresseur
|
|
||||||
- Fréquence compresseur actuelle (Hz)
|
|
||||||
- Fréquence compresseur cible (Hz)
|
|
||||||
- Vitesse ventilateur groupe frigo (rpm)
|
|
||||||
- Voltage DC compresseur (V)
|
|
||||||
- Statut frigo (0-3)
|
|
||||||
- Statut frigo texte (Arret / Refroidissement / Chauffage / Degivrage)
|
|
||||||
- Erreur active frigo
|
|
||||||
|
|
||||||
**Régulation**
|
|
||||||
- Puissance instantanée produite (W)
|
|
||||||
- Puissance instantanée consommée (W)
|
|
||||||
- Temps mise sous tension (h)
|
|
||||||
- Modèle PAC (texte)
|
|
||||||
- Consigne départ eau primaire (°C)
|
|
||||||
- Température eau primaire aller (°C)
|
|
||||||
- Température eau primaire retour (°C)
|
|
||||||
- Débit eau primaire (L/h)
|
|
||||||
- Pression eau primaire (bar)
|
|
||||||
- Circulateur primaire (%)
|
|
||||||
- Température intérieure zone 1 (°C)
|
|
||||||
- Consigne température zone 1 (°C)
|
|
||||||
- Température ballon ECS milieu (°C)
|
|
||||||
- Température ballon ECS bas (°C)
|
|
||||||
- Consigne ECS (°C)
|
|
||||||
- Cycles compresseur (régulation)
|
|
||||||
- Statut PAC (0-9)
|
|
||||||
- Statut PAC texte (Arret / Attente / Chaud / Froid / Hors Gel / ECS / Piscine / etc.)
|
|
||||||
- Erreur active régulation
|
|
||||||
- Signal RF sonde zone 1 (dBm)
|
|
||||||
|
|
||||||
## Protocole
|
## Protocole
|
||||||
|
|
||||||
La PAC Arkteos REG3 expose un flux binaire TCP (serial-over-IP) sur le port 9641 (IHM).
|
La PAC Arkteos REG3 expose un flux binaire TCP (serial-over-IP) sur le port 9641 (IHM).
|
||||||
|
|||||||
+171
-25
@@ -3,11 +3,14 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
KEEPALIVE_INTERVAL = 300
|
KEEPALIVE_INTERVAL = 300
|
||||||
RECONNECT_DELAY = 10
|
RECONNECT_DELAY = 10
|
||||||
|
PAC_CONNECT_TIMEOUT = 10.0
|
||||||
|
PAC_READ_TIMEOUT = 600.0
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -17,9 +20,53 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_allow_client_writes(value: str | None) -> bool:
|
class ConfigurationError(ValueError):
|
||||||
"""Retourne False tant que l'option n'est pas explicitement vraie."""
|
"""Configuration de l'add-on invalide."""
|
||||||
return value is not None and value.strip().lower() == "true"
|
|
||||||
|
|
||||||
|
def parse_allow_client_writes(value: bool | str | None) -> bool:
|
||||||
|
"""Accepte uniquement un booléen ou les chaînes explicites true et false."""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized == "true":
|
||||||
|
return True
|
||||||
|
if normalized == "false":
|
||||||
|
return False
|
||||||
|
raise ConfigurationError("allow_client_writes doit être true ou false")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_port(value: int | str, option_name: str) -> int:
|
||||||
|
"""Valide un port TCP sans accepter de valeur ambiguë."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ConfigurationError(f"{option_name} doit être un entier entre 1 et 65535")
|
||||||
|
if isinstance(value, str):
|
||||||
|
if not value.strip().isdigit():
|
||||||
|
raise ConfigurationError(f"{option_name} doit être un entier entre 1 et 65535")
|
||||||
|
value = int(value.strip())
|
||||||
|
if not isinstance(value, int) or not 1 <= value <= 65535:
|
||||||
|
raise ConfigurationError(f"{option_name} doit être un entier entre 1 et 65535")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration(
|
||||||
|
pac_host: str,
|
||||||
|
pac_port: int | str,
|
||||||
|
proxy_port: int | str,
|
||||||
|
allow_client_writes: bool | str | None,
|
||||||
|
) -> tuple[str, int, int, bool]:
|
||||||
|
"""Retourne une configuration validée avant toute ouverture réseau."""
|
||||||
|
if not isinstance(pac_host, str) or not pac_host.strip():
|
||||||
|
raise ConfigurationError("pac_host doit être une chaîne non vide")
|
||||||
|
return (
|
||||||
|
pac_host.strip(),
|
||||||
|
parse_port(pac_port, "pac_port"),
|
||||||
|
parse_port(proxy_port, "proxy_port"),
|
||||||
|
parse_allow_client_writes(allow_client_writes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ArkteosProxy:
|
class ArkteosProxy:
|
||||||
@@ -35,9 +82,30 @@ class ArkteosProxy:
|
|||||||
self.proxy_port = proxy_port
|
self.proxy_port = proxy_port
|
||||||
self.allow_client_writes = allow_client_writes
|
self.allow_client_writes = allow_client_writes
|
||||||
self.clients: set[asyncio.StreamWriter] = set()
|
self.clients: set[asyncio.StreamWriter] = set()
|
||||||
|
self.client_tasks: set[asyncio.Task[None]] = set()
|
||||||
self.pac_writer: asyncio.StreamWriter | None = None
|
self.pac_writer: asyncio.StreamWriter | None = None
|
||||||
self.pac_write_lock = asyncio.Lock()
|
self.pac_write_lock = asyncio.Lock()
|
||||||
self.stop_event = asyncio.Event()
|
self.stop_event = asyncio.Event()
|
||||||
|
self.server: asyncio.AbstractServer | None = None
|
||||||
|
self.pac_reader_task: asyncio.Task[None] | None = None
|
||||||
|
self.keepalive_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
def request_stop(self) -> None:
|
||||||
|
"""Déclenche l'arrêt sans bloquer un gestionnaire de signal."""
|
||||||
|
if self.stop_event.is_set():
|
||||||
|
return
|
||||||
|
logger.info("Arrêt du proxy demandé")
|
||||||
|
self.stop_event.set()
|
||||||
|
if self.server is not None:
|
||||||
|
self.server.close()
|
||||||
|
if self.keepalive_task is not None:
|
||||||
|
self.keepalive_task.cancel()
|
||||||
|
if self.pac_reader_task is not None:
|
||||||
|
self.pac_reader_task.cancel()
|
||||||
|
if self.pac_writer is not None:
|
||||||
|
self.pac_writer.close()
|
||||||
|
for writer in tuple(self.clients):
|
||||||
|
writer.close()
|
||||||
|
|
||||||
async def write_to_pac(self, data: bytes) -> None:
|
async def write_to_pac(self, data: bytes) -> None:
|
||||||
"""Écrit un bloc complet vers la PAC sans l'altérer."""
|
"""Écrit un bloc complet vers la PAC sans l'altérer."""
|
||||||
@@ -55,13 +123,21 @@ class ArkteosProxy:
|
|||||||
logger.info("Démarrage lecture PAC")
|
logger.info("Démarrage lecture PAC")
|
||||||
try:
|
try:
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
data = await reader.read(4096)
|
try:
|
||||||
|
async with asyncio.timeout(PAC_READ_TIMEOUT):
|
||||||
|
data = await reader.read(4096)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning("PAC restée silencieuse pendant %.0f secondes", PAC_READ_TIMEOUT)
|
||||||
|
return
|
||||||
if not data:
|
if not data:
|
||||||
logger.info("PAC a fermé la connexion")
|
logger.info("PAC a fermé la connexion")
|
||||||
return
|
return
|
||||||
await self.broadcast_to_clients(data)
|
await self.broadcast_to_clients(data)
|
||||||
except (ConnectionError, OSError) as error:
|
except (ConnectionError, OSError) as error:
|
||||||
logger.info("Erreur lecture PAC : %s", error)
|
logger.info("Erreur lecture PAC : %s", error)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not self.stop_event.is_set():
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
logger.info("Arrêt lecture PAC")
|
logger.info("Arrêt lecture PAC")
|
||||||
|
|
||||||
@@ -80,10 +156,17 @@ class ArkteosProxy:
|
|||||||
logger.info("Démarrage keepalive PAC")
|
logger.info("Démarrage keepalive PAC")
|
||||||
try:
|
try:
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
await asyncio.sleep(KEEPALIVE_INTERVAL)
|
try:
|
||||||
await self.send_keepalive()
|
async with asyncio.timeout(KEEPALIVE_INTERVAL):
|
||||||
|
await self.stop_event.wait()
|
||||||
|
return
|
||||||
|
except TimeoutError:
|
||||||
|
await self.send_keepalive()
|
||||||
except (ConnectionError, OSError) as error:
|
except (ConnectionError, OSError) as error:
|
||||||
logger.info("Erreur keepalive PAC : %s", error)
|
logger.info("Erreur keepalive PAC : %s", error)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not self.stop_event.is_set():
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
logger.info("Arrêt keepalive PAC")
|
logger.info("Arrêt keepalive PAC")
|
||||||
|
|
||||||
@@ -92,6 +175,9 @@ class ArkteosProxy:
|
|||||||
reader: asyncio.StreamReader,
|
reader: asyncio.StreamReader,
|
||||||
writer: asyncio.StreamWriter,
|
writer: asyncio.StreamWriter,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
task = asyncio.current_task()
|
||||||
|
if task is not None:
|
||||||
|
self.client_tasks.add(task)
|
||||||
peername = writer.get_extra_info("peername")
|
peername = writer.get_extra_info("peername")
|
||||||
logger.info("Nouveau client : %s", peername)
|
logger.info("Nouveau client : %s", peername)
|
||||||
self.clients.add(writer)
|
self.clients.add(writer)
|
||||||
@@ -113,8 +199,13 @@ class ArkteosProxy:
|
|||||||
return
|
return
|
||||||
except (ConnectionError, OSError) as error:
|
except (ConnectionError, OSError) as error:
|
||||||
logger.info("Erreur client %s : %s", peername, error)
|
logger.info("Erreur client %s : %s", peername, error)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not self.stop_event.is_set():
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
await self.close_client(writer)
|
await self.close_client(writer)
|
||||||
|
if task is not None:
|
||||||
|
self.client_tasks.discard(task)
|
||||||
|
|
||||||
async def close_client(self, writer: asyncio.StreamWriter) -> None:
|
async def close_client(self, writer: asyncio.StreamWriter) -> None:
|
||||||
if writer not in self.clients:
|
if writer not in self.clients:
|
||||||
@@ -131,17 +222,38 @@ class ArkteosProxy:
|
|||||||
for writer in tuple(self.clients):
|
for writer in tuple(self.clients):
|
||||||
await self.close_client(writer)
|
await self.close_client(writer)
|
||||||
|
|
||||||
|
async def stop_client_tasks(self) -> None:
|
||||||
|
current_task = asyncio.current_task()
|
||||||
|
tasks = [task for task in self.client_tasks if task is not current_task]
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
async def run_pac_connection(self) -> None:
|
async def run_pac_connection(self) -> None:
|
||||||
reader, writer = await asyncio.open_connection(self.pac_host, self.pac_port)
|
try:
|
||||||
|
async with asyncio.timeout(PAC_CONNECT_TIMEOUT):
|
||||||
|
reader, writer = await asyncio.open_connection(self.pac_host, self.pac_port)
|
||||||
|
except TimeoutError as error:
|
||||||
|
logger.warning("Délai de connexion PAC dépassé après %.0f secondes", PAC_CONNECT_TIMEOUT)
|
||||||
|
raise ConnectionError("Délai de connexion PAC dépassé") from error
|
||||||
|
|
||||||
self.pac_writer = writer
|
self.pac_writer = writer
|
||||||
logger.info("Connecté à la PAC %s:%s", self.pac_host, self.pac_port)
|
logger.info("Connecté à la PAC %s:%s", self.pac_host, self.pac_port)
|
||||||
reader_task = asyncio.create_task(self.pac_reader(reader))
|
self.pac_reader_task = asyncio.create_task(self.pac_reader(reader))
|
||||||
keepalive_task = asyncio.create_task(self.pac_keepalive())
|
self.keepalive_task = asyncio.create_task(self.pac_keepalive())
|
||||||
try:
|
try:
|
||||||
await reader_task
|
try:
|
||||||
|
await self.pac_reader_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not self.stop_event.is_set():
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
keepalive_task.cancel()
|
if self.keepalive_task is not None:
|
||||||
await asyncio.gather(keepalive_task, return_exceptions=True)
|
self.keepalive_task.cancel()
|
||||||
|
await asyncio.gather(self.keepalive_task, return_exceptions=True)
|
||||||
|
self.keepalive_task = None
|
||||||
|
self.pac_reader_task = None
|
||||||
if self.pac_writer is writer:
|
if self.pac_writer is writer:
|
||||||
self.pac_writer = None
|
self.pac_writer = None
|
||||||
writer.close()
|
writer.close()
|
||||||
@@ -150,6 +262,14 @@ class ArkteosProxy:
|
|||||||
except (ConnectionError, OSError):
|
except (ConnectionError, OSError):
|
||||||
pass
|
pass
|
||||||
await self.close_clients()
|
await self.close_clients()
|
||||||
|
await self.stop_client_tasks()
|
||||||
|
|
||||||
|
async def wait_for_reconnect_delay(self) -> None:
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(RECONNECT_DELAY):
|
||||||
|
await self.stop_event.wait()
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
mode = (
|
mode = (
|
||||||
@@ -158,36 +278,62 @@ class ArkteosProxy:
|
|||||||
else "Mode lecture seule : écritures des clients bloquées"
|
else "Mode lecture seule : écritures des clients bloquées"
|
||||||
)
|
)
|
||||||
logger.info(mode)
|
logger.info(mode)
|
||||||
server = await asyncio.start_server(self.handle_client, "0.0.0.0", self.proxy_port)
|
self.server = await asyncio.start_server(self.handle_client, "0.0.0.0", self.proxy_port)
|
||||||
logger.info("Proxy en écoute sur 0.0.0.0:%s", self.proxy_port)
|
logger.info("Proxy en écoute sur 0.0.0.0:%s", self.proxy_port)
|
||||||
try:
|
try:
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
await self.run_pac_connection()
|
await self.run_pac_connection()
|
||||||
except (ConnectionError, OSError) as error:
|
except (ConnectionError, OSError) as error:
|
||||||
logger.info("Échec connexion PAC : %s", error)
|
if not self.stop_event.is_set():
|
||||||
|
logger.info("Échec connexion PAC : %s", error)
|
||||||
if not self.stop_event.is_set():
|
if not self.stop_event.is_set():
|
||||||
logger.info("Nouvelle tentative de connexion PAC dans 10s...")
|
logger.info("Nouvelle tentative de connexion PAC dans 10s...")
|
||||||
await asyncio.sleep(RECONNECT_DELAY)
|
await self.wait_for_reconnect_delay()
|
||||||
finally:
|
finally:
|
||||||
self.stop_event.set()
|
self.request_stop()
|
||||||
server.close()
|
if self.server is not None:
|
||||||
await server.wait_closed()
|
await self.server.wait_closed()
|
||||||
|
self.server = None
|
||||||
await self.close_clients()
|
await self.close_clients()
|
||||||
|
await self.stop_client_tasks()
|
||||||
logger.info("Proxy arrêté")
|
logger.info("Proxy arrêté")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
async def run_proxy(proxy: ArkteosProxy) -> None:
|
||||||
pac_host = sys.argv[1] if len(sys.argv) > 1 else "192.168.X.X"
|
loop = asyncio.get_running_loop()
|
||||||
pac_port = int(sys.argv[2]) if len(sys.argv) > 2 else 9641
|
installed_signals: list[signal.Signals] = []
|
||||||
proxy_port = int(sys.argv[3]) if len(sys.argv) > 3 else 9641
|
for signal_name in (signal.SIGTERM, signal.SIGINT):
|
||||||
allow_client_writes = parse_allow_client_writes(sys.argv[4] if len(sys.argv) > 4 else None)
|
try:
|
||||||
|
loop.add_signal_handler(signal_name, proxy.request_stop)
|
||||||
|
installed_signals.append(signal_name)
|
||||||
|
except (NotImplementedError, RuntimeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await proxy.serve()
|
||||||
|
finally:
|
||||||
|
for signal_name in installed_signals:
|
||||||
|
loop.remove_signal_handler(signal_name)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
pac_host, pac_port, proxy_port, allow_client_writes = validate_configuration(
|
||||||
|
sys.argv[1] if len(sys.argv) > 1 else "192.168.X.X",
|
||||||
|
sys.argv[2] if len(sys.argv) > 2 else "9641",
|
||||||
|
sys.argv[3] if len(sys.argv) > 3 else "9641",
|
||||||
|
sys.argv[4] if len(sys.argv) > 4 else None,
|
||||||
|
)
|
||||||
|
except ConfigurationError as error:
|
||||||
|
logger.error("Configuration invalide : %s", error)
|
||||||
|
return 2
|
||||||
proxy = ArkteosProxy(pac_host, pac_port, proxy_port, allow_client_writes)
|
proxy = ArkteosProxy(pac_host, pac_port, proxy_port, allow_client_writes)
|
||||||
try:
|
try:
|
||||||
asyncio.run(proxy.serve())
|
asyncio.run(run_proxy(proxy))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Arrêt demandé")
|
logger.info("Arrêt demandé")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
name: Arkteos Proxy
|
name: Arkteos Proxy
|
||||||
version: "1.0.3"
|
version: "1.0.6"
|
||||||
slug: arkteos_proxy
|
slug: arkteos_proxy
|
||||||
description: Proxy TCP pour PAC Arkteos REG3 — permet les connexions simultanées de Node-RED et de l'app mobile Arkteos.
|
description: Proxy TCP pour PAC Arkteos REG3 — maintient une connexion unique vers la PAC et partage le flux avec plusieurs clients locaux. Les écritures client sont désactivées par défaut.
|
||||||
url: https://gitea.i-host.fr/raph666/arkteos-proxy-addon
|
url: "https://github.com/raph666/arkteos-proxy-addon"
|
||||||
arch:
|
arch:
|
||||||
- aarch64
|
- aarch64
|
||||||
- amd64
|
- amd64
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
name: Arkteos Proxy
|
name: Arkteos Proxy
|
||||||
url: https://gitea.i-host.fr/raph666/arkteos-proxy-addon
|
url: "https://github.com/raph666/arkteos-proxy-addon"
|
||||||
maintainer: raph666
|
maintainer: raph666
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ bashio::log.info "Démarrage du proxy Arkteos"
|
|||||||
bashio::log.info "PAC : ${PAC_HOST}:${PAC_PORT}"
|
bashio::log.info "PAC : ${PAC_HOST}:${PAC_PORT}"
|
||||||
bashio::log.info "Proxy en écoute sur port : ${PROXY_PORT}"
|
bashio::log.info "Proxy en écoute sur port : ${PROXY_PORT}"
|
||||||
|
|
||||||
exec python3 /arkteos_proxy.py "${PAC_HOST}" "${PAC_PORT}" "${PROXY_PORT}" "${ALLOW_CLIENT_WRITES}"
|
exec python3 /arkteos_proxy.py "${PAC_HOST}" "${PAC_PORT}" "${PROXY_PORT}" "${ALLOW_CLIENT_WRITES:-false}"
|
||||||
|
|||||||
+126
-3
@@ -2,7 +2,13 @@ import asyncio
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from arkteos_proxy import ArkteosProxy, KEEPALIVE_INTERVAL, RECONNECT_DELAY
|
from arkteos_proxy import (
|
||||||
|
ArkteosProxy,
|
||||||
|
ConfigurationError,
|
||||||
|
KEEPALIVE_INTERVAL,
|
||||||
|
RECONNECT_DELAY,
|
||||||
|
validate_configuration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class QueueReader:
|
class QueueReader:
|
||||||
@@ -149,18 +155,135 @@ class ArkteosProxyTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
proxy.stop_event.set()
|
proxy.stop_event.set()
|
||||||
|
|
||||||
proxy.run_pac_connection = fake_pac_connection
|
proxy.run_pac_connection = fake_pac_connection
|
||||||
|
proxy.wait_for_reconnect_delay = AsyncMock()
|
||||||
with (
|
with (
|
||||||
patch("arkteos_proxy.asyncio.start_server", new_callable=AsyncMock, return_value=FakeServer()),
|
patch("arkteos_proxy.asyncio.start_server", new_callable=AsyncMock, return_value=FakeServer()),
|
||||||
patch("arkteos_proxy.asyncio.sleep", new_callable=AsyncMock) as sleep,
|
|
||||||
):
|
):
|
||||||
await proxy.serve()
|
await proxy.serve()
|
||||||
|
|
||||||
self.assertEqual(attempts, 2)
|
self.assertEqual(attempts, 2)
|
||||||
sleep.assert_awaited_once_with(RECONNECT_DELAY)
|
proxy.wait_for_reconnect_delay.assert_awaited_once_with()
|
||||||
|
|
||||||
def test_missing_option_defaults_to_read_only(self):
|
def test_missing_option_defaults_to_read_only(self):
|
||||||
self.assertFalse(ArkteosProxy("pac", 9641, 9641).allow_client_writes)
|
self.assertFalse(ArkteosProxy("pac", 9641, 9641).allow_client_writes)
|
||||||
|
|
||||||
|
async def test_connection_timeout_stops_a_single_attempt(self):
|
||||||
|
proxy = ArkteosProxy("pac", 9641, 9641)
|
||||||
|
|
||||||
|
async def never_connect(*_args, **_kwargs):
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arkteos_proxy.PAC_CONNECT_TIMEOUT", 0.01),
|
||||||
|
patch("arkteos_proxy.asyncio.open_connection", new_callable=AsyncMock, side_effect=never_connect) as connect,
|
||||||
|
):
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
await proxy.run_pac_connection()
|
||||||
|
|
||||||
|
connect.assert_awaited_once_with("pac", 9641)
|
||||||
|
|
||||||
|
async def test_silent_pac_closes_writer_and_cancels_keepalive(self):
|
||||||
|
proxy = ArkteosProxy("pac", 9641, 9641)
|
||||||
|
reader = QueueReader()
|
||||||
|
writer = FakeWriter()
|
||||||
|
keepalive_cancelled = asyncio.Event()
|
||||||
|
|
||||||
|
async def tracked_keepalive():
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
keepalive_cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
proxy.pac_keepalive = tracked_keepalive
|
||||||
|
with (
|
||||||
|
patch("arkteos_proxy.PAC_READ_TIMEOUT", 0.01),
|
||||||
|
patch("arkteos_proxy.asyncio.open_connection", new_callable=AsyncMock, return_value=(reader, writer)),
|
||||||
|
):
|
||||||
|
await proxy.run_pac_connection()
|
||||||
|
|
||||||
|
self.assertTrue(writer.closed)
|
||||||
|
self.assertTrue(keepalive_cancelled.is_set())
|
||||||
|
self.assertIsNone(proxy.pac_writer)
|
||||||
|
self.assertIsNone(proxy.keepalive_task)
|
||||||
|
|
||||||
|
async def test_pac_activity_is_distributed_before_connection_closes(self):
|
||||||
|
proxy = ArkteosProxy("pac", 9641, 9641)
|
||||||
|
reader = QueueReader()
|
||||||
|
writer = FakeWriter()
|
||||||
|
client = FakeWriter()
|
||||||
|
proxy.clients.add(client)
|
||||||
|
await reader.items.put(b"pac-data")
|
||||||
|
await reader.items.put(b"")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arkteos_proxy.PAC_READ_TIMEOUT", 0.01),
|
||||||
|
patch("arkteos_proxy.asyncio.open_connection", new_callable=AsyncMock, return_value=(reader, writer)),
|
||||||
|
):
|
||||||
|
await proxy.run_pac_connection()
|
||||||
|
|
||||||
|
self.assertEqual(client.writes, [b"pac-data"])
|
||||||
|
self.assertTrue(writer.closed)
|
||||||
|
|
||||||
|
async def test_graceful_stop_closes_server_clients_and_pac_writer(self):
|
||||||
|
proxy = ArkteosProxy("pac", 9641, 0)
|
||||||
|
server = FakeServer()
|
||||||
|
pac_writer = FakeWriter()
|
||||||
|
client = FakeWriter()
|
||||||
|
proxy.pac_writer = pac_writer
|
||||||
|
proxy.clients.add(client)
|
||||||
|
|
||||||
|
async def wait_for_stop():
|
||||||
|
await proxy.stop_event.wait()
|
||||||
|
|
||||||
|
proxy.run_pac_connection = wait_for_stop
|
||||||
|
with patch("arkteos_proxy.asyncio.start_server", new_callable=AsyncMock, return_value=server):
|
||||||
|
task = asyncio.create_task(proxy.serve())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
proxy.request_stop()
|
||||||
|
await task
|
||||||
|
|
||||||
|
self.assertTrue(server.closed)
|
||||||
|
self.assertTrue(client.closed)
|
||||||
|
self.assertTrue(pac_writer.closed)
|
||||||
|
self.assertEqual(proxy.client_tasks, set())
|
||||||
|
|
||||||
|
async def test_stop_during_reconnect_does_not_wait_for_full_delay(self):
|
||||||
|
proxy = ArkteosProxy("pac", 9641, 0)
|
||||||
|
server = FakeServer()
|
||||||
|
|
||||||
|
async def failed_connection():
|
||||||
|
raise ConnectionError("PAC indisponible")
|
||||||
|
|
||||||
|
async def stop_during_delay():
|
||||||
|
proxy.request_stop()
|
||||||
|
|
||||||
|
proxy.run_pac_connection = failed_connection
|
||||||
|
proxy.wait_for_reconnect_delay = stop_during_delay
|
||||||
|
with patch("arkteos_proxy.asyncio.start_server", new_callable=AsyncMock, return_value=server):
|
||||||
|
await asyncio.wait_for(proxy.serve(), timeout=0.1)
|
||||||
|
|
||||||
|
self.assertTrue(server.closed)
|
||||||
|
|
||||||
|
def test_configuration_validation_rejects_invalid_values(self):
|
||||||
|
invalid_configurations = (
|
||||||
|
(" ", "9641", "9641", "false"),
|
||||||
|
("pac", "0", "9641", "false"),
|
||||||
|
("pac", "65536", "9641", "false"),
|
||||||
|
("pac", "9641", "invalid", "false"),
|
||||||
|
("pac", "9641", "9641", "maybe"),
|
||||||
|
)
|
||||||
|
for configuration in invalid_configurations:
|
||||||
|
with self.subTest(configuration=configuration):
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
validate_configuration(*configuration)
|
||||||
|
|
||||||
|
def test_configuration_validation_accepts_supported_values(self):
|
||||||
|
self.assertEqual(
|
||||||
|
validate_configuration(" pac ", "9641", 9641, "false"),
|
||||||
|
("pac", 9641, 9641, False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user