Hello, Here you find my script
import socket
import time
import json
import threading
from datetime import datetime
import paho.mqtt.client as mqtt
SPA_IP = "192.168.1.11"
SPA_PORT = 8899
MQTT_HOST = "192.168.1.XX"
MQTT_PORT = 1883
MQTT_USER = "Usernam"
MQTT_PASSWORD = "Password"
DEVICE_ID = "spa_joyonway"
DEVICE = {
"identifiers": [DEVICE_ID],
"name": "Spa Joyonway",
"manufacturer": "Joyonway",
"model": "SPA Controller",
"sw_version": "2.4"
}
LIGHT_MAP = {
0x00: "OFF",
0x01: "Multi",
0x02: "Rouge",
0x03: "Vert",
0x04: "Jaune",
0x05: "Bleu",
0x06: "Marron",
0x07: "Bleu Clair",
0x08: "Blanc",
}
COLOR_MAP = {
0x00: "#000000",
0x01: "#FFFFFF",
0x02: "#FF0000",
0x03: "#00FF00",
0x04: "#FFFF00",
0x05: "#0000FF",
0x06: "#8B4513",
0x07: "#87CEEB",
0x08: "#FFFFFF",
}
COMMANDS = {
"LIGHT_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 40 40 02 04 00 00 81 ed ba a0 1b 14 1d"),
"LIGHT_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 40 40 02 04 00 00 80 5a 20 cd c1 1d"),
"LEFT_PUMP_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 06 04 00 00 02 04 00 00 00 8b 3e e4 13 1d"),
"LEFT_PUMP_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 06 00 00 00 02 04 00 00 00 08 bd 10 33 1d"),
"RIGHT_PUMP_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 18 10 00 00 02 04 00 00 00 40 d1 2d e0 1d"),
"RIGHT_PUMP_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 18 00 00 00 02 04 00 00 00 4c df ff 63 1d"),
"BLOWER_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 04 04 02 04 00 00 00 0f 7f 1b 11 76 1d"),
"BLOWER_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 04 00 02 04 00 00 00 fc c2 86 4f 1d"),
"FILTRATION_ON": bytes.fromhex("1a 01 30 10 3c a1 00 a1 80 80 00 00 02 04 00 00 00 79 54 80 d8 1d"),
"FILTRATION_OFF": bytes.fromhex("1a 01 30 10 3c a1 00 a1 80 00 00 00 02 04 00 00 00 c5 52 14 d5 1d"),
"TEMP_10": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 32 00 34 22 13 8e 1d"),
"TEMP_38": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 64 00 96 20 61 e1 1d"),
"COLOR_WHITE": bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 40 40 02 04 00 00 87 31 de 4f dd 1d"),
}
SETPOINT_FRAMES_C = {
10: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 32 00 34 22 13 8e 1d"),
11: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 34 00 26 c9 c0 8a 1d"),
12: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 36 00 28 90 71 89 1d"),
13: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 37 00 af 3c a9 88 1d"),
14: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 39 00 85 b3 bf 82 1d"),
15: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 3b 00 8b ea 0e 81 1d"),
16: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 3d 00 99 01 dd 85 1d"),
17: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 3f 00 97 58 6c 86 1d"),
18: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 40 00 6a 02 16 dd 1d"),
19: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 42 00 64 5b a7 de 1d"),
20: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 44 00 76 b0 74 da 1d"),
21: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 46 00 78 e9 c5 d9 1d"),
22: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 48 00 52 66 d3 d3 1d"),
23: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 49 00 d5 ca 0b d2 1d"),
24: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 4b 00 db 93 ba d1 1d"),
25: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 4d 00 c9 78 69 d5 1d"),
26: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 4f 00 c7 21 d8 d6 1d"),
27: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 51 00 9d 66 44 c1 1d"),
28: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 52 00 14 93 2d c3 1d"),
29: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 54 00 06 78 fe c7 1d"),
30: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 56 00 08 21 4f c4 1d"),
31: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 58 00 22 ae 59 ce 1d"),
32: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 5a 00 2c f7 e8 cd 1d"),
33: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 5b 00 ab 5b 30 cc 1d"),
34: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 5d 00 b9 b0 e3 c8 1d"),
35: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 5f 00 b7 e9 52 cb 1d"),
36: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 61 00 0d 3e db e7 1d"),
37: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 63 00 03 67 6a e4 1d"),
38: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 64 00 96 20 61 e1 1d"),
39: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 66 00 98 79 d0 e2 1d"),
40: bytes.fromhex("1a 01 30 10 3c a1 00 a1 00 00 80 80 02 04 00 68 00 b2 f6 c6 e8 1d"),
}
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
spa_socket = None
socket_lock = threading.Lock()
last_values = {}
def send(topic, value, force=False):
value = str(value)
if force or last_values.get(topic) != value:
last_values[topic] = value
client.publish(topic, value, retain=True)
def send_spa_command(name):
cmd = COMMANDS.get(name)
if not cmd:
return
def worker():
for _ in range(10):
try:
with socket_lock:
if spa_socket:
spa_socket.sendall(cmd)
except Exception as e:
print("Erreur commande :", e)
time.sleep(0.5)
threading.Thread(target=worker, daemon=True).start()
def send_setpoint_thermostat(temp_c):
try:
temp_c = int(round(float(temp_c)))
except:
return
frame = SETPOINT_FRAMES_C.get(temp_c)
if frame is None:
print("Consigne invalide :", temp_c)
return
def worker():
for _ in range(10):
try:
with socket_lock:
if spa_socket:
spa_socket.sendall(frame)
except Exception as e:
print("Erreur thermostat :", e)
time.sleep(0.5)
threading.Thread(target=worker, daemon=True).start()
# Retour MQTT immédiat pour Jeedom + Home Assistant
send("spa/setpoint", temp_c, force=True)
send("spa/thermostat", temp_c, force=True)
print(f"Thermostat -> {temp_c}°C")
def send_spa_command_verified(command_name, topic_check, expected_value):
send_spa_command(command_name)
def verifier():
for attempt in range(2):
time.sleep(9)
current = last_values.get(topic_check)
if str(current) == str(expected_value):
print(f"[OK] {command_name} validée")
return
if attempt == 0:
print(f"[RETRY] {command_name} non validée -> renvoi")
send_spa_command(command_name)
print(f"[ERREUR] {command_name} non validée")
threading.Thread(target=verifier, daemon=True).start()
def ha_discovery():
def sw(name, topic):
client.publish(
f"homeassistant/switch/{DEVICE_ID}_{topic}/config",
json.dumps({
"name": name,
"unique_id": f"{DEVICE_ID}_{topic}",
"state_topic": f"spa/{topic}",
"command_topic": f"spa/cmd/{topic}",
"payload_on": "1",
"payload_off": "0",
"device": DEVICE
}),
retain=True
)
def sensor(name, topic):
client.publish(
f"homeassistant/sensor/{DEVICE_ID}_{topic}/config",
json.dumps({
"name": name,
"unique_id": f"{DEVICE_ID}_{topic}",
"state_topic": f"spa/{topic}",
"device": DEVICE
}),
retain=True
)
def binary_sensor(name, topic):
client.publish(
f"homeassistant/binary_sensor/{DEVICE_ID}_{topic}/config",
json.dumps({
"name": name,
"unique_id": f"{DEVICE_ID}_{topic}",
"state_topic": f"spa/{topic}",
"payload_on": "1",
"payload_off": "0",
"device": DEVICE
}),
retain=True
)
def button(name, topic):
client.publish(
f"homeassistant/button/{DEVICE_ID}_{topic}/config",
json.dumps({
"name": name,
"unique_id": f"{DEVICE_ID}_{topic}",
"command_topic": f"spa/cmd/{topic}",
"device": DEVICE
}),
retain=True
)
def number(name, topic):
client.publish(
f"homeassistant/number/{DEVICE_ID}_{topic}/config",
json.dumps({
"name": name,
"unique_id": f"{DEVICE_ID}_{topic}",
"state_topic": "spa/setpoint",
"command_topic": f"spa/cmd/{topic}",
"min": 10,
"max": 40,
"step": 1,
"unit_of_measurement": "°C",
"mode": "slider",
"device": DEVICE
}),
retain=True
)
sw("Spa Lumière", "etat_lumiere")
sw("Spa Pompe Gauche", "pompe_gauche")
sw("Spa Pompe Droite", "pompe_droite")
sw("Spa Blower", "blower")
sw("Spa Filtration", "filtration")
sensor("Spa Eau", "water_temp")
sensor("Spa Consigne", "setpoint")
sensor("Spa Thermostat MQTT", "thermostat")
sensor("Spa Couleur", "couleur")
sensor("Spa Couleur HEX", "couleur_hex")
sensor("Spa Heure", "heure")
binary_sensor("Spa Chauffe", "heater")
binary_sensor("Spa Ozonateur", "ozonateur")
button("Température 10°C", "temp_10")
button("Température 38°C", "temp_38")
button("Couleur Blanche", "color_white")
number("Spa Thermostat", "thermostat")
def on_connect(client, userdata, flags, reason_code, properties):
print("MQTT connecté")
client.subscribe("spa/cmd/#")
ha_discovery()
def on_message(client, userdata, msg):
topic = msg.topic
payload = msg.payload.decode().strip().upper()
if topic == "spa/cmd/etat_lumiere":
if payload in ["1", "ON"]:
send_spa_command_verified("LIGHT_ON", "spa/etat_lumiere", "1")
else:
send_spa_command_verified("LIGHT_OFF", "spa/etat_lumiere", "0")
elif topic == "spa/cmd/pompe_gauche":
if payload in ["1", "ON"]:
send_spa_command_verified("LEFT_PUMP_ON", "spa/pompe_gauche", "1")
else:
send_spa_command_verified("LEFT_PUMP_OFF", "spa/pompe_gauche", "0")
elif topic == "spa/cmd/pompe_droite":
if payload in ["1", "ON"]:
send_spa_command_verified("RIGHT_PUMP_ON", "spa/pompe_droite", "1")
else:
send_spa_command_verified("RIGHT_PUMP_OFF", "spa/pompe_droite", "0")
elif topic == "spa/cmd/blower":
if payload in ["1", "ON"]:
send_spa_command_verified("BLOWER_ON", "spa/blower", "1")
else:
send_spa_command_verified("BLOWER_OFF", "spa/blower", "0")
elif topic == "spa/cmd/filtration":
if payload in ["1", "ON"]:
send_spa_command_verified("FILTRATION_ON", "spa/filtration", "1")
else:
send_spa_command_verified("FILTRATION_OFF", "spa/filtration", "0")
elif topic == "spa/cmd/temp_10":
send_spa_command_verified("TEMP_10", "spa/setpoint", "10")
elif topic == "spa/cmd/temp_38":
send_spa_command_verified("TEMP_38", "spa/setpoint", "38")
elif topic == "spa/cmd/color_white":
send_spa_command_verified("COLOR_WHITE", "spa/couleur", "Blanc")
elif topic in ["spa/cmd/thermostat", "spa/cmd/consigne"]:
# Compatible slider Jeedom : payload attendu = 10 à 40
send_setpoint_thermostat(payload)
client.on_connect = on_connect
client.on_message = on_message
client.connect(MQTT_HOST, MQTT_PORT, 60)
client.loop_start()
while True:
try:
print("Connexion spa...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(30)
s.connect((SPA_IP, SPA_PORT))
with socket_lock:
spa_socket = s
buffer = b""
print("Spa connecté")
while True:
data = s.recv(4096)
if not data:
raise Exception("disconnect")
buffer += data
buffer = buffer[-4096:]
idx = buffer.find(b'\x1a\xff')
if idx == -1:
continue
frame = buffer[idx:]
if len(frame) < 24:
continue
# TEMP ENTIÈRES
water_c = int(round((frame[9] - 32) * 5 / 9))
setpoint_c = int(round((frame[16] - 32) * 5 / 9))
send("spa/water_temp", water_c)
send("spa/setpoint", setpoint_c)
send("spa/thermostat", setpoint_c)
send("spa/pompe_gauche", "1" if frame[12] & 0x04 else "0")
send("spa/pompe_droite", "1" if frame[12] & 0x10 else "0")
send("spa/blower", "1" if frame[14] & 0x08 else "0")
send("spa/filtration", "1" if frame[17] & 0x80 else "0")
send("spa/ozonateur", "1" if frame[14] & 0x01 else "0")
send("spa/heater", "1" if frame[14] & 0x04 else "0")
light_code = frame[17] & 0x0F
send("spa/etat_lumiere", "1" if light_code != 0 else "0")
send("spa/couleur", LIGHT_MAP.get(light_code, "MODE"))
send("spa/couleur_hex", COLOR_MAP.get(light_code, "#000000"))
send("spa/heure", datetime.now().strftime("%H:%M:%S"), force=True)
except Exception as e:
print("TCP reconnect:", e)
time.sleep(5)
finally:
try:
with socket_lock:
spa_socket = None
s.close()
except:
pass