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: