Compare commits
20
Commits
2129962e0f
..
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
153b0abdd0 | ||
|
|
2a4161f24a | ||
|
|
bc3ea8562a | ||
|
|
90659cb24b | ||
|
|
2948f2f0c0 | ||
|
|
a738c02a54 | ||
|
|
e7d616c888 | ||
|
|
5110173467 | ||
|
|
2da9b89f13 | ||
|
|
02049b0f3f | ||
|
|
587cebf5f6 | ||
|
|
2fb19fb221 | ||
|
|
99db64cdad | ||
|
|
a60ad6e3ef | ||
|
|
14be7e4a0e | ||
|
|
ce9c6a6d2b | ||
|
|
16fbce3f4d | ||
|
|
2302a5d337 | ||
|
|
7e276fabdd | ||
|
|
6494f6cf03 |
+23
@@ -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
|
||||
@@ -0,0 +1,306 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Objet du projet
|
||||
|
||||
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
|
||||
mettre ce flux à disposition de plusieurs clients TCP.
|
||||
|
||||
Gitea est le dépôt principal :
|
||||
|
||||
- https://gitea.i-host.fr/raph666/arkteos-proxy-addon
|
||||
|
||||
GitHub est uniquement un miroir secondaire :
|
||||
|
||||
- https://github.com/raph666/arkteos-proxy-addon
|
||||
|
||||
## 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 qu’une 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 l’ordre des octets transmis sans preuve issue :
|
||||
|
||||
- d’une capture réelle ;
|
||||
- du comportement actuel ;
|
||||
- ou d’une documentation fiable.
|
||||
|
||||
### Keepalive
|
||||
|
||||
Le proxy envoie actuellement un octet nul toutes les 300 secondes.
|
||||
|
||||
Le rôle exact de ce keepalive côté PAC n’est 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 n’existe 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 d’un 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 d’entré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 l’historique 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 d’un client ;
|
||||
- déconnexion d’un 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
|
||||
```
|
||||
|
||||
S’il existe des tests :
|
||||
|
||||
```bash
|
||||
python3 -m pytest -v
|
||||
```
|
||||
|
||||
Toujours exécuter :
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
## Versionnement
|
||||
|
||||
La version de l’add-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 l’absence 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 d’une tâche, indiquer :
|
||||
|
||||
- fichiers modifiés ;
|
||||
- fichiers ajoutés ;
|
||||
- comportement modifié ;
|
||||
- validations exécutées ;
|
||||
- résultats des tests ;
|
||||
- avertissements restants ;
|
||||
- confirmation de l’absence de commit, push ou tag si cela n’était pas demandé.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
Toutes les modifications importantes de ce projet sont documentées ici.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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 l’entrée manquante pour la correction du Dockerfile publiée en 1.0.1.
|
||||
- Documentation de l’utilisation de l’image 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 l’image multiarchitecture `ghcr.io/home-assistant/base:latest`.
|
||||
|
||||
## [1.0.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Add-on Home Assistant pour le proxy TCP Arkteos.
|
||||
- Maintien d’une connexion unique vers la PAC.
|
||||
- Multiplexage du flux vers plusieurs clients TCP.
|
||||
- Reconnexion automatique à la PAC après une coupure.
|
||||
- Keepalive composé d’un octet nul toutes les 300 secondes ; son rôle exact côté PAC n’est 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 d’un flow Solarwatt ajouté par erreur.
|
||||
- Nettoyage de l’historique Git contenant ce fichier étranger au projet.
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
ARG BUILD_FROM
|
||||
FROM $BUILD_FROM
|
||||
FROM ghcr.io/home-assistant/base:latest
|
||||
|
||||
RUN apk add --no-cache python3
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -2,17 +2,22 @@
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
1. Dans Home Assistant : **Paramètres → Modules complémentaires → trois points → Dépôts**
|
||||
2. Ajoute l'URL de ce repo : `https://github.com/raph666/arkteos-proxy-addon`
|
||||
2. Ajoute l'URL de ce repo : `https://gitea.i-host.fr/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) et clique **Save**
|
||||
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
|
||||
|
||||
| Option | Description | Défaut |
|
||||
@@ -20,6 +25,25 @@ 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` |
|
||||
|
||||
## Fonctionnement du proxy
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Sécurité réseau
|
||||
|
||||
Ne pas exposer le port du proxy sur Internet. Limite l’accè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 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
|
||||
|
||||
|
||||
+172
-159
@@ -1,180 +1,193 @@
|
||||
#!/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 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
|
||||
|
||||
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)
|
||||
def parse_allow_client_writes(value: str | None) -> bool:
|
||||
"""Retourne False tant que l'option n'est pas explicitement vraie."""
|
||||
return value is not None and value.strip().lower() == "true"
|
||||
|
||||
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")
|
||||
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.pac_writer: asyncio.StreamWriter | None = None
|
||||
self.pac_write_lock = asyncio.Lock()
|
||||
self.stop_event = asyncio.Event()
|
||||
|
||||
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()
|
||||
|
||||
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():
|
||||
try:
|
||||
data = pac_socket.recv(4096)
|
||||
except Exception as e:
|
||||
log(f"Erreur lecture PAC : {e}")
|
||||
break
|
||||
while not self.stop_event.is_set():
|
||||
data = await reader.read(4096)
|
||||
if not data:
|
||||
log("PAC a fermé la connexion")
|
||||
break
|
||||
|
||||
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 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 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.")
|
||||
logger.info("PAC a fermé la connexion")
|
||||
return
|
||||
|
||||
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()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
server_socket.settimeout(1)
|
||||
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}")
|
||||
await self.broadcast_to_clients(data)
|
||||
except (ConnectionError, OSError) as error:
|
||||
logger.info("Erreur lecture PAC : %s", error)
|
||||
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():
|
||||
await asyncio.sleep(KEEPALIVE_INTERVAL)
|
||||
await self.send_keepalive()
|
||||
except (ConnectionError, OSError) as error:
|
||||
logger.info("Erreur keepalive PAC : %s", error)
|
||||
finally:
|
||||
logger.info("Arrêt keepalive PAC")
|
||||
|
||||
async def handle_client(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
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)
|
||||
finally:
|
||||
await self.close_client(writer)
|
||||
|
||||
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 run_pac_connection(self) -> None:
|
||||
reader, writer = await asyncio.open_connection(self.pac_host, self.pac_port)
|
||||
self.pac_writer = writer
|
||||
logger.info("Connecté à la PAC %s:%s", self.pac_host, self.pac_port)
|
||||
reader_task = asyncio.create_task(self.pac_reader(reader))
|
||||
keepalive_task = asyncio.create_task(self.pac_keepalive())
|
||||
try:
|
||||
await reader_task
|
||||
finally:
|
||||
keepalive_task.cancel()
|
||||
await asyncio.gather(keepalive_task, return_exceptions=True)
|
||||
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()
|
||||
|
||||
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)
|
||||
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:
|
||||
logger.info("Échec connexion PAC : %s", error)
|
||||
if not self.stop_event.is_set():
|
||||
logger.info("Nouvelle tentative de connexion PAC dans 10s...")
|
||||
await asyncio.sleep(RECONNECT_DELAY)
|
||||
finally:
|
||||
self.stop_event.set()
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
await self.close_clients()
|
||||
logger.info("Proxy arrêté")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
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
|
||||
allow_client_writes = parse_allow_client_writes(sys.argv[4] if len(sys.argv) > 4 else None)
|
||||
proxy = ArkteosProxy(pac_host, pac_port, proxy_port, allow_client_writes)
|
||||
try:
|
||||
asyncio.run(proxy.serve())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Arrêt demandé")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
start_proxy()
|
||||
except KeyboardInterrupt:
|
||||
log("Arrêt demandé")
|
||||
stop_event.set()
|
||||
sys.exit(0)
|
||||
main()
|
||||
|
||||
+4
-2
@@ -1,8 +1,8 @@
|
||||
name: Arkteos Proxy
|
||||
version: "1.0.0"
|
||||
version: "1.0.3"
|
||||
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
|
||||
url: https://gitea.i-host.fr/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
@@ -1,3 +1,3 @@
|
||||
name: Arkteos Proxy
|
||||
url: https://github.com/raph666/arkteos-proxy-addon
|
||||
url: https://gitea.i-host.fr/raph666/arkteos-proxy-addon
|
||||
maintainer: raph666
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from arkteos_proxy import ArkteosProxy, KEEPALIVE_INTERVAL, RECONNECT_DELAY
|
||||
|
||||
|
||||
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
|
||||
with (
|
||||
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()
|
||||
|
||||
self.assertEqual(attempts, 2)
|
||||
sleep.assert_awaited_once_with(RECONNECT_DELAY)
|
||||
|
||||
def test_missing_option_defaults_to_read_only(self):
|
||||
self.assertFalse(ArkteosProxy("pac", 9641, 9641).allow_client_writes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user