A Local LLM Suggests, I Tap, the Thermostat Obeys
I don’t let an LLM control my thermostat. I let it nag me, one button at a time, and the arrangement works better than either full manual or full auto ever did.
the contract
Every hour during waking hours, a local Ollama model gets a small packet of facts: indoor temperature, outdoor temperature, who’s home, solar generation, time of day. Its entire allowed vocabulary is two shapes:
HOLD :: <reason>
SUGGEST :: <setpoint> :: <reason>
HOLD means silence. No notification, no log entry I’ll ever read, nothing. SUGGEST becomes a phone notification with the reason and a button: Set 78°. Tap it and a handler applies the setpoint. Ignore it and nothing happens, and the next hour starts fresh.
That’s the whole system. The design position: AI in the suggestion loop, human in the action loop. The model gets to be smart; only I get to move the equipment.
why not let it act
Climate control is the tempting first target for “let the AI run it,” and the failure mode is miserable: you notice the house is wrong hours later, then reverse-engineer a decision no one wrote down. An unattended wrong setpoint costs comfort, money, and with an elderly relative or a nursery, more than that.
The suggestion loop keeps the useful part, a tireless watcher that notices “it cooled off outside, you could open up and coast.” I skip the hard part on purpose: teaching a model my family’s comfort by trial and error, with the family as the test subjects.
Two knobs enforce it. The button is the only path from model output to climate.set_temperature, and the handler clamps to a sanity range (60–85°F) even then. A parse failure or a setpoint outside the range means the suggestion drops, silence. The model can’t cook anyone. It can only be ignored, which it often is, and that’s the system working.
why local
The packet leaves nothing to the cloud: an hourly heartbeat of who’s home and what the house is doing stays on my hardware. A small local model handles this fine, because the task is small: read eight numbers, apply common sense, emit one line in a fixed grammar. No reasoning marathon, no context window, no subscription. The GPU that runs my other local jobs absorbs it without noticing.
The strict output grammar is doing quiet heavy lifting here. Small models ramble when you let them. Two allowed shapes and a parser that drops anything else turns “small model rambles sometimes” from a bug into a non-event.
the config
Sanitized. Thermostat and sensor names are stand-ins.
- id: climate_suggestion_hourly
alias: "Climate — hourly LLM suggestion"
trigger:
- platform: time_pattern
hours: "/1"
condition:
- condition: time
after: "06:00:00"
before: "22:00:00"
action:
- service: ai_task.generate_data
data:
task_name: climate_suggest
instructions: >
You suggest thermostat setpoints. Reply with EXACTLY one line:
either HOLD :: <short reason>
or SUGGEST :: <setpoint °F, integer> :: <short reason>
Suggest only when a change would clearly improve comfort or cost.
Facts: inside {{ states('sensor.inside_temp') }}°F,
outside {{ states('sensor.outside_temp') }}°F,
thermostat set to {{ state_attr('climate.main','temperature') }}°F,
mode {{ states('climate.main') }},
occupancy: {{ states('zone.home') }} people home,
solar: {{ states('sensor.solar_power') }} W,
time: {{ now().strftime('%H:%M') }}.
response_variable: verdict
- variables:
parts: "{{ (verdict.data | default('')).split('::') | map('trim') | list }}"
- condition: template
value_template: >
{{ parts | count == 3 and parts[0] == 'SUGGEST'
and parts[1] | int(0) >= 60 and parts[1] | int(0) <= 85 }}
- service: notify.my_phone
data:
title: "🌡️ Suggestion: {{ parts[1] }}°"
message: "{{ parts[2] }}"
data:
actions:
- action: "SET_TEMP_{{ parts[1] }}"
title: "Set {{ parts[1] }}°"
- id: climate_suggestion_apply
alias: "Climate — apply suggested setpoint"
trigger:
- platform: event
event_type: mobile_app_notification_action
condition:
- condition: template
value_template: "{{ trigger.event.data.action.startswith('SET_TEMP_') }}"
action:
- variables:
setpoint: "{{ trigger.event.data.action.split('_')[-1] | int }}"
- condition: template
value_template: "{{ setpoint >= 60 and setpoint <= 85 }}"
- service: climate.set_temperature
target:
entity_id: climate.main
data:
temperature: "{{ setpoint }}"
- service: notify.my_phone
data:
message: "Thermostat set to {{ setpoint }}°. 🌡️"
mode: single
The range check appears twice, once before the notification and again in the handler. The handler must not trust its input even though I wrote the thing that sends it. Notification actions are strings, and strings that fire service calls on your HVAC deserve validation on both ends.
a month of living with it
Most hours: HOLD, silence. Two or three times a day it pipes up, and about a third of those earn a tap, evening coast-downs mostly, plus the occasional “solar is peaking, pre-cool now and coast through the expensive hours,” which I would not have thought to do at 2pm on a Tuesday.
The taps I skip teach me the model’s blind spots. It can’t know a kid is home sick or that the oven is about to run for two hours. In the suggestion loop those gaps cost one swipe-to-dismiss. In an action loop they’d cost an evening of a wrong house and a family asking pointed questions about my hobby.
Cheapest AI-in-the-home lesson I know: give the model a strict grammar, a narrow lane, and no hands. Rig it so the worst possible outcome is a notification you ignore, then let it surprise you inside those walls.



Post Comment