diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000..987b442
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,43 @@
+name: Build prod docker app
+
+on:
+ push:
+ branches: [ main ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v2
+ with:
+ registry: registry.hub.docker.com
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_SECRET }}
+
+ - name: Set up Docker Context for Buildx
+ id: buildx-context
+ run: |
+ docker context create builders
+ - name: Set up Docker Buildx
+ id: buildx
+ uses: docker/setup-buildx-action@v2
+ with:
+ version: latest
+ endpoint: builders
+ - name: Get version
+ id: get_version
+ run: |
+ echo "VERSION=$(cat version.info)" >> $GITHUB_ENV
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: registry.hub.docker.com/${{ secrets.REGISTRY_USERNAME }}/${{ github.event.repository.name }}:${{ env.VERSION }}
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..db113e8
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+# Use the official Python 3.11 image as a parent image
+FROM python:3.11-slim
+
+# Set environment variables
+# Prevents Python from writing pyc files to disc (equivalent to python -B option)
+ENV PYTHONDONTWRITEBYTECODE 1
+# Prevents Python from buffering stdout and stderr (equivalent to python -u option)
+ENV PYTHONUNBUFFERED 1
+
+# Set work directory
+WORKDIR /usr/src/app
+
+# Install OS dependencies (if any), keeping the image as small as possible
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Python dependencies
+# Copy the requirements.txt file into the container at /usr/src/app/
+COPY requirements.txt .
+# Install the Python dependencies
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy the rest of the codebase into the container
+COPY version.info .
+COPY ./src ./src
+
+# Command to run the application
+CMD ["python", "src/server.py"]
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/config.py b/src/config.py
similarity index 100%
rename from config.py
rename to src/config.py
diff --git a/event_hook.py b/src/event_hook.py
similarity index 100%
rename from event_hook.py
rename to src/event_hook.py
diff --git a/modbus_client.py b/src/modbus_client.py
similarity index 75%
rename from modbus_client.py
rename to src/modbus_client.py
index 4ffcb83..e9cd0af 100644
--- a/modbus_client.py
+++ b/src/modbus_client.py
@@ -14,32 +14,40 @@ from state_service import StateService
class ModbusClient:
def __init__(self, config: ModbusConfig, state_service: StateService):
self._config = config
+ self._client = None
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...")
+ def connect(self) -> None:
+ logger.info(f"Modbus | Connecting to {self._config.host}:{self._config.port}.")
+ self._client = ModbusTcpClient(host=self._config.host, port=self._config.port)
+ self._client.connect()
+ if not self._client.is_socket_open():
+ raise Exception('Modbus | Could not open connection.')
+ logger.info("Modbus | Connected.")
+ 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...")
+ def disconnect(self) -> None:
+ logger.info("Modbus | Stopping the modbus polling.")
self._shutdown_event.set()
- if self._poll_timer:
+ if hasattr(self, '_poll_timer') and self._poll_timer:
self._poll_timer.cancel()
+ logger.info("Modbus | Closing the connection.")
+ self._client.close()
- def _schedule_next_poll(self):
+ def _schedule_next_poll(self) -> None:
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):
+ def _poll_modbus_server(self) -> None:
slave = self._config.slave
- if self._loops_to_skip>0:
- logger.debug("Skipping poll")
+ if self._loops_to_skip > 0:
+ logger.debug("Modbus | Skipping poll")
self._loops_to_skip -= 1
self._schedule_next_poll()
return
@@ -124,41 +132,41 @@ class ModbusClient:
finally:
self._schedule_next_poll()
- def write_operate(self, value: bool):
- logger.info(f"Modbus | Writing {value} to operate coil.")
+ def write_operate(self, value: bool) -> None:
+ logger.debug(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}")
+ logger.debug(f"Modbus | {response}")
- def set_temperature(self, value: float):
+ def set_temperature(self, value: float) -> None:
temp = int(value*10)
- logger.info(f"Modbus | Writing {value} to register 1.")
+ logger.debug(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}")
+ logger.debug(f"Modbus | {response}")
- def set_mode(self, value: Mode):
+ def set_mode(self, value: Mode) -> None:
mode = value.value
- logger.info(f"Modbus | Writing {value} to register 0.")
+ logger.debug(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}")
+ logger.debug(f"Modbus | {response}")
- def set_fan_speed(self, value):
+ def set_fan_speed(self, value) -> None:
fan_speed = value.value
- logger.info(f"Modbus | Writing {value} to register 0.")
+ logger.debug(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}")
+ logger.debug(f"Modbus | {response}")
diff --git a/src/models/__init__.py b/src/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/models/fan_speed_enums.py b/src/models/fan_speed_enums.py
similarity index 100%
rename from models/fan_speed_enums.py
rename to src/models/fan_speed_enums.py
diff --git a/models/ha_device_config.py b/src/models/ha_device_config.py
similarity index 100%
rename from models/ha_device_config.py
rename to src/models/ha_device_config.py
diff --git a/models/ha_mqtt_discovery_config.py b/src/models/ha_mqtt_discovery_config.py
similarity index 100%
rename from models/ha_mqtt_discovery_config.py
rename to src/models/ha_mqtt_discovery_config.py
diff --git a/models/mode_enums.py b/src/models/mode_enums.py
similarity index 100%
rename from models/mode_enums.py
rename to src/models/mode_enums.py
diff --git a/models/mqtt_fan_speed_enums.py b/src/models/mqtt_fan_speed_enums.py
similarity index 100%
rename from models/mqtt_fan_speed_enums.py
rename to src/models/mqtt_fan_speed_enums.py
diff --git a/models/mqtt_message.py b/src/models/mqtt_message.py
similarity index 100%
rename from models/mqtt_message.py
rename to src/models/mqtt_message.py
diff --git a/models/mqtt_mode_enums.py b/src/models/mqtt_mode_enums.py
similarity index 100%
rename from models/mqtt_mode_enums.py
rename to src/models/mqtt_mode_enums.py
diff --git a/models/mqtt_topcis.py b/src/models/mqtt_topcis.py
similarity index 100%
rename from models/mqtt_topcis.py
rename to src/models/mqtt_topcis.py
diff --git a/models/on_connect_event.py b/src/models/on_connect_event.py
similarity index 100%
rename from models/on_connect_event.py
rename to src/models/on_connect_event.py
diff --git a/models/on_disconnect_event.py b/src/models/on_disconnect_event.py
similarity index 100%
rename from models/on_disconnect_event.py
rename to src/models/on_disconnect_event.py
diff --git a/models/on_message_event.py b/src/models/on_message_event.py
similarity index 100%
rename from models/on_message_event.py
rename to src/models/on_message_event.py
diff --git a/models/state.py b/src/models/state.py
similarity index 100%
rename from models/state.py
rename to src/models/state.py
diff --git a/mqtt_client.py b/src/mqtt_client.py
similarity index 67%
rename from mqtt_client.py
rename to src/mqtt_client.py
index 3d9f7e8..10bce8c 100644
--- a/mqtt_client.py
+++ b/src/mqtt_client.py
@@ -1,5 +1,3 @@
-from typing import List
-
import paho.mqtt.client as mqtt
from loguru import logger
@@ -18,8 +16,10 @@ 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
@@ -28,18 +28,28 @@ class MqttClient:
self.on_message = EventHook[OnMessageEvent]()
self.on_disconnect = EventHook[OnDisconnectEvent]()
- def connect(self):
+ def connect(self) -> None:
host = self._config.host
port = self._config.port
- logger.info(f"MQTT | Connecting to {host}:{port}")
+ logger.info(f"MQTT | Connecting to {host}:{port}")
self._client.connect(
host=host,
port=port,
keepalive=self._config.keepalive
)
- def go_online(self):
+ def go_online(self) -> None:
+ topics = [
+ self._ha_discovery_config.power_command_topic,
+ self._ha_discovery_config.mode_command_topic,
+ self._ha_discovery_config.temperature_command_topic,
+ self._ha_discovery_config.fan_mode_command_topic
+ ]
+
+ for topic in topics:
+ self._client.subscribe(topic=topic, qos=0)
+
self._client.will_set(
topic=self._ha_discovery_config.availability_topic,
payload=self._ha_discovery_config.payload_not_available,
@@ -56,44 +66,21 @@ class MqttClient:
retain=True
)
- def go_offline(self):
+ def go_offline(self) -> None:
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):
+ def loop_forever(self, timeout=1.0, max_packets=1, retry_first_connection=False) -> None:
+ logger.info("MQTT | Starting loop")
self._client.loop_forever(timeout=timeout, max_packets=max_packets,
retry_first_connection=retry_first_connection)
+ logger.info("MQTT | Loop has ended")
- 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")
+ def publish_mode(self, mode: MqttMode) -> None:
+ logger.debug(f"MQTT | Publishing mode {mode} to HA")
self._client.publish(
topic=self._ha_discovery_config.mode_state_topic,
payload=mode.value,
@@ -101,7 +88,8 @@ class MqttClient:
retain=True
)
- def publish_temperature_state(self, set_temperature: float):
+ def publish_temperature_state(self, set_temperature: float) -> None:
+ logger.debug(f"MQTT | Publishing set temperature {set_temperature} to HA")
self._client.publish(
topic=self._ha_discovery_config.temperature_state_topic,
payload=str(set_temperature),
@@ -109,7 +97,8 @@ class MqttClient:
retain=True
)
- def publish_current_temperature_state(self, current_temperature: float):
+ def publish_current_temperature_state(self, current_temperature: float) -> None:
+ logger.debug(f"MQTT | Publishing current temperature {current_temperature} to HA")
self._client.publish(
topic=self._ha_discovery_config.current_temperature_topic,
payload=str(current_temperature),
@@ -117,10 +106,38 @@ class MqttClient:
retain=True
)
- def publish_fan_speed(self, fan_speed: MqttFanSpeed):
+ def publish_fan_speed(self, fan_speed: MqttFanSpeed) -> None:
+ logger.debug(f"MQTT | Publishing fan speed {fan_speed} to HA")
self._client.publish(
topic=self._ha_discovery_config.fan_mode_state_topic,
payload=fan_speed.value,
qos=0,
retain=True
)
+
+ def exit(self) -> None:
+ logger.info('MQTT | Stopping loop.')
+ self._client.loop_stop(True)
+ logger.info('MQTT | Disconnecting.')
+ self._client.disconnect()
+
+ def _on_connect(self, client: mqtt.Client, userdata, flags, rc: int) -> None:
+ if rc == 0:
+ logger.info("MQTT | Connected!")
+ 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) -> None:
+ 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, rc: int) -> None:
+ logger.info(f"MQTT | Disconnected with result code {rc}")
+ self.on_disconnect.fire(OnDisconnectEvent(rc=rc))
diff --git a/server.py b/src/server.py
similarity index 83%
rename from server.py
rename to src/server.py
index 997d847..d659caa 100644
--- a/server.py
+++ b/src/server.py
@@ -1,3 +1,4 @@
+import signal
import sys
from time import sleep
@@ -12,8 +13,6 @@ 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
@@ -22,7 +21,7 @@ from state_service import StateService
class Server:
def __init__(self):
- logger.info("Starting server")
+ logger.info("Server | Setup server")
self._config = load_config()
self._version = load_version()
self._topics = self._get_mqtt_topics()
@@ -35,20 +34,30 @@ class Server:
)
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()
-
+ def start(self) -> None:
+ logger.info("Server | Startup server")
+ try:
+ self._modbus_client.connect()
+ except Exception as e:
+ logger.error(e)
+ self.stop()
+ self._mqtt_client.connect()
+ self._mqtt_client.go_online()
self._mqtt_client.loop_forever()
- def _on_state_changed(self, changes: State):
+ def stop(self, signum=None, frame=None) -> None:
+ logger.info(f"Server | Shutting down")
+ self._mqtt_client.exit()
+ self._modbus_client.disconnect()
+
+ logger.info(f"Server | Done. Bye!")
+ sys.exit(0)
+
+ def _on_state_changed(self, changes: State) -> None:
if changes.running is False:
self._mqtt_client.publish_mode(MqttMode.OFF)
if changes.running is True:
@@ -68,7 +77,7 @@ class Server:
if changes.fan_speed:
self._publish_fan_speed_state(changes.fan_speed)
- def _publish_mode_state(self, mode):
+ def _publish_mode_state(self, mode) -> None:
if mode == Mode.AUTO:
self._mqtt_client.publish_mode(MqttMode.AUTO)
if mode == Mode.COOL:
@@ -80,7 +89,7 @@ class Server:
if mode == Mode.HEATING:
self._mqtt_client.publish_mode(MqttMode.HEAT)
- def _publish_fan_speed_state(self, fan_speed: FanSpeed):
+ def _publish_fan_speed_state(self, fan_speed: FanSpeed) -> None:
if fan_speed == FanSpeed.AUTO:
self._mqtt_client.publish_fan_speed(MqttFanSpeed.AUTO)
if fan_speed == FanSpeed.LOW:
@@ -92,10 +101,7 @@ class Server:
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):
+ def _on_mqtt_message(self, event: OnMessageEvent) -> None:
topic = event.msg.topic
payload = event.msg.payload
@@ -116,6 +122,7 @@ class Server:
command = MqttMode.from_value(str(payload))
if command == MqttMode.OFF:
self._modbus_client.write_operate(value=False)
+ self._mqtt_client.publish_mode(mode=command)
else:
self._modbus_client.write_operate(value=True)
self._mqtt_client.publish_mode(mode=command)
@@ -138,7 +145,7 @@ class Server:
self._modbus_client.set_fan_speed(value=FanSpeed.UNKNOWN)
self._mqtt_client.publish_fan_speed(fan_speed=command)
- def _modbus_set_mode(self, command):
+ def _modbus_set_mode(self, command) -> None:
if command == MqttMode.AUTO:
self._modbus_client.set_mode(value=Mode.AUTO)
elif command == MqttMode.COOL:
@@ -150,12 +157,7 @@ class Server:
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):
+ def _get_mqtt_topics(self) -> MqttTopics:
unique_id = self._config.id
return MqttTopics(
availability=f"{unique_id}/availability",
@@ -169,15 +171,6 @@ class Server:
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
@@ -196,11 +189,23 @@ class Server:
device=HaDeviceConfig(
identifiers=[f"lg-{config.id}"],
model=config.model,
- name=f"LG {config.name}",
+ name=f"LG",
sw_version=self._version
)
)
+logger.configure(handlers=[{"sink": sys.stdout, "format": "{time:YYYY-MM-DD HH:mm:ss.SSS} | "
+ "{level: <8} | "
+ "{message}"}])
+
logger.info("Welcome to lg-airco-modbus-mqtt!")
server = Server()
+try:
+ server.start()
+except KeyboardInterrupt:
+ logger.warning("Server | Interrupt received, stopping server...")
+ server.stop()
+
+signal.signal(signal.SIGINT, server.stop)
+signal.signal(signal.SIGTERM, server.stop)
diff --git a/state_service.py b/src/state_service.py
similarity index 95%
rename from state_service.py
rename to src/state_service.py
index 1b8829a..3b01e98 100644
--- a/state_service.py
+++ b/src/state_service.py
@@ -31,7 +31,7 @@ class StateService:
self._process_changes(delta_state)
def _process_changes(self, delta_state: State):
- logger.debug(f"Changed state: {delta_state}")
+ logger.debug(f"State | Changed state: {delta_state}")
self.state_changed.fire(delta_state)
def get_state(self) -> State: