[{"content":"I wanted a status page that tells me when something at home goes down. Home Assistant, internal DNS, my Zigbee coordinator, all of it. And I wanted the checks to run from outside my network, because if the thing checking \u0026ldquo;is it up\u0026rdquo; is sitting on the same LAN, it\u0026rsquo;s not really telling you anything. Problem is, I didn\u0026rsquo;t want to port-forward anything into my home network to make that work. No way.\nSo I ended up with a free Oracle Cloud VM running Gatus, and it reaches into my home LAN entirely over Tailscale. No public ports opened on the router, no home services sitting behind a reverse proxy on the internet. Only thing exposed is the Gatus dashboard, and even that\u0026rsquo;s tailnet-only.\nArchitecture ┌───────────────────────┐ │ oracle1 (OCI VM) │ │ ┌─────────────────┐ │ │ │ docktail │ │ Tailscale service (tailnet-only) │ │ (labels-based │──┼──▶ gatus.\u0026lt;tailnet\u0026gt;.ts.net ◀── my phone/laptop │ │ TS proxy) │ │ │ │ │ │ Tailscale service (tailnet-only) │ │ ┌───────────┐ │──┼──▶ ntfy.\u0026lt;tailnet\u0026gt;.ts.net ◀── my phone/laptop │ │ │ gatus │ │ │ │ │ └─────┬─────┘ │ │ │ └────────┼────────┘ │ │ tailscaled │ (installed on host, plain tailnet client) └───────────┼───────────┘ │ WireGuard, encrypted, tailnet-wide ▼ ┌───────────────────────┐ │ Talos subnet router │ Connector CR: advertises │ (pod in home k8s) │ 192.168.1.0/24, 192.168.20.0/26 └───────────┼───────────┘ │ ▼ Home LAN — 192.168.1.137 (Zigbee coordinator), 192.168.1.53 (internal DNS), Home Assistant, etc. There are two different Tailscale roles here, don\u0026rsquo;t mix them up:\noracle1 is just a normal tailnet client. It doesn\u0026rsquo;t advertise any routes, it\u0026rsquo;s just another peer that happens to run Gatus and Docktail on it. The actual subnet router is separate, running as a Tailscale Operator Connector pod inside my home Talos Kubernetes cluster. It advertises the home LAN CIDRs into the tailnet. This is the only reason oracle1 can hit 192.168.1.137 directly. Tailscale routes that traffic over the encrypted mesh like oracle1 is sitting right there on my home LAN, without touching the router config at all. Setting up the VPS Oracle\u0026rsquo;s free tier gives you a VM.Standard.E2.1.Micro, nothing fancy, 1/8 OCPU and 1GB RAM, but it\u0026rsquo;s more than enough to run Gatus polling a couple dozen endpoints. Provisioned with OpenTofu:\nprovider \u0026#34;oci\u0026#34; { tenancy_ocid = var.tenancy_ocid user_ocid = var.user_ocid fingerprint = var.fingerprint private_key_path = var.private_key_path region = var.region } The security list only opens SSH, HTTP and HTTPS to the internet. No rule for Tailscale\u0026rsquo;s UDP port anywhere. Don\u0026rsquo;t need one — Tailscale either relays through DERP or punches through NAT via STUN, so the tailnet mesh doesn\u0026rsquo;t need an inbound hole on the firewall.\nGetting traffic to Gatus, no reverse proxy needed Instead of running Traefik or nginx in front of Gatus, the compose stack uses Docktail. It reads Docker labels off each container and registers it as its own Tailscale service, reachable only inside the tailnet — nothing here touches Funnel, nothing gets a public HTTPS endpoint:\ndocktail: image: ghcr.io/marvinvr/docktail:latest volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/tailscale:/var/run/tailscale environment: - TAILSCALE_OAUTH_CLIENT_ID={{ ts_oauth_client_id }} - TAILSCALE_OAUTH_CLIENT_SECRET={{ ts_oauth_client_secret }} - DEFAULT_SERVICE_TAGS=tag:docktail-service gatus: labels: - \u0026#34;docktail.service.enable=true\u0026#34; - \u0026#34;docktail.service.name=gatus\u0026#34; - \u0026#34;docktail.service.port=8080\u0026#34; - \u0026#34;docktail.service.service-port=443\u0026#34; Gatus is only reachable inside the tailnet at gatus.\u0026lt;tailnet\u0026gt;.ts.net through MagicDNS. ntfy, which handles alert delivery, gets the same treatment — its own tailnet service at ntfy.\u0026lt;tailnet\u0026gt;.ts.net. Both stay tailnet-private, nothing is punched out to the public internet.\nGatus config Endpoints are grouped by area — iot, media, tools, infra, DNS. Two of them only work because the subnet router exists:\n- name: coordinator group: iot url: \u0026#34;http://192.168.1.137\u0026#34; conditions: [\u0026#34;[CONNECTED] == true\u0026#34;] - name: tailscale-apple-tv group: infra url: \u0026#34;icmp://apple-tv.antelope-puffin.ts.net\u0026#34; interval: 30s conditions: [\u0026#34;[CONNECTED] == true\u0026#34;] alerts: - description: \u0026#34;Tailscale subnet router (Apple TV) is down! Home network unreachable.\u0026#34; First one\u0026rsquo;s a plain LAN IP. Gatus, running on a VM in San Jose, is pinging a Zigbee coordinator sitting on my home network directly. No Tailscale client on the coordinator itself, nothing. Just works because the subnet router made the whole CIDR routable.\nSecond one\u0026rsquo;s got a naming issue I still haven\u0026rsquo;t cleaned up. The alert text says \u0026ldquo;Apple TV\u0026rdquo; because that\u0026rsquo;s what used to run the subnet router, ages back. It\u0026rsquo;s now a dedicated Connector pod in my Talos cluster, but I never updated the alert description. Still works fine, just misleading if I ever have to debug this at 2am and forget what it actually means now.\nAlerts go out through ntfy:\nalerting: ntfy: topic: \u0026#34;gatus\u0026#34; url: \u0026#34;https://ntfy.antelope-puffin.ts.net\u0026#34; priority: 3 token: \u0026#34;{{ ntfy_monitoring_token }}\u0026#34; The subnet router itself This is the part that actually makes everything work, and it lives at home, not on the VPS. A Tailscale Operator Connector resource running as a pod in my Talos Kubernetes cluster (still named k3s-subnet-router from before I moved off k3s, haven\u0026rsquo;t renamed it either):\napiVersion: tailscale.com/v1alpha1 kind: Connector metadata: name: k3s-subnet-router spec: hostname: k3s-subnet-router exitNode: true subnetRouter: advertiseRoutes: - \u0026#34;192.168.1.0/24\u0026#34; - \u0026#34;192.168.20.0/26\u0026#34; That\u0026rsquo;s it. One pod advertises two home CIDRs into the tailnet, and any tailnet peer that accepts routes — oracle1 included — can reach anything in those ranges like it\u0026rsquo;s sitting locally. No need to install Tailscale on the coordinator, the DNS box, or anything else at home. One router handles the whole subnet.\nThis has been solid: zero public exposure of anything at home, zero recurring cost, and a status page that actually tells me whether my home network is reachable from the outside world, not just whether my home Wi-Fi thinks everything\u0026rsquo;s fine.\n","permalink":"https://blog.sriramsv.com/posts/gatus-oracle-tailscale-monitoring/","summary":"\u003cp\u003eI wanted a status page that tells me when something at home goes down. Home Assistant, internal\nDNS, my Zigbee coordinator, all of it. And I wanted the checks to run from outside my network,\nbecause if the thing checking \u0026ldquo;is it up\u0026rdquo; is sitting on the same LAN, it\u0026rsquo;s not really telling you\nanything. Problem is, I didn\u0026rsquo;t want to port-forward anything into my home network to make that\nwork. No way.\u003c/p\u003e","title":"Monitoring my home network from a free Oracle VPS, without exposing anything"},{"content":"I wanted my house to know when I\u0026rsquo;d gone to bed. I do own an Apple Watch, but wearing it overnight is a hassle — it means remembering to charge it during the day instead of overnight, and honestly a watch on my wrist all night is just uncomfortable. So instead of leaning on wrist-based sleep tracking, I built the detection out of presence sensors already in the walls: mmWave motion sensors, a bed sensor, and whatever the TV and Sonos happen to be doing.\nIt turned into two related but distinct pieces: a continuous \u0026ldquo;activity score\u0026rdquo; that acts as a dashboard diagnostic, and a Bayesian classifier that actually decides when the house should switch into Sleep mode.\nThe building block: per-room activity fractions mmWave presence sensors are chatty — they flicker on and off far more than a human\u0026rsquo;s actual movement would suggest. Rather than reacting to raw on/off state, every room gets a history_stats sensor that computes the fraction of the last five minutes it\u0026rsquo;s spent \u0026ldquo;on\u0026rdquo;:\n- platform: history_stats name: \u0026#34;Living Room Activity Fraction\u0026#34; entity_id: binary_sensor.living_room_presence state: \u0026#34;on\u0026#34; type: ratio duration: \u0026#34;00:05:00\u0026#34; end: \u0026#34;{{ now() }}\u0026#34; scan_interval: 60 This is effectively a low-pass filter: a single missed detection or a stray retrigger gets averaged out instead of causing a rapid state change downstream. Six of these exist — living room, dining, kitchen, stairs, bedroom, and guest room.\nCombining them into one number sensor.activity_score is a weighted sum of those fractions, plus a bonus if media is playing, normalized to a 0–1 range:\n{% set lr = states(\u0026#39;sensor.living_room_activity_fraction\u0026#39;) | float(0) / 100 * 3 %} {% set dining = states(\u0026#39;sensor.dining_activity_fraction\u0026#39;) | float(0) / 100 * 2 %} {% set kitchen = states(\u0026#39;sensor.kitchen_activity_fraction\u0026#39;) | float(0) / 100 * 2 %} {% set stairs = states(\u0026#39;sensor.stairs_activity_fraction\u0026#39;) | float(0) / 100 * 1 %} {% set guest_mode = states(\u0026#39;input_select.guest_mode\u0026#39;) | lower %} {% set guest = states(\u0026#39;sensor.guest_room_activity_fraction\u0026#39;) | float(0) / 100 * 2 if guest_mode != \u0026#39;off\u0026#39; else 0 %} {% set presence = lr + dining + kitchen + stairs + guest %} {% set tv_on = is_state(\u0026#39;media_player.apple_tv\u0026#39;, \u0026#39;playing\u0026#39;) or is_state(\u0026#39;media_player.lg_tv\u0026#39;, \u0026#39;playing\u0026#39;) %} {% set sonos_on = is_state(\u0026#39;media_player.living_room_sonos\u0026#39;, \u0026#39;playing\u0026#39;) %} {% set media = (2 if tv_on else 0) + (1 if sonos_on else 0) %} {{ ([((presence + media) / 9), 1.0] | min) | round(2) }} The weights aren\u0026rsquo;t scientific — living room activity counts for more than stairs, guest room only counts at all if someone\u0026rsquo;s staying over. It\u0026rsquo;s a rough proxy for \u0026ldquo;how much is going on in the house right now,\u0026rdquo; and it\u0026rsquo;s mostly there to look at on a dashboard: a mini-graph-card showing the last 24 hours, bounded 0–1, next to the raw sleep booleans. Notably, it doesn\u0026rsquo;t drive any automation directly — it\u0026rsquo;s a diagnostic, not a decision input.\nThe thing that actually detects sleep Deciding \u0026ldquo;has this person gone to sleep\u0026rdquo; turned out to need something more deliberate than a single blended number. That\u0026rsquo;s binary_sensor.sleeping, built on Home Assistant\u0026rsquo;s native bayesian platform: a prior probability of 0.40, a threshold of 0.85, and four observations —\nIs it currently within the overnight window (11pm–10am)? Is the bed-presence sensor on? Is bedroom activity fraction ≥ 60%? Is living-room presence off? Each observation nudges the posterior probability up or down; once it crosses 0.85, the sensor flips to \u0026ldquo;on\u0026rdquo;:\nbinary_sensor: - platform: bayesian name: Sleeping unique_id: bayesian_sleeping prior: 0.40 probability_threshold: 0.85 observations: # Time window: 11pm–10am — all observed sleep periods fall fully in this window - platform: template value_template: \u0026#34;{{ now().hour \u0026gt;= 23 or now().hour \u0026lt; 10 }}\u0026#34; prob_given_true: 0.95 prob_given_false: 0.12 # Bed occupied: at least one person in bed - platform: state entity_id: binary_sensor.bed_presence_2d2168_bed_occupied_either to_state: \u0026#34;on\u0026#34; prob_given_true: 0.90 prob_given_false: 0.20 # Bedroom presence fraction ≥ 60% over last 10 min (smoothed via history_stats) - platform: numeric_state entity_id: sensor.bedroom_presence_fraction above: 60 prob_given_true: 0.80 prob_given_false: 0.30 # Living room presence off: no one in living room - platform: state entity_id: binary_sensor.living_room_presence to_state: \u0026#34;off\u0026#34; prob_given_true: 0.85 prob_given_false: 0.30 That raw signal then gets debounced — 5 minutes on, 5 minutes off — into binary_sensor.sleeping_smoothed, which is the one everything else actually listens to:\ntemplate: - binary_sensor: - name: \u0026#34;Sleeping (Smoothed)\u0026#34; unique_id: sleeping_smoothed icon: mdi:sleep state: \u0026#34;{{ is_state(\u0026#39;binary_sensor.sleeping\u0026#39;, \u0026#39;on\u0026#39;) }}\u0026#34; delay_on: minutes: 5 delay_off: minutes: 5 Wiring it into house mode sleeping_smoothed staying \u0026ldquo;on\u0026rdquo; for 10+ minutes during the overnight window triggers a transition to Sleep mode. Stripped down to the essentials, the trigger looks like this:\nautomation: - alias: \u0026#34;Transition: Home-\u0026gt;Sleep\u0026#34; trigger: - trigger: state entity_id: binary_sensor.sleeping_smoothed to: \u0026#34;on\u0026#34; for: minutes: 10 condition: - condition: time after: \u0026#34;22:00:00\u0026#34; before: \u0026#34;07:00:00\u0026#34; action: - action: input_select.select_option target: entity_id: input_select.house_mode data: option: \u0026#34;Sleep\u0026#34; In practice mine has a couple more guard conditions (an opt-out toggle, a check that we\u0026rsquo;re not already in Sleep mode) and fires a passive notification alongside the mode switch, but the shape above is the whole idea: wait for sustained confidence, then flip one state.\nEntering Sleep mode cascades through the rest of the house:\nnotifications get muted security arms per-room _sleep events fire, turning off lights via a room_sleep_mode blueprint presence detection in common areas gets disabled (so the now-empty living room doesn\u0026rsquo;t confuse anything) the garage door closes a tower fan kicks on if a door\u0026rsquo;s still open, a browser_mod card pops a warning Waking is the mirror image, gated on either the bed sensor clearing before a scheduled wake time (input_datetime.sleep_mode_off) or a 2-hour timeout if it doesn\u0026rsquo;t.\nWhat\u0026rsquo;s next The biggest weakness right now is latency. The 5-minute activity-fraction window plus the 5-minute on/off debounce on sleeping_smoothed stacks up fast — by the time the house actually commits to Sleep mode, I\u0026rsquo;ve often already been in bed for 10-15 minutes. That\u0026rsquo;s fine for muting notifications, but it\u0026rsquo;s noticeably slow for anything I\u0026rsquo;d want to feel instantaneous, like lights. The next round of work is tightening that feedback loop: shortening the smoothing windows where the sensors allow it, and looking at whether a faster-reacting signal (bed sensor transition, say) can front-run the Bayesian classifier for the parts of Sleep mode that don\u0026rsquo;t need to wait for high confidence.\nThe other lever is just more data. Right now the classifier only sees room presence and bed occupancy — adding a couple more low-effort signals (phone charging state, lock/alarm-arm events, ambient light level in the bedroom) would give the Bayesian model more independent observations to work with, which should let it reach the same confidence threshold faster without loosening it.\nThe lesson so far: you don\u0026rsquo;t need to wear anything to infer sleep reasonably well. A handful of cheap presence sensors, a low-pass filter, and a Bayesian classifier with a sensible threshold gets you most of the way there — and unlike a watch\u0026rsquo;s black-box sleep score, every input into the decision is something I can query, graph, and tune myself.\n","permalink":"https://blog.sriramsv.com/posts/homeassistant-activity-score/","summary":"\u003cp\u003eI wanted my house to know when I\u0026rsquo;d gone to bed. I do own an Apple Watch, but wearing it overnight\nis a hassle — it means remembering to charge it during the day instead of overnight, and honestly\na watch on my wrist all night is just uncomfortable. So instead of leaning on wrist-based sleep\ntracking, I built the detection out of presence sensors already in the walls: mmWave motion\nsensors, a bed sensor, and whatever the TV and Sonos happen to be doing.\u003c/p\u003e","title":"Detecting sleep without wearing a watch to bed: a Home Assistant activity score"}]