Siphon v0.5.x: Zero-Downtime Hot Reloading and The Garmin-Tailscale Funnel
Overview
The "Zero-Trust" mantra often comes with a significant usability tax. If you want to trigger a Home Assistant automation from a wearable while you're out on a run, you're usually forced to choose between the security risk of an open port or the complexity of a VPN that your watch can't natively run. You generally don't want to expose the whole Home Assistant API to the internet, especially when all you need is to trigger a light from a watch. Principle of least privilege.
Siphon v0.5.0 solves this. By combining the new webhook collector, hass sink and tailscale funnel you can build a secure, declarative ingress point that bridges the gap between your wrist and your smart home — with no custom app development and no open firewall rules.
Hot reloading: engineering for uptime and ease
In previous versions, updating your config.yaml required a manual restart of the Siphon binary or container. In a
distributed system, this "blip" in telemetry ingestion is often unacceptable.
V0.5.0 introduces a true hot reloading engine. When you save your configuration via the integrated web editor, Siphon performs a graceful transition:
- It validates the new YAML schema.
- It signals the existing engine to drain active connections.
- It spawns a new engine instance with the updated pipeline graph.
- The HTTP server remains alive throughout, ensuring no incoming webhooks are dropped during the transition.
Pipeline engine changes
Two smaller but load-bearing changes shipped in v0.5.0 that affect how pipelines are written.
Transform block is now an array
Previously, transform was a YAML map. Maps have no guaranteed key order, which meant chained transformations — where one expression depends on the result of a previous one — could silently evaluate in the wrong order depending on the parser implementation.
It is now an ordered array:
1transform:
2 - temp_c: float(raw_temp)
3 - temp_f: (temp_c * 9/5) + 32 # safe: temp_c is guaranteed to exist
4 - label: string(temp_f) + "°F" # safe: temp_f is guaranteed to exist
If you are migrating an existing config, replace the map syntax (key: value) with the array syntax (- key: value).
Topic selectors for variables
In stateful pipelines that subscribe to multiple upstream topics, each source pipeline's variables are accessible via its name as a namespace. This is what makes the cron-based aggregation pattern work cleanly — one dispatcher can pull the latest cached state from several independent collectors and merge them into a single payload.
In this example, the file collector reads the CPU temperature and the rest collector (new in v0.5.0) polls an HTTPS
endpoint on an interval and publishes the response body as a raw payload. Combined with a jsonpath parser and topic
namespacing, they compose cleanly into the aggregation pattern:
1collectors:
2 weather:
3 type: rest
4 params:
5 interval: 60
6 topics:
7 weather: "https://api.example.com/weather/current"
8
9 cpu_temp:
10 type: file
11 params:
12 interval: 60
13 topics:
14 cpu_raw: "/sys/class/thermal/thermal_zone0/temp"
15
16sinks:
17 notify:
18 type: mqtt
19 params:
20 url: "tcp://192.168.1.1:1883"
21
22pipelines:
23 - name: outdoor_temp
24 topics: ["weather"]
25 parser:
26 type: jsonpath
27 vars:
28 state: "$.temperature"
29
30 - name: cpu_temp
31 topics: ["cpu_raw"]
32 parser:
33 type: regex
34 vars:
35 temp_str: "[0-9]+"
36
37 # Merge them using the internal Expr engine
38 - name: unified_report
39 type: cron
40 stateful: true
41 schedule: "0 0 * * * *"
42 topics: ["outdoor_temp", "cpu_temp"]
43 sinks:
44 - name: notify
45 format: expr
46 spec: |
47 {
48 "out_temp": outdoor_temp?.state ?? 0,
49 "cpu_temp": cpu_temp?.temp_str ?? 0
50 }
The ?. safe-navigation operator prevents panics when a source pipeline hasn't yet received data since startup.
For more details, see configuration project page.
The Garmin-Tailscale Funnel pattern
The highlight of this release is the ingress pattern. A Garmin watch triggers a local Home Assistant automation with a single button press — over the public internet, fully encrypted, zero ports opened.
The full stack
%%{init: {'theme': 'dark', 'themeVariables': {'edgeLabelBackground':'#1a1a1a', 'lineColor': '#94a3b8', 'textColor': '#94a3b8'}}}%%
flowchart LR
classDef external fill:#2a1215,stroke:#7f1d1d,stroke-width:1px,color:#ef4444
classDef tunnel fill:#0f172a,stroke:#2563eb,stroke-width:1px,color:#60a5fa
classDef siphon fill:#281809,stroke:#b45309,stroke-width:1px,color:#fbbf24
classDef ha fill:#064e3b,stroke:#059669,stroke-width:1px,color:#34d399
A["⌚<br/>Garmin<br/>watch"]:::external
B["🌐<br/>tailscale<br/>funnel"]:::tunnel
C["⚙️<br/>webhook<br/>collector"]:::siphon
D["🔄<br/>pipeline"]:::siphon
E["📡<br/>hass<br/>sink"]:::siphon
F["🏠<br/>Home Assistant"]:::ha
A -->|"HTTPS<br/>POST"| B
B -->|"tcp<br/>proxy"| C
C -->|"internal<br/>event bus"| D
D --> E
E -->|"MQTT<br/>publishes PRESS"| F
Step 1: Expose Siphon via Tailscale Funnel
Tailscale is a zero-config VPN built on WireGuard. Funnel is its feature that exposes a
local service to the public internet via a stable *.ts.net hostname with automatic Let's Encrypt TLS — no port
forwarding or DNS required.
Tailscale is purely my personal choice — other solutions exist. That said, for home lab and hobbyist use, Tailscale hits a sweet spot that's hard to beat: the free tier covers personal use generously, setup takes minutes rather than days, and you get a properly signed TLS certificate and a stable public hostname without touching your router or registering a domain. It removes the entire class of "expose this to the internet" problems that usually involve either a cloud VPS, a DDNS hack, or opening holes in a firewall — none of which are great when you just want a Garmin watch to poke a button. It's also genuinely portable: clients exist for Android, iOS, Windows, Linux, and — critically for this setup — there are packages for OpenWrt, so it runs directly on the router.
Tailscale's funnel feature provides a public HTTPS endpoint that terminates TLS and proxies traffic to a local service.
Our setup has a twist: tailscale daemon runs on the OpenWrt router, while Siphon runs on the Home Assistant VM.
Since tailscale funnel only accepts localhost as an upstream (this statement is valid for version 1.80.3 of
tailscale daemon which is the newest available on my router currently, newer versions should not require the socat
workaround anymore), we bridge the gap with socat:
1# On the OpenWrt router
2opkg update && opkg install socat
3
4# Forward localhost:8888 → HA VM:8080 (bind to loopback only)
5socat TCP-LISTEN:8888,bind=127.0.0.1,fork,reuseaddr TCP:192.168.6.5:8080 &
6
7# Expose via Funnel
8tailscale funnel --bg http://localhost:8888
To survive reboots, I've deployed a
procdinit script at/etc/init.d/siphon-funnelwithrespawn— procd will restart socat automatically if it crashes.
The result is a stable public endpoint: https://{node}.{tailnet name}.ts.net.
Step 2: The Siphon Pipeline
The pipeline is three blocks in config.yaml:
Collector — listens for incoming POST requests, authenticates via Bearer token, deduplicates by payload hash
within a 1-second window to prevent double-triggers, if hands are shaky:
1collectors:
2 garmin_wh:
3 type: webhook
4 topics:
5 garmin_raw: /webhook/garmin
6 params:
7 port: 8080
8 token: "%%WEBHOOK_TOKEN%%"
9 dedupe_ttl: 1
Sinks — two complementary sinks handle the event: garmin_trigger registers a device_automation trigger in Home
Assistant via MQTT Auto-Discovery (requires v0.5.4+) — no entity required, pure event pushing. garmin_trigger_cnt is a
total_increasing sensor that tracks the cumulative press count — for statistical reasons, not exactly needed:
1sinks:
2 garmin_trigger:
3 type: hass
4 params:
5 url: "%%MQTT_HOST%%"
6 user: "%%MQTT_USER%%"
7 pass: "%%MQTT_PASS%%"
8 object_id: garmin_button
9 component: device_automation
10 trigger_type: button_short_press
11 subtype: button_1
12 garmin_trigger_cnt:
13 type: hass
14 params:
15 url: "%%MQTT_HOST%%"
16 user: "%%MQTT_USER%%"
17 pass: "%%MQTT_PASS%%"
18 object_id: garmin_count
19 component: sensor
20 state_class: total_increasing
21 icon: "mdi:watch"
22 value_template: "{{ value_json.count }}"
Pipeline — stateful: a running cnt is maintained across events. Each webhook hit publishes a discrete PRESS to
the device trigger action topic; the counter sink records the total. The webhook body is ignored — no parser block
is needed, any incoming POST triggers the pipeline:
1pipelines:
2 - name: garmin
3 stateful: true
4 topics:
5 - garmin_raw
6 transform:
7 - cnt: (cnt ?? 0) + 1
8 sinks:
9 - name: garmin_trigger
10 format: expr
11 spec: '"PRESS"'
12 - name: garmin_trigger_cnt
13 format: expr
14 spec: |
15 { "count" : cnt ?? 0 }
Step 3: The Home Assistant Automation
With the device trigger registered, wire up an automation. Siphon registers itself as a device under the MQTT
integration — use the device_id shown in HA's device registry:
1alias: Garmin trigger
2description: Fires on each Garmin {API}Call press via Siphon webhook
3triggers:
4 - domain: mqtt
5 device_id: {Siphon's device ID}
6 type: button_short_press
7 subtype: button_1
8 trigger: device
9conditions: []
10actions:
11 - action: light.toggle
12 metadata: {}
13 target:
14 entity_id: light.living_room
15 data: {}
16mode: single
The trigger fires on the button_short_press / button_1 combination declared in the sink. No polling, no state entity
— the press lands in HA as a native device event and just toggles the light in the living room.
Step 4: The {API}Call configuration
This configuration was used (the \\\" escaping is required by the Connect IQ config UI parser):
1{deviceName:"Home",actionName:"Siphon",url:"https://{node name}.{tailnet name}.ts.net/webhook/garmin", headers:"{Authorization:\\\"Bearer {token}\\\"}", method: "POST", POSTcontent:"{action: \\\"triggered\\\"}"}
The POSTcontent value is essentially ignored here, but it can be used to verify its content in the pipeline if needed.
More important is the Bearer token, which only allows the request through when the device is authorized.
v0.5.x Patch notes
v0.5.4 (2026-04-20)
- hass sink: native MQTT Device Triggers (
component: device_automation) for stateless event pushing — no entity lifecycle required. - hass sink: unified, data-driven topic system — a
componentTopicsmap drives both discovery payload generation andSend()routing for all supported HA component types. - hass sink: consistent auto-generation and per-topic overrides for
state_topic,command_topic, andavailability_topic.
v0.5.3 (2026-04-19)
- fix webhook collector: duplicate payloads now correctly return
409 Conflictinstead of200 OK.
v0.5.2 (2026-04-19)
- fix webhook collector:
dedupe_ttlwas not respected — cleanup ticker was hardcoded to 1 minute regardless of configured TTL value.
v0.5.1 (2026-04-19)
- General refactor and added unit tests.
v0.5.0 (2026-04-13)
- Add editor status line displaying current version.
- Add hot reload support.
- Transform block changed to array for deterministic ordering.
- Added topic as selector for variables.
- Added
restandwebhookcollectors. - Added
mqttandhasssinks. - Sinks: added
Close()to interface for clean shutdown of long-running sinks (e.g. MQTT).