diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d941a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +ibls +*.bak +__pycache__/ +*.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d941a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +ibls +*.bak +__pycache__/ +*.pyc diff --git a/ibsystem2mqtt.yaml b/ibsystem2mqtt.yaml new file mode 100644 index 0000000..c01f56d --- /dev/null +++ b/ibsystem2mqtt.yaml @@ -0,0 +1,114 @@ +mqtt: + brokers: + - name: ha + host: 192.168.50.151 + port: 1883 + user: mqtt + password: mqtt123 + prefix: ibsystem + ha_prefix: homeassistant +ibsystem: + host: 127.0.0.1 + port: 2001 + rs: 0 + max_ids: 40 + full_ids: + - 1 + - 4 + timeout_ms: 10000 +polling: + interval_sec: 2.0 + parallel_workers: 8 +logging: + level: INFO + file: /var/log/ibsystem2mqtt.log +http: + enabled: true + host: 0.0.0.0 + port: 8080 +friendly_names: + rs0_id2: ID2 Sterownik ogrzewania podłogowego - ID2 + rs0_id2_output_do_0: zawór mieszający Rozdzielacz R1-p.tech (zimnej, dobór z powrotu) + rs0_id2_output_do_1: zawór mieszający Rozdzielacz R1-p.tech (ciepłej, dobór z zasilania[bufor] + rs0_id2_output_do_2: zawór mieszający Rozdzielacz R2-salon (zimnej, dobór z powrotu) + rs0_id2_output_do_3: zawór mieszający Rozdzielacz R2-salon (ciepłej, dobór z zasilania[bufor] + rs0_id3: ID3 - ID3 + rs0_id3_output_do_0: siłownik rozdzielacz R1 - Basia + rs0_id3_output_do_1: siłownik rozdzielacz R1 - Ania + rs0_id3_output_do_2: siłownik pokoju - WC + rs0_id3_output_do_3: siłownik pokoju - Wiatrołap + rs0_id4: ID4 - ID4 + rs0_id4_output_do_0: siłownik rozdzielacz R2 - salon + rs0_id4_output_do_1: siłownik rozdzielacz R2 - biuro Wojtek + rs0_id4_output_do_2: siłownik rozdzielacz R2 - sypialnia + rs0_id4_output_do_3: siłownik rozdzielacz R2 - duża łazienka + rs0_id5: ID10 Sterownik kominka - ID5 + rs0_id5_output_do_0: pompa obiegowa kominka + rs0_id5_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id6: ID6 - ID6 + rs0_id6_output_do_0: pompa głębinowa + rs0_id6_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id30: Wiatrołap - ID30 + rs0_id30_output_do_0: Oświetlenie H1 (Led bar AP) + rs0_id30_output_do_1: Oświetlenie H2 led + rs0_id30_output_do_2: Oświetlenie K1 szynoprzewód nowodworski + rs0_id30_output_do_3: Oświetlenie K2 szynoprzewód nowodworski + rs0_id31: Kuchnia - ID31 + rs0_id31_output_do_0: Oświetlenie K3 halogeny okno + rs0_id31_output_do_1: Oświetlenie K4 lampy nad barem + rs0_id31_output_do_2: Oświetlenie K5 półki led + rs0_id31_output_do_3: Oświetlenie K6 led okno + rs0_id32: Jadalnia / Salon - ID32 + rs0_id32_output_do_0: Oświetlenie J1 + rs0_id32_output_do_1: Oświetlenie J2 + rs0_id32_output_do_2: Oświetlenie S1 Ring + rs0_id32_output_do_3: Oświetlenie S2 lampy za kanapą + rs0_id33: Salon - ID33 + rs0_id33_output_do_0: Oświetlenie lampa przed tv + rs0_id33_output_do_1: Oświetlenie led + rs0_id33_output_do_2: Oświetlenie dekoracyjne koło lampy + rs0_id33_output_do_3: Oświetlenie led sufit wnęka + rs0_id34: Korytarz - ID34 + rs0_id34_output_do_0: Oświetlenie H3 szynoprzewód nowodworski + rs0_id34_output_do_1: Oświetlenie H4 szynoprzewód nowodworski + rs0_id34_output_do_2: Oświetlenie H5 ledy pionowe + rs0_id34_output_do_3: Oświetlenie H6 led kominek + rs0_id35: Biuro Wojtek/ Garderoba - ID35 + rs0_id35_output_do_0: Oświetlenie główne Biuro Wojtek + rs0_id35_output_do_1: Oświetlenie Lampa nad biurkiem Wojtek + rs0_id35_output_do_2: Oświetlenie G1 + rs0_id35_output_do_3: Oświetlenie G2 led nad szafami i koło lustra + rs0_id36: Sypialnia - ID36 + rs0_id36_output_do_0: Oświetlenie R1 główne + rs0_id36_output_do_1: Oświetlenie R2 nakastlik P + rs0_id36_output_do_2: Oświetlenie R3 nakastlik L + rs0_id36_output_do_3: Oświetlenie R4 led na suficie + rs0_id37: Łazienka - ID37 + rs0_id37_output_do_0: Oświetlenie L1 + rs0_id37_output_do_1: Oświetlenie L2 + rs0_id37_output_do_2: Oświetlenie L3 + rs0_id37_output_do_3: Oświetlenie L4 + rs0_id38: Łazienka/WC - ID38 + rs0_id38_output_do_0: Oświetlenie L5 + rs0_id38_output_do_1: Oświetlenie L6 + rs0_id38_output_do_2: Oświetlenie W1 + rs0_id38_output_do_3: Oświetlenie W2 + rs0_id39: Pokój Basi - ID39 + rs0_id39_output_do_0: Oświetlenie D1 główne + rs0_id39_output_do_1: Oświetlenie D2 nad biurkiem + rs0_id39_output_do_2: Oświetlenie D3 chmurka + rs0_id39_output_do_3: Oświetlenie D4 łóżko + rs0_id40: Biuro Ani - ID40 + rs0_id40_output_do_0: Oświetlenie B1 główne + rs0_id40_output_do_1: Oświetlenie B2 nad biurkiem + rs0_id40_output_do_2: Oświetlenie B3 biurko lampa + rs0_id40_output_do_3: Oświetlenie B4 dla ledów + rs0_id41: WC/Poddasze - ID41 + rs0_id41_output_do_0: Oświetlenie W2 góra halogeny + rs0_id41_output_do_1: Oświetlenie W3 halogen prysznic + led + rs0_id41_output_do_2: Oświetlenie P1 Poddasze 1 + rs0_id41_output_do_3: Oświetlenie P2 Poddasze 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d941a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +ibls +*.bak +__pycache__/ +*.pyc diff --git a/ibsystem2mqtt.yaml b/ibsystem2mqtt.yaml new file mode 100644 index 0000000..c01f56d --- /dev/null +++ b/ibsystem2mqtt.yaml @@ -0,0 +1,114 @@ +mqtt: + brokers: + - name: ha + host: 192.168.50.151 + port: 1883 + user: mqtt + password: mqtt123 + prefix: ibsystem + ha_prefix: homeassistant +ibsystem: + host: 127.0.0.1 + port: 2001 + rs: 0 + max_ids: 40 + full_ids: + - 1 + - 4 + timeout_ms: 10000 +polling: + interval_sec: 2.0 + parallel_workers: 8 +logging: + level: INFO + file: /var/log/ibsystem2mqtt.log +http: + enabled: true + host: 0.0.0.0 + port: 8080 +friendly_names: + rs0_id2: ID2 Sterownik ogrzewania podłogowego - ID2 + rs0_id2_output_do_0: zawór mieszający Rozdzielacz R1-p.tech (zimnej, dobór z powrotu) + rs0_id2_output_do_1: zawór mieszający Rozdzielacz R1-p.tech (ciepłej, dobór z zasilania[bufor] + rs0_id2_output_do_2: zawór mieszający Rozdzielacz R2-salon (zimnej, dobór z powrotu) + rs0_id2_output_do_3: zawór mieszający Rozdzielacz R2-salon (ciepłej, dobór z zasilania[bufor] + rs0_id3: ID3 - ID3 + rs0_id3_output_do_0: siłownik rozdzielacz R1 - Basia + rs0_id3_output_do_1: siłownik rozdzielacz R1 - Ania + rs0_id3_output_do_2: siłownik pokoju - WC + rs0_id3_output_do_3: siłownik pokoju - Wiatrołap + rs0_id4: ID4 - ID4 + rs0_id4_output_do_0: siłownik rozdzielacz R2 - salon + rs0_id4_output_do_1: siłownik rozdzielacz R2 - biuro Wojtek + rs0_id4_output_do_2: siłownik rozdzielacz R2 - sypialnia + rs0_id4_output_do_3: siłownik rozdzielacz R2 - duża łazienka + rs0_id5: ID10 Sterownik kominka - ID5 + rs0_id5_output_do_0: pompa obiegowa kominka + rs0_id5_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id6: ID6 - ID6 + rs0_id6_output_do_0: pompa głębinowa + rs0_id6_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id30: Wiatrołap - ID30 + rs0_id30_output_do_0: Oświetlenie H1 (Led bar AP) + rs0_id30_output_do_1: Oświetlenie H2 led + rs0_id30_output_do_2: Oświetlenie K1 szynoprzewód nowodworski + rs0_id30_output_do_3: Oświetlenie K2 szynoprzewód nowodworski + rs0_id31: Kuchnia - ID31 + rs0_id31_output_do_0: Oświetlenie K3 halogeny okno + rs0_id31_output_do_1: Oświetlenie K4 lampy nad barem + rs0_id31_output_do_2: Oświetlenie K5 półki led + rs0_id31_output_do_3: Oświetlenie K6 led okno + rs0_id32: Jadalnia / Salon - ID32 + rs0_id32_output_do_0: Oświetlenie J1 + rs0_id32_output_do_1: Oświetlenie J2 + rs0_id32_output_do_2: Oświetlenie S1 Ring + rs0_id32_output_do_3: Oświetlenie S2 lampy za kanapą + rs0_id33: Salon - ID33 + rs0_id33_output_do_0: Oświetlenie lampa przed tv + rs0_id33_output_do_1: Oświetlenie led + rs0_id33_output_do_2: Oświetlenie dekoracyjne koło lampy + rs0_id33_output_do_3: Oświetlenie led sufit wnęka + rs0_id34: Korytarz - ID34 + rs0_id34_output_do_0: Oświetlenie H3 szynoprzewód nowodworski + rs0_id34_output_do_1: Oświetlenie H4 szynoprzewód nowodworski + rs0_id34_output_do_2: Oświetlenie H5 ledy pionowe + rs0_id34_output_do_3: Oświetlenie H6 led kominek + rs0_id35: Biuro Wojtek/ Garderoba - ID35 + rs0_id35_output_do_0: Oświetlenie główne Biuro Wojtek + rs0_id35_output_do_1: Oświetlenie Lampa nad biurkiem Wojtek + rs0_id35_output_do_2: Oświetlenie G1 + rs0_id35_output_do_3: Oświetlenie G2 led nad szafami i koło lustra + rs0_id36: Sypialnia - ID36 + rs0_id36_output_do_0: Oświetlenie R1 główne + rs0_id36_output_do_1: Oświetlenie R2 nakastlik P + rs0_id36_output_do_2: Oświetlenie R3 nakastlik L + rs0_id36_output_do_3: Oświetlenie R4 led na suficie + rs0_id37: Łazienka - ID37 + rs0_id37_output_do_0: Oświetlenie L1 + rs0_id37_output_do_1: Oświetlenie L2 + rs0_id37_output_do_2: Oświetlenie L3 + rs0_id37_output_do_3: Oświetlenie L4 + rs0_id38: Łazienka/WC - ID38 + rs0_id38_output_do_0: Oświetlenie L5 + rs0_id38_output_do_1: Oświetlenie L6 + rs0_id38_output_do_2: Oświetlenie W1 + rs0_id38_output_do_3: Oświetlenie W2 + rs0_id39: Pokój Basi - ID39 + rs0_id39_output_do_0: Oświetlenie D1 główne + rs0_id39_output_do_1: Oświetlenie D2 nad biurkiem + rs0_id39_output_do_2: Oświetlenie D3 chmurka + rs0_id39_output_do_3: Oświetlenie D4 łóżko + rs0_id40: Biuro Ani - ID40 + rs0_id40_output_do_0: Oświetlenie B1 główne + rs0_id40_output_do_1: Oświetlenie B2 nad biurkiem + rs0_id40_output_do_2: Oświetlenie B3 biurko lampa + rs0_id40_output_do_3: Oświetlenie B4 dla ledów + rs0_id41: WC/Poddasze - ID41 + rs0_id41_output_do_0: Oświetlenie W2 góra halogeny + rs0_id41_output_do_1: Oświetlenie W3 halogen prysznic + led + rs0_id41_output_do_2: Oświetlenie P1 Poddasze 1 + rs0_id41_output_do_3: Oświetlenie P2 Poddasze 2 diff --git a/ibsystem2mqtt_v5.py b/ibsystem2mqtt_v5.py new file mode 100755 index 0000000..3622504 --- /dev/null +++ b/ibsystem2mqtt_v5.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""IBSystem -> MQTT Bridge for Home Assistant v5""" +import subprocess, re, time, json, threading, logging, signal, sys +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from http.server import HTTPServer, BaseHTTPRequestHandler +from datetime import datetime +import paho.mqtt.client as mqtt +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +DEFAULT_CONFIG = {"mqtt": {"brokers": [{"name": "ha", "host": "ha.local", "port": 1883}], "user": "mqtt", "password": "mqtt123", "prefix": "ibsystem", "ha_prefix": "homeassistant"}, "ibsystem": {"host": "127.0.0.1", "port": 2001, "rs": 0, "max_ids": 40, "full_ids": [1], "timeout_ms": 10000}, "polling": {"interval_sec": 2.0, "parallel_workers": 8}, "logging": {"level": "INFO", "file": None}, "http": {"enabled": True, "host": "0.0.0.0", "port": 8080}, "friendly_names": {}} + +class BridgeStats: + def __init__(self): + self.start_time = datetime.now() + self.poll_cycles = 0 + self.last_poll_time = None + self.last_poll_duration = 0.0 + self.devices_discovered = 0 + self.entities_discovered = 0 + self.messages_received = 0 + self.commands_executed = 0 + self.errors = 0 + self.last_error = None + self._lock = threading.Lock() + def record_poll(self, duration, devices): + with self._lock: self.poll_cycles += 1; self.last_poll_time = datetime.now(); self.last_poll_duration = duration; self.devices_discovered = devices + def record_entity(self): + with self._lock: self.entities_discovered += 1 + def record_command(self): + with self._lock: self.commands_executed += 1; self.messages_received += 1 + def record_error(self, error): + with self._lock: self.errors += 1; self.last_error = f"{datetime.now().isoformat()}: {error}" + def to_dict(self): + with self._lock: + uptime = datetime.now() - self.start_time + return {"status": "running", "uptime_seconds": int(uptime.total_seconds()), "uptime_human": str(uptime).split('.')[0], "start_time": self.start_time.isoformat(), "poll_cycles": self.poll_cycles, "last_poll_time": self.last_poll_time.isoformat() if self.last_poll_time else None, "last_poll_duration_ms": round(self.last_poll_duration * 1000, 1), "devices_discovered": self.devices_discovered, "entities_discovered": self.entities_discovered, "messages_received": self.messages_received, "commands_executed": self.commands_executed, "errors": self.errors, "last_error": self.last_error} +STATS = BridgeStats() + +def setup_logging(config): + level = getattr(logging, config.get("level", "INFO").upper(), logging.INFO) + handlers = [logging.StreamHandler()] + if config.get("file"): handlers.append(logging.FileHandler(config["file"])) + logging.basicConfig(level=level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=handlers) + return logging.getLogger("ibsystem2mqtt") + +class FriendlyNames: + def __init__(self, config): self.names = config.get("friendly_names") or {} + def get_device_name(self, rs, dev_id): return self.names.get(f"rs{rs}_id{dev_id}", f"IBSystem RS{rs} ID{dev_id}") + def get_entity_name(self, rs, dev_id, path): + safe_path = re.sub(r"[^a-zA-Z0-9_]+", "_", path) + if f"rs{rs}_id{dev_id}_{safe_path}" in self.names: return self.names[f"rs{rs}_id{dev_id}_{safe_path}"] + device_name = self.names.get(f"rs{rs}_id{dev_id}") + readable = self._path_to_readable(path) + return readable + def _path_to_readable(self, path): + m = re.match(r"output\.do\.(\d+)$", path) + if m: return f"Wyjscie DO {m.group(1)}" + m = re.match(r"output\.px\.(\d+)$", path) + if m: return f"PWM {m.group(1)}" + m = re.match(r"input\.di\.(\d+)\.(a|b)$", path) + if m: return f"Wejscie DI {m.group(1)} {m.group(2).upper()}" + m = re.match(r"input\.t\.(\d+)\.value$", path) + if m: return f"Temperatura {m.group(1)}" + return path.replace(".", " ").title() + +@dataclass +class DeviceRecord: + rs: str + id: str + path: str + val: str + @property + def unique_id(self): return f"ibsystem_rs{self.rs}_id{self.id}_{re.sub(r'[^a-zA-Z0-9_]+', '_', self.path)}" + @property + def device_id(self): return f"ibsystem_rs{self.rs}_id{self.id}" + @property + def component(self): + if self.path.startswith("output.do."): return "switch" + if self.path.startswith("input.di."): return "binary_sensor" + return "sensor" + @property + def device_class(self): return "temperature" if "temp" in self.path.lower() or self.path.startswith("input.t.") else None + @property + def unit_of_measurement(self): return "C" if self.path.startswith("input.t.") and "value" in self.path else None + +class DiagnosticsHandler(BaseHTTPRequestHandler): + bridge = None + def log_message(self, format, *args): pass + def do_GET(self): + if self.path in ("/", "/status"): self._send_json(STATS.to_dict()) + elif self.path == "/health": self._send_json({"status": "ok"}) + elif self.path == "/config" and self.bridge: + cfg = json.loads(json.dumps(self.bridge.config)); cfg["mqtt"]["password"] = "***"; self._send_json(cfg) + elif self.path == "/entities" and self.bridge: + self._send_json({"count": len(self.bridge.discovered), "entities": sorted(list(self.bridge.discovered))}) + else: self.send_error(404) + def _send_json(self, data): + content = json.dumps(data, indent=2).encode() + self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", len(content)); self.end_headers(); self.wfile.write(content) + +class DiagnosticsServer: + def __init__(self, host, port, logger): self.host = host; self.port = port; self.logger = logger; self.server = None + def start(self, bridge): + DiagnosticsHandler.bridge = bridge + try: + self.server = HTTPServer((self.host, self.port), DiagnosticsHandler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.logger.info(f"Diagnostics: http://{self.host}:{self.port}") + except Exception as e: self.logger.error(f"Diagnostics failed: {e}") + def stop(self): + if self.server: self.server.shutdown() + +class IBLSRunner: + def __init__(self, host, port, timeout_ms, logger): self.host = host; self.port = port; self.timeout_ms = timeout_ms; self.logger = logger; self._lock = threading.Lock() + def run(self, command): + cmd = f'/ibsystem/ibls -a {self.host} --pretty -p {self.port} --timeout={self.timeout_ms} -c "{command}"' + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) + if result.returncode != 0: self.logger.warning(f"ibls failed: {result.stderr}"); STATS.record_error(f"ibls: {result.stderr[:50]}"); return None + return result.stdout.strip() + except Exception as e: self.logger.error(f"ibls error: {e}"); STATS.record_error(str(e)); return None + def get_device(self, rs, dev_id, full=False): + cmd = f"get(rs.{rs}.id.{dev_id};);" if full else f"get(rs.{rs}.id.{dev_id}.input;);get(rs.{rs}.id.{dev_id}.output;);" + output = self.run(cmd) + if not output: return [] + records = [] + for line in output.splitlines(): + m = re.match(r"rs\.(\d+)\.id\.(\d+)\.(.+?)\s*=\s*(.+)", line.strip()) + if m: records.append(DeviceRecord(rs=m.group(1), id=m.group(2), path=m.group(3).strip(), val=m.group(4).strip())) + return records + def set_output(self, rs, dev_id, output_path, value): + with self._lock: return self.run(f"set(rs.{rs}.id.{dev_id}.{output_path}={value};);") is not None + +class MQTTBridge: + def __init__(self, config, ibls, logger): + self.config = config; self.ibls = ibls; self.logger = logger + self.friendly_names = FriendlyNames(config) + self.mqtt_prefix = config["mqtt"]["prefix"]; self.ha_prefix = config["mqtt"]["ha_prefix"] + self.clients = []; self.discovered = set(); self.last_published = {} + self._cmd_lock = threading.Lock(); self._last_cmd = {} + def start(self): + for broker in self.config["mqtt"]["brokers"]: + client = mqtt.Client(client_id=f"ibsystem2mqtt-{broker['name']}", protocol=mqtt.MQTTv311) + client.username_pw_set(self.config["mqtt"]["user"], self.config["mqtt"]["password"]) + client.on_message = self._on_message; client._broker_name = broker["name"] + client.will_set(f"{self.mqtt_prefix}/bridge/availability", "offline", retain=True) + prefix = self.mqtt_prefix + logger = self.logger + def _make_on_connect(bname, pfx, lgr): + def on_connect(c, userdata, flags, rc): + if rc == 0: + c.subscribe(f"{pfx}/+/+/set/#") + lgr.info(f"(Re)connected+subscribed: {bname}") + else: + lgr.error(f"Connect failed {bname}: rc={rc}") + return on_connect + client.on_connect = _make_on_connect(broker['name'], prefix, logger) + try: + client.connect(broker["host"], broker["port"], 60); client.loop_start(); self.clients.append(client) + self.logger.info(f"Connected: {broker['name']} ({broker['host']})") + except Exception as e: self.logger.error(f"MQTT failed {broker['name']}: {e}"); STATS.record_error(f"MQTT: {broker['name']}") + self._publish_all(f"{self.mqtt_prefix}/bridge/availability", "online") + def stop(self): + self._publish_all(f"{self.mqtt_prefix}/bridge/availability", "offline") + for c in self.clients: c.loop_stop(); c.disconnect() + def _on_message(self, client, userdata, msg): + try: + STATS.record_command(); parts = msg.topic.split("/") + if len(parts) < 5 or parts[0] != self.mqtt_prefix or parts[3] != "set": return + rs, dev_id, target = parts[1].replace("rs",""), parts[2].replace("id",""), parts[4] + m = re.match(r"do(\d+)$", target) + if not m: return + do_num = m.group(1); payload = msg.payload.decode().strip().upper() + value = "1" if payload in ("ON","1","TRUE") else "0" if payload in ("OFF","0","FALSE") else None + if not value: return + key = (rs, dev_id, do_num, value); now = time.time() + with self._cmd_lock: + self._last_cmd = {k:v for k,v in self._last_cmd.items() if v > now-0.5} + if key in self._last_cmd: return + self._last_cmd[key] = now + self.logger.info(f"Command: rs{rs}/id{dev_id}/do{do_num} = {value}") + if self.ibls.set_output(int(rs), int(dev_id), f"setting.light.{do_num}", value): + self._publish_all(f"{self.mqtt_prefix}/rs{rs}/id{dev_id}/output/do/{do_num}/state", "ON" if value=="1" else "OFF") + except Exception as e: self.logger.error(f"Message error: {e}"); STATS.record_error(str(e)) + def poll_and_publish(self): + cfg = self.config["ibsystem"]; rs = cfg["rs"]; full_ids = set(cfg.get("full_ids", [])) + all_records = []; devices = 0 + with ThreadPoolExecutor(max_workers=self.config["polling"].get("parallel_workers", 4)) as ex: + futures = {ex.submit(self.ibls.get_device, rs, i, i in full_ids): i for i in range(1, cfg["max_ids"]+1)} + for f in as_completed(futures): + try: + recs = f.result() + if recs: devices += 1; all_records.extend(recs) + except: pass + for rec in all_records: self._publish_discovery(rec); self._publish_state(rec) + return devices + def _publish_discovery(self, rec): + if rec.unique_id in self.discovered: return + component = rec.component; state_topic = self._state_topic(rec) + payload = {"has_entity_name": False, "name": self.friendly_names.get_entity_name(rec.rs, rec.id, rec.path), "state_topic": state_topic, "unique_id": rec.unique_id, "availability_topic": f"{self.mqtt_prefix}/bridge/availability", "device": {"identifiers": [rec.device_id], "name": self.friendly_names.get_device_name(rec.rs, rec.id), "manufacturer": "IBSystem"}} + if component == "switch": + m = re.match(r"output\.do\.(\d+)$", rec.path) + if m: payload.update({"command_topic": f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/set/do{m.group(1)}", "payload_on": "ON", "payload_off": "OFF", "state_on": "ON", "state_off": "OFF"}) + elif component == "binary_sensor": payload.update({"payload_on": "1", "payload_off": "0"}) + elif rec.device_class: payload["device_class"] = rec.device_class + if rec.unit_of_measurement: payload["unit_of_measurement"] = rec.unit_of_measurement; payload["value_template"] = "{{ value | float / 10 }}" if rec.device_class == "temperature" else None + self._publish_all(f"{self.ha_prefix}/{component}/{rec.unique_id}/config", json.dumps(payload)) + self.discovered.add(rec.unique_id); STATS.record_entity() + def _publish_state(self, rec): + topic = self._state_topic(rec) + value = ("ON" if rec.val in ("1","true","ON") else "OFF") if rec.component == "switch" else rec.val + for c in self.clients: + cache = self.last_published.setdefault(c._broker_name, {}) + if cache.get(topic) != value: cache[topic] = value; c.publish(topic, value, retain=True) + def _state_topic(self, rec): + p = rec.path + m = re.match(r"output\.do\.(\d+)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/output/do/{m.group(1)}/state" + m = re.match(r"input\.di\.(\d+)\.(a|b)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/input/di/{m.group(1)}/{m.group(2)}" + m = re.match(r"input\.t\.(\d+)\.(\w+)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/input/t/{m.group(1)}/{m.group(2)}" + return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/{p.replace('.', '/')}" + def _publish_all(self, topic, payload, retain=True): + for c in self.clients: c.publish(topic, payload, retain=retain) + +def load_config(path=None): + if path and HAS_YAML and Path(path).exists(): + with open(path) as f: user = yaml.safe_load(f) + cfg = json.loads(json.dumps(DEFAULT_CONFIG)) + for k,v in user.items(): cfg[k].update(v) if isinstance(v,dict) and k in cfg else cfg.update({k:v}) + return cfg + return json.loads(json.dumps(DEFAULT_CONFIG)) + +def main(): + import argparse + p = argparse.ArgumentParser(); p.add_argument("-c", "--config"); p.add_argument("-v", "--verbose", action="store_true") + args = p.parse_args(); config = load_config(args.config) + if args.verbose: config["logging"]["level"] = "DEBUG" + logger = setup_logging(config["logging"]); logger.info("Starting IBSystem2MQTT v5") + ibls = IBLSRunner(config["ibsystem"]["host"], config["ibsystem"]["port"], config["ibsystem"]["timeout_ms"], logger) + bridge = MQTTBridge(config, ibls, logger) + diag = DiagnosticsServer(config["http"]["host"], config["http"]["port"], logger) if config.get("http",{}).get("enabled") else None + if diag: diag.start(bridge) + def shutdown(sig, frame): logger.info("Shutdown"); diag and diag.stop(); bridge.stop(); sys.exit(0) + signal.signal(signal.SIGINT, shutdown); signal.signal(signal.SIGTERM, shutdown) + bridge.start() + for c in bridge.clients: c.subscribe(f"{bridge.mqtt_prefix}/+/+/set/#") + interval = config["polling"]["interval_sec"] + while True: + try: + t0 = time.time(); devices = bridge.poll_and_publish(); elapsed = time.time() - t0 + STATS.record_poll(elapsed, devices); logger.debug(f"Poll: {devices} in {elapsed:.2f}s") + time.sleep(max(0, interval - elapsed)) + except Exception as e: import traceback; logger.error(f"Loop error: {e}\n{traceback.format_exc()}"); STATS.record_error(str(e)); time.sleep(interval) + +if __name__ == "__main__": main() diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d941a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +ibls +*.bak +__pycache__/ +*.pyc diff --git a/ibsystem2mqtt.yaml b/ibsystem2mqtt.yaml new file mode 100644 index 0000000..c01f56d --- /dev/null +++ b/ibsystem2mqtt.yaml @@ -0,0 +1,114 @@ +mqtt: + brokers: + - name: ha + host: 192.168.50.151 + port: 1883 + user: mqtt + password: mqtt123 + prefix: ibsystem + ha_prefix: homeassistant +ibsystem: + host: 127.0.0.1 + port: 2001 + rs: 0 + max_ids: 40 + full_ids: + - 1 + - 4 + timeout_ms: 10000 +polling: + interval_sec: 2.0 + parallel_workers: 8 +logging: + level: INFO + file: /var/log/ibsystem2mqtt.log +http: + enabled: true + host: 0.0.0.0 + port: 8080 +friendly_names: + rs0_id2: ID2 Sterownik ogrzewania podłogowego - ID2 + rs0_id2_output_do_0: zawór mieszający Rozdzielacz R1-p.tech (zimnej, dobór z powrotu) + rs0_id2_output_do_1: zawór mieszający Rozdzielacz R1-p.tech (ciepłej, dobór z zasilania[bufor] + rs0_id2_output_do_2: zawór mieszający Rozdzielacz R2-salon (zimnej, dobór z powrotu) + rs0_id2_output_do_3: zawór mieszający Rozdzielacz R2-salon (ciepłej, dobór z zasilania[bufor] + rs0_id3: ID3 - ID3 + rs0_id3_output_do_0: siłownik rozdzielacz R1 - Basia + rs0_id3_output_do_1: siłownik rozdzielacz R1 - Ania + rs0_id3_output_do_2: siłownik pokoju - WC + rs0_id3_output_do_3: siłownik pokoju - Wiatrołap + rs0_id4: ID4 - ID4 + rs0_id4_output_do_0: siłownik rozdzielacz R2 - salon + rs0_id4_output_do_1: siłownik rozdzielacz R2 - biuro Wojtek + rs0_id4_output_do_2: siłownik rozdzielacz R2 - sypialnia + rs0_id4_output_do_3: siłownik rozdzielacz R2 - duża łazienka + rs0_id5: ID10 Sterownik kominka - ID5 + rs0_id5_output_do_0: pompa obiegowa kominka + rs0_id5_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id6: ID6 - ID6 + rs0_id6_output_do_0: pompa głębinowa + rs0_id6_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id30: Wiatrołap - ID30 + rs0_id30_output_do_0: Oświetlenie H1 (Led bar AP) + rs0_id30_output_do_1: Oświetlenie H2 led + rs0_id30_output_do_2: Oświetlenie K1 szynoprzewód nowodworski + rs0_id30_output_do_3: Oświetlenie K2 szynoprzewód nowodworski + rs0_id31: Kuchnia - ID31 + rs0_id31_output_do_0: Oświetlenie K3 halogeny okno + rs0_id31_output_do_1: Oświetlenie K4 lampy nad barem + rs0_id31_output_do_2: Oświetlenie K5 półki led + rs0_id31_output_do_3: Oświetlenie K6 led okno + rs0_id32: Jadalnia / Salon - ID32 + rs0_id32_output_do_0: Oświetlenie J1 + rs0_id32_output_do_1: Oświetlenie J2 + rs0_id32_output_do_2: Oświetlenie S1 Ring + rs0_id32_output_do_3: Oświetlenie S2 lampy za kanapą + rs0_id33: Salon - ID33 + rs0_id33_output_do_0: Oświetlenie lampa przed tv + rs0_id33_output_do_1: Oświetlenie led + rs0_id33_output_do_2: Oświetlenie dekoracyjne koło lampy + rs0_id33_output_do_3: Oświetlenie led sufit wnęka + rs0_id34: Korytarz - ID34 + rs0_id34_output_do_0: Oświetlenie H3 szynoprzewód nowodworski + rs0_id34_output_do_1: Oświetlenie H4 szynoprzewód nowodworski + rs0_id34_output_do_2: Oświetlenie H5 ledy pionowe + rs0_id34_output_do_3: Oświetlenie H6 led kominek + rs0_id35: Biuro Wojtek/ Garderoba - ID35 + rs0_id35_output_do_0: Oświetlenie główne Biuro Wojtek + rs0_id35_output_do_1: Oświetlenie Lampa nad biurkiem Wojtek + rs0_id35_output_do_2: Oświetlenie G1 + rs0_id35_output_do_3: Oświetlenie G2 led nad szafami i koło lustra + rs0_id36: Sypialnia - ID36 + rs0_id36_output_do_0: Oświetlenie R1 główne + rs0_id36_output_do_1: Oświetlenie R2 nakastlik P + rs0_id36_output_do_2: Oświetlenie R3 nakastlik L + rs0_id36_output_do_3: Oświetlenie R4 led na suficie + rs0_id37: Łazienka - ID37 + rs0_id37_output_do_0: Oświetlenie L1 + rs0_id37_output_do_1: Oświetlenie L2 + rs0_id37_output_do_2: Oświetlenie L3 + rs0_id37_output_do_3: Oświetlenie L4 + rs0_id38: Łazienka/WC - ID38 + rs0_id38_output_do_0: Oświetlenie L5 + rs0_id38_output_do_1: Oświetlenie L6 + rs0_id38_output_do_2: Oświetlenie W1 + rs0_id38_output_do_3: Oświetlenie W2 + rs0_id39: Pokój Basi - ID39 + rs0_id39_output_do_0: Oświetlenie D1 główne + rs0_id39_output_do_1: Oświetlenie D2 nad biurkiem + rs0_id39_output_do_2: Oświetlenie D3 chmurka + rs0_id39_output_do_3: Oświetlenie D4 łóżko + rs0_id40: Biuro Ani - ID40 + rs0_id40_output_do_0: Oświetlenie B1 główne + rs0_id40_output_do_1: Oświetlenie B2 nad biurkiem + rs0_id40_output_do_2: Oświetlenie B3 biurko lampa + rs0_id40_output_do_3: Oświetlenie B4 dla ledów + rs0_id41: WC/Poddasze - ID41 + rs0_id41_output_do_0: Oświetlenie W2 góra halogeny + rs0_id41_output_do_1: Oświetlenie W3 halogen prysznic + led + rs0_id41_output_do_2: Oświetlenie P1 Poddasze 1 + rs0_id41_output_do_3: Oświetlenie P2 Poddasze 2 diff --git a/ibsystem2mqtt_v5.py b/ibsystem2mqtt_v5.py new file mode 100755 index 0000000..3622504 --- /dev/null +++ b/ibsystem2mqtt_v5.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""IBSystem -> MQTT Bridge for Home Assistant v5""" +import subprocess, re, time, json, threading, logging, signal, sys +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from http.server import HTTPServer, BaseHTTPRequestHandler +from datetime import datetime +import paho.mqtt.client as mqtt +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +DEFAULT_CONFIG = {"mqtt": {"brokers": [{"name": "ha", "host": "ha.local", "port": 1883}], "user": "mqtt", "password": "mqtt123", "prefix": "ibsystem", "ha_prefix": "homeassistant"}, "ibsystem": {"host": "127.0.0.1", "port": 2001, "rs": 0, "max_ids": 40, "full_ids": [1], "timeout_ms": 10000}, "polling": {"interval_sec": 2.0, "parallel_workers": 8}, "logging": {"level": "INFO", "file": None}, "http": {"enabled": True, "host": "0.0.0.0", "port": 8080}, "friendly_names": {}} + +class BridgeStats: + def __init__(self): + self.start_time = datetime.now() + self.poll_cycles = 0 + self.last_poll_time = None + self.last_poll_duration = 0.0 + self.devices_discovered = 0 + self.entities_discovered = 0 + self.messages_received = 0 + self.commands_executed = 0 + self.errors = 0 + self.last_error = None + self._lock = threading.Lock() + def record_poll(self, duration, devices): + with self._lock: self.poll_cycles += 1; self.last_poll_time = datetime.now(); self.last_poll_duration = duration; self.devices_discovered = devices + def record_entity(self): + with self._lock: self.entities_discovered += 1 + def record_command(self): + with self._lock: self.commands_executed += 1; self.messages_received += 1 + def record_error(self, error): + with self._lock: self.errors += 1; self.last_error = f"{datetime.now().isoformat()}: {error}" + def to_dict(self): + with self._lock: + uptime = datetime.now() - self.start_time + return {"status": "running", "uptime_seconds": int(uptime.total_seconds()), "uptime_human": str(uptime).split('.')[0], "start_time": self.start_time.isoformat(), "poll_cycles": self.poll_cycles, "last_poll_time": self.last_poll_time.isoformat() if self.last_poll_time else None, "last_poll_duration_ms": round(self.last_poll_duration * 1000, 1), "devices_discovered": self.devices_discovered, "entities_discovered": self.entities_discovered, "messages_received": self.messages_received, "commands_executed": self.commands_executed, "errors": self.errors, "last_error": self.last_error} +STATS = BridgeStats() + +def setup_logging(config): + level = getattr(logging, config.get("level", "INFO").upper(), logging.INFO) + handlers = [logging.StreamHandler()] + if config.get("file"): handlers.append(logging.FileHandler(config["file"])) + logging.basicConfig(level=level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=handlers) + return logging.getLogger("ibsystem2mqtt") + +class FriendlyNames: + def __init__(self, config): self.names = config.get("friendly_names") or {} + def get_device_name(self, rs, dev_id): return self.names.get(f"rs{rs}_id{dev_id}", f"IBSystem RS{rs} ID{dev_id}") + def get_entity_name(self, rs, dev_id, path): + safe_path = re.sub(r"[^a-zA-Z0-9_]+", "_", path) + if f"rs{rs}_id{dev_id}_{safe_path}" in self.names: return self.names[f"rs{rs}_id{dev_id}_{safe_path}"] + device_name = self.names.get(f"rs{rs}_id{dev_id}") + readable = self._path_to_readable(path) + return readable + def _path_to_readable(self, path): + m = re.match(r"output\.do\.(\d+)$", path) + if m: return f"Wyjscie DO {m.group(1)}" + m = re.match(r"output\.px\.(\d+)$", path) + if m: return f"PWM {m.group(1)}" + m = re.match(r"input\.di\.(\d+)\.(a|b)$", path) + if m: return f"Wejscie DI {m.group(1)} {m.group(2).upper()}" + m = re.match(r"input\.t\.(\d+)\.value$", path) + if m: return f"Temperatura {m.group(1)}" + return path.replace(".", " ").title() + +@dataclass +class DeviceRecord: + rs: str + id: str + path: str + val: str + @property + def unique_id(self): return f"ibsystem_rs{self.rs}_id{self.id}_{re.sub(r'[^a-zA-Z0-9_]+', '_', self.path)}" + @property + def device_id(self): return f"ibsystem_rs{self.rs}_id{self.id}" + @property + def component(self): + if self.path.startswith("output.do."): return "switch" + if self.path.startswith("input.di."): return "binary_sensor" + return "sensor" + @property + def device_class(self): return "temperature" if "temp" in self.path.lower() or self.path.startswith("input.t.") else None + @property + def unit_of_measurement(self): return "C" if self.path.startswith("input.t.") and "value" in self.path else None + +class DiagnosticsHandler(BaseHTTPRequestHandler): + bridge = None + def log_message(self, format, *args): pass + def do_GET(self): + if self.path in ("/", "/status"): self._send_json(STATS.to_dict()) + elif self.path == "/health": self._send_json({"status": "ok"}) + elif self.path == "/config" and self.bridge: + cfg = json.loads(json.dumps(self.bridge.config)); cfg["mqtt"]["password"] = "***"; self._send_json(cfg) + elif self.path == "/entities" and self.bridge: + self._send_json({"count": len(self.bridge.discovered), "entities": sorted(list(self.bridge.discovered))}) + else: self.send_error(404) + def _send_json(self, data): + content = json.dumps(data, indent=2).encode() + self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", len(content)); self.end_headers(); self.wfile.write(content) + +class DiagnosticsServer: + def __init__(self, host, port, logger): self.host = host; self.port = port; self.logger = logger; self.server = None + def start(self, bridge): + DiagnosticsHandler.bridge = bridge + try: + self.server = HTTPServer((self.host, self.port), DiagnosticsHandler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.logger.info(f"Diagnostics: http://{self.host}:{self.port}") + except Exception as e: self.logger.error(f"Diagnostics failed: {e}") + def stop(self): + if self.server: self.server.shutdown() + +class IBLSRunner: + def __init__(self, host, port, timeout_ms, logger): self.host = host; self.port = port; self.timeout_ms = timeout_ms; self.logger = logger; self._lock = threading.Lock() + def run(self, command): + cmd = f'/ibsystem/ibls -a {self.host} --pretty -p {self.port} --timeout={self.timeout_ms} -c "{command}"' + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) + if result.returncode != 0: self.logger.warning(f"ibls failed: {result.stderr}"); STATS.record_error(f"ibls: {result.stderr[:50]}"); return None + return result.stdout.strip() + except Exception as e: self.logger.error(f"ibls error: {e}"); STATS.record_error(str(e)); return None + def get_device(self, rs, dev_id, full=False): + cmd = f"get(rs.{rs}.id.{dev_id};);" if full else f"get(rs.{rs}.id.{dev_id}.input;);get(rs.{rs}.id.{dev_id}.output;);" + output = self.run(cmd) + if not output: return [] + records = [] + for line in output.splitlines(): + m = re.match(r"rs\.(\d+)\.id\.(\d+)\.(.+?)\s*=\s*(.+)", line.strip()) + if m: records.append(DeviceRecord(rs=m.group(1), id=m.group(2), path=m.group(3).strip(), val=m.group(4).strip())) + return records + def set_output(self, rs, dev_id, output_path, value): + with self._lock: return self.run(f"set(rs.{rs}.id.{dev_id}.{output_path}={value};);") is not None + +class MQTTBridge: + def __init__(self, config, ibls, logger): + self.config = config; self.ibls = ibls; self.logger = logger + self.friendly_names = FriendlyNames(config) + self.mqtt_prefix = config["mqtt"]["prefix"]; self.ha_prefix = config["mqtt"]["ha_prefix"] + self.clients = []; self.discovered = set(); self.last_published = {} + self._cmd_lock = threading.Lock(); self._last_cmd = {} + def start(self): + for broker in self.config["mqtt"]["brokers"]: + client = mqtt.Client(client_id=f"ibsystem2mqtt-{broker['name']}", protocol=mqtt.MQTTv311) + client.username_pw_set(self.config["mqtt"]["user"], self.config["mqtt"]["password"]) + client.on_message = self._on_message; client._broker_name = broker["name"] + client.will_set(f"{self.mqtt_prefix}/bridge/availability", "offline", retain=True) + prefix = self.mqtt_prefix + logger = self.logger + def _make_on_connect(bname, pfx, lgr): + def on_connect(c, userdata, flags, rc): + if rc == 0: + c.subscribe(f"{pfx}/+/+/set/#") + lgr.info(f"(Re)connected+subscribed: {bname}") + else: + lgr.error(f"Connect failed {bname}: rc={rc}") + return on_connect + client.on_connect = _make_on_connect(broker['name'], prefix, logger) + try: + client.connect(broker["host"], broker["port"], 60); client.loop_start(); self.clients.append(client) + self.logger.info(f"Connected: {broker['name']} ({broker['host']})") + except Exception as e: self.logger.error(f"MQTT failed {broker['name']}: {e}"); STATS.record_error(f"MQTT: {broker['name']}") + self._publish_all(f"{self.mqtt_prefix}/bridge/availability", "online") + def stop(self): + self._publish_all(f"{self.mqtt_prefix}/bridge/availability", "offline") + for c in self.clients: c.loop_stop(); c.disconnect() + def _on_message(self, client, userdata, msg): + try: + STATS.record_command(); parts = msg.topic.split("/") + if len(parts) < 5 or parts[0] != self.mqtt_prefix or parts[3] != "set": return + rs, dev_id, target = parts[1].replace("rs",""), parts[2].replace("id",""), parts[4] + m = re.match(r"do(\d+)$", target) + if not m: return + do_num = m.group(1); payload = msg.payload.decode().strip().upper() + value = "1" if payload in ("ON","1","TRUE") else "0" if payload in ("OFF","0","FALSE") else None + if not value: return + key = (rs, dev_id, do_num, value); now = time.time() + with self._cmd_lock: + self._last_cmd = {k:v for k,v in self._last_cmd.items() if v > now-0.5} + if key in self._last_cmd: return + self._last_cmd[key] = now + self.logger.info(f"Command: rs{rs}/id{dev_id}/do{do_num} = {value}") + if self.ibls.set_output(int(rs), int(dev_id), f"setting.light.{do_num}", value): + self._publish_all(f"{self.mqtt_prefix}/rs{rs}/id{dev_id}/output/do/{do_num}/state", "ON" if value=="1" else "OFF") + except Exception as e: self.logger.error(f"Message error: {e}"); STATS.record_error(str(e)) + def poll_and_publish(self): + cfg = self.config["ibsystem"]; rs = cfg["rs"]; full_ids = set(cfg.get("full_ids", [])) + all_records = []; devices = 0 + with ThreadPoolExecutor(max_workers=self.config["polling"].get("parallel_workers", 4)) as ex: + futures = {ex.submit(self.ibls.get_device, rs, i, i in full_ids): i for i in range(1, cfg["max_ids"]+1)} + for f in as_completed(futures): + try: + recs = f.result() + if recs: devices += 1; all_records.extend(recs) + except: pass + for rec in all_records: self._publish_discovery(rec); self._publish_state(rec) + return devices + def _publish_discovery(self, rec): + if rec.unique_id in self.discovered: return + component = rec.component; state_topic = self._state_topic(rec) + payload = {"has_entity_name": False, "name": self.friendly_names.get_entity_name(rec.rs, rec.id, rec.path), "state_topic": state_topic, "unique_id": rec.unique_id, "availability_topic": f"{self.mqtt_prefix}/bridge/availability", "device": {"identifiers": [rec.device_id], "name": self.friendly_names.get_device_name(rec.rs, rec.id), "manufacturer": "IBSystem"}} + if component == "switch": + m = re.match(r"output\.do\.(\d+)$", rec.path) + if m: payload.update({"command_topic": f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/set/do{m.group(1)}", "payload_on": "ON", "payload_off": "OFF", "state_on": "ON", "state_off": "OFF"}) + elif component == "binary_sensor": payload.update({"payload_on": "1", "payload_off": "0"}) + elif rec.device_class: payload["device_class"] = rec.device_class + if rec.unit_of_measurement: payload["unit_of_measurement"] = rec.unit_of_measurement; payload["value_template"] = "{{ value | float / 10 }}" if rec.device_class == "temperature" else None + self._publish_all(f"{self.ha_prefix}/{component}/{rec.unique_id}/config", json.dumps(payload)) + self.discovered.add(rec.unique_id); STATS.record_entity() + def _publish_state(self, rec): + topic = self._state_topic(rec) + value = ("ON" if rec.val in ("1","true","ON") else "OFF") if rec.component == "switch" else rec.val + for c in self.clients: + cache = self.last_published.setdefault(c._broker_name, {}) + if cache.get(topic) != value: cache[topic] = value; c.publish(topic, value, retain=True) + def _state_topic(self, rec): + p = rec.path + m = re.match(r"output\.do\.(\d+)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/output/do/{m.group(1)}/state" + m = re.match(r"input\.di\.(\d+)\.(a|b)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/input/di/{m.group(1)}/{m.group(2)}" + m = re.match(r"input\.t\.(\d+)\.(\w+)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/input/t/{m.group(1)}/{m.group(2)}" + return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/{p.replace('.', '/')}" + def _publish_all(self, topic, payload, retain=True): + for c in self.clients: c.publish(topic, payload, retain=retain) + +def load_config(path=None): + if path and HAS_YAML and Path(path).exists(): + with open(path) as f: user = yaml.safe_load(f) + cfg = json.loads(json.dumps(DEFAULT_CONFIG)) + for k,v in user.items(): cfg[k].update(v) if isinstance(v,dict) and k in cfg else cfg.update({k:v}) + return cfg + return json.loads(json.dumps(DEFAULT_CONFIG)) + +def main(): + import argparse + p = argparse.ArgumentParser(); p.add_argument("-c", "--config"); p.add_argument("-v", "--verbose", action="store_true") + args = p.parse_args(); config = load_config(args.config) + if args.verbose: config["logging"]["level"] = "DEBUG" + logger = setup_logging(config["logging"]); logger.info("Starting IBSystem2MQTT v5") + ibls = IBLSRunner(config["ibsystem"]["host"], config["ibsystem"]["port"], config["ibsystem"]["timeout_ms"], logger) + bridge = MQTTBridge(config, ibls, logger) + diag = DiagnosticsServer(config["http"]["host"], config["http"]["port"], logger) if config.get("http",{}).get("enabled") else None + if diag: diag.start(bridge) + def shutdown(sig, frame): logger.info("Shutdown"); diag and diag.stop(); bridge.stop(); sys.exit(0) + signal.signal(signal.SIGINT, shutdown); signal.signal(signal.SIGTERM, shutdown) + bridge.start() + for c in bridge.clients: c.subscribe(f"{bridge.mqtt_prefix}/+/+/set/#") + interval = config["polling"]["interval_sec"] + while True: + try: + t0 = time.time(); devices = bridge.poll_and_publish(); elapsed = time.time() - t0 + STATS.record_poll(elapsed, devices); logger.debug(f"Poll: {devices} in {elapsed:.2f}s") + time.sleep(max(0, interval - elapsed)) + except Exception as e: import traceback; logger.error(f"Loop error: {e}\n{traceback.format_exc()}"); STATS.record_error(str(e)); time.sleep(interval) + +if __name__ == "__main__": main() diff --git a/ibsystem_dashboard.yaml b/ibsystem_dashboard.yaml new file mode 100644 index 0000000..3654aef --- /dev/null +++ b/ibsystem_dashboard.yaml @@ -0,0 +1,254 @@ +title: IBSystem Sterowniki +views: +- title: Wszystkie sterowniki + path: all + icon: mdi:lightbulb-group + cards: + - type: horizontal-stack + cards: + - type: button + name: WSZYSTKIE ON + icon: mdi:lightbulb-group + icon_height: 40px + tap_action: + action: call-service + service: switch.turn_on + target: + entity_id: + - switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + - type: button + name: WSZYSTKIE OFF + icon: mdi:lightbulb-group-off + icon_height: 40px + tap_action: + action: call-service + service: switch.turn_off + target: + entity_id: + - switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + - type: entities + title: Wiatrołap (ID30) + entities: + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + name: Oświetlenie H1 (Led bar AP) + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + name: Oświetlenie H2 led + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + name: Oświetlenie K1 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + name: Oświetlenie K2 szynoprzewód nowodworski + - type: entities + title: Kuchnia (ID31) + entities: + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + name: Oświetlenie K3 halogeny okno + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + name: Oświetlenie K4 lampy nad barem + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + name: Oświetlenie K5 półki led + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + name: Oświetlenie K6 led okno + - type: entities + title: Jadalnia / Salon (ID32) + entities: + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + name: Oświetlenie J1 + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + name: Oświetlenie J2 + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + name: Oświetlenie S1 Ring + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + name: Oświetlenie S2 lampy za kanapą + - type: entities + title: Salon (ID33) + entities: + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + name: Oświetlenie lampa przed tv + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + name: Oświetlenie led + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + name: Oświetlenie dekoracyjne koło lampy + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + name: Oświetlenie led sufit wnęka + - type: entities + title: Korytarz (ID34) + entities: + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + name: Oświetlenie H3 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + name: Oświetlenie H4 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + name: Oświetlenie H5 ledy pionowe + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + name: Oświetlenie H6 led kominek + - type: entities + title: Biuro Wojtek/ Garderoba (ID35) + entities: + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + name: Oświetlenie główne Biuro Wojtek + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + name: Oświetlenie Lampa nad biurkiem Wojtek + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + name: Oświetlenie G1 + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + name: Oświetlenie G2 led nad szafami i koło lustra + - type: entities + title: Sypialnia (ID36) + entities: + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + name: Oświetlenie R1 główne + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + name: Oświetlenie R2 nakastlik P + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + name: Oświetlenie R3 nakastlik L + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + name: Oświetlenie R4 led na suficie + - type: entities + title: Łazienka (ID37) + entities: + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + name: Oświetlenie L1 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + name: Oświetlenie L2 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + name: Oświetlenie L3 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + name: Oświetlenie L4 + - type: entities + title: Łazienka/WC (ID38) + entities: + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + name: Oświetlenie L5 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + name: Oświetlenie L6 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + name: Oświetlenie W1 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + name: Oświetlenie W2 + - type: entities + title: Pokój Basi (ID39) + entities: + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + name: Oświetlenie D1 główne + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + name: Oświetlenie D2 nad biurkiem + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + name: Oświetlenie D3 chmurka + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + name: Oświetlenie D4 łóżko + - type: entities + title: Biuro Ani (ID40) + entities: + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + name: Oświetlenie B1 główne + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + name: Oświetlenie B2 nad biurkiem + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + name: Oświetlenie B3 biurko lampa + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + name: Oświetlenie B4 dla ledów + - type: entities + title: WC/Poddasze (ID41) + entities: + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + name: Oświetlenie W2 góra halogeny + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + name: Oświetlenie W3 halogen prysznic + led + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + name: Oświetlenie P1 Poddasze 1 + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + name: Oświetlenie P2 Poddasze 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d941a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +ibls +*.bak +__pycache__/ +*.pyc diff --git a/ibsystem2mqtt.yaml b/ibsystem2mqtt.yaml new file mode 100644 index 0000000..c01f56d --- /dev/null +++ b/ibsystem2mqtt.yaml @@ -0,0 +1,114 @@ +mqtt: + brokers: + - name: ha + host: 192.168.50.151 + port: 1883 + user: mqtt + password: mqtt123 + prefix: ibsystem + ha_prefix: homeassistant +ibsystem: + host: 127.0.0.1 + port: 2001 + rs: 0 + max_ids: 40 + full_ids: + - 1 + - 4 + timeout_ms: 10000 +polling: + interval_sec: 2.0 + parallel_workers: 8 +logging: + level: INFO + file: /var/log/ibsystem2mqtt.log +http: + enabled: true + host: 0.0.0.0 + port: 8080 +friendly_names: + rs0_id2: ID2 Sterownik ogrzewania podłogowego - ID2 + rs0_id2_output_do_0: zawór mieszający Rozdzielacz R1-p.tech (zimnej, dobór z powrotu) + rs0_id2_output_do_1: zawór mieszający Rozdzielacz R1-p.tech (ciepłej, dobór z zasilania[bufor] + rs0_id2_output_do_2: zawór mieszający Rozdzielacz R2-salon (zimnej, dobór z powrotu) + rs0_id2_output_do_3: zawór mieszający Rozdzielacz R2-salon (ciepłej, dobór z zasilania[bufor] + rs0_id3: ID3 - ID3 + rs0_id3_output_do_0: siłownik rozdzielacz R1 - Basia + rs0_id3_output_do_1: siłownik rozdzielacz R1 - Ania + rs0_id3_output_do_2: siłownik pokoju - WC + rs0_id3_output_do_3: siłownik pokoju - Wiatrołap + rs0_id4: ID4 - ID4 + rs0_id4_output_do_0: siłownik rozdzielacz R2 - salon + rs0_id4_output_do_1: siłownik rozdzielacz R2 - biuro Wojtek + rs0_id4_output_do_2: siłownik rozdzielacz R2 - sypialnia + rs0_id4_output_do_3: siłownik rozdzielacz R2 - duża łazienka + rs0_id5: ID10 Sterownik kominka - ID5 + rs0_id5_output_do_0: pompa obiegowa kominka + rs0_id5_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id6: ID6 - ID6 + rs0_id6_output_do_0: pompa głębinowa + rs0_id6_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id30: Wiatrołap - ID30 + rs0_id30_output_do_0: Oświetlenie H1 (Led bar AP) + rs0_id30_output_do_1: Oświetlenie H2 led + rs0_id30_output_do_2: Oświetlenie K1 szynoprzewód nowodworski + rs0_id30_output_do_3: Oświetlenie K2 szynoprzewód nowodworski + rs0_id31: Kuchnia - ID31 + rs0_id31_output_do_0: Oświetlenie K3 halogeny okno + rs0_id31_output_do_1: Oświetlenie K4 lampy nad barem + rs0_id31_output_do_2: Oświetlenie K5 półki led + rs0_id31_output_do_3: Oświetlenie K6 led okno + rs0_id32: Jadalnia / Salon - ID32 + rs0_id32_output_do_0: Oświetlenie J1 + rs0_id32_output_do_1: Oświetlenie J2 + rs0_id32_output_do_2: Oświetlenie S1 Ring + rs0_id32_output_do_3: Oświetlenie S2 lampy za kanapą + rs0_id33: Salon - ID33 + rs0_id33_output_do_0: Oświetlenie lampa przed tv + rs0_id33_output_do_1: Oświetlenie led + rs0_id33_output_do_2: Oświetlenie dekoracyjne koło lampy + rs0_id33_output_do_3: Oświetlenie led sufit wnęka + rs0_id34: Korytarz - ID34 + rs0_id34_output_do_0: Oświetlenie H3 szynoprzewód nowodworski + rs0_id34_output_do_1: Oświetlenie H4 szynoprzewód nowodworski + rs0_id34_output_do_2: Oświetlenie H5 ledy pionowe + rs0_id34_output_do_3: Oświetlenie H6 led kominek + rs0_id35: Biuro Wojtek/ Garderoba - ID35 + rs0_id35_output_do_0: Oświetlenie główne Biuro Wojtek + rs0_id35_output_do_1: Oświetlenie Lampa nad biurkiem Wojtek + rs0_id35_output_do_2: Oświetlenie G1 + rs0_id35_output_do_3: Oświetlenie G2 led nad szafami i koło lustra + rs0_id36: Sypialnia - ID36 + rs0_id36_output_do_0: Oświetlenie R1 główne + rs0_id36_output_do_1: Oświetlenie R2 nakastlik P + rs0_id36_output_do_2: Oświetlenie R3 nakastlik L + rs0_id36_output_do_3: Oświetlenie R4 led na suficie + rs0_id37: Łazienka - ID37 + rs0_id37_output_do_0: Oświetlenie L1 + rs0_id37_output_do_1: Oświetlenie L2 + rs0_id37_output_do_2: Oświetlenie L3 + rs0_id37_output_do_3: Oświetlenie L4 + rs0_id38: Łazienka/WC - ID38 + rs0_id38_output_do_0: Oświetlenie L5 + rs0_id38_output_do_1: Oświetlenie L6 + rs0_id38_output_do_2: Oświetlenie W1 + rs0_id38_output_do_3: Oświetlenie W2 + rs0_id39: Pokój Basi - ID39 + rs0_id39_output_do_0: Oświetlenie D1 główne + rs0_id39_output_do_1: Oświetlenie D2 nad biurkiem + rs0_id39_output_do_2: Oświetlenie D3 chmurka + rs0_id39_output_do_3: Oświetlenie D4 łóżko + rs0_id40: Biuro Ani - ID40 + rs0_id40_output_do_0: Oświetlenie B1 główne + rs0_id40_output_do_1: Oświetlenie B2 nad biurkiem + rs0_id40_output_do_2: Oświetlenie B3 biurko lampa + rs0_id40_output_do_3: Oświetlenie B4 dla ledów + rs0_id41: WC/Poddasze - ID41 + rs0_id41_output_do_0: Oświetlenie W2 góra halogeny + rs0_id41_output_do_1: Oświetlenie W3 halogen prysznic + led + rs0_id41_output_do_2: Oświetlenie P1 Poddasze 1 + rs0_id41_output_do_3: Oświetlenie P2 Poddasze 2 diff --git a/ibsystem2mqtt_v5.py b/ibsystem2mqtt_v5.py new file mode 100755 index 0000000..3622504 --- /dev/null +++ b/ibsystem2mqtt_v5.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""IBSystem -> MQTT Bridge for Home Assistant v5""" +import subprocess, re, time, json, threading, logging, signal, sys +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from http.server import HTTPServer, BaseHTTPRequestHandler +from datetime import datetime +import paho.mqtt.client as mqtt +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +DEFAULT_CONFIG = {"mqtt": {"brokers": [{"name": "ha", "host": "ha.local", "port": 1883}], "user": "mqtt", "password": "mqtt123", "prefix": "ibsystem", "ha_prefix": "homeassistant"}, "ibsystem": {"host": "127.0.0.1", "port": 2001, "rs": 0, "max_ids": 40, "full_ids": [1], "timeout_ms": 10000}, "polling": {"interval_sec": 2.0, "parallel_workers": 8}, "logging": {"level": "INFO", "file": None}, "http": {"enabled": True, "host": "0.0.0.0", "port": 8080}, "friendly_names": {}} + +class BridgeStats: + def __init__(self): + self.start_time = datetime.now() + self.poll_cycles = 0 + self.last_poll_time = None + self.last_poll_duration = 0.0 + self.devices_discovered = 0 + self.entities_discovered = 0 + self.messages_received = 0 + self.commands_executed = 0 + self.errors = 0 + self.last_error = None + self._lock = threading.Lock() + def record_poll(self, duration, devices): + with self._lock: self.poll_cycles += 1; self.last_poll_time = datetime.now(); self.last_poll_duration = duration; self.devices_discovered = devices + def record_entity(self): + with self._lock: self.entities_discovered += 1 + def record_command(self): + with self._lock: self.commands_executed += 1; self.messages_received += 1 + def record_error(self, error): + with self._lock: self.errors += 1; self.last_error = f"{datetime.now().isoformat()}: {error}" + def to_dict(self): + with self._lock: + uptime = datetime.now() - self.start_time + return {"status": "running", "uptime_seconds": int(uptime.total_seconds()), "uptime_human": str(uptime).split('.')[0], "start_time": self.start_time.isoformat(), "poll_cycles": self.poll_cycles, "last_poll_time": self.last_poll_time.isoformat() if self.last_poll_time else None, "last_poll_duration_ms": round(self.last_poll_duration * 1000, 1), "devices_discovered": self.devices_discovered, "entities_discovered": self.entities_discovered, "messages_received": self.messages_received, "commands_executed": self.commands_executed, "errors": self.errors, "last_error": self.last_error} +STATS = BridgeStats() + +def setup_logging(config): + level = getattr(logging, config.get("level", "INFO").upper(), logging.INFO) + handlers = [logging.StreamHandler()] + if config.get("file"): handlers.append(logging.FileHandler(config["file"])) + logging.basicConfig(level=level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=handlers) + return logging.getLogger("ibsystem2mqtt") + +class FriendlyNames: + def __init__(self, config): self.names = config.get("friendly_names") or {} + def get_device_name(self, rs, dev_id): return self.names.get(f"rs{rs}_id{dev_id}", f"IBSystem RS{rs} ID{dev_id}") + def get_entity_name(self, rs, dev_id, path): + safe_path = re.sub(r"[^a-zA-Z0-9_]+", "_", path) + if f"rs{rs}_id{dev_id}_{safe_path}" in self.names: return self.names[f"rs{rs}_id{dev_id}_{safe_path}"] + device_name = self.names.get(f"rs{rs}_id{dev_id}") + readable = self._path_to_readable(path) + return readable + def _path_to_readable(self, path): + m = re.match(r"output\.do\.(\d+)$", path) + if m: return f"Wyjscie DO {m.group(1)}" + m = re.match(r"output\.px\.(\d+)$", path) + if m: return f"PWM {m.group(1)}" + m = re.match(r"input\.di\.(\d+)\.(a|b)$", path) + if m: return f"Wejscie DI {m.group(1)} {m.group(2).upper()}" + m = re.match(r"input\.t\.(\d+)\.value$", path) + if m: return f"Temperatura {m.group(1)}" + return path.replace(".", " ").title() + +@dataclass +class DeviceRecord: + rs: str + id: str + path: str + val: str + @property + def unique_id(self): return f"ibsystem_rs{self.rs}_id{self.id}_{re.sub(r'[^a-zA-Z0-9_]+', '_', self.path)}" + @property + def device_id(self): return f"ibsystem_rs{self.rs}_id{self.id}" + @property + def component(self): + if self.path.startswith("output.do."): return "switch" + if self.path.startswith("input.di."): return "binary_sensor" + return "sensor" + @property + def device_class(self): return "temperature" if "temp" in self.path.lower() or self.path.startswith("input.t.") else None + @property + def unit_of_measurement(self): return "C" if self.path.startswith("input.t.") and "value" in self.path else None + +class DiagnosticsHandler(BaseHTTPRequestHandler): + bridge = None + def log_message(self, format, *args): pass + def do_GET(self): + if self.path in ("/", "/status"): self._send_json(STATS.to_dict()) + elif self.path == "/health": self._send_json({"status": "ok"}) + elif self.path == "/config" and self.bridge: + cfg = json.loads(json.dumps(self.bridge.config)); cfg["mqtt"]["password"] = "***"; self._send_json(cfg) + elif self.path == "/entities" and self.bridge: + self._send_json({"count": len(self.bridge.discovered), "entities": sorted(list(self.bridge.discovered))}) + else: self.send_error(404) + def _send_json(self, data): + content = json.dumps(data, indent=2).encode() + self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", len(content)); self.end_headers(); self.wfile.write(content) + +class DiagnosticsServer: + def __init__(self, host, port, logger): self.host = host; self.port = port; self.logger = logger; self.server = None + def start(self, bridge): + DiagnosticsHandler.bridge = bridge + try: + self.server = HTTPServer((self.host, self.port), DiagnosticsHandler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.logger.info(f"Diagnostics: http://{self.host}:{self.port}") + except Exception as e: self.logger.error(f"Diagnostics failed: {e}") + def stop(self): + if self.server: self.server.shutdown() + +class IBLSRunner: + def __init__(self, host, port, timeout_ms, logger): self.host = host; self.port = port; self.timeout_ms = timeout_ms; self.logger = logger; self._lock = threading.Lock() + def run(self, command): + cmd = f'/ibsystem/ibls -a {self.host} --pretty -p {self.port} --timeout={self.timeout_ms} -c "{command}"' + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) + if result.returncode != 0: self.logger.warning(f"ibls failed: {result.stderr}"); STATS.record_error(f"ibls: {result.stderr[:50]}"); return None + return result.stdout.strip() + except Exception as e: self.logger.error(f"ibls error: {e}"); STATS.record_error(str(e)); return None + def get_device(self, rs, dev_id, full=False): + cmd = f"get(rs.{rs}.id.{dev_id};);" if full else f"get(rs.{rs}.id.{dev_id}.input;);get(rs.{rs}.id.{dev_id}.output;);" + output = self.run(cmd) + if not output: return [] + records = [] + for line in output.splitlines(): + m = re.match(r"rs\.(\d+)\.id\.(\d+)\.(.+?)\s*=\s*(.+)", line.strip()) + if m: records.append(DeviceRecord(rs=m.group(1), id=m.group(2), path=m.group(3).strip(), val=m.group(4).strip())) + return records + def set_output(self, rs, dev_id, output_path, value): + with self._lock: return self.run(f"set(rs.{rs}.id.{dev_id}.{output_path}={value};);") is not None + +class MQTTBridge: + def __init__(self, config, ibls, logger): + self.config = config; self.ibls = ibls; self.logger = logger + self.friendly_names = FriendlyNames(config) + self.mqtt_prefix = config["mqtt"]["prefix"]; self.ha_prefix = config["mqtt"]["ha_prefix"] + self.clients = []; self.discovered = set(); self.last_published = {} + self._cmd_lock = threading.Lock(); self._last_cmd = {} + def start(self): + for broker in self.config["mqtt"]["brokers"]: + client = mqtt.Client(client_id=f"ibsystem2mqtt-{broker['name']}", protocol=mqtt.MQTTv311) + client.username_pw_set(self.config["mqtt"]["user"], self.config["mqtt"]["password"]) + client.on_message = self._on_message; client._broker_name = broker["name"] + client.will_set(f"{self.mqtt_prefix}/bridge/availability", "offline", retain=True) + prefix = self.mqtt_prefix + logger = self.logger + def _make_on_connect(bname, pfx, lgr): + def on_connect(c, userdata, flags, rc): + if rc == 0: + c.subscribe(f"{pfx}/+/+/set/#") + lgr.info(f"(Re)connected+subscribed: {bname}") + else: + lgr.error(f"Connect failed {bname}: rc={rc}") + return on_connect + client.on_connect = _make_on_connect(broker['name'], prefix, logger) + try: + client.connect(broker["host"], broker["port"], 60); client.loop_start(); self.clients.append(client) + self.logger.info(f"Connected: {broker['name']} ({broker['host']})") + except Exception as e: self.logger.error(f"MQTT failed {broker['name']}: {e}"); STATS.record_error(f"MQTT: {broker['name']}") + self._publish_all(f"{self.mqtt_prefix}/bridge/availability", "online") + def stop(self): + self._publish_all(f"{self.mqtt_prefix}/bridge/availability", "offline") + for c in self.clients: c.loop_stop(); c.disconnect() + def _on_message(self, client, userdata, msg): + try: + STATS.record_command(); parts = msg.topic.split("/") + if len(parts) < 5 or parts[0] != self.mqtt_prefix or parts[3] != "set": return + rs, dev_id, target = parts[1].replace("rs",""), parts[2].replace("id",""), parts[4] + m = re.match(r"do(\d+)$", target) + if not m: return + do_num = m.group(1); payload = msg.payload.decode().strip().upper() + value = "1" if payload in ("ON","1","TRUE") else "0" if payload in ("OFF","0","FALSE") else None + if not value: return + key = (rs, dev_id, do_num, value); now = time.time() + with self._cmd_lock: + self._last_cmd = {k:v for k,v in self._last_cmd.items() if v > now-0.5} + if key in self._last_cmd: return + self._last_cmd[key] = now + self.logger.info(f"Command: rs{rs}/id{dev_id}/do{do_num} = {value}") + if self.ibls.set_output(int(rs), int(dev_id), f"setting.light.{do_num}", value): + self._publish_all(f"{self.mqtt_prefix}/rs{rs}/id{dev_id}/output/do/{do_num}/state", "ON" if value=="1" else "OFF") + except Exception as e: self.logger.error(f"Message error: {e}"); STATS.record_error(str(e)) + def poll_and_publish(self): + cfg = self.config["ibsystem"]; rs = cfg["rs"]; full_ids = set(cfg.get("full_ids", [])) + all_records = []; devices = 0 + with ThreadPoolExecutor(max_workers=self.config["polling"].get("parallel_workers", 4)) as ex: + futures = {ex.submit(self.ibls.get_device, rs, i, i in full_ids): i for i in range(1, cfg["max_ids"]+1)} + for f in as_completed(futures): + try: + recs = f.result() + if recs: devices += 1; all_records.extend(recs) + except: pass + for rec in all_records: self._publish_discovery(rec); self._publish_state(rec) + return devices + def _publish_discovery(self, rec): + if rec.unique_id in self.discovered: return + component = rec.component; state_topic = self._state_topic(rec) + payload = {"has_entity_name": False, "name": self.friendly_names.get_entity_name(rec.rs, rec.id, rec.path), "state_topic": state_topic, "unique_id": rec.unique_id, "availability_topic": f"{self.mqtt_prefix}/bridge/availability", "device": {"identifiers": [rec.device_id], "name": self.friendly_names.get_device_name(rec.rs, rec.id), "manufacturer": "IBSystem"}} + if component == "switch": + m = re.match(r"output\.do\.(\d+)$", rec.path) + if m: payload.update({"command_topic": f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/set/do{m.group(1)}", "payload_on": "ON", "payload_off": "OFF", "state_on": "ON", "state_off": "OFF"}) + elif component == "binary_sensor": payload.update({"payload_on": "1", "payload_off": "0"}) + elif rec.device_class: payload["device_class"] = rec.device_class + if rec.unit_of_measurement: payload["unit_of_measurement"] = rec.unit_of_measurement; payload["value_template"] = "{{ value | float / 10 }}" if rec.device_class == "temperature" else None + self._publish_all(f"{self.ha_prefix}/{component}/{rec.unique_id}/config", json.dumps(payload)) + self.discovered.add(rec.unique_id); STATS.record_entity() + def _publish_state(self, rec): + topic = self._state_topic(rec) + value = ("ON" if rec.val in ("1","true","ON") else "OFF") if rec.component == "switch" else rec.val + for c in self.clients: + cache = self.last_published.setdefault(c._broker_name, {}) + if cache.get(topic) != value: cache[topic] = value; c.publish(topic, value, retain=True) + def _state_topic(self, rec): + p = rec.path + m = re.match(r"output\.do\.(\d+)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/output/do/{m.group(1)}/state" + m = re.match(r"input\.di\.(\d+)\.(a|b)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/input/di/{m.group(1)}/{m.group(2)}" + m = re.match(r"input\.t\.(\d+)\.(\w+)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/input/t/{m.group(1)}/{m.group(2)}" + return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/{p.replace('.', '/')}" + def _publish_all(self, topic, payload, retain=True): + for c in self.clients: c.publish(topic, payload, retain=retain) + +def load_config(path=None): + if path and HAS_YAML and Path(path).exists(): + with open(path) as f: user = yaml.safe_load(f) + cfg = json.loads(json.dumps(DEFAULT_CONFIG)) + for k,v in user.items(): cfg[k].update(v) if isinstance(v,dict) and k in cfg else cfg.update({k:v}) + return cfg + return json.loads(json.dumps(DEFAULT_CONFIG)) + +def main(): + import argparse + p = argparse.ArgumentParser(); p.add_argument("-c", "--config"); p.add_argument("-v", "--verbose", action="store_true") + args = p.parse_args(); config = load_config(args.config) + if args.verbose: config["logging"]["level"] = "DEBUG" + logger = setup_logging(config["logging"]); logger.info("Starting IBSystem2MQTT v5") + ibls = IBLSRunner(config["ibsystem"]["host"], config["ibsystem"]["port"], config["ibsystem"]["timeout_ms"], logger) + bridge = MQTTBridge(config, ibls, logger) + diag = DiagnosticsServer(config["http"]["host"], config["http"]["port"], logger) if config.get("http",{}).get("enabled") else None + if diag: diag.start(bridge) + def shutdown(sig, frame): logger.info("Shutdown"); diag and diag.stop(); bridge.stop(); sys.exit(0) + signal.signal(signal.SIGINT, shutdown); signal.signal(signal.SIGTERM, shutdown) + bridge.start() + for c in bridge.clients: c.subscribe(f"{bridge.mqtt_prefix}/+/+/set/#") + interval = config["polling"]["interval_sec"] + while True: + try: + t0 = time.time(); devices = bridge.poll_and_publish(); elapsed = time.time() - t0 + STATS.record_poll(elapsed, devices); logger.debug(f"Poll: {devices} in {elapsed:.2f}s") + time.sleep(max(0, interval - elapsed)) + except Exception as e: import traceback; logger.error(f"Loop error: {e}\n{traceback.format_exc()}"); STATS.record_error(str(e)); time.sleep(interval) + +if __name__ == "__main__": main() diff --git a/ibsystem_dashboard.yaml b/ibsystem_dashboard.yaml new file mode 100644 index 0000000..3654aef --- /dev/null +++ b/ibsystem_dashboard.yaml @@ -0,0 +1,254 @@ +title: IBSystem Sterowniki +views: +- title: Wszystkie sterowniki + path: all + icon: mdi:lightbulb-group + cards: + - type: horizontal-stack + cards: + - type: button + name: WSZYSTKIE ON + icon: mdi:lightbulb-group + icon_height: 40px + tap_action: + action: call-service + service: switch.turn_on + target: + entity_id: + - switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + - type: button + name: WSZYSTKIE OFF + icon: mdi:lightbulb-group-off + icon_height: 40px + tap_action: + action: call-service + service: switch.turn_off + target: + entity_id: + - switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + - type: entities + title: Wiatrołap (ID30) + entities: + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + name: Oświetlenie H1 (Led bar AP) + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + name: Oświetlenie H2 led + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + name: Oświetlenie K1 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + name: Oświetlenie K2 szynoprzewód nowodworski + - type: entities + title: Kuchnia (ID31) + entities: + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + name: Oświetlenie K3 halogeny okno + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + name: Oświetlenie K4 lampy nad barem + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + name: Oświetlenie K5 półki led + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + name: Oświetlenie K6 led okno + - type: entities + title: Jadalnia / Salon (ID32) + entities: + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + name: Oświetlenie J1 + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + name: Oświetlenie J2 + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + name: Oświetlenie S1 Ring + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + name: Oświetlenie S2 lampy za kanapą + - type: entities + title: Salon (ID33) + entities: + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + name: Oświetlenie lampa przed tv + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + name: Oświetlenie led + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + name: Oświetlenie dekoracyjne koło lampy + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + name: Oświetlenie led sufit wnęka + - type: entities + title: Korytarz (ID34) + entities: + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + name: Oświetlenie H3 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + name: Oświetlenie H4 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + name: Oświetlenie H5 ledy pionowe + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + name: Oświetlenie H6 led kominek + - type: entities + title: Biuro Wojtek/ Garderoba (ID35) + entities: + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + name: Oświetlenie główne Biuro Wojtek + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + name: Oświetlenie Lampa nad biurkiem Wojtek + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + name: Oświetlenie G1 + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + name: Oświetlenie G2 led nad szafami i koło lustra + - type: entities + title: Sypialnia (ID36) + entities: + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + name: Oświetlenie R1 główne + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + name: Oświetlenie R2 nakastlik P + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + name: Oświetlenie R3 nakastlik L + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + name: Oświetlenie R4 led na suficie + - type: entities + title: Łazienka (ID37) + entities: + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + name: Oświetlenie L1 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + name: Oświetlenie L2 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + name: Oświetlenie L3 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + name: Oświetlenie L4 + - type: entities + title: Łazienka/WC (ID38) + entities: + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + name: Oświetlenie L5 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + name: Oświetlenie L6 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + name: Oświetlenie W1 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + name: Oświetlenie W2 + - type: entities + title: Pokój Basi (ID39) + entities: + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + name: Oświetlenie D1 główne + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + name: Oświetlenie D2 nad biurkiem + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + name: Oświetlenie D3 chmurka + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + name: Oświetlenie D4 łóżko + - type: entities + title: Biuro Ani (ID40) + entities: + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + name: Oświetlenie B1 główne + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + name: Oświetlenie B2 nad biurkiem + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + name: Oświetlenie B3 biurko lampa + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + name: Oświetlenie B4 dla ledów + - type: entities + title: WC/Poddasze (ID41) + entities: + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + name: Oświetlenie W2 góra halogeny + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + name: Oświetlenie W3 halogen prysznic + led + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + name: Oświetlenie P1 Poddasze 1 + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + name: Oświetlenie P2 Poddasze 2 diff --git a/sync_names.py b/sync_names.py new file mode 100755 index 0000000..adf375c --- /dev/null +++ b/sync_names.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Synchronizuje friendly_names i dashboard IBSystem z dokumentacji projektu. +Źródło: /smart-home/proj-ib-lighting-rs485-zarki.yaml +""" +import yaml, re, sys, os, subprocess, shutil, io +from datetime import datetime +import copy + +PROJECT_YAML = "/smart-home/proj-ib-lighting-rs485-zarki.yaml" +CONFIG_YAML = "/ibsystem/ibsystem2mqtt.yaml" +DASHBOARD_LOCAL = "/ibsystem/ibsystem_dashboard.yaml" +DASHBOARD_HA = "root@192.168.50.151:/config/dashboards/ibsystem_dashboard.yaml" +HA_SSH_PORT = 2222 +MQTT_HOST = "192.168.50.151" +MQTT_USER = "mqtt" +MQTT_PASS = "mqtt123" + +# Zakresy ID sterowników oświetlenia (pomijamy ogrzewanie itp.) +LIGHTING_ID_MIN = 30 +LIGHTING_ID_MAX = 99 + +def log(msg): print(f"[{datetime.now():%H:%M:%S}] {msg}") + +def device_id_to_rs_id(device_id): + m = re.match(r"rs\.(\d+)\.id\.(\d+)", device_id) + return (m.group(1), m.group(2)) if m else (None, None) + +def io_key(rs, dev_id, io_id): + safe = re.sub(r"[^a-zA-Z0-9]+", "_", io_id).strip("_") + return f"rs{rs}_id{dev_id}_{safe}" + +def entity_id(rs, dev_id, io_id): + safe = re.sub(r"[^a-zA-Z0-9]+", "_", io_id).strip("_") + return f"switch.ibsystem_rs{rs}_id{dev_id}_rs{rs}_id{dev_id}_{safe}" + +def parse_project_yaml(path): + with open(path) as f: + data = yaml.safe_load(f) + devices = data["project"]["hardware_specification"]["devices"] + result = [] + for dev in devices: + device_id = dev.get("device_id", "") + location = dev.get("location", "").strip() + rs, dev_id = device_id_to_rs_id(device_id) + if not rs: + continue + io_list = [] + for io in dev.get("io", []): + io_id = io.get("io_id", "") + desc = io.get("description", "").strip() + status = io.get("status", "ok") + if io_id.startswith("output.do.") and desc and status != "nc" \ + and not desc.startswith("Opcjonalne"): + io_list.append({"io_id": io_id, "description": desc}) + result.append({ + "device_id": device_id, + "rs": rs, + "dev_id": dev_id, + "location": location, + "io": io_list, + }) + return result + +def build_friendly_names(devices): + names = {} + for dev in devices: + rs, dev_id, location = dev["rs"], dev["dev_id"], dev["location"] + if location: + names[f"rs{rs}_id{dev_id}"] = f"{location} - ID{dev_id}" + for io in dev["io"]: + names[io_key(rs, dev_id, io["io_id"])] = io["description"] + return names + +def build_dashboard(devices): + # Tylko sterowniki oświetlenia (ID30+) + light_devs = [d for d in devices + if d["io"] and LIGHTING_ID_MIN <= int(d["dev_id"]) <= LIGHTING_ID_MAX] + + # Lista wszystkich switch entity_id dla WSZYSTKIE ON/OFF + all_switches = [] + for dev in light_devs: + rs, dev_id = dev["rs"], dev["dev_id"] + for io in dev["io"]: + all_switches.append(entity_id(rs, dev_id, io["io_id"])) + + # Karty per urządzenie + device_cards = [] + for dev in light_devs: + rs, dev_id, location = dev["rs"], dev["dev_id"], dev["location"] + title = f"{location} (ID{dev_id})" if location else f"ID{dev_id}" + entities = [] + for io in dev["io"]: + entities.append({ + "entity": entity_id(rs, dev_id, io["io_id"]), + "name": io["description"], + }) + device_cards.append({ + "type": "entities", + "title": title, + "entities": entities, + }) + + dashboard = { + "title": "IBSystem Sterowniki", + "views": [{ + "title": "Wszystkie sterowniki", + "path": "all", + "icon": "mdi:lightbulb-group", + "cards": [ + { + "type": "horizontal-stack", + "cards": [ + { + "type": "button", + "name": "WSZYSTKIE ON", + "icon": "mdi:lightbulb-group", + "icon_height": "40px", + "tap_action": { + "action": "call-service", + "service": "switch.turn_on", + "target": {"entity_id": copy.deepcopy(all_switches)}, + }, + }, + { + "type": "button", + "name": "WSZYSTKIE OFF", + "icon": "mdi:lightbulb-group-off", + "icon_height": "40px", + "tap_action": { + "action": "call-service", + "service": "switch.turn_off", + "target": {"entity_id": copy.deepcopy(all_switches)}, + }, + }, + ], + }, + ] + device_cards, + }], + } + return dashboard + +def update_friendly_names(config_path, new_names, dry_run=False): + with open(config_path) as f: + config = yaml.safe_load(f) + old_names = config.get("friendly_names") or {} + changed = {k for k in (set(old_names) | set(new_names)) if old_names.get(k) != new_names.get(k)} + if not changed: + log("friendly_names: bez zmian.") + return False + log(f"friendly_names: {len(changed)} zmian.") + if dry_run: + for k in sorted(changed): + print(f" {k}: {old_names.get(k)!r} → {new_names.get(k)!r}") + return True + shutil.copy(config_path, config_path + ".bak") + config["friendly_names"] = new_names + with open(config_path, "w") as f: + yaml.dump(config, f, allow_unicode=True, default_flow_style=False, sort_keys=False) + log(f" Zapisano {config_path}") + return True + +def update_dashboard(dashboard, local_path, ha_path, dry_run=False): + # Serializuj do YAML + buf = io.StringIO() + yaml.dump(dashboard, buf, allow_unicode=True, default_flow_style=False, + sort_keys=False, width=200) + new_content = buf.getvalue() + + # Sprawdź czy się zmieniło + try: + with open(local_path) as f: + old_content = f.read() + if old_content == new_content: + log("dashboard: bez zmian.") + return False + except FileNotFoundError: + pass + + log("dashboard: aktualizuję.") + if dry_run: + log(" (dry-run, nie zapisuję)") + return True + + with open(local_path, "w") as f: + f.write(new_content) + + # Kopiuj na HA przez SSH + host, remote_path = ha_path.split(":", 1) + result = subprocess.run( + ["scp", "-P", str(HA_SSH_PORT), "-o", "StrictHostKeyChecking=no", + local_path, ha_path], + capture_output=True, text=True + ) + if result.returncode != 0: + log(f" BŁĄD SCP: {result.stderr.strip()}") + else: + log(f" Skopiowano → {ha_path}") + return True + +def reload_ibsystem(): + log("Czyszczę MQTT discovery cache...") + os.system( + f"timeout 5 mosquitto_sub -h {MQTT_HOST} -u {MQTT_USER} -P {MQTT_PASS} " + f"-t 'homeassistant/#' --retained-only -v 2>/dev/null | " + f"grep '/config' | grep 'ibsystem' | awk '{{print $1}}' | " + f"while read t; do mosquitto_pub -h {MQTT_HOST} -u {MQTT_USER} -P {MQTT_PASS} " + f"-t \"$t\" -n -r; done" + ) + log("Restartuję ibsystem2mqtt...") + subprocess.run(["systemctl", "restart", "ibsystem2mqtt"], check=True) + +def main(): + dry_run = "--dry-run" in sys.argv + log(f"Parsing {PROJECT_YAML}...") + + try: + devices = parse_project_yaml(PROJECT_YAML) + except Exception as e: + log(f"BŁĄD parsowania: {e}") + sys.exit(1) + + log(f" {len(devices)} urządzeń, " + f"{sum(len(d['io']) for d in devices)} wyjść DO.") + + names_changed = update_friendly_names(CONFIG_YAML, build_friendly_names(devices), dry_run) + dashboard_changed = update_dashboard(build_dashboard(devices), DASHBOARD_LOCAL, + DASHBOARD_HA, dry_run) + + if (names_changed or dashboard_changed) and not dry_run: + reload_ibsystem() + else: + log("Brak zmian wymagających restartu.") + +if __name__ == "__main__": + main() diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d941a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +ibls +*.bak +__pycache__/ +*.pyc diff --git a/ibsystem2mqtt.yaml b/ibsystem2mqtt.yaml new file mode 100644 index 0000000..c01f56d --- /dev/null +++ b/ibsystem2mqtt.yaml @@ -0,0 +1,114 @@ +mqtt: + brokers: + - name: ha + host: 192.168.50.151 + port: 1883 + user: mqtt + password: mqtt123 + prefix: ibsystem + ha_prefix: homeassistant +ibsystem: + host: 127.0.0.1 + port: 2001 + rs: 0 + max_ids: 40 + full_ids: + - 1 + - 4 + timeout_ms: 10000 +polling: + interval_sec: 2.0 + parallel_workers: 8 +logging: + level: INFO + file: /var/log/ibsystem2mqtt.log +http: + enabled: true + host: 0.0.0.0 + port: 8080 +friendly_names: + rs0_id2: ID2 Sterownik ogrzewania podłogowego - ID2 + rs0_id2_output_do_0: zawór mieszający Rozdzielacz R1-p.tech (zimnej, dobór z powrotu) + rs0_id2_output_do_1: zawór mieszający Rozdzielacz R1-p.tech (ciepłej, dobór z zasilania[bufor] + rs0_id2_output_do_2: zawór mieszający Rozdzielacz R2-salon (zimnej, dobór z powrotu) + rs0_id2_output_do_3: zawór mieszający Rozdzielacz R2-salon (ciepłej, dobór z zasilania[bufor] + rs0_id3: ID3 - ID3 + rs0_id3_output_do_0: siłownik rozdzielacz R1 - Basia + rs0_id3_output_do_1: siłownik rozdzielacz R1 - Ania + rs0_id3_output_do_2: siłownik pokoju - WC + rs0_id3_output_do_3: siłownik pokoju - Wiatrołap + rs0_id4: ID4 - ID4 + rs0_id4_output_do_0: siłownik rozdzielacz R2 - salon + rs0_id4_output_do_1: siłownik rozdzielacz R2 - biuro Wojtek + rs0_id4_output_do_2: siłownik rozdzielacz R2 - sypialnia + rs0_id4_output_do_3: siłownik rozdzielacz R2 - duża łazienka + rs0_id5: ID10 Sterownik kominka - ID5 + rs0_id5_output_do_0: pompa obiegowa kominka + rs0_id5_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id5_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id6: ID6 - ID6 + rs0_id6_output_do_0: pompa głębinowa + rs0_id6_output_do_1: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_2: elektro zawór zraszaczy sekcja X + rs0_id6_output_do_3: elektro zawór zraszaczy sekcja X + rs0_id30: Wiatrołap - ID30 + rs0_id30_output_do_0: Oświetlenie H1 (Led bar AP) + rs0_id30_output_do_1: Oświetlenie H2 led + rs0_id30_output_do_2: Oświetlenie K1 szynoprzewód nowodworski + rs0_id30_output_do_3: Oświetlenie K2 szynoprzewód nowodworski + rs0_id31: Kuchnia - ID31 + rs0_id31_output_do_0: Oświetlenie K3 halogeny okno + rs0_id31_output_do_1: Oświetlenie K4 lampy nad barem + rs0_id31_output_do_2: Oświetlenie K5 półki led + rs0_id31_output_do_3: Oświetlenie K6 led okno + rs0_id32: Jadalnia / Salon - ID32 + rs0_id32_output_do_0: Oświetlenie J1 + rs0_id32_output_do_1: Oświetlenie J2 + rs0_id32_output_do_2: Oświetlenie S1 Ring + rs0_id32_output_do_3: Oświetlenie S2 lampy za kanapą + rs0_id33: Salon - ID33 + rs0_id33_output_do_0: Oświetlenie lampa przed tv + rs0_id33_output_do_1: Oświetlenie led + rs0_id33_output_do_2: Oświetlenie dekoracyjne koło lampy + rs0_id33_output_do_3: Oświetlenie led sufit wnęka + rs0_id34: Korytarz - ID34 + rs0_id34_output_do_0: Oświetlenie H3 szynoprzewód nowodworski + rs0_id34_output_do_1: Oświetlenie H4 szynoprzewód nowodworski + rs0_id34_output_do_2: Oświetlenie H5 ledy pionowe + rs0_id34_output_do_3: Oświetlenie H6 led kominek + rs0_id35: Biuro Wojtek/ Garderoba - ID35 + rs0_id35_output_do_0: Oświetlenie główne Biuro Wojtek + rs0_id35_output_do_1: Oświetlenie Lampa nad biurkiem Wojtek + rs0_id35_output_do_2: Oświetlenie G1 + rs0_id35_output_do_3: Oświetlenie G2 led nad szafami i koło lustra + rs0_id36: Sypialnia - ID36 + rs0_id36_output_do_0: Oświetlenie R1 główne + rs0_id36_output_do_1: Oświetlenie R2 nakastlik P + rs0_id36_output_do_2: Oświetlenie R3 nakastlik L + rs0_id36_output_do_3: Oświetlenie R4 led na suficie + rs0_id37: Łazienka - ID37 + rs0_id37_output_do_0: Oświetlenie L1 + rs0_id37_output_do_1: Oświetlenie L2 + rs0_id37_output_do_2: Oświetlenie L3 + rs0_id37_output_do_3: Oświetlenie L4 + rs0_id38: Łazienka/WC - ID38 + rs0_id38_output_do_0: Oświetlenie L5 + rs0_id38_output_do_1: Oświetlenie L6 + rs0_id38_output_do_2: Oświetlenie W1 + rs0_id38_output_do_3: Oświetlenie W2 + rs0_id39: Pokój Basi - ID39 + rs0_id39_output_do_0: Oświetlenie D1 główne + rs0_id39_output_do_1: Oświetlenie D2 nad biurkiem + rs0_id39_output_do_2: Oświetlenie D3 chmurka + rs0_id39_output_do_3: Oświetlenie D4 łóżko + rs0_id40: Biuro Ani - ID40 + rs0_id40_output_do_0: Oświetlenie B1 główne + rs0_id40_output_do_1: Oświetlenie B2 nad biurkiem + rs0_id40_output_do_2: Oświetlenie B3 biurko lampa + rs0_id40_output_do_3: Oświetlenie B4 dla ledów + rs0_id41: WC/Poddasze - ID41 + rs0_id41_output_do_0: Oświetlenie W2 góra halogeny + rs0_id41_output_do_1: Oświetlenie W3 halogen prysznic + led + rs0_id41_output_do_2: Oświetlenie P1 Poddasze 1 + rs0_id41_output_do_3: Oświetlenie P2 Poddasze 2 diff --git a/ibsystem2mqtt_v5.py b/ibsystem2mqtt_v5.py new file mode 100755 index 0000000..3622504 --- /dev/null +++ b/ibsystem2mqtt_v5.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""IBSystem -> MQTT Bridge for Home Assistant v5""" +import subprocess, re, time, json, threading, logging, signal, sys +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from http.server import HTTPServer, BaseHTTPRequestHandler +from datetime import datetime +import paho.mqtt.client as mqtt +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +DEFAULT_CONFIG = {"mqtt": {"brokers": [{"name": "ha", "host": "ha.local", "port": 1883}], "user": "mqtt", "password": "mqtt123", "prefix": "ibsystem", "ha_prefix": "homeassistant"}, "ibsystem": {"host": "127.0.0.1", "port": 2001, "rs": 0, "max_ids": 40, "full_ids": [1], "timeout_ms": 10000}, "polling": {"interval_sec": 2.0, "parallel_workers": 8}, "logging": {"level": "INFO", "file": None}, "http": {"enabled": True, "host": "0.0.0.0", "port": 8080}, "friendly_names": {}} + +class BridgeStats: + def __init__(self): + self.start_time = datetime.now() + self.poll_cycles = 0 + self.last_poll_time = None + self.last_poll_duration = 0.0 + self.devices_discovered = 0 + self.entities_discovered = 0 + self.messages_received = 0 + self.commands_executed = 0 + self.errors = 0 + self.last_error = None + self._lock = threading.Lock() + def record_poll(self, duration, devices): + with self._lock: self.poll_cycles += 1; self.last_poll_time = datetime.now(); self.last_poll_duration = duration; self.devices_discovered = devices + def record_entity(self): + with self._lock: self.entities_discovered += 1 + def record_command(self): + with self._lock: self.commands_executed += 1; self.messages_received += 1 + def record_error(self, error): + with self._lock: self.errors += 1; self.last_error = f"{datetime.now().isoformat()}: {error}" + def to_dict(self): + with self._lock: + uptime = datetime.now() - self.start_time + return {"status": "running", "uptime_seconds": int(uptime.total_seconds()), "uptime_human": str(uptime).split('.')[0], "start_time": self.start_time.isoformat(), "poll_cycles": self.poll_cycles, "last_poll_time": self.last_poll_time.isoformat() if self.last_poll_time else None, "last_poll_duration_ms": round(self.last_poll_duration * 1000, 1), "devices_discovered": self.devices_discovered, "entities_discovered": self.entities_discovered, "messages_received": self.messages_received, "commands_executed": self.commands_executed, "errors": self.errors, "last_error": self.last_error} +STATS = BridgeStats() + +def setup_logging(config): + level = getattr(logging, config.get("level", "INFO").upper(), logging.INFO) + handlers = [logging.StreamHandler()] + if config.get("file"): handlers.append(logging.FileHandler(config["file"])) + logging.basicConfig(level=level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=handlers) + return logging.getLogger("ibsystem2mqtt") + +class FriendlyNames: + def __init__(self, config): self.names = config.get("friendly_names") or {} + def get_device_name(self, rs, dev_id): return self.names.get(f"rs{rs}_id{dev_id}", f"IBSystem RS{rs} ID{dev_id}") + def get_entity_name(self, rs, dev_id, path): + safe_path = re.sub(r"[^a-zA-Z0-9_]+", "_", path) + if f"rs{rs}_id{dev_id}_{safe_path}" in self.names: return self.names[f"rs{rs}_id{dev_id}_{safe_path}"] + device_name = self.names.get(f"rs{rs}_id{dev_id}") + readable = self._path_to_readable(path) + return readable + def _path_to_readable(self, path): + m = re.match(r"output\.do\.(\d+)$", path) + if m: return f"Wyjscie DO {m.group(1)}" + m = re.match(r"output\.px\.(\d+)$", path) + if m: return f"PWM {m.group(1)}" + m = re.match(r"input\.di\.(\d+)\.(a|b)$", path) + if m: return f"Wejscie DI {m.group(1)} {m.group(2).upper()}" + m = re.match(r"input\.t\.(\d+)\.value$", path) + if m: return f"Temperatura {m.group(1)}" + return path.replace(".", " ").title() + +@dataclass +class DeviceRecord: + rs: str + id: str + path: str + val: str + @property + def unique_id(self): return f"ibsystem_rs{self.rs}_id{self.id}_{re.sub(r'[^a-zA-Z0-9_]+', '_', self.path)}" + @property + def device_id(self): return f"ibsystem_rs{self.rs}_id{self.id}" + @property + def component(self): + if self.path.startswith("output.do."): return "switch" + if self.path.startswith("input.di."): return "binary_sensor" + return "sensor" + @property + def device_class(self): return "temperature" if "temp" in self.path.lower() or self.path.startswith("input.t.") else None + @property + def unit_of_measurement(self): return "C" if self.path.startswith("input.t.") and "value" in self.path else None + +class DiagnosticsHandler(BaseHTTPRequestHandler): + bridge = None + def log_message(self, format, *args): pass + def do_GET(self): + if self.path in ("/", "/status"): self._send_json(STATS.to_dict()) + elif self.path == "/health": self._send_json({"status": "ok"}) + elif self.path == "/config" and self.bridge: + cfg = json.loads(json.dumps(self.bridge.config)); cfg["mqtt"]["password"] = "***"; self._send_json(cfg) + elif self.path == "/entities" and self.bridge: + self._send_json({"count": len(self.bridge.discovered), "entities": sorted(list(self.bridge.discovered))}) + else: self.send_error(404) + def _send_json(self, data): + content = json.dumps(data, indent=2).encode() + self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", len(content)); self.end_headers(); self.wfile.write(content) + +class DiagnosticsServer: + def __init__(self, host, port, logger): self.host = host; self.port = port; self.logger = logger; self.server = None + def start(self, bridge): + DiagnosticsHandler.bridge = bridge + try: + self.server = HTTPServer((self.host, self.port), DiagnosticsHandler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.logger.info(f"Diagnostics: http://{self.host}:{self.port}") + except Exception as e: self.logger.error(f"Diagnostics failed: {e}") + def stop(self): + if self.server: self.server.shutdown() + +class IBLSRunner: + def __init__(self, host, port, timeout_ms, logger): self.host = host; self.port = port; self.timeout_ms = timeout_ms; self.logger = logger; self._lock = threading.Lock() + def run(self, command): + cmd = f'/ibsystem/ibls -a {self.host} --pretty -p {self.port} --timeout={self.timeout_ms} -c "{command}"' + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) + if result.returncode != 0: self.logger.warning(f"ibls failed: {result.stderr}"); STATS.record_error(f"ibls: {result.stderr[:50]}"); return None + return result.stdout.strip() + except Exception as e: self.logger.error(f"ibls error: {e}"); STATS.record_error(str(e)); return None + def get_device(self, rs, dev_id, full=False): + cmd = f"get(rs.{rs}.id.{dev_id};);" if full else f"get(rs.{rs}.id.{dev_id}.input;);get(rs.{rs}.id.{dev_id}.output;);" + output = self.run(cmd) + if not output: return [] + records = [] + for line in output.splitlines(): + m = re.match(r"rs\.(\d+)\.id\.(\d+)\.(.+?)\s*=\s*(.+)", line.strip()) + if m: records.append(DeviceRecord(rs=m.group(1), id=m.group(2), path=m.group(3).strip(), val=m.group(4).strip())) + return records + def set_output(self, rs, dev_id, output_path, value): + with self._lock: return self.run(f"set(rs.{rs}.id.{dev_id}.{output_path}={value};);") is not None + +class MQTTBridge: + def __init__(self, config, ibls, logger): + self.config = config; self.ibls = ibls; self.logger = logger + self.friendly_names = FriendlyNames(config) + self.mqtt_prefix = config["mqtt"]["prefix"]; self.ha_prefix = config["mqtt"]["ha_prefix"] + self.clients = []; self.discovered = set(); self.last_published = {} + self._cmd_lock = threading.Lock(); self._last_cmd = {} + def start(self): + for broker in self.config["mqtt"]["brokers"]: + client = mqtt.Client(client_id=f"ibsystem2mqtt-{broker['name']}", protocol=mqtt.MQTTv311) + client.username_pw_set(self.config["mqtt"]["user"], self.config["mqtt"]["password"]) + client.on_message = self._on_message; client._broker_name = broker["name"] + client.will_set(f"{self.mqtt_prefix}/bridge/availability", "offline", retain=True) + prefix = self.mqtt_prefix + logger = self.logger + def _make_on_connect(bname, pfx, lgr): + def on_connect(c, userdata, flags, rc): + if rc == 0: + c.subscribe(f"{pfx}/+/+/set/#") + lgr.info(f"(Re)connected+subscribed: {bname}") + else: + lgr.error(f"Connect failed {bname}: rc={rc}") + return on_connect + client.on_connect = _make_on_connect(broker['name'], prefix, logger) + try: + client.connect(broker["host"], broker["port"], 60); client.loop_start(); self.clients.append(client) + self.logger.info(f"Connected: {broker['name']} ({broker['host']})") + except Exception as e: self.logger.error(f"MQTT failed {broker['name']}: {e}"); STATS.record_error(f"MQTT: {broker['name']}") + self._publish_all(f"{self.mqtt_prefix}/bridge/availability", "online") + def stop(self): + self._publish_all(f"{self.mqtt_prefix}/bridge/availability", "offline") + for c in self.clients: c.loop_stop(); c.disconnect() + def _on_message(self, client, userdata, msg): + try: + STATS.record_command(); parts = msg.topic.split("/") + if len(parts) < 5 or parts[0] != self.mqtt_prefix or parts[3] != "set": return + rs, dev_id, target = parts[1].replace("rs",""), parts[2].replace("id",""), parts[4] + m = re.match(r"do(\d+)$", target) + if not m: return + do_num = m.group(1); payload = msg.payload.decode().strip().upper() + value = "1" if payload in ("ON","1","TRUE") else "0" if payload in ("OFF","0","FALSE") else None + if not value: return + key = (rs, dev_id, do_num, value); now = time.time() + with self._cmd_lock: + self._last_cmd = {k:v for k,v in self._last_cmd.items() if v > now-0.5} + if key in self._last_cmd: return + self._last_cmd[key] = now + self.logger.info(f"Command: rs{rs}/id{dev_id}/do{do_num} = {value}") + if self.ibls.set_output(int(rs), int(dev_id), f"setting.light.{do_num}", value): + self._publish_all(f"{self.mqtt_prefix}/rs{rs}/id{dev_id}/output/do/{do_num}/state", "ON" if value=="1" else "OFF") + except Exception as e: self.logger.error(f"Message error: {e}"); STATS.record_error(str(e)) + def poll_and_publish(self): + cfg = self.config["ibsystem"]; rs = cfg["rs"]; full_ids = set(cfg.get("full_ids", [])) + all_records = []; devices = 0 + with ThreadPoolExecutor(max_workers=self.config["polling"].get("parallel_workers", 4)) as ex: + futures = {ex.submit(self.ibls.get_device, rs, i, i in full_ids): i for i in range(1, cfg["max_ids"]+1)} + for f in as_completed(futures): + try: + recs = f.result() + if recs: devices += 1; all_records.extend(recs) + except: pass + for rec in all_records: self._publish_discovery(rec); self._publish_state(rec) + return devices + def _publish_discovery(self, rec): + if rec.unique_id in self.discovered: return + component = rec.component; state_topic = self._state_topic(rec) + payload = {"has_entity_name": False, "name": self.friendly_names.get_entity_name(rec.rs, rec.id, rec.path), "state_topic": state_topic, "unique_id": rec.unique_id, "availability_topic": f"{self.mqtt_prefix}/bridge/availability", "device": {"identifiers": [rec.device_id], "name": self.friendly_names.get_device_name(rec.rs, rec.id), "manufacturer": "IBSystem"}} + if component == "switch": + m = re.match(r"output\.do\.(\d+)$", rec.path) + if m: payload.update({"command_topic": f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/set/do{m.group(1)}", "payload_on": "ON", "payload_off": "OFF", "state_on": "ON", "state_off": "OFF"}) + elif component == "binary_sensor": payload.update({"payload_on": "1", "payload_off": "0"}) + elif rec.device_class: payload["device_class"] = rec.device_class + if rec.unit_of_measurement: payload["unit_of_measurement"] = rec.unit_of_measurement; payload["value_template"] = "{{ value | float / 10 }}" if rec.device_class == "temperature" else None + self._publish_all(f"{self.ha_prefix}/{component}/{rec.unique_id}/config", json.dumps(payload)) + self.discovered.add(rec.unique_id); STATS.record_entity() + def _publish_state(self, rec): + topic = self._state_topic(rec) + value = ("ON" if rec.val in ("1","true","ON") else "OFF") if rec.component == "switch" else rec.val + for c in self.clients: + cache = self.last_published.setdefault(c._broker_name, {}) + if cache.get(topic) != value: cache[topic] = value; c.publish(topic, value, retain=True) + def _state_topic(self, rec): + p = rec.path + m = re.match(r"output\.do\.(\d+)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/output/do/{m.group(1)}/state" + m = re.match(r"input\.di\.(\d+)\.(a|b)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/input/di/{m.group(1)}/{m.group(2)}" + m = re.match(r"input\.t\.(\d+)\.(\w+)$", p) + if m: return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/input/t/{m.group(1)}/{m.group(2)}" + return f"{self.mqtt_prefix}/rs{rec.rs}/id{rec.id}/{p.replace('.', '/')}" + def _publish_all(self, topic, payload, retain=True): + for c in self.clients: c.publish(topic, payload, retain=retain) + +def load_config(path=None): + if path and HAS_YAML and Path(path).exists(): + with open(path) as f: user = yaml.safe_load(f) + cfg = json.loads(json.dumps(DEFAULT_CONFIG)) + for k,v in user.items(): cfg[k].update(v) if isinstance(v,dict) and k in cfg else cfg.update({k:v}) + return cfg + return json.loads(json.dumps(DEFAULT_CONFIG)) + +def main(): + import argparse + p = argparse.ArgumentParser(); p.add_argument("-c", "--config"); p.add_argument("-v", "--verbose", action="store_true") + args = p.parse_args(); config = load_config(args.config) + if args.verbose: config["logging"]["level"] = "DEBUG" + logger = setup_logging(config["logging"]); logger.info("Starting IBSystem2MQTT v5") + ibls = IBLSRunner(config["ibsystem"]["host"], config["ibsystem"]["port"], config["ibsystem"]["timeout_ms"], logger) + bridge = MQTTBridge(config, ibls, logger) + diag = DiagnosticsServer(config["http"]["host"], config["http"]["port"], logger) if config.get("http",{}).get("enabled") else None + if diag: diag.start(bridge) + def shutdown(sig, frame): logger.info("Shutdown"); diag and diag.stop(); bridge.stop(); sys.exit(0) + signal.signal(signal.SIGINT, shutdown); signal.signal(signal.SIGTERM, shutdown) + bridge.start() + for c in bridge.clients: c.subscribe(f"{bridge.mqtt_prefix}/+/+/set/#") + interval = config["polling"]["interval_sec"] + while True: + try: + t0 = time.time(); devices = bridge.poll_and_publish(); elapsed = time.time() - t0 + STATS.record_poll(elapsed, devices); logger.debug(f"Poll: {devices} in {elapsed:.2f}s") + time.sleep(max(0, interval - elapsed)) + except Exception as e: import traceback; logger.error(f"Loop error: {e}\n{traceback.format_exc()}"); STATS.record_error(str(e)); time.sleep(interval) + +if __name__ == "__main__": main() diff --git a/ibsystem_dashboard.yaml b/ibsystem_dashboard.yaml new file mode 100644 index 0000000..3654aef --- /dev/null +++ b/ibsystem_dashboard.yaml @@ -0,0 +1,254 @@ +title: IBSystem Sterowniki +views: +- title: Wszystkie sterowniki + path: all + icon: mdi:lightbulb-group + cards: + - type: horizontal-stack + cards: + - type: button + name: WSZYSTKIE ON + icon: mdi:lightbulb-group + icon_height: 40px + tap_action: + action: call-service + service: switch.turn_on + target: + entity_id: + - switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + - type: button + name: WSZYSTKIE OFF + icon: mdi:lightbulb-group-off + icon_height: 40px + tap_action: + action: call-service + service: switch.turn_off + target: + entity_id: + - switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + - switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + - switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + - switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + - switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + - switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + - switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + - switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + - switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + - switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + - switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + - switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + - switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + - type: entities + title: Wiatrołap (ID30) + entities: + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_0 + name: Oświetlenie H1 (Led bar AP) + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_1 + name: Oświetlenie H2 led + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_2 + name: Oświetlenie K1 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id30_rs0_id30_output_do_3 + name: Oświetlenie K2 szynoprzewód nowodworski + - type: entities + title: Kuchnia (ID31) + entities: + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_0 + name: Oświetlenie K3 halogeny okno + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_1 + name: Oświetlenie K4 lampy nad barem + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_2 + name: Oświetlenie K5 półki led + - entity: switch.ibsystem_rs0_id31_rs0_id31_output_do_3 + name: Oświetlenie K6 led okno + - type: entities + title: Jadalnia / Salon (ID32) + entities: + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_0 + name: Oświetlenie J1 + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_1 + name: Oświetlenie J2 + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_2 + name: Oświetlenie S1 Ring + - entity: switch.ibsystem_rs0_id32_rs0_id32_output_do_3 + name: Oświetlenie S2 lampy za kanapą + - type: entities + title: Salon (ID33) + entities: + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_0 + name: Oświetlenie lampa przed tv + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_1 + name: Oświetlenie led + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_2 + name: Oświetlenie dekoracyjne koło lampy + - entity: switch.ibsystem_rs0_id33_rs0_id33_output_do_3 + name: Oświetlenie led sufit wnęka + - type: entities + title: Korytarz (ID34) + entities: + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_0 + name: Oświetlenie H3 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_1 + name: Oświetlenie H4 szynoprzewód nowodworski + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_2 + name: Oświetlenie H5 ledy pionowe + - entity: switch.ibsystem_rs0_id34_rs0_id34_output_do_3 + name: Oświetlenie H6 led kominek + - type: entities + title: Biuro Wojtek/ Garderoba (ID35) + entities: + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_0 + name: Oświetlenie główne Biuro Wojtek + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_1 + name: Oświetlenie Lampa nad biurkiem Wojtek + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_2 + name: Oświetlenie G1 + - entity: switch.ibsystem_rs0_id35_rs0_id35_output_do_3 + name: Oświetlenie G2 led nad szafami i koło lustra + - type: entities + title: Sypialnia (ID36) + entities: + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_0 + name: Oświetlenie R1 główne + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_1 + name: Oświetlenie R2 nakastlik P + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_2 + name: Oświetlenie R3 nakastlik L + - entity: switch.ibsystem_rs0_id36_rs0_id36_output_do_3 + name: Oświetlenie R4 led na suficie + - type: entities + title: Łazienka (ID37) + entities: + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_0 + name: Oświetlenie L1 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_1 + name: Oświetlenie L2 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_2 + name: Oświetlenie L3 + - entity: switch.ibsystem_rs0_id37_rs0_id37_output_do_3 + name: Oświetlenie L4 + - type: entities + title: Łazienka/WC (ID38) + entities: + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_0 + name: Oświetlenie L5 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_1 + name: Oświetlenie L6 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_2 + name: Oświetlenie W1 + - entity: switch.ibsystem_rs0_id38_rs0_id38_output_do_3 + name: Oświetlenie W2 + - type: entities + title: Pokój Basi (ID39) + entities: + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_0 + name: Oświetlenie D1 główne + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_1 + name: Oświetlenie D2 nad biurkiem + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_2 + name: Oświetlenie D3 chmurka + - entity: switch.ibsystem_rs0_id39_rs0_id39_output_do_3 + name: Oświetlenie D4 łóżko + - type: entities + title: Biuro Ani (ID40) + entities: + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_0 + name: Oświetlenie B1 główne + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_1 + name: Oświetlenie B2 nad biurkiem + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_2 + name: Oświetlenie B3 biurko lampa + - entity: switch.ibsystem_rs0_id40_rs0_id40_output_do_3 + name: Oświetlenie B4 dla ledów + - type: entities + title: WC/Poddasze (ID41) + entities: + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_0 + name: Oświetlenie W2 góra halogeny + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_1 + name: Oświetlenie W3 halogen prysznic + led + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_2 + name: Oświetlenie P1 Poddasze 1 + - entity: switch.ibsystem_rs0_id41_rs0_id41_output_do_3 + name: Oświetlenie P2 Poddasze 2 diff --git a/sync_names.py b/sync_names.py new file mode 100755 index 0000000..adf375c --- /dev/null +++ b/sync_names.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Synchronizuje friendly_names i dashboard IBSystem z dokumentacji projektu. +Źródło: /smart-home/proj-ib-lighting-rs485-zarki.yaml +""" +import yaml, re, sys, os, subprocess, shutil, io +from datetime import datetime +import copy + +PROJECT_YAML = "/smart-home/proj-ib-lighting-rs485-zarki.yaml" +CONFIG_YAML = "/ibsystem/ibsystem2mqtt.yaml" +DASHBOARD_LOCAL = "/ibsystem/ibsystem_dashboard.yaml" +DASHBOARD_HA = "root@192.168.50.151:/config/dashboards/ibsystem_dashboard.yaml" +HA_SSH_PORT = 2222 +MQTT_HOST = "192.168.50.151" +MQTT_USER = "mqtt" +MQTT_PASS = "mqtt123" + +# Zakresy ID sterowników oświetlenia (pomijamy ogrzewanie itp.) +LIGHTING_ID_MIN = 30 +LIGHTING_ID_MAX = 99 + +def log(msg): print(f"[{datetime.now():%H:%M:%S}] {msg}") + +def device_id_to_rs_id(device_id): + m = re.match(r"rs\.(\d+)\.id\.(\d+)", device_id) + return (m.group(1), m.group(2)) if m else (None, None) + +def io_key(rs, dev_id, io_id): + safe = re.sub(r"[^a-zA-Z0-9]+", "_", io_id).strip("_") + return f"rs{rs}_id{dev_id}_{safe}" + +def entity_id(rs, dev_id, io_id): + safe = re.sub(r"[^a-zA-Z0-9]+", "_", io_id).strip("_") + return f"switch.ibsystem_rs{rs}_id{dev_id}_rs{rs}_id{dev_id}_{safe}" + +def parse_project_yaml(path): + with open(path) as f: + data = yaml.safe_load(f) + devices = data["project"]["hardware_specification"]["devices"] + result = [] + for dev in devices: + device_id = dev.get("device_id", "") + location = dev.get("location", "").strip() + rs, dev_id = device_id_to_rs_id(device_id) + if not rs: + continue + io_list = [] + for io in dev.get("io", []): + io_id = io.get("io_id", "") + desc = io.get("description", "").strip() + status = io.get("status", "ok") + if io_id.startswith("output.do.") and desc and status != "nc" \ + and not desc.startswith("Opcjonalne"): + io_list.append({"io_id": io_id, "description": desc}) + result.append({ + "device_id": device_id, + "rs": rs, + "dev_id": dev_id, + "location": location, + "io": io_list, + }) + return result + +def build_friendly_names(devices): + names = {} + for dev in devices: + rs, dev_id, location = dev["rs"], dev["dev_id"], dev["location"] + if location: + names[f"rs{rs}_id{dev_id}"] = f"{location} - ID{dev_id}" + for io in dev["io"]: + names[io_key(rs, dev_id, io["io_id"])] = io["description"] + return names + +def build_dashboard(devices): + # Tylko sterowniki oświetlenia (ID30+) + light_devs = [d for d in devices + if d["io"] and LIGHTING_ID_MIN <= int(d["dev_id"]) <= LIGHTING_ID_MAX] + + # Lista wszystkich switch entity_id dla WSZYSTKIE ON/OFF + all_switches = [] + for dev in light_devs: + rs, dev_id = dev["rs"], dev["dev_id"] + for io in dev["io"]: + all_switches.append(entity_id(rs, dev_id, io["io_id"])) + + # Karty per urządzenie + device_cards = [] + for dev in light_devs: + rs, dev_id, location = dev["rs"], dev["dev_id"], dev["location"] + title = f"{location} (ID{dev_id})" if location else f"ID{dev_id}" + entities = [] + for io in dev["io"]: + entities.append({ + "entity": entity_id(rs, dev_id, io["io_id"]), + "name": io["description"], + }) + device_cards.append({ + "type": "entities", + "title": title, + "entities": entities, + }) + + dashboard = { + "title": "IBSystem Sterowniki", + "views": [{ + "title": "Wszystkie sterowniki", + "path": "all", + "icon": "mdi:lightbulb-group", + "cards": [ + { + "type": "horizontal-stack", + "cards": [ + { + "type": "button", + "name": "WSZYSTKIE ON", + "icon": "mdi:lightbulb-group", + "icon_height": "40px", + "tap_action": { + "action": "call-service", + "service": "switch.turn_on", + "target": {"entity_id": copy.deepcopy(all_switches)}, + }, + }, + { + "type": "button", + "name": "WSZYSTKIE OFF", + "icon": "mdi:lightbulb-group-off", + "icon_height": "40px", + "tap_action": { + "action": "call-service", + "service": "switch.turn_off", + "target": {"entity_id": copy.deepcopy(all_switches)}, + }, + }, + ], + }, + ] + device_cards, + }], + } + return dashboard + +def update_friendly_names(config_path, new_names, dry_run=False): + with open(config_path) as f: + config = yaml.safe_load(f) + old_names = config.get("friendly_names") or {} + changed = {k for k in (set(old_names) | set(new_names)) if old_names.get(k) != new_names.get(k)} + if not changed: + log("friendly_names: bez zmian.") + return False + log(f"friendly_names: {len(changed)} zmian.") + if dry_run: + for k in sorted(changed): + print(f" {k}: {old_names.get(k)!r} → {new_names.get(k)!r}") + return True + shutil.copy(config_path, config_path + ".bak") + config["friendly_names"] = new_names + with open(config_path, "w") as f: + yaml.dump(config, f, allow_unicode=True, default_flow_style=False, sort_keys=False) + log(f" Zapisano {config_path}") + return True + +def update_dashboard(dashboard, local_path, ha_path, dry_run=False): + # Serializuj do YAML + buf = io.StringIO() + yaml.dump(dashboard, buf, allow_unicode=True, default_flow_style=False, + sort_keys=False, width=200) + new_content = buf.getvalue() + + # Sprawdź czy się zmieniło + try: + with open(local_path) as f: + old_content = f.read() + if old_content == new_content: + log("dashboard: bez zmian.") + return False + except FileNotFoundError: + pass + + log("dashboard: aktualizuję.") + if dry_run: + log(" (dry-run, nie zapisuję)") + return True + + with open(local_path, "w") as f: + f.write(new_content) + + # Kopiuj na HA przez SSH + host, remote_path = ha_path.split(":", 1) + result = subprocess.run( + ["scp", "-P", str(HA_SSH_PORT), "-o", "StrictHostKeyChecking=no", + local_path, ha_path], + capture_output=True, text=True + ) + if result.returncode != 0: + log(f" BŁĄD SCP: {result.stderr.strip()}") + else: + log(f" Skopiowano → {ha_path}") + return True + +def reload_ibsystem(): + log("Czyszczę MQTT discovery cache...") + os.system( + f"timeout 5 mosquitto_sub -h {MQTT_HOST} -u {MQTT_USER} -P {MQTT_PASS} " + f"-t 'homeassistant/#' --retained-only -v 2>/dev/null | " + f"grep '/config' | grep 'ibsystem' | awk '{{print $1}}' | " + f"while read t; do mosquitto_pub -h {MQTT_HOST} -u {MQTT_USER} -P {MQTT_PASS} " + f"-t \"$t\" -n -r; done" + ) + log("Restartuję ibsystem2mqtt...") + subprocess.run(["systemctl", "restart", "ibsystem2mqtt"], check=True) + +def main(): + dry_run = "--dry-run" in sys.argv + log(f"Parsing {PROJECT_YAML}...") + + try: + devices = parse_project_yaml(PROJECT_YAML) + except Exception as e: + log(f"BŁĄD parsowania: {e}") + sys.exit(1) + + log(f" {len(devices)} urządzeń, " + f"{sum(len(d['io']) for d in devices)} wyjść DO.") + + names_changed = update_friendly_names(CONFIG_YAML, build_friendly_names(devices), dry_run) + dashboard_changed = update_dashboard(build_dashboard(devices), DASHBOARD_LOCAL, + DASHBOARD_HA, dry_run) + + if (names_changed or dashboard_changed) and not dry_run: + reload_ibsystem() + else: + log("Brak zmian wymagających restartu.") + +if __name__ == "__main__": + main() diff --git a/watchdog.sh b/watchdog.sh new file mode 100755 index 0000000..6b331ab --- /dev/null +++ b/watchdog.sh @@ -0,0 +1,23 @@ +#!/bin/bash +HEALTH_URL=http://127.0.0.1:8080/health +FAILURE_FILE=/tmp/ibsystem2mqtt_failures +MAX_FAILURES=3 + +if curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then + echo 0 > "$FAILURE_FILE" +else + FAILURES=$(cat "$FAILURE_FILE" 2>/dev/null || echo 0) + FAILURES=$((FAILURES + 1)) + echo $FAILURES > "$FAILURE_FILE" + if [ $FAILURES -ge $MAX_FAILURES ]; then + systemctl restart ibsystem2mqtt + echo 0 > "$FAILURE_FILE" + fi +fi + +AVAIL=$(mosquitto_sub -h 192.168.50.151 -p 1883 -u mqtt -P mqtt123 \ + -t ibsystem/bridge/availability -C 1 --quiet 2>/dev/null) +if [ "$AVAIL" = "offline" ] && systemctl is-active --quiet ibsystem2mqtt; then + logger -t ibsystem_watchdog "availability=offline - restarting" + systemctl restart ibsystem2mqtt +fi