mirror of
https://github.com/filipvh/lg-airco-modbus-mqtt.git
synced 2024-10-08 03:19:00 +00:00
initial commit
This commit is contained in:
parent
a621a1f195
commit
4df409c520
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.idea
|
||||||
|
config.yaml
|
14
config.example.yaml
Normal file
14
config.example.yaml
Normal file
@ -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
|
40
config.py
Normal file
40
config.py
Normal file
@ -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
|
19
event_hook.py
Normal file
19
event_hook.py
Normal file
@ -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 = []
|
164
modbus_client.py
Normal file
164
modbus_client.py
Normal file
@ -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}")
|
20
models/fan_speed_enums.py
Normal file
20
models/fan_speed_enums.py
Normal file
@ -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
|
11
models/ha_device_config.py
Normal file
11
models/ha_device_config.py
Normal file
@ -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
|
33
models/ha_mqtt_discovery_config.py
Normal file
33
models/ha_mqtt_discovery_config.py
Normal file
@ -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
|
21
models/mode_enums.py
Normal file
21
models/mode_enums.py
Normal file
@ -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
|
||||||
|
|
20
models/mqtt_fan_speed_enums.py
Normal file
20
models/mqtt_fan_speed_enums.py
Normal file
@ -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
|
8
models/mqtt_message.py
Normal file
8
models/mqtt_message.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MqttMessage(BaseModel):
|
||||||
|
topic: str
|
||||||
|
payload: str
|
||||||
|
qos: int
|
||||||
|
retain: bool
|
21
models/mqtt_mode_enums.py
Normal file
21
models/mqtt_mode_enums.py
Normal file
@ -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
|
13
models/mqtt_topcis.py
Normal file
13
models/mqtt_topcis.py
Normal file
@ -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
|
9
models/on_connect_event.py
Normal file
9
models/on_connect_event.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from pydantic import BaseModel, IPvAnyAddress, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OnConnectEvent(BaseModel):
|
||||||
|
flags: Dict = Field(...)
|
5
models/on_disconnect_event.py
Normal file
5
models/on_disconnect_event.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class OnDisconnectEvent(BaseModel):
|
||||||
|
rc: int
|
7
models/on_message_event.py
Normal file
7
models/on_message_event.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from models.mqtt_message import MqttMessage
|
||||||
|
|
||||||
|
|
||||||
|
class OnMessageEvent(BaseModel):
|
||||||
|
msg: MqttMessage
|
14
models/state.py
Normal file
14
models/state.py
Normal file
@ -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
|
126
mqtt_client.py
Normal file
126
mqtt_client.py
Normal file
@ -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
|
||||||
|
)
|
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -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
|
206
server.py
Normal file
206
server.py
Normal file
@ -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()
|
38
state_service.py
Normal file
38
state_service.py
Normal file
@ -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
|
1
version.info
Normal file
1
version.info
Normal file
@ -0,0 +1 @@
|
|||||||
|
1.0.0
|
Loading…
Reference in New Issue
Block a user