Acquisition
Modbus TCP, RTU, ASCII and UDP master with batched reads, retries and an error taxonomy. Poll groups from 50 ms to 24 h, hot-reloaded. Several slaves share one bus and take turns.
Software-defined protocol gateway · open source
Multi-Bus Gateway reads the equipment you already have — energy meters, PV inverters, batteries, drives, heat pumps, sensors — over Modbus, HTTP or MQTT, and serves it to whoever needs it: MQTT, InfluxDB and Grafana, Home Assistant, SCADA over OPC UA, or a virtual Modbus meter that a Victron GX or a Fronius inverter takes for the real one.
Retrofit, don't replace. One container, your hardware.
Running every day on a real PV plant since March 2026: a Janitza UMG 512-PRO at the grid connection, polled every 250 ms and served again as two virtual meters — an EM24 that a Victron Ekrano GX uses as its grid meter, and a Fronius Smart Meter for the Fronius inverters. Four Fronius inverters with power-limit rules, a Seplos battery bank heard without transmitting, a Samsung heat pump over a remote serial bridge, and further meters on their own circuits. Add as many as you want to watch: a warehouse, a second building, a cold room.
Every device is described by a template. The gateway polls it, names every value the same way on every device, checks it, and routes it — each device with its own outputs.
Everything below ships in the open-source gateway.
Modbus TCP, RTU, ASCII and UDP master with batched reads, retries and an error taxonomy. Poll groups from 50 ms to 24 h, hot-reloaded. Several slaves share one bus and take turns.
Bridges on the Devices page: our serial bridge on a Raspberry Pi, a transparent converter (Waveshare, USR, Elfin) or a Modbus TCP gateway. Scan a bus, see how busy it is, move a device between bridges.
The RTU tap decodes a bus that already has a master — a BMS master pack, a vendor datalogger — without ever sending a byte. Requests and responses paired, writes watched.
The register map as a portable file, with an in-browser editor, CSV/YAML import and scan recognition. 22 bundled: Janitza UMG 512-PRO, Carlo Gavazzi EM24, Eastron, ABB, Schneider iEM3000, Fronius (SunSpec, Solar API, Smart Meter), Seplos BMS, Delta VFD-EL, Samsung heat pump, Zigbee2MQTT, Tasmota and more.
Serve any source as a Carlo Gavazzi EM24, a Fronius Smart Meter or a SunSpec meter. Composite meters from several devices. Absence is never served as 0: fail, sentinel or hold — and an in-band quality block.
Every device and value for SCADA and HMI panels, with quality and read time, under stable node ids. Encrypted, with the gateway's accounts or service tokens. Set points and methods with the same safety as every command. For FUXA, UaExpert, Ignition or a Weintek panel.
Per device: on change or always, retain, QoS, TLS. Home Assistant autodiscovery, writable registers as number and select entities. InfluxDB with a disk-backed buffer: no data lost across an outage.
Steps, conditions with hysteresis, trim (follow a signal into a band), schedules. Timers, minimum run and stop, starts per hour, counters. Several rules on one target with layers and a referee. Sequences across devices with undo. New rules run in shadow first: they decide, and write nothing until you arm them. Rules export and import, and every decision goes to InfluxDB and MQTT, ready to chart.
An endpoint gathers units read several ways — Solar API and SunSpec, Modbus and MQTT — with a precedence per field, failover when a source stops answering, and group totals.
Writes are off until you allow them. Bounds from the template, read-back, dead-man leases that revert to a safe value, equipment protection that every writer meets — rules, API, MQTT, OPC UA.
A safe expression engine with previous values, rates, time of day and cross-device references. Results flow to every output like measured values.
Discovery wizard (network scan, unit IDs, SunSpec walk, MQTT browse, Fronius, ESPHome). Frame-level bus monitor, the register probe for byte and word order, payload samples.
A snapshot on every change, diff and rollback, a last-known-good boot, backups with secrets stripped by default, deleted devices restorable.
Login with admin, operator and viewer roles, passkeys, service tokens, an audit trail, an IP allowlist, HTTPS and reverse-proxy support. Trusted-LAN by default, hardened when you need it.
Prometheus metrics, a status page with error causes, a persisted event log, infrastructure alerts over MQTT and webhooks, and a power-quality event recorder for Janitza meters.
One meter at the grid connection measures everything. The gateway serves it again as the Carlo Gavazzi EM24 the Victron ESS expects. If the source goes stale the virtual meter goes silent, so the GX's own fail-safe takes over instead of acting on a frozen value.
The real rules on the plant: one per inverter, each watching its own three phase voltages and asking for less power in four steps, down to 50 %, holding until the voltage is back under 248 V. Armed since 20 October 2026, after weeks in shadow next to the Node-RED controller they replaced, matching its steps within seconds on real over-voltage days.
- id: ov-u1 # one per inverter: ov-u1 … ov-u4
target: { device: pv-u1, command: power_limit }
mode: armed # weeks in shadow first, compared with the old controller
kind: steps
signal: "max(pv-u1.voltage_l1_n, pv-u1.voltage_l2_n, pv-u1.voltage_l3_n)"
signal_valid: { min: 100, max: 350, max_step: 10 } # night zeros, glitches
steps:
- { at: 250.0, value: 80, label: Warning }
- { at: 251.0, value: 70, label: Moderate }
- { at: 252.5, value: 60, label: Severe }
- { at: 253.0, value: 50, label: Emergency, fast: true }
release_below: 248.0
normal: { value: 100 }
params: { revert_s: 0, ramp_s: 10 }
timing: { every_s: 2, debounce: 3, min_interval_s: 30, reassert_s: 120 }
stale_after_s: 90
on_stale: hold # at night the voltages read 0: keep, never curtail a sleeping inverter
on_disable: safe # disarmed: back to 100 %
The open-source FUXA SCADA is provisioned from the gateway in one command, and a check compares every tag with the gateway: 1199 of 1199 match, also after the gateway restarts.
The bus already has a master. The gateway only listens, pairs each request with its response and publishes every pack — without a single frame of its own on the wire.
The RS-485 bus is far from the server. A small bridge next to it carries the bus over the LAN; the gateway sets its baud and parity, and the devices follow the bridge if it moves to another address.
Every command a template declares listens on its own topic. A value sent to fronius-u1/cmd/power_limit meets the same gates as a click in the gateway: writes allowed, MQTT writes allowed, a broker with a login, the template's bounds, the equipment's protection. The answer comes back with the value before and after and how long it took; a request outside the bounds is refused, with the reason. In Home Assistant the registers behind the commands appear as number and select entities, executed through the same command.
Recipes for problems we hear about. Every one uses what the gateway already has — devices, rules, MQTT, outputs — and every number in them is yours to change.
The grid connection allows 45 kW of export. A trim rule watches the headroom to the cap and lowers the inverters' power limit while it is exceeded, raising it again with a margin. The cap itself can come over MQTT — from the grid operator, an EMS, Node-RED — and change during the day. Tested on a real four-inverter plant with a 20 kW cap, exports swinging 10–46 kW under clouds: 18 kW on average, at most 21.2 kW, every command answered.
# 1 · keep the export under 45 kW
- id: export-cap
target: { endpoint: pv, group: inverters, command: power_limit }
kind: trim
# the room left under the cap, in W (export reads negative on this meter):
# exporting 40 kW → 45000 − 40000 = 5000 W of room
# exporting 47 kW → 45000 − 47000 = −2000 W: over the cap
signal: "45000 + umg512.power_active_total"
signal_unit: W under the cap
below: 0 # over 45 kW: lower the inverters' limit
above: 3000 # under 42 kW: raise it back (a 3 kW margin, so it does not hunt)
step: 10 # % per adjustment, at most
gain: 0.002 # % per W past the threshold: 5 kW over → 10 %
min: 0
max: 100
params: { revert_s: 300 } # the inverter's own timer: back to 100 % if the gateway stops…
timing: { every_s: 5, debounce: 2, min_interval_s: 15, reassert_s: 120 } # …refreshed every 2 min
stale_after_s: 30
on_stale: hold # meter lost: keep the last limit
on_disable: safe # disarmed: back to 100 %
# four inverters on one Fronius datamanager: ~5 s a write, so 15 s between
# adjustments keeps it under the ~26 writes a minute it can take
# the cap from MQTT instead of fixed — an MQTT device `site` with
# export_cap_kw on site/limits/export (the grid operator, an EMS, Node-RED):
# signal: "site.export_cap_kw * 1000 + umg512.power_active_total"
# 2 · with the over-voltage rule on the same inverters: the template's
# channel "power" combines with min — the lower limit is the one written
Before the export rule cuts the inverters at 45 kW, the batteries take what would go over: the Victron ESS through its GX, or the chargers, raise their charging as the export nears the cap. Only when they are full, or already charging at their maximum, does the cap curtail the inverters. Two rules on two channels, the battery one first — what is still being built is the Victron GX template with its ESS commands.
The rule nudges the drive's frequency up while power is exported and down while it is imported, between 10:00 and 16:00. A second rule tops up the day: if the sun gave the pump less than four hours, it runs from 17:00 until it has. The drive's own protection holds every writer: a minimum stop of 5 s and at most 30 starts an hour.
# 1 · follow the solar surplus, 10:00–16:00
- id: vfd-surplus
target: { device: vfd1, command: run_at } # Delta VFD-EL: 0 Hz = stop
kind: trim
layer: optimize
signal: '-site-meter.power_active_total if between("10:00", "16:00") else 0'
above: 500 # exporting more than 500 W: faster
below: 100 # under 100 W (or importing): slower
step: 2 # Hz per adjustment
min: 15
max: 50
start: 15
off_value: 0
timing: { every_s: 5, debounce: 2, min_interval_s: 10, reassert_s: 0 }
stale_after_s: 60
on_stale: safe # meter lost: stop
# 2 · top up the day if the sun did not do it
- id: vfd-quota
target: { device: vfd1, command: run_at }
kind: condition
layer: schedule # above optimize: wins while it has an opinion
# the hours of both rules count: it stops once the day has had four
when: 'between("17:00", "20:00") and run_today_h("vfd-surplus") + run_today_h("vfd-quota") < 4'
then: { params: { value: 30 } }
else: null # otherwise: no opinion
# the drive's protection comes from its template (channel "motor"):
# min stop 5 s, at most 30 starts an hour — for every writer
When the day-ahead price drops below zero, every exported kWh costs money. Read the price from your supplier's or the exchange's API as an HTTP/JSON device (or from an EMS over MQTT) and curtail the inverters while it is negative. No price, or a stale one: full power, so a broken API never stops production.
- id: negative-price
target: { endpoint: pv, group: inverters, command: power_limit }
kind: condition
when: "market.price_eur_mwh < 0" # an HTTP/JSON or MQTT device
then: { params: { value: 0 } }
else: { params: { value: 100 } }
timing: { every_s: 30, debounce: 2, min_interval_s: 300 }
stale_after_s: 3600
on_stale: 100 # no price: full power
A site with a 40 kW import contract and a heat pump, chargers or a compressor. When the grid meter shows more than 40 kW, the heat pump pauses until the import falls back under 32 kW. As a safety layer it wins over schedules and comfort rules, and the minimum interval protects the compressor.
- id: import-cap
target: { device: heat-pump, command: power }
kind: condition
layer: safety # wins over schedules and comfort rules
when: "grid.power_active_total > 40000"
release: "grid.power_active_total < 32000"
then: { params: { value: 0 } }
else: null # otherwise: let the other rules decide
timing: { every_s: 5, debounce: 3, min_interval_s: 300 }
Above 50.2 Hz the inverters reduce their power in steps, down to 30 % at 51 Hz, and come back only under 50.1 Hz. Readings outside 45–55 Hz or jumping more than 1 Hz are ignored as glitches. The same pattern protects a real plant from over-voltage.
- id: of-derate
target: { endpoint: pv, group: inverters, command: power_limit }
kind: steps
signal: "grid.frequency"
signal_valid: { min: 45, max: 55, max_step: 1 }
steps:
- { at: 50.2, value: 90 }
- { at: 50.5, value: 60 }
- { at: 51.0, value: 30, fast: true }
release_below: 50.1
normal: { value: 100 }
on_stale: 100
A level transmitter read through a Modbus I/O module, a pump on a frequency drive. The pump starts under 30 % and stops over 80 %, with a minimum run and stop time and at most 6 starts an hour. If the level reading is lost the pump stops, and the drive's fault code becomes an alarm.
- id: tank
target: { device: pump-drive, command: run_at }
kind: condition
when: "tank.level_pct < 30"
release: "tank.level_pct > 80"
then: { params: { value: 45 } } # Hz
else: { params: { value: 0 } }
timing: { every_s: 5, debounce: 2, min_on_s: 300, min_off_s: 120,
max_starts_per_h: 6 }
stale_after_s: 30
on_stale: safe # level lost: stop
A boiler or a pump on a weekly schedule: on at 23:00 on the night tariff, off at 06:00, a different window at the weekend. The operator's “run for an hour now” button on an HMI or in Node-RED is a force on the same rule over MQTT: after the hour it goes back to the schedule by itself.
- id: boiler-week
target: { device: boiler, command: power }
kind: schedule
schedule:
- { days: Mon-Fri, at: "23:00", value: 1 } # night tariff
- { days: Tue-Sat, at: "06:00", value: 0 } # the morning after each night
- { days: "Sat,Sun", at: "13:00", value: 1, label: weekend }
- { days: "Sat,Sun", at: "17:00", value: 0 }
off_value: 0
# run it for an hour now, from an HMI or Node-RED:
# mbg/rules/boiler-week/set {"force": {"value": 1, "seconds": 3600}}
Once the battery is at 98 % and the site exports more than 5 kW, there is nowhere left to put the energy cheaply. The inverters are curtailed to 60 % until either condition goes away, with 30 seconds of agreement before each change and at most one change every two minutes.
- id: full-and-exporting
target: { endpoint: pv, group: inverters, command: power_limit }
kind: condition
when: "battery.soc >= 98 and grid.power_active_total < -5000"
then: { params: { value: 60 } }
else: { params: { value: 100 } }
timing: { every_s: 5, debounce: 6, min_interval_s: 120 }
on_stale: hold
Compressors, energy meters and drives already sit on RS-485 lines across the plant. Bridges carry the lines over the LAN, templates name every value the same way, and the SCADA reads everything over OPC UA, with quality and read time. The same values go to InfluxDB and Grafana for the energy team.
A Modbus energy meter, an inverter and a few Zigbee sensors appear in Home Assistant by themselves, through MQTT discovery, each as its own device with stable names. Settings the template allows writing, such as a power limit, become number and select entities.
A temperature sensor over MQTT (Zigbee or BLE) and a calculated flag, “too warm” over 8 °C, declared as an alarm that must hold for ten minutes. Then it becomes an alert: the alerts service sends it to the on-call phone by SMS and Telegram, at night as well, and pages again by SMS if nobody acknowledges it within 15 minutes.
Pro
For installations that cannot go without their data: two gateways on the same devices, one active and one standby. Part of the Pro edition; tested on a bench, the tests on real hardware are under way.
The heat pump reports error E101. The gateway raises the alarm its template declares once it has held for the alarm's hold time — here 30 seconds, so a blip never wakes anyone. The alerts service routes it: throttled, outside quiet hours, to Telegram, an SMS through the site's LTE router and e-mail; if nobody acknowledges a critical one within 15 minutes, it pages again by SMS. It says when the error clears. In production with 67 rules.
What we are building next. Tell us which one your site needs first — it moves up the list.
The gateway is open source. The Pro edition adds the modules a critical installation needs, and the people who stand behind it.
Free · AGPL-3.0
Price on request
Everything in Community, plus:
Docker with Compose v2. The stack brings the gateway, an MQTT broker, InfluxDB and Grafana; use only the gateway if you have your own.
mkdir multi-bus-gateway && cd multi-bus-gateway
curl -fsSLO https://raw.githubusercontent.com/sm26449/multi-bus-gateway/main/docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/sm26449/multi-bus-gateway/main/.env.example -o .env
mkdir -p mosquitto/config && curl -fsSL https://raw.githubusercontent.com/sm26449/multi-bus-gateway/main/mosquitto/config/mosquitto.conf -o mosquitto/config/mosquitto.conf
# edit .env: set the four required secrets
docker compose pull && docker compose up -d
docker compose logs multi-bus-gateway | grep -A3 'FIRST RUN' # the admin password, once
# → http://localhost:8080
Tell us about your installation: what you have, what you need it to do. The message comes straight to us, and we answer by e-mail.
Or write directly: [email protected]