23 Commits
Author SHA1 Message Date
raph666 97c82b38e4 fix: improve proxy liveness and graceful shutdown 2026-07-22 14:37:05 +02:00
raph666 4566d53ebf docs: update addon description 2026-07-22 14:12:23 +02:00
raph666 918e5a91a5 docs: use GitHub as public addon repository 2026-07-22 13:34:49 +02:00
raph666 153b0abdd0 fix: correct addon version to 1.0.3 2026-07-22 12:07:16 +02:00
raph666 2a4161f24a docs: complete changelog for 1.0.1 2026-07-22 11:59:30 +02:00
raph666 bc3ea8562a fix: use explicit Home Assistant base image 2026-07-22 11:57:17 +02:00
raph666 90659cb24b release: prepare arkteos proxy addon 1.0.0 2026-07-22 11:40:34 +02:00
raph666 2948f2f0c0 Remove unrelated Solarwatt flow 2026-07-21 17:05:09 +02:00
raph666andGitHub a738c02a54 Update README.md 2026-04-01 11:02:30 +02:00
raph666andGitHub e7d616c888 Update README.md 2026-04-01 11:00:05 +02:00
raph666andGitHub 5110173467 Update README.md 2026-04-01 10:10:53 +02:00
raph666andGitHub 2da9b89f13 Update README.md 2026-03-31 19:00:28 +02:00
raph666andGitHub 02049b0f3f Update README.md 2026-03-31 18:50:51 +02:00
raph666andGitHub 587cebf5f6 Add files via upload 2026-03-31 18:49:59 +02:00
raph666andGitHub 2fb19fb221 Update README.md 2026-03-31 18:42:41 +02:00
raph666andGitHub 99db64cdad Update README.md 2026-03-31 18:41:28 +02:00
raph666andGitHub a60ad6e3ef Update README.md 2026-03-31 18:36:28 +02:00
raph666andGitHub 14be7e4a0e Update README.md 2026-03-31 18:36:12 +02:00
raph666andGitHub ce9c6a6d2b Update README.md 2026-03-31 18:35:46 +02:00
raph666andGitHub 16fbce3f4d Update config.yaml 2026-03-31 18:26:25 +02:00
raph666andGitHub 2302a5d337 Add files via upload 2026-03-31 18:12:44 +02:00
raph666andGitHub 7e276fabdd Update README.md 2026-03-31 18:07:08 +02:00
raph666andGitHub 6494f6cf03 Initial commit 2026-03-31 18:05:43 +02:00
13 changed files with 1062 additions and 432 deletions
+23
View File
@@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
# Environnements locaux
.venv/
.env
# Journaux et fichiers temporaires
*.log
*.tmp
*.swp
*~
# Tests et caches
.pytest_cache/
.coverage
htmlcov/
# Éditeurs et système
.vscode/
.idea/
.DS_Store
+310
View File
@@ -0,0 +1,310 @@
# AGENTS.md
## Objet du projet
Ce dépôt contient ladd-on Home Assistant `arkteos-proxy-addon`.
Son rôle est de maintenir une connexion TCP unique vers une PAC Arkteos et de
mettre ce flux à disposition de plusieurs clients TCP.
Gitea est le dépôt principal de développement interne via `origin` :
- https://gitea.i-host.fr/raph666/arkteos-proxy-addon
GitHub est le miroir public via `github` et lURL de distribution de ladd-on :
- https://github.com/raph666/arkteos-proxy-addon
Les fichiers publics destinés aux utilisateurs, notamment `README.md`,
`config.yaml` et `repository.yaml`, doivent utiliser lURL GitHub, sauf demande
contraire. LURL Gitea reste réservée à la documentation interne.
## Architecture
Le proxy comporte deux côtés distincts :
- côté PAC :
- connexion sortante vers `pac_host:pac_port` ;
- port par défaut : `9641` ;
- côté clients :
- écoute sur `0.0.0.0:proxy_port` ;
- port par défaut : `9641`.
Le proxy distribue les données reçues de la PAC vers tous les clients connectés.
Le proxy peut aussi relayer les données envoyées par les clients vers la PAC
lorsque cette fonctionnalité est explicitement activée.
## Contraintes importantes
### Connexion PAC unique
La PAC ne doit avoir quune seule connexion TCP active.
Ne jamais ajouter une seconde connexion concurrente vers la PAC.
Tous les clients Home Assistant, outils de diagnostic ou autres consommateurs
doivent passer par le proxy.
### Compatibilité protocolaire
Ne pas inventer de protocole, checksum ou validation non observée.
Ne modifier ni le contenu ni lordre des octets transmis sans preuve issue :
- dune capture réelle ;
- du comportement actuel ;
- ou dune documentation fiable.
### Keepalive
Le proxy envoie actuellement un octet nul toutes les 300 secondes.
Le rôle exact de ce keepalive côté PAC nest pas confirmé.
Ne pas modifier :
- sa valeur ;
- sa fréquence ;
- son activation ;
sans test préalable et justification documentée.
### Écritures vers la PAC
Le proxy peut fonctionner en mode bidirectionnel.
Les données envoyées par les clients peuvent atteindre la PAC si le mode
d’écriture est activé.
Règles :
- lecture seule par défaut ;
- aucune écriture client ne doit être autorisée implicitement ;
- toute option d’écriture doit être explicite ;
- les écritures vers la PAC doivent être sérialisées ;
- utiliser `writer.drain()` après une écriture ;
- ne jamais journaliser les données brutes envoyées par un client ;
- ne jamais exposer le port du proxy sur Internet ;
- aucune authentification réseau ne doit être supposée si elle nexiste pas.
## Sécurité
Ne jamais ajouter au dépôt :
- mot de passe ;
- token ;
- clé API ;
- secret Home Assistant ;
- clé privée ;
- adresse IP personnelle inutile ;
- flow Node-RED contenant des identifiants réels ;
- contenu issu dun autre projet sans rapport avec Arkteos.
Les exemples doivent utiliser des valeurs neutres :
- `192.168.1.100`
- `PAC_IP`
- `CHANGEME`
- `example.local`
Ne jamais afficher une valeur sensible dans les rapports ou les logs.
## Fichiers principaux
Vérifier en priorité :
- `config.yaml`
- `repository.yaml`
- `Dockerfile`
- `run.sh`
- `arkteos_proxy.py`
- `README.md`
- `CHANGELOG.md`
- `.gitignore`
- `AGENTS.md`
Le point dentrée réel doit toujours être confirmé depuis `run.sh` et le
Dockerfile.
## Règles de modification
Avant toute modification :
1. afficher l’état Git ;
2. inspecter le code existant ;
3. distinguer les faits observés des hypothèses ;
4. présenter le plan si la modification touche le réseau ou le protocole.
Ne jamais :
- supprimer une fonction utile sans accord ;
- modifier un port par défaut sans demande ;
- modifier le protocole silencieusement ;
- remplacer une logique existante par une implémentation supposée ;
- modifier plusieurs sujets sans rapport dans le même changement ;
- réécrire lhistorique Git sans accord explicite ;
- utiliser `git push --force` ;
- créer un commit, un tag ou un push sans demande explicite.
## Style Python
- utiliser `asyncio` lorsque pertinent ;
- gérer explicitement les déconnexions ;
- fermer proprement les writers ;
- utiliser `await writer.wait_closed()` lorsque possible ;
- annuler proprement les tâches ;
- éviter les tâches asyncio résiduelles après arrêt ;
- journaliser les exceptions réseau sans arrêter définitivement le proxy ;
- temporiser les boucles de reconnexion ;
- éviter les variables globales mutables non protégées ;
- utiliser un verrou dédié pour les écritures vers la PAC ;
- ne pas réutiliser le verrou de la liste des clients pour les écritures réseau.
## Journalisation
Les logs doivent indiquer clairement :
- connexion à la PAC ;
- déconnexion de la PAC ;
- tentative de reconnexion ;
- connexion dun client ;
- déconnexion dun client ;
- mode lecture seule ou bidirectionnel ;
- erreur réseau utile au diagnostic.
Ne pas journaliser :
- secrets ;
- mots de passe ;
- contenu brut des commandes clients ;
- flux binaires complets en fonctionnement normal.
Les logs fréquents doivent éviter de saturer le journal Home Assistant.
## Tests
Toute modification fonctionnelle doit être accompagnée de tests lorsque
possible.
Tester au minimum les zones concernées :
- connexion à la PAC ;
- reconnexion après coupure ;
- connexion et déconnexion des clients ;
- distribution PAC vers plusieurs clients ;
- blocage des écritures en mode lecture seule ;
- relais client vers PAC en mode autorisé ;
- sérialisation des écritures concurrentes ;
- keepalive ;
- fermeture propre ;
- absence de vraie connexion à une PAC pendant les tests.
Les tests doivent utiliser des mocks, des serveurs asyncio locaux ou des writers
simulés.
## Validations obligatoires
Avant de proposer un commit :
```bash
python3 -m compileall .
bash -n run.sh
```
Valider les fichiers YAML :
```bash
python3 - <<'PY'
from pathlib import Path
import yaml
for filename in ("config.yaml", "repository.yaml"):
with Path(filename).open(encoding="utf-8") as file:
data = yaml.safe_load(file)
if not isinstance(data, dict):
raise SystemExit(f"ERREUR : {filename}")
print(f"OK YAML : {filename}")
PY
```
Sil existe des tests :
```bash
python3 -m pytest -v
```
Toujours exécuter :
```bash
git diff --check
git status --short
```
## Versionnement
La version de ladd-on est définie dans `config.yaml`.
Avant une release :
- vérifier la cohérence avec `CHANGELOG.md` ;
- vérifier la documentation ;
- vérifier que les tests passent ;
- vérifier labsence de secret ;
- vérifier les URLs Gitea et GitHub ;
- créer le commit avant le tag ;
- créer un tag au format `vX.Y.Z`.
Ne jamais déplacer ou recréer un tag déjà publié.
## Workflow Git
Dépôt principal :
```bash
git pull
git push
```
Ces commandes doivent utiliser Gitea via `origin`.
Miroir GitHub :
```bash
git push github main
git push github --tags
```
Les remotes attendus sont :
- `origin` → Gitea ;
- `github` → GitHub.
Ne jamais inverser ces rôles sans demande explicite.
## Documentation
Toute modification fonctionnelle doit être reflétée dans :
- `README.md` ;
- `CHANGELOG.md` ;
- `config.yaml` si une option est ajoutée ou modifiée.
La documentation doit décrire le comportement réel du code.
Ne jamais présenter le proxy comme strictement passif si les clients peuvent
écrire vers la PAC.
## Rapport attendu après modification
À la fin dune tâche, indiquer :
- fichiers modifiés ;
- fichiers ajoutés ;
- comportement modifié ;
- validations exécutées ;
- résultats des tests ;
- avertissements restants ;
- confirmation de labsence de commit, push ou tag si cela n’était pas demandé.
+80
View File
@@ -0,0 +1,80 @@
# Changelog
Toutes les modifications importantes de ce projet sont documentées ici.
## [Unreleased]
## [1.0.6] - 2026-07-22
### Fixed
- Ajout dun délai maximal pour l’établissement de la connexion à la PAC.
- Détection dune connexion PAC silencieuse malgré une socket toujours ouverte.
- Fermeture propre du serveur, des clients et de la connexion PAC lors de larrêt de ladd-on.
- Validation plus stricte de la configuration au démarrage.
### Tests
- Ajout de tests pour les timeouts, la reconnexion, larrêt gracieux et les configurations invalides.
## [1.0.5] - 2026-07-22
### Changed
- Mise à jour de la description de ladd-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 dinstallation 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
### Fixed
- Correction de la version déclarée dans `config.yaml`.
- La release `v1.0.2` contenait encore la version interne `1.0.1`.
## [1.0.2] - 2026-07-22
### Fixed
- Ajout de lentrée manquante pour la correction du Dockerfile publiée en 1.0.1.
- Documentation de lutilisation de limage de base explicite `ghcr.io/home-assistant/base:latest`.
## [1.0.1] - 2026-07-22
### Fixed
- Correction du Dockerfile pour le système de build actuel de Home Assistant.
- Utilisation explicite de limage multiarchitecture `ghcr.io/home-assistant/base:latest`.
## [1.0.0] - 2026-07-21
### Added
- Add-on Home Assistant pour le proxy TCP Arkteos.
- Maintien dune connexion unique vers la PAC.
- Multiplexage du flux vers plusieurs clients TCP.
- Reconnexion automatique à la PAC après une coupure.
- Keepalive composé dun octet nul toutes les 300 secondes ; son rôle exact côté PAC nest pas confirmé.
- Fonctionnement en lecture seule par défaut.
- Option `allow_client_writes` pour activer explicitement le relais bidirectionnel des données entre les clients et la PAC.
- Sérialisation des écritures vers la PAC lorsque le mode bidirectionnel est activé.
- Support des architectures déclarées dans `config.yaml`.
- Documentation de sécurité réseau.
### Changed
- Gitea devient le dépôt principal.
- GitHub est conservé comme miroir secondaire.
- Correction du bit exécutable de `run.sh`.
### Removed
- Suppression dun flow Solarwatt ajouté par erreur.
- Nettoyage de lhistorique Git contenant ce fichier étranger au projet.
+1 -2
View File
@@ -1,5 +1,4 @@
ARG BUILD_FROM
FROM $BUILD_FROM
FROM ghcr.io/home-assistant/base:latest
RUN apk add --no-cache python3
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 raph666
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+22 -28
View File
@@ -1,8 +1,6 @@
# Arkteos Proxy — Home Assistant Addon
Proxy TCP pour PAC Arkteos REG3 (Zuran 4, Baguio 4, etc.).
Permet les connexions simultanées de Node-RED et de l'application mobile Arkteos, en relayant le flux binaire de la PAC vers tous les clients connectés.
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.
## Installation
@@ -10,8 +8,8 @@ Permet les connexions simultanées de Node-RED et de l'application mobile Arkteo
2. Ajoute l'URL de ce repo : `https://github.com/raph666/arkteos-proxy-addon`
3. Installe l'addon **Arkteos Proxy**
4. Configure l'IP de ta PAC dans les options
5. Configure le port exposé dans la section **Network** (défaut : 9641)
6. Clique **Save** dans Network puis démarre l'addon
5. Configure le port exposé dans la section **Network** (défaut : 9641) et clique **Save**
6. Démarre l'addon
## Configuration
@@ -20,37 +18,33 @@ Permet les connexions simultanées de Node-RED et de l'application mobile Arkteo
| `pac_host` | Adresse IP de la PAC | `192.168.X.X` |
| `pac_port` | Port TCP de la PAC | `9641` |
| `proxy_port` | Port d'écoute interne du proxy | `9641` |
| `allow_client_writes` | Autorise le relais des données clients vers la PAC | `false` |
## Intégration Node-RED
## Fonctionnement du proxy
Un flow Node-RED prêt à l'emploi est disponible dans ce repo : `nodered/arkteos_reg3_nodered.json`.
Le proxy se connecte à la PAC avec `pac_host` et `pac_port`. Il écoute les clients TCP sur toutes les interfaces réseau, avec `proxy_port`.
### Import
Les données reçues de la PAC sont distribuées à tous les clients connectés. Par défaut, les données reçues dun client sont ignorées : le client reste connecté et peut continuer à recevoir le flux de la PAC.
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**
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.
### Entités créées dans HA
Un keepalive composé dun 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.
Le flow publie automatiquement 12 entités via MQTT Discovery dans un appareil **PAC Arkteos Zuran 4** :
## Robustesse de la connexion
**Groupe frigorifique**
- Température extérieure (°C)
- Fréquence compresseur actuelle (Hz)
- Voltage DC compresseur (V)
L’établissement de la connexion vers la PAC est limité dans le temps. Le proxy se reconnecte automatiquement après une coupure ou lorsquaucun octet nest reçu de la PAC pendant une période prolongée, même si la socket reste ouverte.
**Régulation**
- Puissance instantanée produite (kW)
- Puissance instantanée consommée (kW)
- Température eau primaire aller (°C)
- Température eau primaire retour (°C)
- Pression eau primaire (bar)
- 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)
Lors dun arrêt, dune mise à jour ou dun redémarrage de ladd-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 nest requis pour le fonctionnement de ladd-on.
## Sécurité réseau
Ne pas exposer le port du proxy sur Internet. Limite laccès au réseau local ou à des clients de confiance : lorsque `allow_client_writes` est activée, un client qui accède au proxy peut potentiellement transmettre des données à la PAC.
Le proxy ne met en œuvre ni authentification, ni filtrage protocolaire, ni contrôle daccès. Il ne doit donc pas être considéré comme une barrière de contrôle daccès, particulièrement en mode bidirectionnel.
## Protocole
+309 -150
View File
@@ -1,180 +1,339 @@
#!/usr/bin/env python3
# Proxy TCP pour PAC Arkteos REG3
# Permet les connexions simultanées de Node-RED et de l'app mobile Arkteos
# Usage : arkteos_proxy.py <pac_host> <pac_port> <proxy_port>
"""Proxy TCP pour PAC Arkteos REG3."""
import socket
import threading
import sys
import time
import asyncio
import logging
import signal
import sys
# Configuration depuis les arguments (injectés par run.sh depuis les options HA)
PAC_HOST = sys.argv[1] if len(sys.argv) > 1 else "192.168.X.X"
PAC_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9641
PROXY_PORT = int(sys.argv[3]) if len(sys.argv) > 3 else 9641
KEEPALIVE_INTERVAL = 300
RECONNECT_DELAY = 10
PAC_CONNECT_TIMEOUT = 10.0
PAC_READ_TIMEOUT = 600.0
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
stream=sys.stdout
format="%(asctime)s %(levelname)s %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger(__name__)
stop_event = threading.Event()
clients = []
clients_lock = threading.Lock()
def log(msg):
logger.info(msg)
class ConfigurationError(ValueError):
"""Configuration de l'add-on invalide."""
def connect_to_pac():
while not stop_event.is_set():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.connect((PAC_HOST, PAC_PORT))
log(f"Connecté à la PAC {PAC_HOST}:{PAC_PORT}")
return s
except Exception as e:
log(f"Échec connexion PAC : {e}. Nouvelle tentative dans 10s...")
time.sleep(10)
return None
def pac_reader(pac_socket):
log("Démarrage thread lecture PAC")
try:
while not stop_event.is_set():
try:
data = pac_socket.recv(4096)
except Exception as e:
log(f"Erreur lecture PAC : {e}")
break
if not data:
log("PAC a fermé la connexion")
break
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")
dead_clients = []
with clients_lock:
for c in clients:
try:
c.sendall(data)
except Exception:
dead_clients.append(c)
if dead_clients:
with clients_lock:
for c in dead_clients:
try:
c.close()
except Exception:
pass
if c in clients:
clients.remove(c)
log("Client déconnecté nettoyé")
finally:
log("Arrêt thread lecture PAC")
pac_socket.close()
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 pac_keepalive(pac_socket):
log("Démarrage thread keepalive PAC")
while not stop_event.is_set():
time.sleep(300)
try:
pac_socket.sendall(b'\x00')
log("Keepalive envoyé à la PAC")
except Exception as e:
log(f"Erreur keepalive PAC : {e}")
break
log("Arrêt thread keepalive PAC")
def handle_client(client_socket, client_addr, pac_socket):
log(f"Nouveau client : {client_addr}")
with clients_lock:
clients.append(client_socket)
try:
while not stop_event.is_set():
try:
data = client_socket.recv(1024)
except Exception:
break
if not data:
break
try:
pac_socket.sendall(data)
except Exception as e:
log(f"Erreur envoi PAC depuis {client_addr} : {e}")
break
finally:
with clients_lock:
if client_socket in clients:
clients.remove(client_socket)
try:
client_socket.close()
except Exception:
pass
log(f"Client déconnecté : {client_addr}")
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),
)
def start_proxy():
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('0.0.0.0', PROXY_PORT))
server_socket.listen(5)
log(f"Proxy en écoute sur 0.0.0.0:{PROXY_PORT}")
pac_socket = connect_to_pac()
if pac_socket is None:
log("Impossible de se connecter à la PAC. Arrêt.")
class ArkteosProxy:
def __init__(
self,
pac_host: str,
pac_port: int,
proxy_port: int,
allow_client_writes: bool = False,
) -> None:
self.pac_host = pac_host
self.pac_port = pac_port
self.proxy_port = proxy_port
self.allow_client_writes = allow_client_writes
self.clients: set[asyncio.StreamWriter] = set()
self.client_tasks: set[asyncio.Task[None]] = set()
self.pac_writer: asyncio.StreamWriter | None = None
self.pac_write_lock = asyncio.Lock()
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()
reader_thread = threading.Thread(target=pac_reader, args=(pac_socket,), daemon=True)
reader_thread.start()
async def write_to_pac(self, data: bytes) -> None:
"""Écrit un bloc complet vers la PAC sans l'altérer."""
async with self.pac_write_lock:
if self.pac_writer is None:
raise ConnectionError("PAC non connectée")
self.pac_writer.write(data)
await self.pac_writer.drain()
keepalive_thread = threading.Thread(target=pac_keepalive, args=(pac_socket,), daemon=True)
keepalive_thread.start()
async def send_keepalive(self) -> None:
await self.write_to_pac(b"\x00")
logger.info("Keepalive envoyé à la PAC")
async def pac_reader(self, reader: asyncio.StreamReader) -> None:
logger.info("Démarrage lecture PAC")
try:
while not stop_event.is_set():
server_socket.settimeout(1)
while not self.stop_event.is_set():
try:
client_socket, addr = server_socket.accept()
t = threading.Thread(target=handle_client, args=(client_socket, addr, pac_socket), daemon=True)
t.start()
except socket.timeout:
pass
# Reconnexion si la PAC s'est déconnectée
if not reader_thread.is_alive():
log("Thread lecture PAC mort, reconnexion...")
with clients_lock:
for c in clients:
try:
c.close()
except Exception:
pass
clients.clear()
pac_socket = connect_to_pac()
if pac_socket is None:
break
reader_thread = threading.Thread(target=pac_reader, args=(pac_socket,), daemon=True)
reader_thread.start()
keepalive_thread = threading.Thread(target=pac_keepalive, args=(pac_socket,), daemon=True)
keepalive_thread.start()
except Exception as e:
log(f"Erreur serveur proxy : {e}")
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:
logger.info("PAC a fermé la connexion")
return
await self.broadcast_to_clients(data)
except (ConnectionError, OSError) as error:
logger.info("Erreur lecture PAC : %s", error)
except asyncio.CancelledError:
if not self.stop_event.is_set():
raise
finally:
stop_event.set()
server_socket.close()
logger.info("Arrêt lecture PAC")
async def broadcast_to_clients(self, data: bytes) -> None:
dead_clients: list[asyncio.StreamWriter] = []
for writer in tuple(self.clients):
try:
pac_socket.close()
except Exception:
writer.write(data)
await writer.drain()
except (ConnectionError, OSError):
dead_clients.append(writer)
for writer in dead_clients:
await self.close_client(writer)
async def pac_keepalive(self) -> None:
logger.info("Démarrage keepalive PAC")
try:
while not self.stop_event.is_set():
try:
async with asyncio.timeout(KEEPALIVE_INTERVAL):
await self.stop_event.wait()
return
except TimeoutError:
await self.send_keepalive()
except (ConnectionError, OSError) as error:
logger.info("Erreur keepalive PAC : %s", error)
except asyncio.CancelledError:
if not self.stop_event.is_set():
raise
finally:
logger.info("Arrêt keepalive PAC")
async def handle_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
task = asyncio.current_task()
if task is not None:
self.client_tasks.add(task)
peername = writer.get_extra_info("peername")
logger.info("Nouveau client : %s", peername)
self.clients.add(writer)
blocked_write_logged = False
try:
while not self.stop_event.is_set():
data = await reader.read(1024)
if not data:
return
if not self.allow_client_writes:
if not blocked_write_logged:
logger.info("Tentative d’écriture client ignorée")
blocked_write_logged = True
continue
try:
await self.write_to_pac(data)
except (ConnectionError, OSError) as error:
logger.info("Erreur envoi PAC depuis %s : %s", peername, error)
return
except (ConnectionError, OSError) as error:
logger.info("Erreur client %s : %s", peername, error)
except asyncio.CancelledError:
if not self.stop_event.is_set():
raise
finally:
await self.close_client(writer)
if task is not None:
self.client_tasks.discard(task)
async def close_client(self, writer: asyncio.StreamWriter) -> None:
if writer not in self.clients:
return
self.clients.discard(writer)
writer.close()
try:
await writer.wait_closed()
except (ConnectionError, OSError):
pass
log("Proxy arrêté.")
logger.info("Client déconnecté nettoyé")
async def close_clients(self) -> None:
for writer in tuple(self.clients):
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:
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
logger.info("Connecté à la PAC %s:%s", self.pac_host, self.pac_port)
self.pac_reader_task = asyncio.create_task(self.pac_reader(reader))
self.keepalive_task = asyncio.create_task(self.pac_keepalive())
try:
try:
await self.pac_reader_task
except asyncio.CancelledError:
if not self.stop_event.is_set():
raise
finally:
if self.keepalive_task is not None:
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:
self.pac_writer = None
writer.close()
try:
await writer.wait_closed()
except (ConnectionError, OSError):
pass
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:
mode = (
"Mode bidirectionnel : écritures des clients autorisées"
if self.allow_client_writes
else "Mode lecture seule : écritures des clients bloquées"
)
logger.info(mode)
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)
try:
while not self.stop_event.is_set():
try:
await self.run_pac_connection()
except (ConnectionError, OSError) as error:
if not self.stop_event.is_set():
logger.info("Échec connexion PAC : %s", error)
if not self.stop_event.is_set():
logger.info("Nouvelle tentative de connexion PAC dans 10s...")
await self.wait_for_reconnect_delay()
finally:
self.request_stop()
if self.server is not None:
await self.server.wait_closed()
self.server = None
await self.close_clients()
await self.stop_client_tasks()
logger.info("Proxy arrêté")
async def run_proxy(proxy: ArkteosProxy) -> None:
loop = asyncio.get_running_loop()
installed_signals: list[signal.Signals] = []
for signal_name in (signal.SIGTERM, signal.SIGINT):
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)
try:
asyncio.run(run_proxy(proxy))
except KeyboardInterrupt:
logger.info("Arrêt demandé")
return 0
if __name__ == "__main__":
try:
start_proxy()
except KeyboardInterrupt:
log("Arrêt demandé")
stop_event.set()
sys.exit(0)
raise SystemExit(main())
-248
View File
@@ -1,248 +0,0 @@
[
{
"id": "arkteos_flow",
"type": "tab",
"label": "Arkteos REG3",
"disabled": false,
"info": "Intégration PAC Arkteos Zuran 4 via addon proxy.\nStreaming permanent sur <IP_HA>:9641.\nPublication MQTT avec discovery automatique HA.\nTrames 163 bytes (frigo), 227 bytes (régulation).\nRemplacer <IP_HA> par l'IP de Home Assistant dans le nœud PAC via proxy."
},
{
"id": "arkteos_mqtt_broker",
"type": "mqtt-broker",
"name": "Mosquitto local",
"broker": "core-mosquitto",
"port": "1883",
"clientid": "nodered_arkteos",
"usetls": false,
"compatmode": false,
"keepalive": "60",
"cleansession": true,
"birthTopic": "",
"birthQos": "0",
"birthPayload": "",
"closeTopic": "",
"closeQos": "0",
"closePayload": "",
"willTopic": "",
"willQos": "0",
"willPayload": "",
"credentials": {
"user": "<MQTT_USER>",
"password": "<MQTT_PASSWORD>"
}
},
{
"id": "arkteos_tcp_in",
"type": "tcp in",
"z": "arkteos_flow",
"name": "PAC via proxy",
"server": "<IP_HA>",
"port": "9641",
"datamode": "stream",
"datatype": "buffer",
"newline": "",
"topic": "",
"trim": false,
"base64": false,
"tls": "",
"x": 160,
"y": 100,
"wires": [["arkteos_parse_frame"]]
},
{
"id": "arkteos_parse_frame",
"type": "function",
"z": "arkteos_flow",
"name": "Parse frame",
"func": "const buf = msg.payload;\nif (!Buffer.isBuffer(buf) || buf.length === 0) return null;\n\nconst size = buf.length;\n\nfunction read16s(b, i) {\n const v = b[i] + b[i + 1] * 256;\n return v >= 32768 ? v - 65536 : v;\n}\nfunction read16u(b, i) {\n return b[i] + b[i + 1] * 256;\n}\nfunction read8(b, i) {\n return b[i];\n}\n\nif (size === 163) {\n msg.payload = {\n frame_type: 'frigo',\n exterieur_temp: read16s(buf, 24) / 10,\n freq_comp_actuelle: read16u(buf, 52),\n dc_voltage: read16u(buf, 62)\n };\n return msg;\n}\n\nif (size === 227) {\n msg.payload = {\n frame_type: 'regulation',\n puissance_inst_produite: read16u(buf, 16) / 10,\n puissance_inst_consommee: read16u(buf, 18) / 10,\n primaire_temp_eau_aller: read16s(buf, 54) / 10,\n primaire_temp_eau_retour: read16s(buf, 56) / 10,\n primaire_pression: read8(buf, 62) / 10,\n zone1_temp_interieur: read16s(buf, 68) / 10,\n zone1_consigne: read16s(buf, 70) / 10,\n ecs_temp_eau_milieu: read16s(buf, 108) / 10,\n ecs_temp_eau_bas: read16s(buf, 110) / 10\n };\n return msg;\n}\n\nreturn null;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 100,
"wires": [["arkteos_route"]]
},
{
"id": "arkteos_route",
"type": "switch",
"z": "arkteos_flow",
"name": "Route par type",
"property": "payload.frame_type",
"propertyType": "msg",
"rules": [
{ "t": "eq", "v": "frigo", "vt": "str" },
{ "t": "eq", "v": "regulation", "vt": "str" }
],
"checkall": "false",
"repair": false,
"outputs": 2,
"x": 580,
"y": 100,
"wires": [
["arkteos_debug_frigo_parsed", "arkteos_publish_frigo"],
["arkteos_debug_regulation_parsed", "arkteos_publish_regulation"]
]
},
{
"id": "arkteos_debug_frigo_parsed",
"type": "debug",
"z": "arkteos_flow",
"name": "Frigo parsé",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 810,
"y": 40,
"wires": []
},
{
"id": "arkteos_debug_regulation_parsed",
"type": "debug",
"z": "arkteos_flow",
"name": "Regulation parsée",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 830,
"y": 160,
"wires": []
},
{
"id": "arkteos_publish_frigo",
"type": "function",
"z": "arkteos_flow",
"name": "Publish frigo",
"func": "const data = msg.payload;\nconst msgs = [];\n\nconst sensors = [\n { topic: 'arkteos/frigo/exterieur_temp', value: data.exterieur_temp },\n { topic: 'arkteos/frigo/freq_comp_actuelle', value: data.freq_comp_actuelle },\n { topic: 'arkteos/frigo/dc_voltage', value: data.dc_voltage }\n];\n\nfor (const s of sensors) {\n msgs.push({ topic: s.topic, payload: String(s.value), qos: 0, retain: true });\n}\n\nreturn [msgs];\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 800,
"y": 80,
"wires": [["arkteos_debug_frigo_published", "arkteos_mqtt_out"]]
},
{
"id": "arkteos_publish_regulation",
"type": "function",
"z": "arkteos_flow",
"name": "Publish regulation",
"func": "const data = msg.payload;\nconst msgs = [];\n\nconst sensors = [\n { topic: 'arkteos/regulation/puissance_inst_produite', value: data.puissance_inst_produite },\n { topic: 'arkteos/regulation/puissance_inst_consommee', value: data.puissance_inst_consommee },\n { topic: 'arkteos/regulation/primaire_temp_eau_aller', value: data.primaire_temp_eau_aller },\n { topic: 'arkteos/regulation/primaire_temp_eau_retour', value: data.primaire_temp_eau_retour },\n { topic: 'arkteos/regulation/primaire_pression', value: data.primaire_pression },\n { topic: 'arkteos/regulation/zone1_temp_interieur', value: data.zone1_temp_interieur },\n { topic: 'arkteos/regulation/zone1_consigne', value: data.zone1_consigne },\n { topic: 'arkteos/regulation/ecs_temp_eau_milieu', value: data.ecs_temp_eau_milieu },\n { topic: 'arkteos/regulation/ecs_temp_eau_bas', value: data.ecs_temp_eau_bas }\n];\n\nfor (const s of sensors) {\n msgs.push({ topic: s.topic, payload: String(s.value), qos: 0, retain: true });\n}\n\nreturn [msgs];\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 800,
"y": 200,
"wires": [["arkteos_debug_regulation_published", "arkteos_mqtt_out"]]
},
{
"id": "arkteos_debug_frigo_published",
"type": "debug",
"z": "arkteos_flow",
"name": "Frigo MQTT publié",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 1040,
"y": 40,
"wires": []
},
{
"id": "arkteos_debug_regulation_published",
"type": "debug",
"z": "arkteos_flow",
"name": "Regulation MQTT publiée",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 1060,
"y": 240,
"wires": []
},
{
"id": "arkteos_mqtt_out",
"type": "mqtt out",
"z": "arkteos_flow",
"name": "MQTT publish",
"topic": "",
"qos": "0",
"retain": "true",
"broker": "arkteos_mqtt_broker",
"x": 1060,
"y": 140,
"wires": []
},
{
"id": "arkteos_discovery_inject",
"type": "inject",
"z": "arkteos_flow",
"name": "Send discovery (1x au démarrage)",
"props": [{ "p": "payload" }],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": 3,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 200,
"y": 360,
"wires": [["arkteos_discovery"]]
},
{
"id": "arkteos_discovery",
"type": "function",
"z": "arkteos_flow",
"name": "MQTT Discovery",
"func": "const device = {\n identifiers: ['arkteos_zuran4'],\n name: 'PAC Arkteos Zuran 4',\n model: 'Zuran 4',\n manufacturer: 'Arkteos'\n};\n\nconst sensors = [\n {\n unique_id: 'arkteos_exterieur_temp',\n name: 'Température extérieure',\n state_topic: 'arkteos/frigo/exterieur_temp',\n unit_of_measurement: '°C',\n device_class: 'temperature',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_freq_comp_actuelle',\n name: 'Fréquence compresseur actuelle',\n state_topic: 'arkteos/frigo/freq_comp_actuelle',\n unit_of_measurement: 'Hz',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_dc_voltage',\n name: 'Voltage DC compresseur',\n state_topic: 'arkteos/frigo/dc_voltage',\n unit_of_measurement: 'V',\n device_class: 'voltage',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_puissance_inst_produite',\n name: 'Puissance instantanée produite',\n state_topic: 'arkteos/regulation/puissance_inst_produite',\n unit_of_measurement: 'kW',\n device_class: 'power',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_puissance_inst_consommee',\n name: 'Puissance instantanée consommée',\n state_topic: 'arkteos/regulation/puissance_inst_consommee',\n unit_of_measurement: 'kW',\n device_class: 'power',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_primaire_temp_eau_aller',\n name: 'Température eau primaire aller',\n state_topic: 'arkteos/regulation/primaire_temp_eau_aller',\n unit_of_measurement: '°C',\n device_class: 'temperature',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_primaire_temp_eau_retour',\n name: 'Température eau primaire retour',\n state_topic: 'arkteos/regulation/primaire_temp_eau_retour',\n unit_of_measurement: '°C',\n device_class: 'temperature',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_primaire_pression',\n name: 'Pression eau primaire',\n state_topic: 'arkteos/regulation/primaire_pression',\n unit_of_measurement: 'bar',\n device_class: 'pressure',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_zone1_temp_interieur',\n name: 'Température intérieure zone 1',\n state_topic: 'arkteos/regulation/zone1_temp_interieur',\n unit_of_measurement: '°C',\n device_class: 'temperature',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_zone1_consigne',\n name: 'Consigne température zone 1',\n state_topic: 'arkteos/regulation/zone1_consigne',\n unit_of_measurement: '°C',\n device_class: 'temperature',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_ecs_temp_eau_milieu',\n name: 'Température ballon ECS milieu',\n state_topic: 'arkteos/regulation/ecs_temp_eau_milieu',\n unit_of_measurement: '°C',\n device_class: 'temperature',\n state_class: 'measurement'\n },\n {\n unique_id: 'arkteos_ecs_temp_eau_bas',\n name: 'Température ballon ECS bas',\n state_topic: 'arkteos/regulation/ecs_temp_eau_bas',\n unit_of_measurement: '°C',\n device_class: 'temperature',\n state_class: 'measurement'\n }\n];\n\nconst msgs = [];\nfor (const s of sensors) {\n const config = Object.assign({}, s, { device });\n msgs.push({\n topic: `homeassistant/sensor/${s.unique_id}/config`,\n payload: JSON.stringify(config),\n qos: 0,\n retain: true\n });\n}\n\nreturn [msgs];\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 480,
"y": 360,
"wires": [["arkteos_debug_discovery", "arkteos_mqtt_out"]]
},
{
"id": "arkteos_debug_discovery",
"type": "debug",
"z": "arkteos_flow",
"name": "Discovery MQTT",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 710,
"y": 420,
"wires": []
}
]
+5 -3
View File
@@ -1,8 +1,8 @@
name: Arkteos Proxy
version: "1.0.0"
version: "1.0.6"
slug: arkteos_proxy
description: Proxy TCP pour PAC Arkteos REG3 — permet les connexions simultanées de Node-RED et de l'app mobile Arkteos.
url: https://github.com/raph666/arkteos-proxy-addon
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://github.com/raph666/arkteos-proxy-addon"
arch:
- aarch64
- amd64
@@ -14,10 +14,12 @@ options:
pac_host: "192.168.X.X"
pac_port: 9641
proxy_port: 9641
allow_client_writes: false
schema:
pac_host: str
pac_port: int
proxy_port: int
allow_client_writes: bool
ports:
9641/tcp: 9641
ports_description:
+1 -1
View File
@@ -1,3 +1,3 @@
name: Arkteos Proxy
url: https://github.com/raph666/arkteos-proxy-addon
url: "https://github.com/raph666/arkteos-proxy-addon"
maintainer: raph666
Regular → Executable
+2 -1
View File
@@ -3,9 +3,10 @@
PAC_HOST=$(bashio::config 'pac_host')
PAC_PORT=$(bashio::config 'pac_port')
PROXY_PORT=$(bashio::config 'proxy_port')
ALLOW_CLIENT_WRITES=$(bashio::config 'allow_client_writes')
bashio::log.info "Démarrage du proxy Arkteos"
bashio::log.info "PAC : ${PAC_HOST}:${PAC_PORT}"
bashio::log.info "Proxy en écoute sur port : ${PROXY_PORT}"
exec python3 /arkteos_proxy.py "${PAC_HOST}" "${PAC_PORT}" "${PROXY_PORT}"
exec python3 /arkteos_proxy.py "${PAC_HOST}" "${PAC_PORT}" "${PROXY_PORT}" "${ALLOW_CLIENT_WRITES:-false}"
View File
+289
View File
@@ -0,0 +1,289 @@
import asyncio
import unittest
from unittest.mock import AsyncMock, patch
from arkteos_proxy import (
ArkteosProxy,
ConfigurationError,
KEEPALIVE_INTERVAL,
RECONNECT_DELAY,
validate_configuration,
)
class QueueReader:
def __init__(self):
self.items = asyncio.Queue()
async def read(self, _size):
return await self.items.get()
class FakeWriter:
def __init__(self):
self.writes = []
self.drain_calls = 0
self.closed = False
def write(self, data):
self.writes.append(data)
async def drain(self):
self.drain_calls += 1
def close(self):
self.closed = True
async def wait_closed(self):
return None
def get_extra_info(self, _name):
return ("127.0.0.1", 12345)
class SerialWriter(FakeWriter):
def __init__(self):
super().__init__()
self.active_drains = 0
self.maximum_active_drains = 0
async def drain(self):
self.drain_calls += 1
self.active_drains += 1
self.maximum_active_drains = max(self.maximum_active_drains, self.active_drains)
await asyncio.sleep(0)
self.active_drains -= 1
class FakeServer:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
async def wait_closed(self):
return None
class ArkteosProxyTests(unittest.IsolatedAsyncioTestCase):
async def test_default_mode_blocks_client_writes_and_keeps_client_connected(self):
proxy = ArkteosProxy("pac", 9641, 9641)
pac_writer = FakeWriter()
proxy.pac_writer = pac_writer
reader = QueueReader()
client_writer = FakeWriter()
task = asyncio.create_task(proxy.handle_client(reader, client_writer))
await reader.items.put(b"client-data")
await asyncio.sleep(0)
self.assertEqual(pac_writer.writes, [])
self.assertIn(client_writer, proxy.clients)
await proxy.broadcast_to_clients(b"pac-data")
self.assertEqual(client_writer.writes, [b"pac-data"])
await reader.items.put(b"")
await task
async def test_bidirectional_mode_relays_data_and_drains_writer(self):
proxy = ArkteosProxy("pac", 9641, 9641, allow_client_writes=True)
pac_writer = FakeWriter()
proxy.pac_writer = pac_writer
reader = QueueReader()
client_writer = FakeWriter()
task = asyncio.create_task(proxy.handle_client(reader, client_writer))
await reader.items.put(b"client-data")
await reader.items.put(b"")
await task
self.assertEqual(pac_writer.writes, [b"client-data"])
self.assertEqual(pac_writer.drain_calls, 1)
async def test_two_clients_writes_are_serialized(self):
proxy = ArkteosProxy("pac", 9641, 9641, allow_client_writes=True)
pac_writer = SerialWriter()
proxy.pac_writer = pac_writer
first_reader = QueueReader()
second_reader = QueueReader()
first_task = asyncio.create_task(proxy.handle_client(first_reader, FakeWriter()))
second_task = asyncio.create_task(proxy.handle_client(second_reader, FakeWriter()))
await first_reader.items.put(b"first")
await second_reader.items.put(b"second")
await first_reader.items.put(b"")
await second_reader.items.put(b"")
await asyncio.gather(first_task, second_task)
self.assertCountEqual(pac_writer.writes, [b"first", b"second"])
self.assertEqual(pac_writer.maximum_active_drains, 1)
async def test_keepalive_uses_serialized_write_and_keeps_protocol_values(self):
proxy = ArkteosProxy("pac", 9641, 9641)
pac_writer = SerialWriter()
proxy.pac_writer = pac_writer
await proxy.send_keepalive()
self.assertEqual(KEEPALIVE_INTERVAL, 300)
self.assertEqual(pac_writer.writes, [b"\x00"])
self.assertEqual(pac_writer.drain_calls, 1)
self.assertEqual(pac_writer.maximum_active_drains, 1)
async def test_pac_disconnect_closes_clients(self):
proxy = ArkteosProxy("pac", 9641, 9641)
first_client = FakeWriter()
second_client = FakeWriter()
proxy.clients.update({first_client, second_client})
await proxy.close_clients()
self.assertTrue(first_client.closed)
self.assertTrue(second_client.closed)
self.assertEqual(proxy.clients, set())
async def test_reconnection_loop_is_preserved(self):
proxy = ArkteosProxy("pac", 9641, 0)
attempts = 0
async def fake_pac_connection():
nonlocal attempts
attempts += 1
if attempts == 1:
raise ConnectionError("PAC fermée")
proxy.stop_event.set()
proxy.run_pac_connection = fake_pac_connection
proxy.wait_for_reconnect_delay = AsyncMock()
with (
patch("arkteos_proxy.asyncio.start_server", new_callable=AsyncMock, return_value=FakeServer()),
):
await proxy.serve()
self.assertEqual(attempts, 2)
proxy.wait_for_reconnect_delay.assert_awaited_once_with()
def test_missing_option_defaults_to_read_only(self):
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__":
unittest.main()