Implement configuration via Pydantic & change project structure
This commit is contained in:
parent
33b3432787
commit
c4b28a56e6
10 changed files with 215 additions and 47 deletions
38
Readme.md
38
Readme.md
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
# Introduction
|
# Introduction
|
||||||
|
|
||||||
Simple script utilities to add BME680 sensor readings to Apple Homekit using a Raspberry PI with minimal configuration.
|
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
|
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.
|
Sensor values are collected and exposed in HomeKit and as prometheus metrics.
|
||||||
The prometheus metrics can be accessed on port `8000`.
|
|
||||||
|
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:
|
Run the program once to pair it with your ios. ex:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd sensors
|
python3 -m app.main
|
||||||
python3 main.py
|
|
||||||
Setup payload: X-HM://0023K50QET2YB
|
Setup payload: X-HM://0023K50QET2YB
|
||||||
Scan this code with your HomeKit app on your iOS device:
|
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.
|
Copy the systemd service.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp bme680-homekit.service /etc/systemd/system
|
sudo cp ./systemd/bme680-homekit.service /etc/systemd/system
|
||||||
sudo systemctl status bme680-homekit
|
sudo systemctl status bme680-homekit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -54,6 +73,7 @@ sudo systemctl status bme680-homekit
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the service
|
Start the service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl start bme680-homekit
|
sudo systemctl start bme680-homekit
|
||||||
sudo systemctl status 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`
|
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`
|
Grafana can be used to create dashboard and visualise prometheus metrics. To install it run `grafana/install.sh`
|
||||||
|
|
||||||
|
|
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
117
app/config.py
Normal file
117
app/config.py
Normal file
|
@ -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,
|
||||||
|
)
|
41
app/main.py
Normal file
41
app/main.py
Normal file
|
@ -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()
|
0
app/sensors/__init__.py
Normal file
0
app/sensors/__init__.py
Normal file
|
@ -1,21 +1,12 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import bme680
|
from prometheus_client import Gauge
|
||||||
from prometheus_client import Gauge, start_http_server
|
from pyhap.accessory import Accessory
|
||||||
from pyhap.accessory import Accessory, Bridge
|
|
||||||
from pyhap.accessory_driver import AccessoryDriver
|
|
||||||
from pyhap.const import CATEGORY_SENSOR
|
from pyhap.const import CATEGORY_SENSOR
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
def fail(message: str):
|
|
||||||
"""
|
|
||||||
Fail crashes the program and logs the message.
|
|
||||||
"""
|
|
||||||
logging.critical(message)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
class Bme680Sensor(Accessory):
|
class Bme680Sensor(Accessory):
|
||||||
|
@ -23,14 +14,17 @@ class Bme680Sensor(Accessory):
|
||||||
|
|
||||||
category = CATEGORY_SENSOR # This is for the icon in the iOS Home app.
|
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
|
"""Here, we just store a reference to the current temperature characteristic and
|
||||||
add a method that will be executed every time its value changes.
|
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.
|
# 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_humidity_oversample(bme680.OS_2X)
|
||||||
self.sensor.set_pressure_oversample(bme680.OS_4X)
|
self.sensor.set_pressure_oversample(bme680.OS_4X)
|
||||||
|
@ -81,34 +75,17 @@ class Bme680Sensor(Accessory):
|
||||||
@Accessory.run_at_interval(120)
|
@Accessory.run_at_interval(120)
|
||||||
def run(self):
|
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:
|
try:
|
||||||
self._run()
|
self._run()
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
# This happens from time to time, best we stop and let systemd restart us.
|
# 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):
|
def stop(self):
|
||||||
"""We override this method to clean up any resources or perform final actions, as
|
"""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.
|
this is called by the AccessoryDriver when the Accessory is being stopped.
|
||||||
"""
|
"""
|
||||||
print("Stopping accessory.")
|
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()
|
|
12
config.yaml
Normal file
12
config.yaml
Normal file
|
@ -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"
|
|
@ -1,3 +1,4 @@
|
||||||
bme680==1.1.1
|
bme680==1.1.1
|
||||||
HAP-python==4.4.0
|
HAP-python==4.4.0
|
||||||
prometheus-client==0.14.1
|
prometheus-client== 0.19.0
|
||||||
|
PyYAML==6.0.1
|
|
@ -7,7 +7,7 @@ WorkingDirectory=/home/pi/bme680-homekit/
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
User=pi
|
User=pi
|
||||||
ExecStart=/usr/bin/python3 /home/pi/bme680-homekit/sensors/main.py
|
ExecStart=/usr/bin/python3 -m app.main
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
2
sensors/install.sh → systemd/install.sh
Executable file → Normal file
2
sensors/install.sh → systemd/install.sh
Executable file → Normal file
|
@ -1,5 +1,5 @@
|
||||||
# Install the systemd service
|
# Install the systemd service
|
||||||
cp bme680-homekit.service /etc/systemd/system
|
cp ./systemd/bme680-homekit.service /etc/systemd/system
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl start bme680-homekit
|
systemctl start bme680-homekit
|
||||||
systemctl enable bme680-homekit
|
systemctl enable bme680-homekit
|
Loading…
Reference in a new issue