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.
| 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.
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-zeroGLOBfilter 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
derivativehelper per temp withtime_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.
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.
Add an entities card to the target dashboard via the WebSocket API.
- Connect
BASE_URL…/api/websocket; onauth_requiredsend{"type":"auth","access_token":"TOKEN"}; awaitauth_ok. {"id":1,"type":"lovelace/config","url_path":"DASH_URL_PATH"}→ returns config.- 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"}]}- 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 returnsunknown_command, uselovelace/save_config.
UI alternative: edit dashboard → Add Card → Entities → add the three entities.
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 keepsunknownfrom 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).
- Window:
7200(seconds) in both SQL queries = 2h lookback. Change both. - Convergence threshold:
0.2in the trigger. - Cooldown:
21600(6h) in the condition. - Diff sign: Sensor C is
outside − inside; wrap in|absif 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.