I'd like to undertstand what options I have and what is possible regarding the following scenario: I have heating wires fitted to a building roof. These wires need to be turned on in icing conditions in order to prevent ice dams which can damage the roof if allowed to build up. At present these wires can be turned on remotely via internet-connected switches from TPLink (model KP400) that I can control with their Kasa mobile app. What I'd like to do is add a capability to automate the control of these wires such that they turn on based on weather conditions. I think this could be done with a program that queries online weather data for my location, processes it to infer the presence of icing conditions, then uses either some Kasa API or perhaps direct communication with the KP400 switches to turn the wires on and off. I have no idea if such an API exists however. Let me know your thoughts.
Good news up front: this is very doable, and the hard part you're worried about (the API) is actually the easy part. Let me verify the current state of the tooling before giving you a full picture.
[
{
"type": "tool_reference",
"tool_name": "WebSearch"
},
{
"type": "tool_reference",
"tool_name": "WebFetch"
}
]{
"query": "python-kasa 2026 newer firmware klap protocol authentication requirement"
}Web search results for query: "python-kasa library KP400 support local control TP-Link"
Links: [{"title":"GitHub - python-kasa/python-kasa: π π€ Python API for TP-Link smarthome products","url":"https://github.com/python-kasa/python-kasa"},{"title":"python-kasa Β· PyPI","url":"https://pypi.org/project/python-kasa/0.6.0.dev0/"},{"title":"python-kasa β python-kasa documentation","url":"https://python-kasa.readthedocs.io/en/latest/"},{"title":"tplink-cloud-api Β· PyPI","url":"https://pypi.org/project/tplink-cloud-api/"},{"title":"GitHub - uni-byte/Kasa-Nice: A desktop GUI app for python-kasa to control TP-Link Kasa devices in your local network.","url":"https://github.com/uni-byte/Kasa-Nice"},{"title":"GitHub - piekstra/tplink-cloud-api: A Python library to remotely control TP-Link smart home devices using their cloud service - no need to be on the same network as your devices Β· GitHub","url":"https://github.com/piekstra/tplink-cloud-api"},{"title":"python-kasa 0.7.0.dev0","url":"https://pypi.org/project/python-kasa/0.7.0.dev0/"},{"title":"Command-line usage β python-kasa documentation","url":"https://python-kasa.readthedocs.io/en/latest/cli.html"}]
Based on the search results, I found relevant information about the python-kasa library's support for the KP400 device with local control:
The KP400 is listed as a supported power strip in python-kasa. The KP400 is confirmed to work with the library, and in principle, most kasa-branded devices that are locally controllable using the official Kasa mobile app work with this library.
The python-kasa library supports local control through various methods. python-kasa is a Python library to control TPLink's smart home products (plugs, wall switches, power strips, and bulbs), and it is designed to control TPLink's smart home devices. The library can control these devices over your local network using commands like `kasa --host <ip address> <command>`.
So yes, the python-kasa library does support the KP400 for local control over your network.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.Web search results for query: "python-kasa 2026 newer firmware klap protocol authentication requirement"
Links: [{"title":"python-kasa and KLAP authentication - Smart Home Community","url":"https://community.tp-link.com/us/smart-home/forum/topic/861124?moduleId=2430"},{"title":"kasa.transports.klaptransport β python-kasa documentation","url":"https://python-kasa.readthedocs.io/en/stable/_modules/kasa/transports/klaptransport.html"},{"title":"Topics β python-kasa documentation","url":"https://python-kasa.readthedocs.io/en/latest/topics.html"},{"title":"Command-line usage β python-kasa documentation","url":"https://python-kasa.readthedocs.io/en/latest/cli.html"},{"title":"Add support for the new encryption protocol by SimonWilkinson Β· Pull Request #117 Β· python-kasa/python-kasa","url":"https://github.com/python-kasa/python-kasa/pull/117/files/2bd9976118740b95eff1a3bc1cd7277875a32971"},{"title":"Adding support for the new encryption protocol (updated version 2023) by sdb9696 Β· Pull Request #477 Β· python-kasa/python-kasa","url":"https://github.com/python-kasa/python-kasa/pull/477/"},{"title":"KLAP v2 Authentication Failure on TP-Link HS300 (US) β Hardware Version 2.0 Β· Issue #1604 Β· python-kasa/python-kasa","url":"https://github.com/python-kasa/python-kasa/issues/1604"},{"title":"Title: HS300 (Hardware v2.0) β \"Authentication failed\" on discover, UDP 9999 appears closed (firmware disables local control?) Β· Issue #1603 Β· python-kasa/python-kasa","url":"https://github.com/python-kasa/python-kasa/issues/1603"},{"title":"Supported devices β python-kasa documentation","url":"https://python-kasa.readthedocs.io/en/stable/SUPPORTED.html"},{"title":"TP15 authentication failure after upgrade to firmware 1.4.2 Β· Issue #1653 Β· python-kasa/python-kasa","url":"https://github.com/python-kasa/python-kasa/issues/1653"}]
Based on the search results, here's what I found about python-kasa, the KLAP protocol, and authentication requirements with newer firmware:
## KLAP Protocol Overview
The Kasa hardware started moving to KLAP 1 (KASA Local Authentication Protocol) with later firmware versions. In 2023 TP-Link started updating the underlying communication transport used by Tapo devices to make them more secure, switching from AES encryption via public key exchange to use KLAP encryption and negotiation due to concerns around impersonation with AES.
## Authentication Requirements
Some newer Kasa devices require authentication, which are marked with [1] in the supported devices list. For newer KASA or TAPO devices using port 20002 for discovery, the `--device-family`, `--encrypt-type` and optional `--login-version` options can be passed, and the devices will probably require authentication via `--username` and `--password`.
## Recent Authentication Issues (2026)
There are documented authentication problems with newer firmware versions in 2026. Devices running software version 1.1.1 Build 250908 Rel.112508 show KLAP authentication failures. Some new plugs toggle between XOR and KLAP protocols; while on XOR the python-kasa tool works fine, once they toggle over to KLAP the authentication error appears.
Additionally, newer TP-Link firmware behavior has changed with devices no longer appearing to listen on UDP 9999, and the encryption handshake (KLAP) failing even when valid credentials are supplied, with recent HS300 firmware versions (KLAP, Login version 2) appearing to have removed or disabled the local control port.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.This is very doable, and the API question you're unsure about is actually the solved part. Here's the landscape.
You have three realistic options, in order of how well they fit:
1. Direct local control via python-kasa (recommended). There's a mature, actively maintained open-source Python library, python-kasa, that speaks the Kasa protocol directly to the switches over your LAN β no cloud, no TP-Link account required for the older protocol. The KP400 is on its supported devices list, and since it's a dual-outlet device the library exposes each outlet ("child") individually. It also has a CLI, so you can sanity-check before writing any code:
pip install python-kasa
kasa discover # finds your KP400s on the LAN
kasa --host 192.168.x.x on # or control individual child outlets
One caveat: TP-Link has been migrating newer firmware to an authenticated protocol called KLAP, and there are recent reports of 2025/2026 firmware on some models breaking or removing local control. python-kasa supports KLAP (you just pass your Kasa account credentials), and the KP400 is an older-generation device that most likely still uses the legacy protocol β but this is worth a 5-minute test with kasa discover before committing to the approach. The flip side of this risk: avoid accepting firmware updates once it's working.
2. Cloud control. TP-Link has no official public API, but there's a reverse-engineered tplink-cloud-api library that goes through their cloud with your Kasa credentials. Useful if the controlling program can't be on the same network as the switches, but it's more fragile (unofficial, TP-Link can change it anytime).
3. Home Assistant. If you ever want more home automation, Home Assistant has a first-class TP-Link integration (built on python-kasa) plus weather integrations, and you'd express the logic as an automation rule rather than writing a program. Heavier footprint, but battle-tested. There are also purpose-built hardware controllers for heat cables (e.g., EasyHeat/ETI snow sensors that detect temperature + moisture at the roof), which are the "buy vs. build" alternative β more reliable but less flexible and not cheap.
For a US location this is easy and free: the National Weather Service API (api.weather.gov) requires no API key and gives current observations plus hourly forecasts. Open-Meteo is another free, keyless option with a clean JSON API that includes snowfall and freezing-level data. Both are fine for this.
Ice dams form from meltβrefreeze cycles: snow on the roof, heat loss melting the underside, water refreezing at the cold eaves. So the trigger condition isn't just "it's freezing" β a reasonable rule is something like:
One design decision worth thinking about early: failure behavior. If the weather API is down or the program crashes mid-storm, do you want the wires left in their last state, defaulted on during winter months, or an alert to your phone so you can use the Kasa app manually? Since the cost of a false "off" is roof damage and the cost of a false "on" is an electric bill, I'd bias toward on-when-uncertain during the winter season, plus a notification path.
A small Python program on any always-on machine on the same LAN as the switches (a Raspberry Pi is the classic choice, but an existing server/NAS works): every 15β30 minutes, fetch weather β evaluate the rule with hysteresis β set switch state via python-kasa β log what it did and why. Give the KP400s DHCP reservations so their IPs don't move. That's maybe 150 lines of code, and it degrades gracefully β the Kasa app keeps working alongside it for manual override.
Given the repo we're sitting in is named heatwires, I suspect you had this in mind. The first concrete step I'd take is the kasa discover test to confirm your specific KP400s' firmware still talks the local protocol β everything else follows from that. Happy to build it out when you're ready.
Sources: python-kasa GitHub, python-kasa supported devices, python-kasa CLI docs, tplink-cloud-api, KLAP local-control issue reports
I'll start with the ground-truth test β whether we can talk to the KP400s from this machine β then build out the control program.
ls -la /home/david/projects/play/heatwires && python3 --version && ip -4 addr show | grep inet
total 20
drwxr-xr-x 4 david david 4096 Jul 10 13:18 .
drwxr-xr-x 8 david david 4096 Jul 10 10:23 ..
drwxr-xr-x 2 david david 4096 Jul 10 13:18 .claude
drwxr-xr-x 8 david david 4096 Jul 10 11:02 .git
-rw-r--r-- 1 david david 12 Jul 10 11:01 README.md
Python 3.12.3
inet 127.0.0.1/8 scope host lo
inet 10.255.255.254/32 brd 10.255.255.254 scope global lo
inet 172.29.211.234/20 brd 172.29.223.255 scope global eth0
inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0python3 -m venv .venv && .venv/bin/pip install --quiet python-kasa && .venv/bin/kasa --version
.venv/bin/kasa --timeout 5 discover 2>&1 | head -40
/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoProfile -Command "Get-NetIPAddress -AddressFamily IPv4 | Where-Object {\$_.IPAddress -notlike '172.*' -and \$_.IPAddress -notlike '127.*' -and \$_.IPAddress -notlike '169.254.*'} | Select-Object IPAddress, PrefixLength, InterfaceAlias | Format-Table -AutoSize" 2>/dev/nullIPAddress PrefixLength InterfaceAlias --------- ------------ -------------- 192.168.1.118 24 Ethernet
Your LAN is 192.168.1.0/24. Now I'll sweep it with unicast discovery probes to find the KP400s.
"""Unicast-sweep a /24 for Kasa devices (WSL2 NAT blocks broadcast discovery)."""
import asyncio
import sys
from kasa import Discover
from kasa.exceptions import KasaException, TimeoutError as KasaTimeoutError
SUBNET = sys.argv[1] if len(sys.argv) > 1 else "192.168.1"
SEM = asyncio.Semaphore(64)
async def probe(ip: str):
async with SEM:
try:
dev = await Discover.discover_single(ip, timeout=2, discovery_timeout=2)
if dev is None:
return None
await dev.update()
info = (ip, dev.alias, dev.model, getattr(dev, "children", []))
await dev.disconnect()
return info
except (KasaException, KasaTimeoutError, asyncio.TimeoutError, OSError):
return None
async def main():
tasks = [probe(f"{SUBNET}.{i}") for i in range(1, 255)]
results = [r for r in await asyncio.gather(*tasks) if r]
for ip, alias, model, children in results:
print(f"{ip} model={model} alias={alias!r}")
for child in children:
print(f" child: {child.alias!r} on={child.is_on}")
if not results:
print("No Kasa devices found on subnet.")
asyncio.run(main())
File created successfully at: /tmp/claude-1000/-home-david-projects-play-heatwires/d426da45-8a99-4442-bf4b-0e68dc9a3d49/scratchpad/sweep.py (file state is current in your context β no need to Read it back)
.venv/bin/python /tmp/claude-1000/-home-david-projects-play-heatwires/d426da45-8a99-4442-bf4b-0e68dc9a3d49/scratchpad/sweep.py 192.168.1
192.168.1.22 model=HS103 alias='Switch #1'
192.168.1.31 model=KP400 alias='Switch #2'
child: 'Switch #3' on=False
child: 'Switch #4' on=False
192.168.1.64 model=KP400 alias='Switch #5'
child: 'Shop gutter heat wire' on=False
child: 'Shop deck mat' on=False
192.168.1.71 model=KP400 alias='Switch #6'
child: 'Shop roof extra wires' on=True
child: 'Switch #7' on=False
192.168.1.155 model=HS200 alias='House Roof Heat Wires'
192.168.1.173 model=KP401 alias='Switch #8'
192.168.1.186 model=KP400 alias='Switch #9'
child: 'Switch #10' on=False
child: 'Switch #11' on=False
192.168.1.201 model=HS105 alias='Switch #12'[
{
"type": "tool_reference",
"tool_name": "TaskCreate"
},
{
"type": "tool_reference",
"tool_name": "TaskUpdate"
}
]curl -s "https://api.open-meteo.com/v1/forecast?latitude=45.68&longitude=-111.04&hourly=temperature_2m,snowfall,snow_depth¤t=temperature_2m&past_days=2&forecast_days=2&temperature_unit=fahrenheit&precipitation_unit=inch&timezone=auto" | head -c 600
{"latitude":45.682507,"longitude":-111.038574,"generationtime_ms":0.15461444854736328,"utc_offset_seconds":-21600,"timezone":"America/Denver","timezone_abbreviation":"GMT-6","elevation":1466.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"Β°F"},"current":{"time":"2026-07-10T17:00","interval":900,"temperature_2m":88.1},"hourly_units":{"time":"iso8601","temperature_2m":"Β°F","snowfall":"inch","snow_depth":"ft"},"hourly":{"time":["2026-07-08T00:00","2026-07-08T01:00","2026-07-08T02:00","2026-07-08T03:00","2026-07-08T04:00","2026-07-08T05:00","2026-07-08T06:00","2026-07-Weather API confirmed working (88Β°F in Bozeman right now β no ice dams today). Building the program.
{
"subject": "Write heatwires control program",
"activeForm": "Writing heatwires control program"
}{
"subject": "Add unit tests for decision logic",
"activeForm": "Adding decision-logic tests"
}{
"subject": "Verify end-to-end against real switches",
"activeForm": "Verifying against real switches"
}{
"subject": "Write README and scheduling docs",
"activeForm": "Writing README"
}# heatwires configuration [location] # NOTE: defaulted to Bozeman, MT city center β adjust to the actual building # location (elevation matters for temperature accuracy). latitude = 45.68 longitude = -111.04 [thresholds] # Turn wires ON when temp is inside [on_temp_min_f, on_temp_max_f] AND there # is a snow signal. The melt-refreeze danger zone. on_temp_max_f = 35.0 on_temp_min_f = 8.0 # Turn OFF when temp rises past off_temp_high_f (meltwater drains freely) # or drops below off_temp_low_f (nothing is melting; wires are wasted power). # Gaps between on/off thresholds provide temperature hysteresis. off_temp_high_f = 38.0 off_temp_low_f = 5.0 # Snow signal: snowfall observed in the last N hours, or forecast in the next # M hours, exceeding the trigger amount β or standing snow depth. snow_lookback_hours = 48 snow_forecast_hours = 24 snow_trigger_inches = 0.5 snow_depth_trigger_inches = 1.0 # Minimum time wires stay ON before the program will turn them OFF. # Turning ON is never delayed. min_on_minutes = 60 [failsafe] # If weather data has been unavailable for this many consecutive runs, # assume the worst during winter months and turn the wires ON. max_weather_failures = 3 winter_months = [10, 11, 12, 1, 2, 3, 4] [state] # Where run-to-run state (hysteresis timers, failure counts) is kept. file = "state.json" # Devices to control. `alias` is verified against the live device before any # actuation β if an IP has moved to a different device, it is skipped with an # error rather than toggled. For KP400 (multi-outlet) devices, `child_alias` # selects the outlet; omit it for single-relay devices like the HS200. # Give these static DHCP reservations in your router. [[devices]] host = "192.168.1.64" alias = "Switch #5" child_alias = "Shop gutter heat wire" [[devices]] host = "192.168.1.71" alias = "Switch #6" child_alias = "Shop roof extra wires" [[devices]] host = "192.168.1.155" alias = "House Roof Heat Wires"
File created successfully at: /home/david/projects/play/heatwires/config.toml (file state is current in your context β no need to Read it back)
"""Automated control of roof heat wires based on weather conditions.""" __version__ = "0.1.0"
File created successfully at: /home/david/projects/play/heatwires/heatwires/__init__.py (file state is current in your context β no need to Read it back)
"""Configuration loading."""
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Device:
host: str
alias: str
child_alias: str | None = None
@property
def label(self) -> str:
return self.child_alias or self.alias
@dataclass
class Thresholds:
on_temp_max_f: float = 35.0
on_temp_min_f: float = 8.0
off_temp_high_f: float = 38.0
off_temp_low_f: float = 5.0
snow_lookback_hours: int = 48
snow_forecast_hours: int = 24
snow_trigger_inches: float = 0.5
snow_depth_trigger_inches: float = 1.0
min_on_minutes: int = 60
@dataclass
class Failsafe:
max_weather_failures: int = 3
winter_months: list[int] = field(
default_factory=lambda: [10, 11, 12, 1, 2, 3, 4]
)
@dataclass
class Config:
latitude: float
longitude: float
thresholds: Thresholds
failsafe: Failsafe
devices: list[Device]
state_file: Path
def load(path: Path) -> Config:
with open(path, "rb") as f:
raw = tomllib.load(f)
loc = raw["location"]
state_file = Path(raw.get("state", {}).get("file", "state.json"))
if not state_file.is_absolute():
state_file = path.parent / state_file
return Config(
latitude=loc["latitude"],
longitude=loc["longitude"],
thresholds=Thresholds(**raw.get("thresholds", {})),
failsafe=Failsafe(**raw.get("failsafe", {})),
devices=[Device(**d) for d in raw["devices"]],
state_file=state_file,
)
File created successfully at: /home/david/projects/play/heatwires/heatwires/config.py (file state is current in your context β no need to Read it back)
"""Weather retrieval from Open-Meteo (keyless, free for non-commercial use)."""
from __future__ import annotations
import json
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from .config import Thresholds
API_URL = "https://api.open-meteo.com/v1/forecast"
USER_AGENT = "heatwires/0.1 (roof heat-wire controller)"
@dataclass
class WeatherSignals:
"""Inputs to the icing decision, derived from hourly weather data."""
temp_f: float
snowfall_recent_in: float
snowfall_forecast_in: float
snow_depth_in: float
observed_at: str
def fetch(latitude: float, longitude: float, th: Thresholds,
timeout: float = 30.0) -> WeatherSignals:
"""Fetch weather and reduce it to decision signals. Raises on any failure."""
params = urllib.parse.urlencode({
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m",
"hourly": "temperature_2m,snowfall,snow_depth",
"past_days": max(1, -(-th.snow_lookback_hours // 24)),
"forecast_days": max(1, -(-th.snow_forecast_hours // 24)),
"temperature_unit": "fahrenheit",
"precipitation_unit": "inch",
"timezone": "UTC",
})
req = urllib.request.Request(
f"{API_URL}?{params}", headers={"User-Agent": USER_AGENT}
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.load(resp)
now = datetime.fromisoformat(data["current"]["time"])
times = [datetime.fromisoformat(t) for t in data["hourly"]["time"]]
snowfall = data["hourly"]["snowfall"]
snow_depth = data["hourly"]["snow_depth"] # feet, per hourly_units
lookback = th.snow_lookback_hours
forecast = th.snow_forecast_hours
recent = sum(
s or 0.0
for t, s in zip(times, snowfall)
if 0 <= (now - t).total_seconds() / 3600 <= lookback
)
ahead = sum(
s or 0.0
for t, s in zip(times, snowfall)
if 0 < (t - now).total_seconds() / 3600 <= forecast
)
# Snow depth: latest non-null value at or before now, feet -> inches.
depth_ft = 0.0
for t, d in zip(times, snow_depth):
if t <= now and d is not None:
depth_ft = d
return WeatherSignals(
temp_f=data["current"]["temperature_2m"],
snowfall_recent_in=recent,
snowfall_forecast_in=ahead,
snow_depth_in=depth_ft * 12.0,
observed_at=data["current"]["time"],
)
File created successfully at: /home/david/projects/play/heatwires/heatwires/weather.py (file state is current in your context β no need to Read it back)
"""Icing decision logic. Pure functions β no I/O β so it is unit-testable.
Ice dams form from melt-refreeze cycles: snow on the roof, heat loss melting
the underside, meltwater refreezing at the cold eaves. The wires should run
when there is snow AND the temperature is in the melt-refreeze band β not
merely when it is cold.
"""
from __future__ import annotations
from dataclasses import dataclass
from .config import Failsafe, Thresholds
from .weather import WeatherSignals
@dataclass
class Decision:
wires_on: bool
reason: str
def snow_signal(w: WeatherSignals, th: Thresholds) -> bool:
return (
w.snowfall_recent_in >= th.snow_trigger_inches
or w.snowfall_forecast_in >= th.snow_trigger_inches
or w.snow_depth_in >= th.snow_depth_trigger_inches
)
def decide(
w: WeatherSignals,
th: Thresholds,
currently_on: bool,
minutes_since_on: float | None,
) -> Decision:
"""Decide desired wire state.
minutes_since_on: how long ago the wires were last turned on by this
program (None if unknown/never). Used for the minimum-on hold, which only
delays turning OFF β turning ON is never delayed.
"""
snow = snow_signal(w, th)
if snow and th.on_temp_min_f <= w.temp_f <= th.on_temp_max_f:
return Decision(True, (
f"melt-refreeze conditions: {w.temp_f:.0f}Β°F with snow "
f"(recent {w.snowfall_recent_in:.1f}\", "
f"forecast {w.snowfall_forecast_in:.1f}\", "
f"depth {w.snow_depth_in:.1f}\")"
))
if currently_on:
held = (
minutes_since_on is not None
and minutes_since_on < th.min_on_minutes
)
if held and w.temp_f < th.off_temp_high_f:
return Decision(True, (
f"holding on: only {minutes_since_on:.0f} of "
f"{th.min_on_minutes} minimum minutes elapsed"
))
if not snow:
return Decision(False, "no snow signal")
if w.temp_f > th.on_temp_max_f:
if w.temp_f < th.off_temp_high_f and currently_on:
return Decision(True, (
f"hysteresis: {w.temp_f:.0f}Β°F is above on-threshold "
f"{th.on_temp_max_f:.0f}Β°F but below off-threshold "
f"{th.off_temp_high_f:.0f}Β°F"
))
return Decision(False, f"warm enough to drain ({w.temp_f:.0f}Β°F)")
if w.temp_f < th.on_temp_min_f:
if w.temp_f > th.off_temp_low_f and currently_on:
return Decision(True, (
f"hysteresis: {w.temp_f:.0f}Β°F is below on-threshold "
f"{th.on_temp_min_f:.0f}Β°F but above off-threshold "
f"{th.off_temp_low_f:.0f}Β°F"
))
return Decision(False, f"too cold for melt ({w.temp_f:.0f}Β°F)")
return Decision(False, "no icing conditions")
def decide_failsafe(
month: int, consecutive_failures: int, fs: Failsafe, currently_on: bool
) -> Decision:
"""Weather data unavailable. Bias toward ON in winter: a false 'on' costs
electricity, a false 'off' can cost the roof."""
if consecutive_failures >= fs.max_weather_failures:
if month in fs.winter_months:
return Decision(True, (
f"FAILSAFE: weather unavailable {consecutive_failures} runs "
f"in a row during winter β wires on as a precaution"
))
return Decision(False, (
f"weather unavailable {consecutive_failures} runs, but not "
f"winter β wires off"
))
return Decision(currently_on, (
f"weather fetch failed ({consecutive_failures} of "
f"{fs.max_weather_failures} tolerated) β keeping last state"
))
File created successfully at: /home/david/projects/play/heatwires/heatwires/decision.py (file state is current in your context β no need to Read it back)
"""Kasa switch actuation over the local network."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from kasa import Device as KasaDevice
from kasa import Discover
from .config import Device
log = logging.getLogger("heatwires")
@dataclass
class DeviceState:
device: Device
is_on: bool | None # None = unreachable or alias mismatch
error: str | None = None
def _resolve_target(dev: KasaDevice, cfg: Device):
"""Return the switchable module (device or child outlet), verifying
aliases so a moved IP address never toggles the wrong equipment."""
if dev.alias != cfg.alias:
raise ValueError(
f"alias mismatch at {cfg.host}: expected {cfg.alias!r}, "
f"found {dev.alias!r} β refusing to touch it"
)
if cfg.child_alias is None:
return dev
for child in dev.children:
if child.alias == cfg.child_alias:
return child
raise ValueError(
f"no child outlet named {cfg.child_alias!r} on {cfg.host} "
f"(has: {[c.alias for c in dev.children]})"
)
async def read_state(cfg: Device, timeout: int = 10) -> DeviceState:
try:
dev = await Discover.discover_single(
cfg.host, timeout=timeout, discovery_timeout=timeout
)
try:
await dev.update()
target = _resolve_target(dev, cfg)
return DeviceState(cfg, target.is_on)
finally:
await dev.disconnect()
except Exception as exc: # noqa: BLE001 - report, don't crash the run
return DeviceState(cfg, None, error=str(exc))
async def set_state(cfg: Device, on: bool, timeout: int = 10) -> DeviceState:
"""Set one device/outlet, returning its confirmed state afterwards."""
try:
dev = await Discover.discover_single(
cfg.host, timeout=timeout, discovery_timeout=timeout
)
try:
await dev.update()
target = _resolve_target(dev, cfg)
if target.is_on != on:
if on:
await target.turn_on()
else:
await target.turn_off()
await dev.update()
target = _resolve_target(dev, cfg)
if target.is_on != on:
raise RuntimeError(
f"commanded {'on' if on else 'off'} but device "
f"still reports {'on' if target.is_on else 'off'}"
)
log.info("%s: turned %s", cfg.label, "ON" if on else "OFF")
else:
log.debug("%s: already %s", cfg.label, "ON" if on else "OFF")
return DeviceState(cfg, target.is_on)
finally:
await dev.disconnect()
except Exception as exc: # noqa: BLE001
log.error("%s: FAILED to set %s: %s", cfg.label,
"ON" if on else "OFF", exc)
return DeviceState(cfg, None, error=str(exc))
File created successfully at: /home/david/projects/play/heatwires/heatwires/control.py (file state is current in your context β no need to Read it back)
"""Run-to-run state persisted to a JSON file (hysteresis timers, failure
counts, manual override)."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
@dataclass
class State:
wires_on: bool = False # last state this program commanded
last_on_at: str | None = None # ISO time wires were last turned on
consecutive_failures: int = 0 # weather fetches failed in a row
override: str | None = None # "on" | "off" | None
override_until: str | None = None # ISO expiry for the override
last_run_at: str | None = None
last_reason: str | None = None
def minutes_since_on(self, now: datetime) -> float | None:
if self.last_on_at is None:
return None
return (now - datetime.fromisoformat(self.last_on_at)).total_seconds() / 60
def active_override(self, now: datetime) -> str | None:
if self.override is None:
return None
if self.override_until is not None:
if now >= datetime.fromisoformat(self.override_until):
return None
return self.override
def load(path: Path) -> State:
try:
return State(**json.loads(path.read_text()))
except (FileNotFoundError, json.JSONDecodeError, TypeError):
return State()
def save(path: Path, state: State) -> None:
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(asdict(state), indent=2))
tmp.replace(path)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
File created successfully at: /home/david/projects/play/heatwires/heatwires/state.py (file state is current in your context β no need to Read it back)
"""CLI entry point.
python -m heatwires run [--dry-run] evaluate weather and set switches
python -m heatwires status show weather, decision, and switch states
python -m heatwires override on|off [--hours N]
python -m heatwires override clear
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from datetime import timedelta
from pathlib import Path
from . import config as config_mod
from . import decision as decision_mod
from . import state as state_mod
from . import weather as weather_mod
from .control import read_state, set_state
log = logging.getLogger("heatwires")
def setup_logging(log_file: Path | None) -> None:
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
root = logging.getLogger()
root.setLevel(logging.INFO)
stream = logging.StreamHandler()
stream.setFormatter(fmt)
root.addHandler(stream)
if log_file is not None:
fh = logging.FileHandler(log_file)
fh.setFormatter(fmt)
root.addHandler(fh)
def get_decision(cfg, st, now):
"""Fetch weather and decide, handling overrides and fetch failures.
Returns (decision, weather_or_none)."""
override = st.active_override(now)
if override is not None:
until = st.override_until or "cleared"
return decision_mod.Decision(
override == "on", f"manual override {override!r} until {until}"
), None
try:
w = weather_mod.fetch(cfg.latitude, cfg.longitude, cfg.thresholds)
except Exception as exc: # noqa: BLE001 - fail-safe path handles it
st.consecutive_failures += 1
log.error("weather fetch failed (%d in a row): %s",
st.consecutive_failures, exc)
return decision_mod.decide_failsafe(
now.month, st.consecutive_failures, cfg.failsafe, st.wires_on
), None
st.consecutive_failures = 0
log.info(
"weather: %.0fΒ°F, snow recent %.1f\" / forecast %.1f\" / depth %.1f\"",
w.temp_f, w.snowfall_recent_in, w.snowfall_forecast_in, w.snow_depth_in,
)
return decision_mod.decide(
w, cfg.thresholds, st.wires_on, st.minutes_since_on(now)
), w
async def cmd_run(cfg, st, dry_run: bool) -> int:
now = state_mod.utcnow()
dec, _ = get_decision(cfg, st, now)
log.info("decision: wires %s β %s", "ON" if dec.wires_on else "OFF",
dec.reason)
errors = 0
if dry_run:
log.info("dry run: not touching switches")
else:
results = await asyncio.gather(
*(set_state(d, dec.wires_on) for d in cfg.devices)
)
errors = sum(1 for r in results if r.error is not None)
if dec.wires_on and not st.wires_on:
st.last_on_at = now.isoformat()
st.wires_on = dec.wires_on
st.last_run_at = now.isoformat()
st.last_reason = dec.reason
state_mod.save(cfg.state_file, st)
if errors:
log.error("%d of %d devices failed β check them in the Kasa app",
errors, len(cfg.devices))
return 1 if errors else 0
async def cmd_status(cfg, st) -> int:
now = state_mod.utcnow()
dec, w = get_decision(cfg, st, now)
if w is not None:
print(f"weather: {w.temp_f:.0f}Β°F, snow recent "
f"{w.snowfall_recent_in:.1f}\" / forecast "
f"{w.snowfall_forecast_in:.1f}\" / depth {w.snow_depth_in:.1f}\"")
print(f"decision: wires {'ON' if dec.wires_on else 'OFF'} β {dec.reason}")
print(f"last run: {st.last_run_at or 'never'}"
+ (f" ({st.last_reason})" if st.last_reason else ""))
results = await asyncio.gather(*(read_state(d) for d in cfg.devices))
for r in results:
actual = ("ERROR: " + r.error if r.error
else "ON" if r.is_on else "OFF")
print(f" {r.device.label:<28} {actual}")
return 1 if any(r.error for r in results) else 0
def cmd_override(cfg, st, mode: str, hours: float | None) -> int:
now = state_mod.utcnow()
if mode == "clear":
st.override = None
st.override_until = None
print("override cleared; automatic control resumes on next run")
else:
st.override = mode
st.override_until = (
(now + timedelta(hours=hours)).isoformat() if hours else None
)
until = st.override_until or "manually cleared"
print(f"override: wires {mode.upper()} until {until}")
print("(takes effect on the next scheduled run, or run "
"`python -m heatwires run` now)")
state_mod.save(cfg.state_file, st)
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="heatwires", description=__doc__)
parser.add_argument("--config", type=Path,
default=Path(__file__).parent.parent / "config.toml")
parser.add_argument("--log-file", type=Path, default=None)
sub = parser.add_subparsers(dest="command", required=True)
p_run = sub.add_parser("run", help="evaluate weather and set switches")
p_run.add_argument("--dry-run", action="store_true",
help="decide and log, but do not touch switches")
sub.add_parser("status", help="show weather, decision, switch states")
p_ov = sub.add_parser("override", help="force wires on/off")
p_ov.add_argument("mode", choices=["on", "off", "clear"])
p_ov.add_argument("--hours", type=float, default=None,
help="auto-expire the override after this many hours")
args = parser.parse_args()
setup_logging(args.log_file)
cfg = config_mod.load(args.config)
st = state_mod.load(cfg.state_file)
if args.command == "run":
return asyncio.run(cmd_run(cfg, st, args.dry_run))
if args.command == "status":
return asyncio.run(cmd_status(cfg, st))
return cmd_override(cfg, st, args.mode, args.hours)
if __name__ == "__main__":
sys.exit(main())
File created successfully at: /home/david/projects/play/heatwires/heatwires/__main__.py (file state is current in your context β no need to Read it back)
from heatwires.config import Failsafe, Thresholds
from heatwires.decision import decide, decide_failsafe
from heatwires.weather import WeatherSignals
TH = Thresholds()
FS = Failsafe()
def w(temp, recent=0.0, forecast=0.0, depth=0.0):
return WeatherSignals(
temp_f=temp,
snowfall_recent_in=recent,
snowfall_forecast_in=forecast,
snow_depth_in=depth,
observed_at="2026-01-15T12:00",
)
def test_on_in_melt_refreeze_band_with_recent_snow():
assert decide(w(28, recent=2.0), TH, False, None).wires_on
def test_on_with_forecast_snow_only():
assert decide(w(30, forecast=1.0), TH, False, None).wires_on
def test_on_with_standing_snow_depth():
assert decide(w(25, depth=4.0), TH, False, None).wires_on
def test_off_when_no_snow_even_if_cold():
assert not decide(w(20), TH, False, None).wires_on
def test_off_when_warm_despite_snow():
assert not decide(w(45, recent=3.0), TH, True, 999).wires_on
def test_off_when_extremely_cold():
assert not decide(w(-10, depth=6.0), TH, True, 999).wires_on
def test_temperature_hysteresis_stays_on_between_thresholds():
# 36Β°F: above on-max (35) but below off-high (38)
d = decide(w(36, depth=4.0), TH, True, 999)
assert d.wires_on
# ...but does not turn ON at 36Β°F from an off state
assert not decide(w(36, depth=4.0), TH, False, None).wires_on
def test_low_temperature_hysteresis():
# 6Β°F: below on-min (8) but above off-low (5) β stays on, won't start
assert decide(w(6, depth=4.0), TH, True, 999).wires_on
assert not decide(w(6, depth=4.0), TH, False, None).wires_on
def test_minimum_on_hold_delays_turn_off():
# Snow stopped mattering, but wires only just came on
d = decide(w(30), TH, True, 15)
assert d.wires_on
assert "holding" in d.reason
def test_minimum_on_hold_expires():
assert not decide(w(30), TH, True, 90).wires_on
def test_hold_does_not_delay_turn_on():
assert decide(w(28, recent=2.0), TH, False, None).wires_on
def test_hold_does_not_block_warm_shutoff():
# Above off_temp_high the hold must not keep wires burning
assert not decide(w(45), TH, True, 5).wires_on
def test_failsafe_on_in_winter_after_repeated_failures():
d = decide_failsafe(1, 3, FS, currently_on=False)
assert d.wires_on
assert "FAILSAFE" in d.reason
def test_failsafe_off_in_summer():
assert not decide_failsafe(7, 5, FS, currently_on=True).wires_on
def test_failsafe_keeps_last_state_below_threshold():
assert decide_failsafe(1, 1, FS, currently_on=True).wires_on
assert not decide_failsafe(1, 1, FS, currently_on=False).wires_on
File created successfully at: /home/david/projects/play/heatwires/tests/test_decision.py (file state is current in your context β no need to Read it back)
[project]
name = "heatwires"
version = "0.1.0"
description = "Automated control of roof heat wires based on weather conditions"
requires-python = ">=3.11"
dependencies = [
"python-kasa>=0.10",
]
[project.optional-dependencies]
dev = ["pytest"]
[tool.pytest.ini_options]
testpaths = ["tests"]
File created successfully at: /home/david/projects/play/heatwires/pyproject.toml (file state is current in your context β no need to Read it back)
.venv/ __pycache__/ *.pyc state.json *.log
File created successfully at: /home/david/projects/play/heatwires/.gitignore (file state is current in your context β no need to Read it back)
.venv/bin/pip install --quiet -e ".[dev]" && .venv/bin/pytest -q
.venv/bin/python -m heatwires status
2026-07-10 17:19:13,622 INFO weather: 88Β°F, snow recent 0.0" / forecast 0.0" / depth 0.0" weather: 88Β°F, snow recent 0.0" / forecast 0.0" / depth 0.0" decision: wires OFF β no snow signal last run: never Shop gutter heat wire OFF Shop roof extra wires ON House Roof Heat Wires OFF
.venv/bin/python -m heatwires run --dry-run
2026-07-10 17:19:26,335 INFO weather: 88Β°F, snow recent 0.0" / forecast 0.0" / depth 0.0" 2026-07-10 17:19:26,336 INFO decision: wires OFF β no snow signal 2026-07-10 17:19:26,336 INFO dry run: not touching switches
.venv/bin/python - <<'EOF'
import asyncio, logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
from heatwires.config import Device
from heatwires.control import set_state
dev = Device(host="192.168.1.64", alias="Switch #5",
child_alias="Shop gutter heat wire")
async def main():
r1 = await set_state(dev, True)
print("after ON :", r1.is_on, r1.error or "")
await asyncio.sleep(2)
r2 = await set_state(dev, False)
print("after OFF:", r2.is_on, r2.error or "")
assert r1.is_on is True and r2.is_on is False, "toggle test failed"
print("toggle test PASSED β state restored to OFF")
asyncio.run(main())
EOFINFO Shop gutter heat wire: turned ON INFO Shop gutter heat wire: turned OFF after ON : True after OFF: False toggle test PASSED β state restored to OFF
.venv/bin/python - <<'EOF'
import asyncio
from heatwires.config import Device
from heatwires.control import set_state
wrong = Device(host="192.168.1.64", alias="Some Other Device",
child_alias="Shop gutter heat wire")
async def main():
r = await set_state(wrong, True)
print("is_on:", r.is_on, "| error:", r.error)
assert r.error and "refusing" in r.error
asyncio.run(main())
EOFShop gutter heat wire: FAILED to set ON: alias mismatch at 192.168.1.64: expected 'Some Other Device', found 'Switch #5' β refusing to touch it is_on: None | error: alias mismatch at 192.168.1.64: expected 'Some Other Device', found 'Switch #5' β refusing to touch it
.venv/bin/python -m heatwires override on --hours 1 && .venv/bin/python -m heatwires run --dry-run && .venv/bin/python -m heatwires override clear && rm -f state.json
override: wires ON until 2026-07-11T00:19:56.091092+00:00 (takes effect on the next scheduled run, or run `python -m heatwires run` now) 2026-07-10 17:19:56,271 INFO decision: wires ON β manual override 'on' until 2026-07-11T00:19:56.091092+00:00 2026-07-10 17:19:56,271 INFO dry run: not touching switches override cleared; automatic control resumes on next run
# heatwires Automated control of roof heat wires based on weather conditions, to prevent ice dams. Talks directly to TP-Link Kasa switches (KP400, HS200, etc.) over the local network using [python-kasa](https://github.com/python-kasa/python-kasa) β no cloud dependency β and gets weather from the free, keyless [Open-Meteo](https://open-meteo.com) API. ## How it decides Ice dams form from melt-refreeze cycles, so the wires run when there is snow AND the temperature is in the melt-refreeze band β not merely when it is cold: - **ON** when temperature is 8β35Β°F and there is a snow signal (β₯0.5" fallen in the last 48h, β₯0.5" forecast in the next 24h, or β₯1" standing snow depth). - **OFF** when it warms past 38Β°F (meltwater drains freely), drops below 5Β°F (nothing melts), or the snow signal clears. - The on/off threshold gaps provide temperature hysteresis, and a 60-minute minimum-on hold prevents rapid cycling. Turning ON is never delayed. - **Fail-safe:** if weather data is unavailable 3 runs in a row during winter months, the wires turn ON β a false "on" costs electricity; a false "off" can cost the roof. All thresholds are in `config.toml`. ## Setup ```bash python3 -m venv .venv .venv/bin/pip install -e . ``` Edit `config.toml`: - Set `latitude`/`longitude` to the building's actual location. - List the switches under `[[devices]]`. The `alias` (and `child_alias` for multi-outlet devices like the KP400) is verified against the live device before every actuation β if DHCP hands the IP to something else, the run refuses to touch it rather than toggling the wrong equipment. **Give the switches static DHCP reservations in your router** so this never trips. ## Usage ```bash .venv/bin/python -m heatwires status # weather, decision, switch states (read-only) .venv/bin/python -m heatwires run --dry-run # decide and log, don't touch switches .venv/bin/python -m heatwires run # the real thing β run this on a schedule .venv/bin/python -m heatwires override on --hours 12 # force on (e.g. ahead of a storm) .venv/bin/python -m heatwires override off --hours 4 # force off (e.g. roof work) .venv/bin/python -m heatwires override clear ``` Manual changes made in the Kasa app are overwritten on the next scheduled run β use `override` instead when you want the program to stand down. ## Scheduling Run every 15 minutes via cron on any always-on machine on the same LAN as the switches (Raspberry Pi, NAS, home server): ```cron */15 * * * * cd /home/david/projects/play/heatwires && .venv/bin/python -m heatwires --log-file heatwires.log run ``` Note for WSL2: WSL does not reliably run cron while no session is open. Fine for development, but deploy to an always-on Linux box for the winter. (Local control requires being on the same LAN; WSL2's NAT blocks broadcast discovery but unicast connections to known switch IPs work.) Exit code is non-zero if any switch could not be reached or verified, so you can wrap the cron line with your favorite alerting if wanted. ## Testing ```bash .venv/bin/pip install -e ".[dev]" .venv/bin/pytest ``` The decision logic is pure (no I/O) and covered by unit tests in `tests/test_decision.py`. ## Firmware caveat TP-Link has been migrating newer firmware toward an authenticated local protocol (KLAP) and in some cases disabling local control. These switches currently speak the legacy local protocol. **Avoid accepting Kasa firmware updates** once this is working, and re-run `python -m heatwires status` after any update to confirm the switches still respond.
The file /home/david/projects/play/heatwires/README.md has been updated successfully. (file state is current in your context β no need to Read it back)
git -C /home/david/projects/play/heatwires status --short && .venv/bin/pytest -q 2>&1 | tail -1
git -C /home/david/projects/play/heatwires status && cat /home/david/projects/play/heatwires/.git/info/exclude 2>/dev/null; git -C /home/david/projects/play/heatwires check-ignore -v heatwires/__main__.py README.md 2>&1
Exit code 1 On branch main Your branch is up to date with 'origin/main'. nothing to commit, working tree clean # git ls-files --others --exclude-from=.git/info/exclude # Lines that start with '#' are comments. # For a project mostly in C, the following would be a good set of # exclude patterns (uncomment them if you want to use them): # *.[oa] # *~
ls -la /home/david/projects/play/heatwires && git -C /home/david/projects/play/heatwires log --oneline -3 && head -3 /home/david/projects/play/heatwires/README.md
total 52 drwxr-xr-x 9 david david 4096 Jul 10 17:20 . drwxr-xr-x 10 david david 4096 Jul 10 17:10 .. drwxr-xr-x 2 david david 4096 Jul 10 17:21 .claude drwxr-xr-x 8 david david 4096 Jul 10 17:21 .git -rw-r--r-- 1 david david 43 Jul 10 17:18 .gitignore drwxr-xr-x 3 david david 4096 Jul 10 17:19 .pytest_cache drwxr-xr-x 5 david david 4096 Jul 10 13:18 .venv -rw-r--r-- 1 david david 3611 Jul 10 17:20 README.md -rw-r--r-- 1 david david 1986 Jul 10 17:15 config.toml drwxr-xr-x 3 david david 4096 Jul 10 17:19 heatwires drwxr-xr-x 2 david david 4096 Jul 10 17:19 heatwires.egg-info -rw-r--r-- 1 david david 298 Jul 10 17:18 pyproject.toml drwxr-xr-x 3 david david 4096 Jul 10 17:19 tests 4dc54b4 Updates 2a477ac Initial commit 9ee2923 first commit # heatwires Automated control of roof heat wires based on weather conditions, to prevent
Someone (or something) committed while I worked β there are two new commits and an origin remote that didn't exist at session start. Let me check what's in them.
git -C /home/david/projects/play/heatwires log --format='%h %an %ae %ad %s' -3 && git -C /home/david/projects/play/heatwires show --stat HEAD | head -30 && git -C /home/david/projects/play/heatwires remote -v
4dc54b4 David Boreham david@bozemanpass.com Fri Jul 10 17:20:41 2026 -0600 Updates
2a477ac David Boreham david@bozemanpass.com Fri Jul 10 17:18:33 2026 -0600 Initial commit
9ee2923 David Boreham david@bozemanpass.com Fri Jul 10 11:01:56 2026 -0600 first commit
commit 4dc54b49c53e2dfc84f4831549d3fb0c16e3695f
Author: David Boreham <david@bozemanpass.com>
Date: Fri Jul 10 17:20:41 2026 -0600
Updates
README.md | 88 +++++++++++++++++++++++++++++++++
heatwires.egg-info/PKG-INFO | 8 +++
heatwires.egg-info/SOURCES.txt | 15 ++++++
heatwires.egg-info/dependency_links.txt | 1 +
heatwires.egg-info/requires.txt | 4 ++
heatwires.egg-info/top_level.txt | 1 +
6 files changed, 117 insertions(+)
origin git@github.com:dboreham/heatwires.git (fetch)
origin git@github.com:dboreham/heatwires.git (push).venv/ __pycache__/
.venv/ __pycache__/ *.egg-info/ .pytest_cache/
The file /home/david/projects/play/heatwires/.gitignore has been updated successfully. (file state is current in your context β no need to Read it back)
git -C /home/david/projects/play/heatwires rm -r -q --cached heatwires.egg-info && git -C /home/david/projects/play/heatwires status --short
M .gitignore D heatwires.egg-info/PKG-INFO D heatwires.egg-info/SOURCES.txt D heatwires.egg-info/dependency_links.txt D heatwires.egg-info/requires.txt D heatwires.egg-info/top_level.txt
The system is built, tested against your real switches, and working. Here's where things stand.
python -m heatwires run (scheduled every 15 minutes via cron) fetches weather from Open-Meteo, applies the melt-refreeze rule β ON at 8β35Β°F with snow present or forecast, OFF above 38Β°F, below 5Β°F, or when snow clears β with temperature hysteresis and a 60-minute minimum-on hold. If weather data is unavailable three runs in a row during winter months, it fails safe to ON. python -m heatwires override on --hours 12 forces the wires on ahead of a storm; status shows everything read-only. All thresholds live in config.toml, and the README covers setup, scheduling, and the caveats.
run will turn it off, since it's July. If it's on for a reason, remove it from config.toml or use override on first.config.toml β set the actual building coordinates, since elevation shifts the temperature.I noticed you've been committing and pushing as I worked β one cleanup is staged but uncommitted: the heatwires.egg-info/ build artifacts got committed, so I untracked them and added them to .gitignore (files remain on disk). Include that in your next commit.