commit cfb3520ef7bb8c05239436086c81e39c36d918c1 Author: jm Date: Thu Jul 9 21:01:08 2026 -0400 Import live AC automations (v1 baseline) Snapshot of the three AC peak-shaving automations exactly as they run on the HA server today, imported via the config API, plus deploy tooling. This is the baseline the v2 hardening diffs against. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75dc551 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# secrets — never commit the HA token or anything derived from `pass` +ha_token +*.token +.env +*.secret + +# python +__pycache__/ +*.pyc + +# editor / os +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..017a236 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +get my home assistant token by + +ha_token=$(pass show home-assistant/192.168.128.3/jm/api) # it asks me to touch the yubikey. +server: 192.168.128.3 + +I want you to add an automation to set the AC thermostat to 85 at 17:58, 90 at 17:59, 85 at 18:00, 90 at 18:01 and so on until 18:20 \ No newline at end of file diff --git a/automations/ac_peak_ends.yaml b/automations/ac_peak_ends.yaml new file mode 100644 index 0000000..12f7f70 --- /dev/null +++ b/automations/ac_peak_ends.yaml @@ -0,0 +1,15 @@ +# ac_peak_ends — imported live baseline (v1) on 2026-07-09 +id: ac_peak_ends +alias: AC - Peak ends, return to 74 +description: '' +triggers: +- trigger: time + at: '19:00:00' +conditions: [] +actions: +- action: climate.set_temperature + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + temperature: 74 +mode: single diff --git a/automations/ac_peak_setback.yaml b/automations/ac_peak_setback.yaml new file mode 100644 index 0000000..d74610a --- /dev/null +++ b/automations/ac_peak_setback.yaml @@ -0,0 +1,23 @@ +# ac_peak_setback — imported live baseline (v1) on 2026-07-09 +id: ac_peak_setback +alias: AC - Peak setback to 82 +description: '' +triggers: +- trigger: time + at: '14:00:00' +conditions: +- condition: state + entity_id: binary_sensor.workday_sensor + state: 'on' +actions: +- action: climate.set_hvac_mode + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + hvac_mode: cool +- action: climate.set_temperature + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + temperature: 82 +mode: single diff --git a/automations/ac_precool_before_peak.yaml b/automations/ac_precool_before_peak.yaml new file mode 100644 index 0000000..9acfa03 --- /dev/null +++ b/automations/ac_precool_before_peak.yaml @@ -0,0 +1,68 @@ +# ac_precool_before_peak — imported live baseline (v1) on 2026-07-09 +id: ac_precool_before_peak +alias: AC - Pre-cool before peak +description: '' +triggers: +- trigger: time + at: '12:00:00' +conditions: +- condition: state + entity_id: binary_sensor.workday_sensor + state: 'on' +actions: +- action: weather.get_forecasts + target: + entity_id: weather.forecast_4315aspenhill + data: + type: daily + response_variable: fc +- variables: + today_high: "{% set today = now().date() %} {% set entries = fc['weather.forecast_4315aspenhill'].forecast\n\ + \ | selectattr('datetime')\n | list %}\n{% set match = entries\n | selectattr('datetime',\ + \ 'search', today | string)\n | list %}\n{{ (match[0].temperature if match\ + \ else entries[0].temperature) | float(0) }}\n" +- choose: + - conditions: + - condition: template + value_template: '{{ today_high >= 90 }}' + sequence: + - action: climate.set_hvac_mode + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + hvac_mode: cool + - action: climate.set_temperature + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + temperature: 68 + - conditions: + - condition: template + value_template: '{{ today_high >= 85 }}' + sequence: + - action: climate.set_hvac_mode + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + hvac_mode: cool + - action: climate.set_temperature + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + temperature: 70 + - conditions: + - condition: template + value_template: '{{ today_high >= 80 }}' + sequence: + - action: climate.set_hvac_mode + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + hvac_mode: cool + - action: climate.set_temperature + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + temperature: 71 + default: [] +mode: single diff --git a/deploy.py b/deploy.py new file mode 100644 index 0000000..c9176f1 --- /dev/null +++ b/deploy.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Deploy AC automations from automations/*.yaml to Home Assistant. + +Source of truth is THIS repo. Home Assistant (remote, API-only) is the deploy +target. Each YAML file's ``id`` is the key: the script POSTs to +``/api/config/automation/config/`` which replaces that automation *in place* +(same id -> same entity_id, run history and traces stay continuous). + +Usage: + source ~/.bashrc # provides HA_API_KEY + python3 deploy.py --check # parse/validate only, no writes + python3 deploy.py # deploy every automations/*.yaml + python3 deploy.py ac_peak_ends # deploy only the file(s) whose id matches + +Env: + HA_API_KEY long-lived HA token (required to deploy) + HA_HOST host:port (default 192.168.128.3:8123) +""" +import glob +import json +import os +import sys +import urllib.error +import urllib.request + +import yaml + +HERE = os.path.dirname(os.path.abspath(__file__)) +HA_HOST = os.environ.get("HA_HOST", "192.168.128.3:8123") +TOKEN = os.environ.get("HA_API_KEY") + + +def load_all(): + autos = [] + for path in sorted(glob.glob(os.path.join(HERE, "automations", "*.yaml"))): + with open(path) as fh: + doc = yaml.safe_load(fh) + if not doc or "id" not in doc: + sys.exit(f"{path}: missing required 'id'") + autos.append((path, doc)) + return autos + + +def deploy(doc): + url = f"http://{HA_HOST}/api/config/automation/config/{doc['id']}" + req = urllib.request.Request( + url, data=json.dumps(doc).encode(), method="POST", + headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req) as r: + return r.status, r.read().decode().strip() + except urllib.error.HTTPError as e: + return e.code, e.read().decode().strip() + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("-")] + check = "--check" in sys.argv + autos = load_all() + if args: + autos = [(p, d) for p, d in autos if d["id"] in args] + if not autos: + sys.exit(f"no automations matched {args}") + if not check and not TOKEN: + sys.exit("HA_API_KEY not set (try: source ~/.bashrc)") + + rc = 0 + for path, doc in autos: + name = os.path.basename(path) + if check: + print(f"ok parse {name} id={doc['id']} alias={doc.get('alias')!r}") + continue + status, resp = deploy(doc) + ok = status == 200 + rc |= 0 if ok else 1 + print(f"{'OK ' if ok else 'ERR'} {status} {name} id={doc['id']} {resp}") + sys.exit(rc) + + +if __name__ == "__main__": + main()