From 917787abbe0bf37aab7a6788e4d11e2eba76e80c Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 18 Sep 2026 14:22:44 +0200 Subject: [PATCH 1/3] Add Mitsubishi and Gree AC consumer modules --- packages/modules/consumers/gree/__init__.py | 0 .../modules/consumers/gree/gree/__init__.py | 0 .../modules/consumers/gree/gree/config.py | 31 +++++++ .../modules/consumers/gree/gree/consumer.py | 86 +++++++++++++++++++ packages/modules/consumers/gree/vendor.py | 14 +++ .../modules/consumers/mitsubishi/__init__.py | 0 .../mitsubishi/mitsubishi_modbus/__init__.py | 0 .../mitsubishi/mitsubishi_modbus/config.py | 31 +++++++ .../mitsubishi/mitsubishi_modbus/consumer.py | 37 ++++++++ .../modules/consumers/mitsubishi/vendor.py | 14 +++ 10 files changed, 213 insertions(+) create mode 100644 packages/modules/consumers/gree/__init__.py create mode 100644 packages/modules/consumers/gree/gree/__init__.py create mode 100644 packages/modules/consumers/gree/gree/config.py create mode 100644 packages/modules/consumers/gree/gree/consumer.py create mode 100644 packages/modules/consumers/gree/vendor.py create mode 100644 packages/modules/consumers/mitsubishi/__init__.py create mode 100644 packages/modules/consumers/mitsubishi/mitsubishi_modbus/__init__.py create mode 100644 packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py create mode 100644 packages/modules/consumers/mitsubishi/mitsubishi_modbus/consumer.py create mode 100644 packages/modules/consumers/mitsubishi/vendor.py diff --git a/packages/modules/consumers/gree/__init__.py b/packages/modules/consumers/gree/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/gree/gree/__init__.py b/packages/modules/consumers/gree/gree/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/gree/gree/config.py b/packages/modules/consumers/gree/gree/config.py new file mode 100644 index 0000000000..d24992db12 --- /dev/null +++ b/packages/modules/consumers/gree/gree/config.py @@ -0,0 +1,31 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class GreeConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 7000): + self.ip_address = ip_address + self.port = port + + +@auto_str +class Gree(ConsumerSetup[GreeConfiguration]): + def __init__(self, + name: str = "Gree Klimaanlage", + type: str = "gree", + id: int = 0, + configuration: Optional[GreeConfiguration] = None, + # UNVERIFIZIERT: kein offizielles Gree-Protokoll veröffentlicht, community-seitig + # reverse-engineert (mehrere unabhängige Implementierungen stimmen überein). + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or GreeConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/gree/gree/consumer.py b/packages/modules/consumers/gree/gree/consumer.py new file mode 100644 index 0000000000..8cdde5010a --- /dev/null +++ b/packages/modules/consumers/gree/gree/consumer.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +import base64 +import json +import logging +import socket +from typing import Optional + +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.consumers.gree.gree.config import Gree + +log = logging.getLogger(__name__) + +# Generischer Schlüssel für Scan/Bind, identisch bei allen Gree-Geräten. +GENERIC_KEY = b"a3K8Bx%2r8Y7#xDh" + + +def _encrypt(key: bytes, payload: dict) -> str: + padder = padding.PKCS7(128).padder() + data = padder.update(json.dumps(payload).encode()) + padder.finalize() + encryptor = Cipher(algorithms.AES(key), modes.ECB()).encryptor() + return base64.b64encode(encryptor.update(data) + encryptor.finalize()).decode() + + +def _decrypt(key: bytes, pack: str) -> dict: + decryptor = Cipher(algorithms.AES(key), modes.ECB()).decryptor() + padded = decryptor.update(base64.b64decode(pack)) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + data = unpadder.update(padded) + unpadder.finalize() + return json.loads(data) + + +def _request(ip_address: str, port: int, envelope: dict, timeout: float = 3.0) -> dict: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(timeout) + sock.sendto(json.dumps(envelope).encode(), (ip_address, port)) + data, _ = sock.recvfrom(4096) + return json.loads(data) + + +def create_consumer(config: Gree): + mac: Optional[str] = None + device_key: Optional[bytes] = None + + def initializer(): + nonlocal mac, device_key + ip_address = config.configuration.ip_address + port = config.configuration.port + scan_resp = _request(ip_address, port, {"t": "scan"}) + device_info = _decrypt(GENERIC_KEY, scan_resp["pack"]) + mac = device_info["mac"] + + bind_pack = _encrypt(GENERIC_KEY, {"mac": mac, "t": "bind", "uid": 0}) + bind_envelope = {"cid": mac, "i": 1, "t": "pack", "uid": 0, "pack": bind_pack} + bind_resp = _request(ip_address, port, bind_envelope) + bind_result = _decrypt(GENERIC_KEY, bind_resp["pack"]) + device_key = bind_result["key"].encode() + + def error_handler() -> None: + initializer() + + def _send_command(opt: list, p: list) -> None: + pack = _encrypt(device_key, {"opt": opt, "p": p, "t": "cmd"}) + envelope = {"cid": "app", "i": 0, "t": "pack", "uid": 0, "tcid": mac, "pack": pack} + _request(config.configuration.ip_address, config.configuration.port, envelope) + + def switch_on() -> None: + _send_command(["Pow"], [1]) + + def switch_off() -> None: + _send_command(["Pow"], [0]) + + # Keine update()-Funktion: der Statusabruf liefert Betriebsparameter (Modus, Solltemperatur + # etc.), aber keine Leistungsaufnahme. Wer den Verbrauch erfassen will, braucht eine separate + # Leistungsmessung (zB Zwischenzähler/Smart-Plug), die diesem Verbraucher zugeordnet wird. + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Gree) diff --git a/packages/modules/consumers/gree/vendor.py b/packages/modules/consumers/gree/vendor.py new file mode 100644 index 0000000000..43ac8cb9fd --- /dev/null +++ b/packages/modules/consumers/gree/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Gree" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/mitsubishi/__init__.py b/packages/modules/consumers/mitsubishi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mitsubishi/mitsubishi_modbus/__init__.py b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py new file mode 100644 index 0000000000..66269b8a1e --- /dev/null +++ b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py @@ -0,0 +1,31 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class MitsubishiModbusConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class MitsubishiModbus(ConsumerSetup[MitsubishiModbusConfiguration]): + def __init__(self, + name: str = "Mitsubishi Electric Klimaanlage (Intesis Modbus-Interface)", + type: str = "mitsubishi_modbus", + id: int = 0, + configuration: Optional[MitsubishiModbusConfiguration] = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MitsubishiModbusConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/mitsubishi/mitsubishi_modbus/consumer.py b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/consumer.py new file mode 100644 index 0000000000..a592611d16 --- /dev/null +++ b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/consumer.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.consumers.mitsubishi.mitsubishi_modbus.config import MitsubishiModbus + +log = logging.getLogger(__name__) + + +def create_consumer(config: MitsubishiModbus): + client: Optional[ModbusTcpClient_] = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def switch_on() -> None: + # Reg 0: AC unit On/Off, 1 = On + client.write_register(0, 1, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + # Reg 0: AC unit On/Off, 0 = Off + client.write_register(0, 0, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + # Keine update()-Funktion: das Intesis-Interface stellt keine Leistungsmessung zur Verfügung. + # Wer den Verbrauch erfassen will, braucht eine separate Leistungsmessung (zB + # Zwischenzähler/Smart-Plug), die diesem Verbraucher zugeordnet wird. + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=MitsubishiModbus) diff --git a/packages/modules/consumers/mitsubishi/vendor.py b/packages/modules/consumers/mitsubishi/vendor.py new file mode 100644 index 0000000000..6688774a09 --- /dev/null +++ b/packages/modules/consumers/mitsubishi/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Mitsubishi Electric" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) From 143c16aea9e67c7c843072d6529c7389e6ecca6a Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 25 Sep 2026 07:37:25 +0200 Subject: [PATCH 2/3] Mitsubishi/Gree: METER_ONLY entfernt, da keine Leistungsmessung Beide Module haben keine update()-Funktion und liefern somit keine Leistungsmessung; ConsumerUsage.METER_ONLY war daher nicht zutreffend. --- packages/modules/consumers/gree/gree/config.py | 3 +-- .../modules/consumers/mitsubishi/mitsubishi_modbus/config.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/modules/consumers/gree/gree/config.py b/packages/modules/consumers/gree/gree/config.py index d24992db12..90838af3e3 100644 --- a/packages/modules/consumers/gree/gree/config.py +++ b/packages/modules/consumers/gree/gree/config.py @@ -24,8 +24,7 @@ def __init__(self, configuration: Optional[GreeConfiguration] = None, # UNVERIFIZIERT: kein offizielles Gree-Protokoll veröffentlicht, community-seitig # reverse-engineert (mehrere unabhängige Implementierungen stimmen überein). - usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, - ConsumerUsage.METER_ONLY), + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF,), **kwargs) -> None: super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( ).type, configuration=configuration or GreeConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py index 66269b8a1e..0c6c5f3a81 100644 --- a/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py +++ b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py @@ -24,8 +24,7 @@ def __init__(self, type: str = "mitsubishi_modbus", id: int = 0, configuration: Optional[MitsubishiModbusConfiguration] = None, - usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, - ConsumerUsage.METER_ONLY), + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF,), **kwargs) -> None: super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( ).type, configuration=configuration or MitsubishiModbusConfiguration(), usage=usage, **kwargs) From 98e392adc54336e0a44a7263d7a923e5ccb0f856 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 25 Sep 2026 13:51:47 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Gree:=20tcid=20im=20Bind-Request=20erg?= =?UTF-8?q?=C3=A4nzt=20(Review=20Lena)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cid muss beim Binden konstant "app" sein, tcid identifiziert das Zielgerät per MAC - bestätigt gegen tomikaa87/gree-remote und cmroche/greeclimate. --- packages/modules/consumers/gree/gree/consumer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/consumers/gree/gree/consumer.py b/packages/modules/consumers/gree/gree/consumer.py index 8cdde5010a..ef5b96763f 100644 --- a/packages/modules/consumers/gree/gree/consumer.py +++ b/packages/modules/consumers/gree/gree/consumer.py @@ -54,7 +54,7 @@ def initializer(): mac = device_info["mac"] bind_pack = _encrypt(GENERIC_KEY, {"mac": mac, "t": "bind", "uid": 0}) - bind_envelope = {"cid": mac, "i": 1, "t": "pack", "uid": 0, "pack": bind_pack} + bind_envelope = {"cid": "app", "tcid": mac, "i": 1, "t": "pack", "uid": 0, "pack": bind_pack} bind_resp = _request(ip_address, port, bind_envelope) bind_result = _decrypt(GENERIC_KEY, bind_resp["pack"]) device_key = bind_result["key"].encode()