diff --git a/Readme.md b/Readme.md index 2a98a2b..4309553 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 @@ -93,11 +113,16 @@ 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` -### 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..8baef8f --- /dev/null +++ b/app/main.py @@ -0,0 +1,47 @@ +import logging +import pprint +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__": + 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) + # 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) + signal.signal(signal.SIGINT, 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 73% rename from sensors/main.py rename to app/sensors/bme.py index 4b81a3d..f21a73b 100644 --- a/sensors/main.py +++ b/app/sensors/bme.py @@ -1,21 +1,13 @@ 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 +import bme680 - -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 +15,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 +76,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..489f95f --- /dev/null +++ b/config.yaml @@ -0,0 +1,12 @@ +prometheus: + enabled: yes + port: 8000 +hap: + port: 51826 + persist_file: /home/denis/bme680-homekit/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/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/requirements.txt b/requirements.txt index f541eb4..ba3f864 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ bme680==1.1.1 HAP-python==4.4.0 -prometheus-client==0.14.1 +prometheus-client== 0.19.0 +PyYAML==6.0.1 +pydantic~=2.6.1 +pydantic-settings==2.1.0 \ No newline at end of file diff --git a/sensors/bme680-homekit.service b/systemd/bme680-homekit.service similarity index 50% rename from sensors/bme680-homekit.service rename to systemd/bme680-homekit.service index 85cac68..a2a3b2d 100644 --- a/sensors/bme680-homekit.service +++ b/systemd/bme680-homekit.service @@ -3,11 +3,12 @@ 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 -ExecStart=/usr/bin/python3 /home/pi/bme680-homekit/sensors/main.py +User=denis +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