Expose live bcachefs options as Home Assistant MQTT switches

This commit is contained in:
feedc0de committed 2026-11-01 20:15:55 +01:00
1 parent 08f167d49f
commit 88f76a6874
6 files changed
+181

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.venv/
__pycache__/
kent-report-*/
+133
View File
@@ -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()
+13
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
paho-mqtt==2.1.0
+8
View File
@@ -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
+23
View File
@@ -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