From 4df409c5205150c36130d910d35a093d05d9d1cb Mon Sep 17 00:00:00 2001 From: Filip Van Ham Date: Sun, 5 Nov 2023 01:27:35 +0100 Subject: [PATCH] initial commit --- .gitignore | 2 + config.example.yaml | 14 ++ config.py | 40 ++++++ event_hook.py | 19 +++ modbus_client.py | 164 +++++++++++++++++++++++ models/fan_speed_enums.py | 20 +++ models/ha_device_config.py | 11 ++ models/ha_mqtt_discovery_config.py | 33 +++++ models/mode_enums.py | 21 +++ models/mqtt_fan_speed_enums.py | 20 +++ models/mqtt_message.py | 8 ++ models/mqtt_mode_enums.py | 21 +++ models/mqtt_topcis.py | 13 ++ models/on_connect_event.py | 9 ++ models/on_disconnect_event.py | 5 + models/on_message_event.py | 7 + models/state.py | 14 ++ mqtt_client.py | 126 ++++++++++++++++++ requirements.txt | 5 + server.py | 206 +++++++++++++++++++++++++++++ state_service.py | 38 ++++++ version.info | 1 + 22 files changed, 797 insertions(+) create mode 100644 .gitignore create mode 100644 config.example.yaml create mode 100644 config.py create mode 100644 event_hook.py create mode 100644 modbus_client.py create mode 100644 models/fan_speed_enums.py create mode 100644 models/ha_device_config.py create mode 100644 models/ha_mqtt_discovery_config.py create mode 100644 models/mode_enums.py create mode 100644 models/mqtt_fan_speed_enums.py create mode 100644 models/mqtt_message.py create mode 100644 models/mqtt_mode_enums.py create mode 100644 models/mqtt_topcis.py create mode 100644 models/on_connect_event.py create mode 100644 models/on_disconnect_event.py create mode 100644 models/on_message_event.py create mode 100644 models/state.py create mode 100644 mqtt_client.py create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 state_service.py create mode 100644 version.info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f29884 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +config.yaml diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..cb415e6 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,14 @@ +name: "AC Kitchen" +id: ac-kitchen +model: "CT18F NQ0" +modbus: + host: 192.168.1.69 + port: 502 + slave: 1 + poll_interval: 1 +mqtt: + host: 192.168.1.42 + port: 1883 + keepalive: 60 + username: USER + password: PASSWORD diff --git a/config.py b/config.py new file mode 100644 index 0000000..07c8e0e --- /dev/null +++ b/config.py @@ -0,0 +1,40 @@ +import yaml + +from pydantic import BaseModel, IPvAnyAddress, Field + + +class ModbusConfig(BaseModel): + host: str = Field(..., example="192.168.1.10") + port: int = Field(..., gt=0, lt=65535) + slave: int = Field(..., gt=0) + poll_interval: int = Field(..., gt=0) + + +class MQTTConfig(BaseModel): + host: str = Field(..., example="mqtt.example.com") + port: int = Field(..., gt=0, lt=65535) + keepalive: int = Field(..., gt=0) + username: str = Field(...) + password: str = Field(...) + + +class Config(BaseModel): + modbus: ModbusConfig + mqtt: MQTTConfig + name: str = Field(...) + id: str = Field(...) + model: str = Field(...) + + +def load_config(): + with open('config.yaml', 'r') as f: + raw_config = yaml.safe_load(f) + config = Config(**raw_config) + return config + + +def load_version(): + with open('version.info', 'r') as f: + # Read the first line and strip any leading/trailing whitespace + version = f.readline().strip() + return version diff --git a/event_hook.py b/event_hook.py new file mode 100644 index 0000000..2661518 --- /dev/null +++ b/event_hook.py @@ -0,0 +1,19 @@ +from collections.abc import Callable +from typing import Generic, TypeVar + +T = TypeVar('T') + + +class EventHook(Generic[T]): + def __init__(self): + self.__handlers: Callable[[T]] = [] + + def add_handler(self, handler: Callable[[T], None]): + self.__handlers.append(handler) + + def fire(self, event_args: T): + for handler in self.__handlers: + handler(event_args) + + def clear(self): + self.__handlers = [] diff --git a/modbus_client.py b/modbus_client.py new file mode 100644 index 0000000..4ffcb83 --- /dev/null +++ b/modbus_client.py @@ -0,0 +1,164 @@ +from threading import Event, Timer + +from loguru import logger +from pymodbus.client import ModbusTcpClient +from pymodbus.exceptions import ConnectionException + +from config import ModbusConfig +from models.fan_speed_enums import FanSpeed +from models.mode_enums import Mode +from models.state import State +from state_service import StateService + + +class ModbusClient: + def __init__(self, config: ModbusConfig, state_service: StateService): + self._config = config + self._state_service = state_service + self._client = ModbusTcpClient(host=config.host, port=config.port) + self._poll_interval = config.poll_interval + self._shutdown_event = Event() + self._loops_to_skip = 0 + + def start_polling(self) -> None: + logger.info("Modbus | Starting the modbus polling...") + self._shutdown_event.clear() + self._schedule_next_poll() + + def stop_polling(self) -> None: + logger.info("Modbus | Stopping the modbus polling...") + self._shutdown_event.set() + if self._poll_timer: + self._poll_timer.cancel() + + def _schedule_next_poll(self): + if not self._shutdown_event.is_set(): + self._poll_timer = Timer(interval=self._poll_interval, function=self._poll_modbus_server) + self._poll_timer.start() + + def _poll_modbus_server(self): + slave = self._config.slave + if self._loops_to_skip>0: + logger.debug("Skipping poll") + self._loops_to_skip -= 1 + self._schedule_next_poll() + return + + if self._shutdown_event.is_set(): + # Stop polling since the shutdown flag is set + return + + try: + if not self._client.is_socket_open(): + self._client.connect() + + """ + Read in operation + """ + in_operation = None + rr = self._client.read_coils(address=0, slave=slave, unit=1) + if rr.isError(): + logger.error("Modbus | Could not read set temperature") + pass + else: + in_operation = rr.bits[0] == 1 + + """ + Read current temperature + """ + current_temperature = None + rr = self._client.read_input_registers(address=2, slave=slave, unit=1) + if rr.isError(): + logger.error("Modbus | Could not read current temperature") + pass + else: + current_temperature = rr.registers[0] / 10 + + """ + Read set temperature + """ + set_temperature = None + rr = self._client.read_holding_registers(address=1, slave=slave, unit=1) + if rr.isError(): + logger.error("Modbus | Could not read set temperature") + pass + else: + set_temperature = rr.registers[0] / 10 + + """ + Read run mode + """ + run_mode = None + rr = self._client.read_holding_registers(address=0, slave=slave, unit=1) + if rr.isError(): + logger.error("Modbus | Could not read run mode") + pass + else: + run_mode = rr.registers[0] + + """ + Read fan speed + """ + fan_speed = None + rr = self._client.read_holding_registers(address=14, slave=slave, unit=1) + if rr.isError(): + logger.error("Modbus | Could not read fan speed") + pass + else: + fan_speed = rr.registers[0] + + """ + Send off the state + """ + self._state_service.merge_in_state(State( + running=in_operation, + current_temperature=current_temperature, + set_temperature=set_temperature, + mode=Mode.from_value(run_mode), + fan_speed=FanSpeed.from_value(fan_speed) + )) + except ConnectionException as e: + logger.error(f"Modbus | Connection exception: {e}") + except Exception as e: + logger.error(f"Modbus | Polling exception: {e}") + finally: + self._schedule_next_poll() + + def write_operate(self, value: bool): + logger.info(f"Modbus | Writing {value} to operate coil.") + self._loops_to_skip = 3 + response = self._client.write_coil(address=0, value=value, slave=self._config.slave) + if response.isError(): + logger.error(f"Modbus | Could not set operate to {value}") + else: + logger.info(f"Modbus | {response}") + + def set_temperature(self, value: float): + temp = int(value*10) + logger.info(f"Modbus | Writing {value} to register 1.") + self._loops_to_skip = 3 + response = self._client.write_register(address=1, value=temp, slave=self._config.slave) + if response.isError(): + logger.error(f"Modbus | Could not set temperature to {temp}") + else: + logger.info(f"Modbus | {response}") + + def set_mode(self, value: Mode): + mode = value.value + logger.info(f"Modbus | Writing {value} to register 0.") + self._loops_to_skip = 3 + response = self._client.write_register(address=0, value=mode, slave=self._config.slave) + if response.isError(): + logger.error(f"Modbus | Could not set mode to {mode}") + else: + logger.info(f"Modbus | {response}") + + def set_fan_speed(self, value): + fan_speed = value.value + logger.info(f"Modbus | Writing {value} to register 0.") + self._loops_to_skip = 3 + response = self._client.write_register(address=14, value=fan_speed, slave=self._config.slave) + if response.isError(): + logger.error(f"Modbus | Could not set fan speed to {fan_speed}") + else: + logger.info(f"Modbus | {response}") diff --git a/models/fan_speed_enums.py b/models/fan_speed_enums.py new file mode 100644 index 0000000..a130d0c --- /dev/null +++ b/models/fan_speed_enums.py @@ -0,0 +1,20 @@ +from enum import Enum + +from loguru import logger + + +class FanSpeed(Enum): + LOW = 1 + MIDDLE = 2 + HIGH = 3 + AUTO = 4 + UNKNOWN = 5 + + @staticmethod + def from_value(value: int): + for member in FanSpeed: + if member.value == value: + return member + # Handle the case where the value is not found + logger.error(f"Could not map integer {value} to a {FanSpeed.__name__} value.") + return None diff --git a/models/ha_device_config.py b/models/ha_device_config.py new file mode 100644 index 0000000..4a315ec --- /dev/null +++ b/models/ha_device_config.py @@ -0,0 +1,11 @@ +from typing import List + +from pydantic import BaseModel + + +class HaDeviceConfig(BaseModel): + identifiers: List[str] + manufacturer: str = "LG" + model: str + name: str + sw_version: str diff --git a/models/ha_mqtt_discovery_config.py b/models/ha_mqtt_discovery_config.py new file mode 100644 index 0000000..f4e9fc0 --- /dev/null +++ b/models/ha_mqtt_discovery_config.py @@ -0,0 +1,33 @@ +from typing import List + +from pydantic import BaseModel + +from models.ha_device_config import HaDeviceConfig +from models.mqtt_fan_speed_enums import MqttFanSpeed + + +class HaMqttDiscoveryConfig(BaseModel): + name: str + availability_topic: str + fan_modes: List[str] = [ + MqttFanSpeed.AUTO.value, + MqttFanSpeed.LOW.value, + MqttFanSpeed.MEDIUM.value, + MqttFanSpeed.HIGH.value, + MqttFanSpeed.UNKNOWN.value + ] + modes: List[str] = ["auto", "off", "cool", "heat", "dry", "fan_only"] + max_temp: int = 30 + min_temp: int = 18 + power_command_topic: str + mode_command_topic: str + temperature_command_topic: str + fan_mode_command_topic: str + mode_state_topic: str + temperature_state_topic: str + fan_mode_state_topic: str + current_temperature_topic: str + payload_available: str = "Online" + payload_not_available: str = "Offline" + unique_id: str + device: HaDeviceConfig diff --git a/models/mode_enums.py b/models/mode_enums.py new file mode 100644 index 0000000..ac26fb5 --- /dev/null +++ b/models/mode_enums.py @@ -0,0 +1,21 @@ +from enum import Enum + +from loguru import logger + + +class Mode(Enum): + COOL = 0 + DRY = 1 + FAN_ONLY = 2 + AUTO = 3 + HEATING = 4 + + @staticmethod + def from_value(value: int): + for member in Mode: + if member.value == value: + return member + # Handle the case where the value is not found + logger.error(f"Could not map integer {value} to a {Mode.__name__} value.") + return None + diff --git a/models/mqtt_fan_speed_enums.py b/models/mqtt_fan_speed_enums.py new file mode 100644 index 0000000..1f39cb0 --- /dev/null +++ b/models/mqtt_fan_speed_enums.py @@ -0,0 +1,20 @@ +from enum import Enum + +from loguru import logger + + +class MqttFanSpeed(Enum): + AUTO = "auto" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + UNKNOWN = "Unknown" + + @staticmethod + def from_value(value: str): + for member in MqttFanSpeed: + if member.value == value: + return member + # Handle the case where the value is not found + logger.error(f"Could not map str {value} to a {MqttFanSpeed.__name__} value.") + return None diff --git a/models/mqtt_message.py b/models/mqtt_message.py new file mode 100644 index 0000000..4236dcc --- /dev/null +++ b/models/mqtt_message.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class MqttMessage(BaseModel): + topic: str + payload: str + qos: int + retain: bool diff --git a/models/mqtt_mode_enums.py b/models/mqtt_mode_enums.py new file mode 100644 index 0000000..68e7e96 --- /dev/null +++ b/models/mqtt_mode_enums.py @@ -0,0 +1,21 @@ +from enum import Enum + +from loguru import logger + + +class MqttMode(Enum): + AUTO = "auto" + OFF = "off" + COOL = "cool" + HEAT = "heat" + DRY = "dry" + FAN_ONLY = "fan_only" + + @staticmethod + def from_value(value: str): + for member in MqttMode: + if member.value == value: + return member + # Handle the case where the value is not found + logger.error(f"Could not map str {value} to a {MqttMode.__name__} value.") + return None diff --git a/models/mqtt_topcis.py b/models/mqtt_topcis.py new file mode 100644 index 0000000..5b70c43 --- /dev/null +++ b/models/mqtt_topcis.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class MqttTopics(BaseModel): + availability: str + power_command: str + mode_command: str + temperature_command: str + fan_mode_command: str + mode_state: str + temperature_state: str + fan_mode_state: str + current_temperature: str diff --git a/models/on_connect_event.py b/models/on_connect_event.py new file mode 100644 index 0000000..85e2875 --- /dev/null +++ b/models/on_connect_event.py @@ -0,0 +1,9 @@ +from typing import List, Dict + +import yaml + +from pydantic import BaseModel, IPvAnyAddress, Field + + +class OnConnectEvent(BaseModel): + flags: Dict = Field(...) diff --git a/models/on_disconnect_event.py b/models/on_disconnect_event.py new file mode 100644 index 0000000..75f48a9 --- /dev/null +++ b/models/on_disconnect_event.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class OnDisconnectEvent(BaseModel): + rc: int diff --git a/models/on_message_event.py b/models/on_message_event.py new file mode 100644 index 0000000..011ab00 --- /dev/null +++ b/models/on_message_event.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + +from models.mqtt_message import MqttMessage + + +class OnMessageEvent(BaseModel): + msg: MqttMessage diff --git a/models/state.py b/models/state.py new file mode 100644 index 0000000..ee4a226 --- /dev/null +++ b/models/state.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import BaseModel + +from models.fan_speed_enums import FanSpeed +from models.mode_enums import Mode + + +class State(BaseModel): + running: Optional[bool] = None + current_temperature: Optional[float] = None + set_temperature: Optional[float] = None + mode: Optional[Mode] = None + fan_speed: Optional[FanSpeed] = None diff --git a/mqtt_client.py b/mqtt_client.py new file mode 100644 index 0000000..3d9f7e8 --- /dev/null +++ b/mqtt_client.py @@ -0,0 +1,126 @@ +from typing import List + +import paho.mqtt.client as mqtt +from loguru import logger + +from config import MQTTConfig +from event_hook import EventHook +from models.ha_mqtt_discovery_config import HaMqttDiscoveryConfig +from models.mqtt_fan_speed_enums import MqttFanSpeed +from models.mqtt_message import MqttMessage +from models.mqtt_mode_enums import MqttMode +from models.on_connect_event import OnConnectEvent +from models.on_disconnect_event import OnDisconnectEvent +from models.on_message_event import OnMessageEvent + + +class MqttClient: + def __init__(self, config: MQTTConfig, ha_discovery_config: HaMqttDiscoveryConfig): + self._config = config + self._ha_discovery_config = ha_discovery_config + self._client = mqtt.Client() + self._client.username_pw_set(username=self._config.username, password=self._config.password) + self._client.on_connect = self._on_connect + self._client.on_message = self._on_message + self._client.on_disconnect = self._on_disconnect + + self.on_connected = EventHook[OnConnectEvent]() + self.on_message = EventHook[OnMessageEvent]() + self.on_disconnect = EventHook[OnDisconnectEvent]() + + def connect(self): + host = self._config.host + port = self._config.port + + logger.info(f"MQTT | Connecting to {host}:{port}") + self._client.connect( + host=host, + port=port, + keepalive=self._config.keepalive + ) + + def go_online(self): + self._client.will_set( + topic=self._ha_discovery_config.availability_topic, + payload=self._ha_discovery_config.payload_not_available, + retain=True + ) + self._client.publish( + topic=f"homeassistant/climate/lg-{self._ha_discovery_config.unique_id}/config", + payload=self._ha_discovery_config.model_dump_json(), + retain=True + ) + self._client.publish( + topic=self._ha_discovery_config.availability_topic, + payload=self._ha_discovery_config.payload_available, + retain=True + ) + + def go_offline(self): + self._client.publish( + topic=self._ha_discovery_config.availability_topic, + payload=self._ha_discovery_config.payload_not_available, + retain=True + ) + + def subscribe(self, topics: List[str], qos=0): + for topic in topics: + self._client.subscribe(topic, qos=qos) + + def loop_forever(self, timeout=1.0, max_packets=1, retry_first_connection=False): + self._client.loop_forever(timeout=timeout, max_packets=max_packets, + retry_first_connection=retry_first_connection) + + def _on_connect(self, client: mqtt.Client, userdata, flags, rc: int): + if rc == 0: + logger.info("MQTT | Connected to the Server") + self.on_connected.fire(OnConnectEvent(flags=flags)) + else: + logger.error("MQTT | Could not connect to Server") + + def _on_message(self, client: mqtt.Client, userdata, msg): + logger.debug( + f"MQTT | Received message | Topic: {msg.topic} | qos: {msg.qos} | retain: {msg.retain} | Payload: {msg.payload}") + self.on_message.fire(OnMessageEvent(msg=MqttMessage( + topic=msg.topic, + payload=msg.payload, + qos=msg.qos, + retain=msg.retain + ))) + + def _on_disconnect(self, client: mqtt.Client, userdata, flags, rc: int): + logger.debug(f"MQTT | Client has disconnected") + self.on_disconnect.fire(OnDisconnectEvent(rc=rc)) + + def publish_mode(self, mode: MqttMode): + logger.info(f"Publishing mode {mode} to HA") + self._client.publish( + topic=self._ha_discovery_config.mode_state_topic, + payload=mode.value, + qos=0, + retain=True + ) + + def publish_temperature_state(self, set_temperature: float): + self._client.publish( + topic=self._ha_discovery_config.temperature_state_topic, + payload=str(set_temperature), + qos=0, + retain=True + ) + + def publish_current_temperature_state(self, current_temperature: float): + self._client.publish( + topic=self._ha_discovery_config.current_temperature_topic, + payload=str(current_temperature), + qos=0, + retain=True + ) + + def publish_fan_speed(self, fan_speed: MqttFanSpeed): + self._client.publish( + topic=self._ha_discovery_config.fan_mode_state_topic, + payload=fan_speed.value, + qos=0, + retain=True + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..48de6b5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +loguru==0.7.2 +paho-mqtt==1.6.1 +pydantic==2.4.2 +pymodbus==3.5.4 +PyYAML==6.0.1 diff --git a/server.py b/server.py new file mode 100644 index 0000000..997d847 --- /dev/null +++ b/server.py @@ -0,0 +1,206 @@ +import sys +from time import sleep + +from loguru import logger + +from config import load_config, load_version +from modbus_client import ModbusClient +from models.fan_speed_enums import FanSpeed +from models.ha_device_config import HaDeviceConfig +from models.ha_mqtt_discovery_config import HaMqttDiscoveryConfig +from models.mode_enums import Mode +from models.mqtt_fan_speed_enums import MqttFanSpeed +from models.mqtt_mode_enums import MqttMode +from models.mqtt_topcis import MqttTopics +from models.on_connect_event import OnConnectEvent +from models.on_disconnect_event import OnDisconnectEvent +from models.on_message_event import OnMessageEvent +from models.state import State +from mqtt_client import MqttClient +from state_service import StateService + + +class Server: + def __init__(self): + logger.info("Starting server") + self._config = load_config() + self._version = load_version() + self._topics = self._get_mqtt_topics() + self._ha_discovery_config = self._get_ha_discovery_config() + + self._state_service = StateService() + self._mqtt_client = MqttClient( + config=self._config.mqtt, + ha_discovery_config=self._ha_discovery_config + ) + self._modbus_client = ModbusClient(config=self._config.modbus, state_service=self._state_service) + + self._mqtt_client.on_connected.add_handler(self._on_mqtt_connected) + self._mqtt_client.on_message.add_handler(self._on_mqtt_message) + self._mqtt_client.on_disconnect.add_handler(self._on_mqtt_disconnect) + self._mqtt_client.connect() + self._mqtt_client.go_online() + self._make_mqtt_subscriptions() + + self._state_service.state_changed.add_handler(self._on_state_changed) + + self._modbus_client.start_polling() + + self._mqtt_client.loop_forever() + + def _on_state_changed(self, changes: State): + if changes.running is False: + self._mqtt_client.publish_mode(MqttMode.OFF) + if changes.running is True: + current_state = self._state_service.get_state() + mode = current_state.mode + self._publish_mode_state(mode) + + if changes.running is None and changes.mode: + self._publish_mode_state(changes.mode) + + if changes.set_temperature: + self._mqtt_client.publish_temperature_state(changes.set_temperature) + + if changes.current_temperature: + self._mqtt_client.publish_current_temperature_state(changes.current_temperature) + + if changes.fan_speed: + self._publish_fan_speed_state(changes.fan_speed) + + def _publish_mode_state(self, mode): + if mode == Mode.AUTO: + self._mqtt_client.publish_mode(MqttMode.AUTO) + if mode == Mode.COOL: + self._mqtt_client.publish_mode(MqttMode.COOL) + if mode == Mode.DRY: + self._mqtt_client.publish_mode(MqttMode.DRY) + if mode == Mode.FAN_ONLY: + self._mqtt_client.publish_mode(MqttMode.FAN_ONLY) + if mode == Mode.HEATING: + self._mqtt_client.publish_mode(MqttMode.HEAT) + + def _publish_fan_speed_state(self, fan_speed: FanSpeed): + if fan_speed == FanSpeed.AUTO: + self._mqtt_client.publish_fan_speed(MqttFanSpeed.AUTO) + if fan_speed == FanSpeed.LOW: + self._mqtt_client.publish_fan_speed(MqttFanSpeed.LOW) + if fan_speed == FanSpeed.MIDDLE: + self._mqtt_client.publish_fan_speed(MqttFanSpeed.MEDIUM) + if fan_speed == FanSpeed.HIGH: + self._mqtt_client.publish_fan_speed(MqttFanSpeed.HIGH) + if fan_speed == FanSpeed.UNKNOWN: + self._mqtt_client.publish_fan_speed(MqttFanSpeed.UNKNOWN) + + def _on_mqtt_connected(self, event: OnConnectEvent): + pass + + def _on_mqtt_message(self, event: OnMessageEvent): + topic = event.msg.topic + payload = event.msg.payload + + if topic == self._ha_discovery_config.power_command_topic: + command = str(payload) + if command == "ON": + self._modbus_client.write_operate(value=True) + elif command == "OFF": + self._modbus_client.write_operate(value=False) + self._mqtt_client.publish_mode(mode=MqttMode.OFF) + + elif topic == self._ha_discovery_config.temperature_command_topic: + command = float(payload) + self._modbus_client.set_temperature(value=command) + self._mqtt_client.publish_temperature_state(set_temperature=command) + + elif topic == self._ha_discovery_config.mode_command_topic: + command = MqttMode.from_value(str(payload)) + if command == MqttMode.OFF: + self._modbus_client.write_operate(value=False) + else: + self._modbus_client.write_operate(value=True) + self._mqtt_client.publish_mode(mode=command) + # If you set mode too quick, the mode the unit was in previously might prevail. + sleep(3) + self._modbus_set_mode(command) + + elif topic == self._ha_discovery_config.fan_mode_command_topic: + logger.debug("Processing fan speed change from HA") + command = MqttFanSpeed.from_value(str(payload)) + if command == MqttFanSpeed.AUTO: + self._modbus_client.set_fan_speed(value=FanSpeed.AUTO) + elif command == MqttFanSpeed.LOW: + self._modbus_client.set_fan_speed(value=FanSpeed.LOW) + elif command == MqttFanSpeed.MEDIUM: + self._modbus_client.set_fan_speed(value=FanSpeed.MIDDLE) + elif command == MqttFanSpeed.HIGH: + self._modbus_client.set_fan_speed(value=FanSpeed.HIGH) + elif command == MqttFanSpeed.UNKNOWN: + self._modbus_client.set_fan_speed(value=FanSpeed.UNKNOWN) + self._mqtt_client.publish_fan_speed(fan_speed=command) + + def _modbus_set_mode(self, command): + if command == MqttMode.AUTO: + self._modbus_client.set_mode(value=Mode.AUTO) + elif command == MqttMode.COOL: + self._modbus_client.set_mode(value=Mode.COOL) + elif command == MqttMode.HEAT: + self._modbus_client.set_mode(value=Mode.HEATING) + elif command == MqttMode.DRY: + self._modbus_client.set_mode(value=Mode.DRY) + elif command == MqttMode.FAN_ONLY: + self._modbus_client.set_mode(value=Mode.FAN_ONLY) + + def _on_mqtt_disconnect(self, event: OnDisconnectEvent): + logger.info(f"Disconnected with result code {event.rc}") + logger.info(f"Exiting...") + sys.exit(0) + + def _get_mqtt_topics(self): + unique_id = self._config.id + return MqttTopics( + availability=f"{unique_id}/availability", + power_command=f"{unique_id}/command/power", + mode_command=f"{unique_id}/command/mode", + temperature_command=f"{unique_id}/command/temperature", + fan_mode_command=f"{unique_id}/command/fan-mode", + mode_state=f"{unique_id}/state/mode", + temperature_state=f"{unique_id}/state/temperature", + fan_mode_state=f"{unique_id}/state/fan-mode", + current_temperature=f"{unique_id}/current-temperature" + ) + + def _make_mqtt_subscriptions(self): + topics = self._topics + self._mqtt_client.subscribe(topics=[ + topics.power_command, + topics.mode_command, + topics.temperature_command, + topics.fan_mode_command + ]) + + def _get_ha_discovery_config(self) -> HaMqttDiscoveryConfig: + topics = self._topics + config = self._config + return HaMqttDiscoveryConfig( + name=config.name, + availability_topic=topics.availability, + power_command_topic=topics.power_command, + mode_command_topic=topics.mode_command, + temperature_command_topic=topics.temperature_command, + fan_mode_command_topic=topics.fan_mode_command, + mode_state_topic=topics.mode_state, + temperature_state_topic=topics.temperature_state, + fan_mode_state_topic=topics.fan_mode_state, + current_temperature_topic=topics.current_temperature, + unique_id=config.id, + device=HaDeviceConfig( + identifiers=[f"lg-{config.id}"], + model=config.model, + name=f"LG {config.name}", + sw_version=self._version + ) + ) + + +logger.info("Welcome to lg-airco-modbus-mqtt!") +server = Server() diff --git a/state_service.py b/state_service.py new file mode 100644 index 0000000..1b8829a --- /dev/null +++ b/state_service.py @@ -0,0 +1,38 @@ +from loguru import logger + +from event_hook import EventHook +from models.state import State + + +class StateService: + + def __init__(self): + self._state = State() + + self.state_changed = EventHook[State]() + + def merge_in_state(self, state: State, skip_emit: bool = False): + # Make a copy of the current state to compare later + old_state_data = self._state.model_dump() + # Dictionary to hold changes + changes = {} + + # Merge new state into the current state + for name, value in state.model_dump().items(): + if value is not None: # Only merge values that are not None + setattr(self._state, name, value) + # If the value is different from the old state, record the change + if old_state_data[name] != value: + changes[name] = value + + # If there are any changes, create a state with only those changes + if changes and not skip_emit: + delta_state = State(**changes) + self._process_changes(delta_state) + + def _process_changes(self, delta_state: State): + logger.debug(f"Changed state: {delta_state}") + self.state_changed.fire(delta_state) + + def get_state(self) -> State: + return self._state diff --git a/version.info b/version.info new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/version.info @@ -0,0 +1 @@ +1.0.0