diff --git a/README.md b/README.md
index b00a396..f585672 100644
--- a/README.md
+++ b/README.md
@@ -75,30 +75,109 @@ Add the desired inputs to your Telegraf TOML configuration (local or hosted):
## Support reports and pastes
-Run `./kent_report.sh` from the directory where you want `kent_report.log` and
-trace output saved. It performs the existing lengthy diagnostics and uploads
-the report to the paste server, printing a URL for manually sharing on IRC.
-It does not send an IRC message. The diagnostics retain the original host's
-Arch packages, bcachefs source checkout, device and encryption-file paths;
-review those and the collected information before running on another host.
+Run `./kent_report.sh` as your regular user (with kubectl access) from the directory
+where you want a new `kent-report-DATE-RANDOM/` directory. It runs the existing
+lengthy diagnostics and uploads a completed report, printing its public cover-page
+URL for manually sharing on IRC. It does not send an IRC message.
-`paste_log.sh` contains the shared function and can also be run directly:
+Each report includes:
+
+- `index.html`: a searchable cover page with collapsible command outputs. Output
+ text is fetched only when expanded and inserted as plain text, never HTML.
+- `outputs/`: a public file listing with descriptive, numbered `.txt` files for
+ each command, including separate dmesg, timestats, reconcile and sysfs files.
+ Direct links work with browsers, curl and wget, including without JavaScript.
+- `kent_report.log`: the complete combined text report with a command index.
+- `manifest.json`: command lines, filenames, exit statuses, durations and sizes.
+
+Both stdout and stderr are captured. A failed command does not abort the rest.
+Commands time out after 300 seconds by default; the report records timeouts and
+exit statuses. The original Arch packages, source checkout, `/mnt`, `/dev/sda`
+and encryption-file paths are retained. Raw `trace.dat` stays in the local report
+directory; only the rendered report files are uploaded. Report directories must
+be new (or empty); existing outputs are never overwritten.
```sh
-./paste_log.sh path/to/log
+./kent_report.sh # collect and upload
+./kent_report.sh --no-upload # collect locally for review
+./kent_report.sh --output ./my-report --timeout 600 --no-upload
+source ./paste_log.sh
+paste_directory ./my-report # upload an already collected report
```
-To make `paste_log` available in interactive Bash, add:
+The upload is copied to a temporary remote directory and renamed after success,
+so the printed URL points at a complete report. Apache configuration changes are
+not required; the report supplies its own static directory listing.
+
+`paste_log.sh` still provides `paste_log path/to/log` for single-file uploads,
+and can be executed directly as `./paste_log.sh path/to/log`. To expose both
+helpers in interactive Bash:
```bash
source "$HOME/telegraf-bcachefs-input/paste_log.sh"
```
-`kent_report.sh` sources the same file relative to its own location; it does not
-rely on interactive shell functions being exported. Uploads use the current
-kubectl context/namespace and require exactly one pod named `brunner-ninja*`.
-Failed uploads return an error without printing a misleading URL. Temporary
-name reservations are cleaned up, and function errors do not exit your shell.
+`kent_report.sh` sources this same file relative to its own location. Uploads use
+the current kubectl context/namespace and require exactly one `brunner-ninja*`
+pod. Failed uploads return an error without printing a URL. Helper errors do not
+exit an interactive shell.
+
+## Home Assistant switches
+
+`bcachefs_mqtt.py` publishes MQTT discovery for **Reconcile** and **Copy GC**,
+grouped under the host's Bcachefs device. On the original host the entities are
+`switch.bcachefs_reconcile_enabled` and `switch.bcachefs_copygc_enabled`.
+
+The bridge reads `reconcile_enabled` and `copygc_enabled` from sysfs every two
+seconds, including changes made from the command line. Home Assistant commands
+write only these two options and then read back actual state; the UI is not
+optimistic. Discovery and state messages are retained, commands are not, and
+retained commands are rejected. No shell commands are executed from MQTT input.
+
+Missing or unreadable options make their switches unavailable on the next poll.
+A clean service stop publishes offline immediately; MQTT's retained last will
+handles a crash or connection loss (network-loss detection can take about 23
+seconds with the 15-second keepalive). On remount, reconnect, or a Home Assistant
+birth message the bridge republishes state. It never restores a previous option
+value automatically: the mounted filesystem is always the source of truth.
+
+Install from a stable checkout path without whitespace:
+
+```sh
+python3 -m venv .venv
+.venv/bin/pip install -r requirements-mqtt.txt
+sudo ./install-mqtt.sh
+```
+
+The installer creates `/etc/default/bcachefs-mqtt` from the example only if it
+is absent, installs the systemd service, and enables/restarts it. Configure
+`MQTT_HOST`, `BCACHEFS_UUID`, and optional credentials/TLS there, outside Git.
+It defaults to the original host's broker and filesystem UUID. The service runs
+as root to write sysfs, with its filesystem read-only except `/sys/fs/bcachefs`.
+It starts at boot even if the filesystem is not yet mounted.
+
+```sh
+sudo systemctl restart bcachefs-mqtt # after bridge/config edits
+systemctl status bcachefs-mqtt
+journalctl -u bcachefs-mqtt
+```
+
+MQTT topics are `bcachefs/UUID/OPTION/state` and `/set`, with `0`/`1` payloads.
+Discovery uses `homeassistant/switch/bcachefs_UUID/OPTION/config`. To uninstall,
+stop/disable the service and clear its two retained discovery config topics.
+
+The integration follows [Home Assistant MQTT switch discovery and availability](https://www.home-assistant.io/integrations/switch.mqtt/)
+and uses the [Paho MQTT Python client](https://eclipse.dev/paho/clients/python/docs/).
+
+## Tests
+
+```sh
+python3 tests/test_helpers.py
+.venv/bin/python -m unittest discover -s tests -p 'test_mqtt_report.py' -v
+```
+
+Tests use temporary option files and stub uploads; they do not toggle a real
+filesystem or publish a real diagnostic report.
## Building the performance collector
diff --git a/kent_report.py b/kent_report.py
new file mode 100644
index 0000000..9e229c1
--- /dev/null
+++ b/kent_report.py
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+"""Collect named diagnostics and render a browsable, static support report."""
+import argparse
+from datetime import datetime, timezone
+import html
+import json
+import os
+from pathlib import Path
+import re
+import shlex
+import shutil
+import signal
+import subprocess
+import time
+
+
+class Report:
+ def __init__(self, directory, timeout=300):
+ self.directory = Path(directory).resolve()
+ self.outputs = self.directory / 'outputs'
+ self.outputs.mkdir(parents=True, exist_ok=False)
+ self.timeout = timeout
+ self.records = []
+ self.created = datetime.now(timezone.utc).isoformat()
+
+ def run(self, name, command, stdin_path=None):
+ filename = f'{len(self.records) + 1:03d}-{name}.txt'
+ path = self.outputs / filename
+ display = shlex.join(command)
+ if stdin_path:
+ display += ' < [filesystem passphrase file]'
+ print(f'Collecting {filename}: {display}', flush=True)
+ start = time.monotonic()
+ status = 0
+ timed_out = False
+ with path.open('wb') as output:
+ source = None
+ try:
+ if stdin_path:
+ source = open(stdin_path, 'rb')
+ process = subprocess.Popen(command, cwd=self.directory, stdin=source or subprocess.DEVNULL,
+ stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
+ try:
+ status = process.wait(timeout=self.timeout)
+ except subprocess.TimeoutExpired:
+ timed_out = True
+ os.killpg(process.pid, signal.SIGTERM)
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait()
+ status = 124
+ output.write(f'\n[Timed out after {self.timeout:g} seconds]\n'.encode())
+ except BaseException:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait()
+ raise
+ except OSError as error:
+ status = 127
+ output.write(f'{error}\n'.encode())
+ finally:
+ if source:
+ source.close()
+ record = {'file': f'outputs/{filename}', 'command': display, 'status': status,
+ 'seconds': round(time.monotonic() - start, 2), 'timeout': timed_out,
+ 'bytes': path.stat().st_size}
+ self.records.append(record)
+ return path
+
+ def render(self):
+ (self.directory / 'manifest.json').write_text(json.dumps({'created': self.created, 'commands': self.records}, indent=2) + '\n')
+ with (self.directory / 'kent_report.log').open('wb') as full:
+ full.write(f'Bcachefs support report\nCreated: {self.created}\n\nCommand index:\n'.encode())
+ for record in self.records:
+ full.write(f"{record['file']}: {record['command']} [exit {record['status']}]\n".encode())
+ for record in self.records:
+ full.write(f"\n\n{'=' * 80}\n$ {record['command']}\nFile: {record['file']}\nExit: {record['status']} | {record['seconds']}s\n\n".encode())
+ with (self.directory / record['file']).open('rb') as part:
+ shutil.copyfileobj(part, full)
+ entries = []
+ for record in self.records:
+ link = html.escape(record['file'], quote=True)
+ title = html.escape(record['command'])
+ status = f"exit {record['status']} · {record['seconds']}s · {record['bytes']:,} bytes"
+ entries.append(f'''{title}
+{status}