Expose live bcachefs options as Home Assistant MQTT switches
This commit is contained in:
1 parent
08f167d49f
commit
88f76a6874
6 files changed
+181
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
kent-report-*/
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expose two live bcachefs options through Home Assistant MQTT discovery."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
import uuid as uuid_module
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
OPTIONS = {"reconcile_enabled": "Reconcile", "copygc_enabled": "Copy GC"}
|
||||
|
||||
|
||||
class Bridge:
|
||||
def __init__(self, client, uuid, sysfs=Path('/sys/fs/bcachefs'), discovery='homeassistant'):
|
||||
self.client = client
|
||||
self.uuid = str(uuid_module.UUID(uuid))
|
||||
self.options = Path(sysfs) / self.uuid / 'options'
|
||||
self.base = f'bcachefs/{self.uuid}'
|
||||
self.discovery = discovery
|
||||
self.lock = threading.RLock()
|
||||
self.last = {}
|
||||
client.on_connect = self.on_connect
|
||||
client.on_message = self.on_message
|
||||
client.will_set(f'{self.base}/availability', 'offline', qos=1, retain=True)
|
||||
|
||||
def publish(self, topic, payload):
|
||||
return self.client.publish(topic, payload, qos=1, retain=True)
|
||||
|
||||
def announce(self):
|
||||
for option, name in OPTIONS.items():
|
||||
topic = f'{self.base}/{option}'
|
||||
config = {
|
||||
'name': name, 'unique_id': f'bcachefs_{self.uuid}_{option}',
|
||||
'default_entity_id': f'switch.bcachefs_{option}',
|
||||
'command_topic': f'{topic}/set', 'state_topic': f'{topic}/state',
|
||||
'payload_on': '1', 'payload_off': '0', 'optimistic': False,
|
||||
'qos': 1, 'retain': False,
|
||||
'availability_mode': 'all',
|
||||
'availability': [{'topic': f'{self.base}/availability'},
|
||||
{'topic': f'{topic}/availability'}],
|
||||
'device': {'identifiers': [f'bcachefs_{self.uuid}'],
|
||||
'name': f'Bcachefs {socket.gethostname()}',
|
||||
'manufacturer': 'bcachefs', 'model': 'Filesystem options'},
|
||||
}
|
||||
self.publish(f'{self.discovery}/switch/bcachefs_{self.uuid}/{option}/config', json.dumps(config))
|
||||
|
||||
def sample(self, force=False):
|
||||
with self.lock:
|
||||
for option in OPTIONS:
|
||||
topic = f'{self.base}/{option}'
|
||||
try:
|
||||
value = (self.options / option).read_text().strip()
|
||||
if value not in ('0', '1'):
|
||||
raise ValueError(f'Unexpected option value: {value!r}')
|
||||
except (OSError, ValueError):
|
||||
value = None
|
||||
if force or option not in self.last or self.last[option] != value:
|
||||
if value is not None:
|
||||
self.publish(f'{topic}/state', value)
|
||||
self.publish(f'{topic}/availability', 'online' if value is not None else 'offline')
|
||||
self.last[option] = value
|
||||
|
||||
def on_connect(self, client, userdata, flags, reason_code, properties):
|
||||
if reason_code.is_failure:
|
||||
logging.error('MQTT connection refused: %s', reason_code)
|
||||
return
|
||||
with self.lock:
|
||||
self.publish(f'{self.base}/availability', 'offline')
|
||||
self.announce()
|
||||
for option in OPTIONS:
|
||||
client.subscribe(f'{self.base}/{option}/set', qos=1)
|
||||
client.subscribe(f'{self.discovery}/status', qos=1)
|
||||
self.sample(force=True)
|
||||
self.publish(f'{self.base}/availability', 'online')
|
||||
logging.info('Connected; discovery and live states published')
|
||||
|
||||
def on_message(self, client, userdata, message):
|
||||
with self.lock:
|
||||
if message.topic == f'{self.discovery}/status':
|
||||
if message.payload == b'online':
|
||||
self.announce()
|
||||
self.sample(force=True)
|
||||
return
|
||||
option = next((name for name in OPTIONS if message.topic == f'{self.base}/{name}/set'), None)
|
||||
if option is None or message.retain or message.payload not in (b'0', b'1'):
|
||||
logging.warning('Ignoring invalid or retained command on %s', message.topic)
|
||||
return
|
||||
try:
|
||||
# Never create a file if the filesystem disappeared between polling and the command.
|
||||
fd = os.open(self.options / option, os.O_WRONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
|
||||
with os.fdopen(fd, 'wb') as target:
|
||||
target.write(message.payload + b'\n')
|
||||
logging.info('Applied %s=%s', option, message.payload.decode())
|
||||
except OSError as error:
|
||||
logging.error('Could not set %s: %s', option, error)
|
||||
self.sample(force=True)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
|
||||
uuid = os.environ['BCACHEFS_UUID']
|
||||
interval = float(os.environ.get('POLL_INTERVAL', '2'))
|
||||
if interval <= 0:
|
||||
raise ValueError('POLL_INTERVAL must be positive')
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f'bcachefs-{uuid}', clean_session=True)
|
||||
bridge = Bridge(client, uuid, discovery=os.environ.get('MQTT_DISCOVERY_PREFIX', 'homeassistant'))
|
||||
if os.environ.get('MQTT_USERNAME'):
|
||||
client.username_pw_set(os.environ['MQTT_USERNAME'], os.environ.get('MQTT_PASSWORD'))
|
||||
if os.environ.get('MQTT_CA_FILE'):
|
||||
client.tls_set(ca_certs=os.environ['MQTT_CA_FILE'])
|
||||
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||
stopped = threading.Event()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(sig, lambda *_: stopped.set())
|
||||
client.connect_async(os.environ['MQTT_HOST'], int(os.environ.get('MQTT_PORT', '1883')), keepalive=15)
|
||||
client.loop_start()
|
||||
try:
|
||||
while not stopped.wait(interval):
|
||||
if client.is_connected():
|
||||
bridge.sample()
|
||||
finally:
|
||||
if client.is_connected():
|
||||
bridge.publish(f'{bridge.base}/availability', 'offline').wait_for_publish(timeout=3)
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
repo=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")
|
||||
[[ $EUID == 0 ]] || { echo 'Run with sudo after preparing .venv (see README).' >&2; exit 1; }
|
||||
[[ $repo != *[[:space:]]* ]] || { echo 'Checkout path must not contain whitespace.' >&2; exit 1; }
|
||||
"$repo/.venv/bin/python" -c 'import paho.mqtt.client'
|
||||
if [[ ! -e /etc/default/bcachefs-mqtt ]]; then
|
||||
install -m 600 "$repo/systemd/bcachefs-mqtt.env.example" /etc/default/bcachefs-mqtt
|
||||
fi
|
||||
sed "s|@REPO@|$repo|g" "$repo/systemd/bcachefs-mqtt.service.in" > /etc/systemd/system/bcachefs-mqtt.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable bcachefs-mqtt.service
|
||||
systemctl restart bcachefs-mqtt.service
|
||||
@@ -0,0 +1 @@
|
||||
paho-mqtt==2.1.0
|
||||
@@ -0,0 +1,8 @@
|
||||
MQTT_HOST=192.168.0.11
|
||||
MQTT_PORT=1883
|
||||
BCACHEFS_UUID=3d3a0763-4dfe-41e6-93c1-8c791ec98176
|
||||
POLL_INTERVAL=2
|
||||
# MQTT_USERNAME=
|
||||
# MQTT_PASSWORD=
|
||||
# MQTT_CA_FILE=/path/to/ca.pem
|
||||
# MQTT_DISCOVERY_PREFIX=homeassistant
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Bcachefs Home Assistant MQTT switches
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
EnvironmentFile=/etc/default/bcachefs-mqtt
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=@REPO@/.venv/bin/python @REPO@/bcachefs_mqtt.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=10
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=-/sys/fs/bcachefs
|
||||
PrivateTmp=yes
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in new issue
Block a user