From c4b28a56e64329825fe2aab768297610255cff4d Mon Sep 17 00:00:00 2001 From: dnutiu Date: Tue, 6 Feb 2024 23:22:47 +0200 Subject: [PATCH 1/9] Implement configuration via Pydantic & change project structure --- Readme.md | 38 +++++-- app/__init__.py | 0 app/config.py | 117 ++++++++++++++++++++ app/main.py | 41 +++++++ app/sensors/__init__.py | 0 sensors/main.py => app/sensors/bme.py | 47 ++------ config.yaml | 12 ++ requirements.txt | 3 +- {sensors => systemd}/bme680-homekit.service | 2 +- {sensors => systemd}/install.sh | 2 +- 10 files changed, 215 insertions(+), 47 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/sensors/__init__.py rename sensors/main.py => app/sensors/bme.py (72%) create mode 100644 config.yaml rename {sensors => systemd}/bme680-homekit.service (76%) rename {sensors => systemd}/install.sh (67%) mode change 100755 => 100644 diff --git a/Readme.md b/Readme.md index 2a98a2b..16db557 100644 --- a/Readme.md +++ b/Readme.md @@ -1,4 +1,3 @@ - # Introduction Simple script utilities to add BME680 sensor readings to Apple Homekit using a Raspberry PI with minimal configuration. @@ -21,18 +20,29 @@ sudo apt-get install libavahi-compat-libdnssd-dev pip3 install -r requirements.txt ``` -### Sensors +### Application -The `sensors` directory contains code for operating the bme680 sensor. +The `app` directory contains the source code of this application -The sensor values are collected and exposed in HomeKit and as prometheus metrics. -The prometheus metrics can be accessed on port `8000`. +Sensor values are collected and exposed in HomeKit and as prometheus metrics. + +By default, metrics can be accessed on port `8000`. + + + +### Configuration + +Before running the program edit the `config.yaml` file and replace at least `persist_file` field. + +The program will search for the `config.yaml` file in the current directory but this can be configured to another directory +by settings the `HOMEKIT_CONFIG` environment variable. + +### Running Run the program once to pair it with your ios. ex: ```bash -cd sensors -python3 main.py +python3 -m app.main Setup payload: X-HM://0023K50QET2YB Scan this code with your HomeKit app on your iOS device: @@ -40,10 +50,19 @@ Or enter this code in your HomeKit app on your iOS device: 053-86-998 ``` +### SystemD Service + +Edit `./systemd/bme680-homekit.service` and replace relevant variables such as `WorkingDirectory`, `ExecStart` +and `User`. + +Run `sudo ./systemd/install.sh` to install SystemD service automatically on your system. + +Or follow the manual steps. + Copy the systemd service. ```bash -sudo cp bme680-homekit.service /etc/systemd/system +sudo cp ./systemd/bme680-homekit.service /etc/systemd/system sudo systemctl status bme680-homekit ``` @@ -54,6 +73,7 @@ sudo systemctl status bme680-homekit ``` Start the service + ```bash sudo systemctl start bme680-homekit sudo systemctl status bme680-homekit @@ -97,7 +117,7 @@ Prometheus is a system for monitoring and alerting. To install it run `prometheu Prometheus server will listen on port `:9090` -### Grafana +### Grafana Grafana can be used to create dashboard and visualise prometheus metrics. To install it run `grafana/install.sh` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..05e4311 --- /dev/null +++ b/app/config.py @@ -0,0 +1,117 @@ +import functools +import os +from pathlib import Path +from typing import Type, Tuple, Any, Dict + +import yaml +from pydantic import BaseModel, Field +from pydantic.fields import FieldInfo +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource + +config_file_default_location = "config.yaml" + + +class PrometheusSettings(BaseModel): + enabled: bool = Field(True, description="Enable prometheus metrics server.") + port: int = Field(8000, description="The prometheus metrics server port.") + + +class Bme680Settings(BaseModel): + enabled: bool = Field(True, description="If the sensor should be enabled on not.") + address: int = Field( + 0x76, description="The I2C address of the sensor. Defaults to primary" + ) + name: str = Field("Climate Sensor", description="The name of the sensor.") + + +class BridgeSettings(BaseModel): + display_name: str = Field( + "Bridge", description="The display name of the HAP bridge." + ) + bme680: Bme680Settings = Field( + Bme680Settings(), description="Settings for the BME680 module." + ) + + +class HomekitAccessoryProtocolSettings(BaseModel): + port: int = Field( + 51826, description="The port of the homekit accessory protocol server." + ) + persist_file: str = Field( + ..., + description="The persistence file which holds the homekit accessory protocol server's state.", + ) + bridge: BridgeSettings = Field( + BridgeSettings(), description="The HAP's default bridge settings." + ) + + +class YamlConfigSettingsSource(PydanticBaseSettingsSource): + """ + A simple settings source class that loads variables from a YAML file + at the project's root. + + Here we happen to choose to use the `env_file_encoding` from Config + when reading `config.yaml` + """ + + @functools.lru_cache + def read_file_content(self): + encoding = self.config.get("env_file_encoding") + return yaml.safe_load( + Path( + os.getenv("HOMEKIT_CONFIG", default=config_file_default_location) + ).read_text(encoding) + ) + + def get_field_value( + self, field: FieldInfo, field_name: str + ) -> Tuple[Any, str, bool]: + file_content_json = self.read_file_content() + field_value = file_content_json.get(field_name) + return field_value, field_name, False + + def prepare_field_value( + self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool + ) -> Any: + return value + + def __call__(self) -> Dict[str, Any]: + d: Dict[str, Any] = {} + + for field_name, field in self.settings_cls.model_fields.items(): + field_value, field_key, value_is_complex = self.get_field_value( + field, field_name + ) + field_value = self.prepare_field_value( + field_name, field, field_value, value_is_complex + ) + if field_value is not None: + d[field_key] = field_value + + return d + + +class Settings(BaseSettings): + prometheus: PrometheusSettings = Field( + PrometheusSettings(), description="Settings for prometheus." + ) + hap: HomekitAccessoryProtocolSettings = Field( + ..., description="Homekit Accessory Protocol server settings." + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> Tuple[PydanticBaseSettingsSource, ...]: + return ( + init_settings, + YamlConfigSettingsSource(settings_cls), + env_settings, + file_secret_settings, + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8fd445f --- /dev/null +++ b/app/main.py @@ -0,0 +1,41 @@ +import signal + +from prometheus_client import start_http_server +from pyhap.accessory import Bridge +from pyhap.accessory_driver import AccessoryDriver + +from app.config import Settings +from app.sensors.bme import Bme680Sensor + + +def get_bridge(accessory_driver: AccessoryDriver, settings: Settings): + """ + Returns a Homekit Bridge. + + Parameters + ---------- + accessory_driver: AccessoryDriver + The accessory driver. + """ + bridge = Bridge(accessory_driver, settings.hap.bridge.display_name) + if settings.hap.bridge.bme680.enabled: + bridge.add_accessory( + Bme680Sensor( + accessory_driver, settings.hap.bridge.bme680.name, settings=settings + ) + ) + return bridge + + +if __name__ == "__main__": + settings = Settings() + # Start prometheus metrics server. Any metrics will be registered automatically. + if settings.prometheus.enabled: + start_http_server(settings.prometheus.port) + # Python HAP + driver = AccessoryDriver( + port=settings.hap.port, persist_file=settings.hap.persist_file + ) + driver.add_accessory(accessory=get_bridge(driver, settings)) + signal.signal(signal.SIGTERM, driver.signal_handler) + driver.start() diff --git a/app/sensors/__init__.py b/app/sensors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensors/main.py b/app/sensors/bme.py similarity index 72% rename from sensors/main.py rename to app/sensors/bme.py index 4b81a3d..715a2ed 100644 --- a/sensors/main.py +++ b/app/sensors/bme.py @@ -1,21 +1,12 @@ import logging import os -import signal import sys -import bme680 -from prometheus_client import Gauge, start_http_server -from pyhap.accessory import Accessory, Bridge -from pyhap.accessory_driver import AccessoryDriver +from prometheus_client import Gauge +from pyhap.accessory import Accessory from pyhap.const import CATEGORY_SENSOR - -def fail(message: str): - """ - Fail crashes the program and logs the message. - """ - logging.critical(message) - sys.exit(1) +from app.config import Settings class Bme680Sensor(Accessory): @@ -23,14 +14,17 @@ class Bme680Sensor(Accessory): category = CATEGORY_SENSOR # This is for the icon in the iOS Home app. - def __init__(self, *args, **kwargs): + def __init__(self, driver, display_name, *, aid=None, settings: Settings): """Here, we just store a reference to the current temperature characteristic and add a method that will be executed every time its value changes. """ # If overriding this method, be sure to call the super's implementation first. - super().__init__(*args, **kwargs) + super().__init__(driver, display_name, aid=aid) - self.sensor = bme680.BME680(bme680.I2C_ADDR_PRIMARY) + self.settings = settings + self.sensor = bme680.BME680( + settings.hap.bridge.bme680.address or bme680.I2C_ADDR_PRIMARY + ) self.sensor.set_humidity_oversample(bme680.OS_2X) self.sensor.set_pressure_oversample(bme680.OS_4X) @@ -81,34 +75,17 @@ class Bme680Sensor(Accessory): @Accessory.run_at_interval(120) def run(self): """ - This function runs the accessory. It polls for data and publishes it at the given interval. + This function runs the accessory. It polls for data and publishes it at the given interval. """ try: self._run() except IOError as e: # This happens from time to time, best we stop and let systemd restart us. - fail(f"Failed due to IOError: {e}") + logging.critical("Failed to run BME680.") + sys.exit(1) def stop(self): """We override this method to clean up any resources or perform final actions, as this is called by the AccessoryDriver when the Accessory is being stopped. """ print("Stopping accessory.") - - -def get_bridge(accessory_driver): - bridge = Bridge(accessory_driver, "Bridge") - bridge.add_accessory(Bme680Sensor(accessory_driver, "Sensor")) - return bridge - - -if __name__ == "__main__": - # Prometheus' metrics server - start_http_server(8000) - # Python HAP - driver = AccessoryDriver( - port=51826, persist_file="/home/pi/bme680-homekit/sensors/accessory.state" - ) - driver.add_accessory(accessory=get_bridge(driver)) - signal.signal(signal.SIGTERM, driver.signal_handler) - driver.start() diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..c90b785 --- /dev/null +++ b/config.yaml @@ -0,0 +1,12 @@ +prometheus: + enabled: yes + port: 8000 +hap: + port: 51826 + persist_file: /home/pi/bme680-homekit/sensors/accessory.state + bridge: + display_name: Bridge + bme680: + enabled: yes + address: 118 # Primary I2C Address + name: "Climate Sensor" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f541eb4..f37462f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ bme680==1.1.1 HAP-python==4.4.0 -prometheus-client==0.14.1 +prometheus-client== 0.19.0 +PyYAML==6.0.1 \ No newline at end of file diff --git a/sensors/bme680-homekit.service b/systemd/bme680-homekit.service similarity index 76% rename from sensors/bme680-homekit.service rename to systemd/bme680-homekit.service index 85cac68..8e4aeb1 100644 --- a/sensors/bme680-homekit.service +++ b/systemd/bme680-homekit.service @@ -7,7 +7,7 @@ WorkingDirectory=/home/pi/bme680-homekit/ Restart=on-failure RestartSec=5s User=pi -ExecStart=/usr/bin/python3 /home/pi/bme680-homekit/sensors/main.py +ExecStart=/usr/bin/python3 -m app.main [Install] WantedBy=multi-user.target diff --git a/sensors/install.sh b/systemd/install.sh old mode 100755 new mode 100644 similarity index 67% rename from sensors/install.sh rename to systemd/install.sh index 495a374..6e5a94f --- a/sensors/install.sh +++ b/systemd/install.sh @@ -1,5 +1,5 @@ # Install the systemd service -cp bme680-homekit.service /etc/systemd/system +cp ./systemd/bme680-homekit.service /etc/systemd/system systemctl daemon-reload systemctl start bme680-homekit systemctl enable bme680-homekit From 86330b097ebf61c6a6d4512ab8feacef0ffc9a25 Mon Sep 17 00:00:00 2001 From: dnutiu Date: Tue, 6 Feb 2024 23:28:16 +0200 Subject: [PATCH 2/9] update Readme.md --- Readme.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index 16db557..4309553 100644 --- a/Readme.md +++ b/Readme.md @@ -113,7 +113,12 @@ You will need to active I2C interface with `sudo raspi-config` -> Interfacing -> ### Prometheus -Prometheus is a system for monitoring and alerting. To install it run `prometheus./install.sh`. +Prometheus is a system for monitoring and alerting. + +Before installing Prometheus you will need to tweak the `prometheus/prometheus.service` file, especially the config +file and storage path since they contain the hardcoded string: `/home/pi/bme680-homekit/`. + +To install it run `prometheus./install.sh`. Prometheus server will listen on port `:9090` From 18080bc7a27426e9a652540f5b7f110bc170ed84 Mon Sep 17 00:00:00 2001 From: dnutiu Date: Wed, 7 Feb 2024 20:56:52 +0200 Subject: [PATCH 3/9] update requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f37462f..4860a7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ bme680==1.1.1 HAP-python==4.4.0 prometheus-client== 0.19.0 -PyYAML==6.0.1 \ No newline at end of file +PyYAML==6.0.1 +pydantic~=2.6.1 \ No newline at end of file From 18ed759c6c118ce1ce988ac70f1b71b856906944 Mon Sep 17 00:00:00 2001 From: dnutiu Date: Wed, 7 Feb 2024 21:11:25 +0200 Subject: [PATCH 4/9] fix missing requirements & imports --- app/sensors/bme.py | 1 + requirements.txt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/sensors/bme.py b/app/sensors/bme.py index 715a2ed..f21a73b 100644 --- a/app/sensors/bme.py +++ b/app/sensors/bme.py @@ -5,6 +5,7 @@ import sys from prometheus_client import Gauge from pyhap.accessory import Accessory from pyhap.const import CATEGORY_SENSOR +import bme680 from app.config import Settings diff --git a/requirements.txt b/requirements.txt index 4860a7e..ba3f864 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ bme680==1.1.1 HAP-python==4.4.0 prometheus-client== 0.19.0 PyYAML==6.0.1 -pydantic~=2.6.1 \ No newline at end of file +pydantic~=2.6.1 +pydantic-settings==2.1.0 \ No newline at end of file From b9822b33e706b17429096d7eb99f7bd5c042a544 Mon Sep 17 00:00:00 2001 From: dnutiu Date: Wed, 7 Feb 2024 21:24:03 +0200 Subject: [PATCH 5/9] quality of life improvements --- app/main.py | 5 +++++ config.yaml | 2 +- prometheus/prometheus.service | 8 ++++---- systemd/bme680-homekit.service | 5 +++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index 8fd445f..ad8454e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,5 @@ +import logging +import pprint import signal from prometheus_client import start_http_server @@ -28,7 +30,10 @@ def get_bridge(accessory_driver: AccessoryDriver, settings: Settings): if __name__ == "__main__": + logging.basicConfig(level="INFO") settings = Settings() + logging.info("Running with settings:") + logging.info(pprint.pformat(settings.model_dump())) # Start prometheus metrics server. Any metrics will be registered automatically. if settings.prometheus.enabled: start_http_server(settings.prometheus.port) diff --git a/config.yaml b/config.yaml index c90b785..f1fef35 100644 --- a/config.yaml +++ b/config.yaml @@ -3,7 +3,7 @@ prometheus: port: 8000 hap: port: 51826 - persist_file: /home/pi/bme680-homekit/sensors/accessory.state + persist_file: /home/denis/bme680-homekit/sensors/accessory.state bridge: display_name: Bridge bme680: diff --git a/prometheus/prometheus.service b/prometheus/prometheus.service index 2a185a1..edaf487 100644 --- a/prometheus/prometheus.service +++ b/prometheus/prometheus.service @@ -6,12 +6,12 @@ After=network-online.target [Service] Restart=on-failure RestartSec=5s -User=pi +User=denis Restart=on-failure -ExecStart=/home/pi/bme680-homekit/prometheus/prometheus-2.36.1.linux-armv6/prometheus \ - --config.file=/home/pi/bme680-homekit/prometheus/prometheus.yml \ - --storage.tsdb.path=/home/pi/bme680-homekit/prometheus/data +ExecStart=/home/denis/bme680-homekit/prometheus/prometheus-2.36.1.linux-armv6/prometheus \ + --config.file=/home/denis/bme680-homekit/prometheus/prometheus.yml \ + --storage.tsdb.path=/home/denis/bme680-homekit/prometheus/data [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/systemd/bme680-homekit.service b/systemd/bme680-homekit.service index 8e4aeb1..a2a3b2d 100644 --- a/systemd/bme680-homekit.service +++ b/systemd/bme680-homekit.service @@ -3,10 +3,11 @@ Description=Bme680 Homekit service After=local-fs.target network-online.target [Service] -WorkingDirectory=/home/pi/bme680-homekit/ +Environment="HOMEKIT_CONFIG=/home/denis/bme680-homekit/config.yaml" +WorkingDirectory=/home/denis/bme680-homekit/ Restart=on-failure RestartSec=5s -User=pi +User=denis ExecStart=/usr/bin/python3 -m app.main [Install] From 668d623dffcbd8d8e1898df8726d970487881f6e Mon Sep 17 00:00:00 2001 From: dnutiu Date: Wed, 7 Feb 2024 21:31:35 +0200 Subject: [PATCH 6/9] handle INT signal --- app/main.py | 1 + config.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index ad8454e..8baef8f 100644 --- a/app/main.py +++ b/app/main.py @@ -43,4 +43,5 @@ if __name__ == "__main__": ) driver.add_accessory(accessory=get_bridge(driver, settings)) signal.signal(signal.SIGTERM, driver.signal_handler) + signal.signal(signal.SIGINT, driver.signal_handler) driver.start() diff --git a/config.yaml b/config.yaml index f1fef35..489f95f 100644 --- a/config.yaml +++ b/config.yaml @@ -3,7 +3,7 @@ prometheus: port: 8000 hap: port: 51826 - persist_file: /home/denis/bme680-homekit/sensors/accessory.state + persist_file: /home/denis/bme680-homekit/accessory.state bridge: display_name: Bridge bme680: From 949f6c235d942b96c12c2219394008dd602ede48 Mon Sep 17 00:00:00 2001 From: dnutiu Date: Wed, 7 Feb 2024 21:49:46 +0200 Subject: [PATCH 7/9] run using asyncio --- app/main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 8baef8f..8d337e0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import asyncio import logging import pprint import signal @@ -29,7 +30,7 @@ def get_bridge(accessory_driver: AccessoryDriver, settings: Settings): return bridge -if __name__ == "__main__": +async def main(): logging.basicConfig(level="INFO") settings = Settings() logging.info("Running with settings:") @@ -45,3 +46,7 @@ if __name__ == "__main__": signal.signal(signal.SIGTERM, driver.signal_handler) signal.signal(signal.SIGINT, driver.signal_handler) driver.start() + + +if __name__ == "__main__": + asyncio.run(main()) From 586bbdc43993d1e0ba9038e086ff9d3e73068b5e Mon Sep 17 00:00:00 2001 From: dnutiu Date: Wed, 7 Feb 2024 21:50:55 +0200 Subject: [PATCH 8/9] use driver.async_start() --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 8d337e0..b425352 100644 --- a/app/main.py +++ b/app/main.py @@ -45,7 +45,7 @@ async def main(): driver.add_accessory(accessory=get_bridge(driver, settings)) signal.signal(signal.SIGTERM, driver.signal_handler) signal.signal(signal.SIGINT, driver.signal_handler) - driver.start() + await driver.async_start() if __name__ == "__main__": From 0d7caf141882614d23c62871934c1c3f362b9733 Mon Sep 17 00:00:00 2001 From: dnutiu Date: Wed, 7 Feb 2024 21:51:42 +0200 Subject: [PATCH 9/9] revert to normal run --- app/main.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index b425352..8baef8f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,3 @@ -import asyncio import logging import pprint import signal @@ -30,7 +29,7 @@ def get_bridge(accessory_driver: AccessoryDriver, settings: Settings): return bridge -async def main(): +if __name__ == "__main__": logging.basicConfig(level="INFO") settings = Settings() logging.info("Running with settings:") @@ -45,8 +44,4 @@ async def main(): driver.add_accessory(accessory=get_bridge(driver, settings)) signal.signal(signal.SIGTERM, driver.signal_handler) signal.signal(signal.SIGINT, driver.signal_handler) - await driver.async_start() - - -if __name__ == "__main__": - asyncio.run(main()) + driver.start()