M-Bus Gateway
← Tilbage til blog
· IoT· gateway· høj tilgængelighed· failover· MQTT· redundans· Raspberry Pi· SIM

IoT gateway høj tilgængelighed og failover-design

IoT gateway HA-design: redundans, automatisk failover, MQTT session persistens, dual-SIM 4G fallback, watchdog-lag, database replikering og nul-data-tab ved nedbrud.

Af M-Bus Gateway

En wM-Bus gateway i kælderen skal fungere 24/7 i 10 år. Her er designprincipperne for høj tilgængelighed.


Fejlmodeller

Mulige fejl og sandsynlighed (pr. 10-årig periode):

1. SD-kort fejl (høj sandsynlighed)
   → SD-kort er det svageste led i Raspberry Pi
   → Løsning: Samsung Pro Endurance (beregnet til konstant skriv)
   → WAL-mode SQLite: Minimere skriv (én gang dagligt)
   → Backup: Platform modtager og gemmer alle payloads permanent

2. Strømafbrydelse (middel sandsynlighed)
   → UPS på gateway anbefales i kritiske installationer
   → Hardware watchdog: Genstart når strøm kommer tilbage
   → Systemd: WantedBy=multi-user.target + Restart=always

3. 4G/SIM-fejl (middel sandsynlighed)
   → 1NCE SIM: 500MB over 10 år, dækning via 450+ operatører
   → Dual-SIM modem: Waveshare SIM7080G understøtter dual-SIM
   → Fallback: WiFi via lokalt netværk (ppp0 → wlan0 failover)

4. wmbusmeters crash (lav sandsynlighed)
   → Systemd Restart=always + RestartSec=5
   → Subprocess auto-restart i Python listener

5. Hetzner server downtime (meget lav sandsynlighed)
   → Hetzner SLA 99,9% (8,7 timer nedetid/år)
   → Gateway buffer: 7 dage × data lokalt i SQLite
   → Data sendes ved næste vellykkede forbindelse

Hardware watchdog (Lag 1)

# gateway/src/watchdog.py

import fcntl
import struct
import asyncio
import logging

WATCHDOG_DEVICE = "/dev/watchdog"
WATCHDOG_IOCTL_SETTIMEOUT = 0xC0045706
WATCHDOG_TIMEOUT = 15  # sekunder

class HardwareWatchdog:
    def __init__(self):
        self._fd = None

    def open(self) -> None:
        self._fd = open(WATCHDOG_DEVICE, "wb", buffering=0)
        # Sæt timeout til 15 sekunder
        fcntl.ioctl(
            self._fd,
            WATCHDOG_IOCTL_SETTIMEOUT,
            struct.pack("I", WATCHDOG_TIMEOUT),
        )

    def feed(self) -> None:
        """Skriv til watchdog — forhindrer reboot."""
        if self._fd:
            self._fd.write(b"\x01")

    def close(self) -> None:
        """Skriv 'V' for at deaktivere watchdog-reboot ved kontrolleret stop."""
        if self._fd:
            self._fd.write(b"V")
            self._fd.close()

async def watchdog_loop(dog: HardwareWatchdog) -> None:
    """Feed hardware watchdog hvert 10. sekund."""
    while True:
        dog.feed()
        await asyncio.sleep(10)

Systemd watchdog (Lag 2)

# /etc/systemd/system/mbus-gateway.service

[Unit]
Description=M-Bus Gateway
After=network-online.target mosquitto.service
Wants=network-online.target
BindsTo=dev-wmbus.device

[Service]
Type=notify
User=mbus
WorkingDirectory=/opt/mbus-gateway
ExecStart=/opt/mbus-gateway/.venv/bin/python -m gateway.src.main
Restart=always
RestartSec=5
WatchdogSec=120
NotifyAccess=main

# Ressource-begrænsning
MemoryMax=256M
CPUQuota=80%

# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mbus-gateway

[Install]
WantedBy=multi-user.target
# Systemd watchdog notification:
import sdnotify

notifier = sdnotify.SystemdNotifier()
notifier.notify("READY=1")  # Systemd: service er klar

# I hoved-loop:
async def main():
    while True:
        await do_work()
        notifier.notify("WATCHDOG=1")  # Reset systemd watchdog timer
        await asyncio.sleep(60)

4G/WiFi failover

# gateway/src/cellular/failover.py

import asyncio
import subprocess
from enum import Enum

class ConnectivityMode(Enum):
    CELLULAR = "ppp0"
    WIFI = "wlan0"
    ETHERNET = "eth0"

async def get_active_interface() -> ConnectivityMode:
    """Tjek hvilken interface der har internet-forbindelse."""
    for mode in ConnectivityMode:
        try:
            result = await asyncio.create_subprocess_exec(
                "ping", "-I", mode.value, "-c", "1", "-W", "3",
                "8.8.8.8",
                stdout=asyncio.subprocess.DEVNULL,
                stderr=asyncio.subprocess.DEVNULL,
            )
            await result.wait()
            if result.returncode == 0:
                return mode
        except Exception:
            continue
    raise RuntimeError("Ingen internet-forbindelse tilgængelig")

async def ensure_connectivity() -> str:
    """Returnér aktiv interface — prøver 4G, WiFi, Ethernet."""
    mode = await get_active_interface()
    return mode.value

MQTT session persistens

# gateway/src/mqtt/client.py

import paho.mqtt.client as mqtt

def create_mqtt_client(gateway_id: str) -> mqtt.Client:
    """
    clean_session=False: Broker husker subscription og queued messages
    ved genforbindelse — ingen data mistes under kortvarig nedetid.
    """
    client = mqtt.Client(
        client_id=f"gw-{gateway_id}",
        clean_session=False,  # Persistent session
        protocol=mqtt.MQTTv311,
    )

    # Last Will: Broker sender dette ved uventet disconnect
    client.will_set(
        topic=f"meters/{gateway_id}/status",
        payload='{"online": false, "reason": "unexpected_disconnect"}',
        qos=1,
        retain=True,
    )

    client.reconnect_delay_set(min_delay=1, max_delay=120)

    return client

Data-buffer og nul-tab design

Gateway data-flow (nul-tab garanti):

1. wmbusmeters → RAM buffer (hele dagen)
   → Data mistes kun ved strømafbrydelse i løbet af dagen
   → Risiko: Op til 23:59 timeers data (acceptabelt — HCA-data er kumulativ)

2. kl. 06:00 UTC → SQLite WAL (commit til disk)
   → Data persisteres inden MQTT-send
   → SQLite WAL: Atomic writes — ingen korrupte filer

3. MQTT send → Hetzner
   → QoS 1: Mindst én levering garanteret
   → Retry ved fejl: Eksponentiel backoff (1s, 2s, 4s, 8s, max 5 min)
   → Timeout: 30 sekunder pr. forsøg

4. ACK modtaget → SQLite: Mark as "sent"
   → Resend logik: Finder "unsent" records ved næste kl. 06:00
   → Buffer: 7 dage (168 forsøg inden data betragtes som tabt)

5. Hetzner server → TimescaleDB
   → Duplikat-beskyttelse: ON CONFLICT DO UPDATE (upsert)
   → Idempotent: Gensend af samme data er ufarlig

Recovery ved SD-kort fejl

# Bootstrap ny Pi fra scratch (ny SD-kort):

# 1. Flash Raspberry Pi OS Lite 64-bit på ny SD-kort
# 2. Boot Pi → SSH ind
# 3. Kør bootstrap-script fra Hetzner:
curl http://178.105.90.8:8765/bootstrap.sh | sudo bash

# Bootstrap-script:
# → Installerer wmbusmeters fra GitHub releases
# → Henter gateway-konfiguration fra Hetzner (gateway_id + MQTT creds)
# → Starter systemd service
# → Pi er operationel inden for 15 minutter

# Data: Ingenting mistes
# → Alle HCA-aflæsninger er gemt på Hetzner TimescaleDB
# → SQLite på Pi er blot en dagsbuffer

Monitoring og alerting

Platform overvåger:
  → "Gateway offline": Ingen heartbeat i 36+ timer → email til udlejer
  → "Payload missing": Ingen daglig data i 48+ timer → alarm
  → "RSSI degraded": Signal fald → teknikernotifikation
  → "Battery low": Under 20% → advarselsmail

Hetzner server monitoring:
  → Uptime Robot: HTTP check hvert 5. minut
  → Grafana Loki: Log-analyse, alerting ved ERROR-rate
  → TimescaleDB: Antal readings pr. gateway pr. dag (fald = alarm)

Konklusion

Gateway HA-design handler om lag-vis redundans: hardware watchdog til Pi-reboot, systemd til service-genstart, MQTT persistent session til dataintegritet, og 7-dages SQLite buffer til server-downtime. Recovery fra SD-kort fejl sker på under 15 minutter via bootstrap-script — ingen data mistes da alle aflæsninger er persisterede på Hetzner.

Se Raspberry Pi setup guide eller MQTT retain/LWT guide.