diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e8fa89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +kent-report-*/ diff --git a/bcachefs_mqtt.py b/bcachefs_mqtt.py new file mode 100644 index 0000000..88c621f --- /dev/null +++ b/bcachefs_mqtt.py @@ -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() diff --git a/install-mqtt.sh b/install-mqtt.sh new file mode 100755 index 0000000..4d70b57 --- /dev/null +++ b/install-mqtt.sh @@ -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 diff --git a/requirements-mqtt.txt b/requirements-mqtt.txt new file mode 100644 index 0000000..96a2716 --- /dev/null +++ b/requirements-mqtt.txt @@ -0,0 +1 @@ +paho-mqtt==2.1.0 diff --git a/systemd/bcachefs-mqtt.env.example b/systemd/bcachefs-mqtt.env.example new file mode 100644 index 0000000..c62a661 --- /dev/null +++ b/systemd/bcachefs-mqtt.env.example @@ -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 diff --git a/systemd/bcachefs-mqtt.service.in b/systemd/bcachefs-mqtt.service.in new file mode 100644 index 0000000..266cc92 --- /dev/null +++ b/systemd/bcachefs-mqtt.service.in @@ -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