Skip to content

Instantly share code, notes, and snippets.

@benben
Last active June 27, 2026 08:04
Show Gist options
  • Select an option

  • Save benben/3f19e6d785d5e4040844e5581c4e13db to your computer and use it in GitHub Desktop.

Select an option

Save benben/3f19e6d785d5e4040844e5581c4e13db to your computer and use it in GitHub Desktop.

Home Assistant: "Outside reaches inside temperature" crossover + alert

Build a dashboard readout + iOS alert that predicts when an outside temperature sensor will meet an inside one, using a least-squares trend of both over the last 2h.

Produces:

  • Two rate sensors — °C/h slope of each temp (OLS regression over trailing 2h).
  • Three template sensors — time-until-crossover (2h31m), clock ETA (22:53), current difference (1.5 °C).
  • A dashboard card with those three lines.
  • An iOS push when the two temps converge.

Everything below is creatable headlessly via the HA REST/WebSocket API, or by hand in the UI. No file/SSH access required.


0. Inputs to gather first

Placeholder What it is How to find
BASE_URL e.g. http://<host>:8123 the HA URL
TOKEN long-lived access token HA → profile (bottom-left) → Security → Long-lived access tokens → Create
SENSOR_OUTSIDE outside temp entity_id Developer Tools → States, filter temp
SENSOR_INSIDE inside temp entity_id same
NOTIFY_SERVICE iOS notify service GET BASE_URL/api/services → look for notify.mobile_app_*
DASH_URL_PATH target dashboard's url_path WS lovelace/dashboards/list, or it's in the dashboard's URL

Auth header for every REST call: Authorization: Bearer TOKEN, Content-Type: application/json.

Important: entity_ids are derived from the name you give a helper and differ per install. After creating each helper, read GET BASE_URL/api/states to capture the real entity_id and use that in later steps. Do not hardcode the names below.


1. Rate sensors — OLS slope over last 2h (SQL)

The recorder DB holds the history. Compute the regression slope in SQL:

slope = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²)   ; x = time(s), y = temp

multiply by 3600 → °C per hour.

First detect the recorder DB engine — query syntax differs. Create an SQL sensor with query SELECT sqlite_version() AS v (column v); if it succeeds the DB is SQLite (HAOS default). If it errors, it's MariaDB/Postgres — adjust the time functions (see note). Delete the probe sensor after.

Create two SQL sensors (one per temp). Query template (SQLite), substitute the entity_id:

SELECT (CAST(COUNT(*) AS REAL)*SUM(x*y) - SUM(x)*SUM(y))
     / NULLIF(CAST(COUNT(*) AS REAL)*SUM(x*x) - SUM(x)*SUM(x), 0) * 3600.0 AS slope
FROM (
  SELECT s.last_updated_ts AS x, CAST(s.state AS REAL) AS y
  FROM states s JOIN states_meta m ON s.metadata_id = m.metadata_id
  WHERE m.entity_id = '<SENSOR_OUTSIDE or SENSOR_INSIDE>'
    AND s.last_updated_ts >= strftime('%s','now') - 7200      -- 2h window
    AND s.state GLOB '*[0-9]*' AND s.state NOT IN ('unknown','unavailable')
)
  • column to use as state: slope
  • NULLIF(...,0) → NULL (state "unknown") when <2 points, avoids divide-by-zero
  • GLOB filter drops non-numeric states

MariaDB: replace strftime('%s','now') with UNIX_TIMESTAMP(), drop the GLOB line (use a REGEXP '^-?[0-9.]+$' guard instead). Postgres: EXTRACT(EPOCH FROM now()).

UI path: Settings → Devices & Services → Helpers → Create Helper → SQL (or add the SQL integration). Name e.g. "Outside Temp Slope" / "Inside Temp Slope", paste query, column slope.

API path (per sensor):

POST BASE_URL/api/config/config_entries/flow            {"handler":"sql","show_advanced_options":true}
POST BASE_URL/api/config/config_entries/flow/<flow_id>  {"name":"Outside Temp Slope"}
POST BASE_URL/api/config/config_entries/flow/<flow_id>  {"query":"<sql above>","column":"slope","advanced_options":{}}

Repeat for the inside sensor. Then read /api/states, note the two entity_ids → call them RATE_OUT, RATE_IN.

Alternative (simpler, less robust): a derivative helper per temp with time_window: 2h, unit_time: h. That is a 2-point (now vs 2h-ago) slope — sensitive to spikes. OLS/SQL above uses all samples; prefer it.


2. Crossover + difference template sensors

Math: gap closes at rate RATE_OUT − RATE_IN.

t_hours = (inside − outside) / (rate_out − rate_in)

Show never when diverging (t ≤ 0) or rates equal (denominator 0). Sign-general — works whether outside is warming toward inside or cooling toward it.

Create three template sensors. Common Jinja prelude (substitute the 4 entity_ids):

{% set o = states('SENSOR_OUTSIDE')|float(0) %}
{% set i = states('SENSOR_INSIDE')|float(0) %}
{% set ro = states('RATE_OUT')|float(0) %}
{% set ri = states('RATE_IN')|float(0) %}
{% set d = ro - ri %}{% set t = (i - o)/d if d != 0 else 0 %}

Sensor A — time until (2h31m): state =

<prelude>{% if d == 0 or t <= 0 %}never{% else %}{% set m = (t*60)|int %}{{ m//60 }}h{{ '%02d'|format(m%60) }}m{% endif %}

Sensor B — clock ETA (22:53): state =

<prelude>{% if d == 0 or t <= 0 %}never{% else %}{{ (now() + timedelta(hours=t)).strftime('%H:%M') }}{% endif %}

Sensor C — current difference (1.5 °C, signed, +=outside warmer): state =

{{ (states('SENSOR_OUTSIDE')|float(0) - states('SENSOR_INSIDE')|float(0))|round(1) }}

set unit °C, device_class temperature, state_class measurement.

UI path: Helpers → Create Helper → Template → "Template a sensor", paste each state template.

API path (per sensor):

POST BASE_URL/api/config/config_entries/flow            {"handler":"template","show_advanced_options":true}
POST BASE_URL/api/config/config_entries/flow/<flow_id>  {"next_step_id":"sensor"}
POST BASE_URL/api/config/config_entries/flow/<flow_id>  {"name":"<Name>","state":"<state template>","unit_of_measurement":"°C","device_class":"temperature","state_class":"measurement"}

(omit unit/device_class/state_class for A and B — they're strings). Read /api/states → note entity_ids CROSS_IN, CROSS_AT, DIFF.


3. Dashboard card

Add an entities card to the target dashboard via the WebSocket API.

  1. Connect BASE_URL …/api/websocket; on auth_required send {"type":"auth","access_token":"TOKEN"}; await auth_ok.
  2. {"id":1,"type":"lovelace/config","url_path":"DASH_URL_PATH"} → returns config.
  3. Insert into config.views[0].cards:
{"type":"entities","title":"Outside → Inside crossover","entities":[
  {"entity":"CROSS_IN","name":"Time until outside = inside","icon":"mdi:timer-sand"},
  {"entity":"CROSS_AT","name":"Crossover at","icon":"mdi:clock-outline"},
  {"entity":"DIFF","name":"Current difference (out − in)","icon":"mdi:thermometer-lines"}]}
  1. Save: {"id":2,"type":"lovelace/config/save","url_path":"DASH_URL_PATH","config":<modified config>}.
    • Save the full config back, never a partial/empty one (that wipes the dashboard).
    • Command name varies by version: try lovelace/config/save; if it returns unknown_command, use lovelace/save_config.

UI alternative: edit dashboard → Add Card → Entities → add the three entities.


4. iOS notification on convergence

Automation: alert when the temps come within 0.2 °C, once per event (morning warm-up crossing and evening cool-down crossing are >6h apart; a 6h cooldown gives one push each and kills wobble).

POST BASE_URL/api/config/automation/config/temp_crossover_notify

body:

{
  "alias": "Temp crossover notification",
  "mode": "single",
  "triggers": [
    {"trigger":"template","value_template":"{{ states('DIFF')|float(99)|abs < 0.2 }}"}
  ],
  "conditions": [
    {"condition":"template","value_template":"{{ this.attributes.last_triggered is none or (now() - this.attributes.last_triggered).total_seconds() > 21600 }}"}
  ],
  "actions": [
    {"action":"NOTIFY_SERVICE","data":{
      "title":"🌡️ Inside/outside temp converged",
      "message":"Outside {{ states('SENSOR_OUTSIDE') }}°C and inside {{ states('SENSOR_INSIDE') }}°C are within {{ states('DIFF')|float(0)|abs }}°C.",
      "data":{"push":{"interruption-level":"active"},"tag":"temp-crossover"}
    }}
  ]
}
  • DIFF = entity_id of Sensor C. float(99) default keeps unknown from reading as in-band.
  • Template trigger is edge-triggered (fires on entry into the band). The 6h cooldown uses the automation's own last_triggered.
  • interruption-level:"active" → respects iOS Sleep/Focus (silent, still delivered). Use "time-sensitive" to break through Focus instead.

UI alternative: Settings → Automations → create; trigger = Template with the value_template above; condition = Template cooldown; action = Call service NOTIFY_SERVICE with the data block.

Test the push once: POST BASE_URL/api/services/notify/<service> with {"title":"test","message":"test"} (note: a Focus mode can silence active-level pushes).


Tuning knobs

  • Window: 7200 (seconds) in both SQL queries = 2h lookback. Change both.
  • Convergence threshold: 0.2 in the trigger.
  • Cooldown: 21600 (6h) in the condition.
  • Diff sign: Sensor C is outside − inside; wrap in |abs if you only want magnitude.
  • Rates need recorder history of the source sensors (on by default) and are rough for the first ~2h after creation until the window fills.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment