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.
This is a great project — and the good news up front: yes, this is very achievable, and there's a well-supported local API for exactly your switches. Let me verify the current state of the key pieces before I lay out your options.
[
{
"type": "tool_reference",
"tool_name": "WebSearch"
},
{
"type": "tool_reference",
"tool_name": "WebFetch"
}
]{
"query": "TP-Link Kasa cloud API official developer local protocol KLAP 2025 2026"
}{
"query": "National Weather Service API free weather forecast api.weather.gov usage"
}Web search results for query: "python-kasa KP400 support local control smart plug 2026"
Links: [{"title":"GitHub - python-kasa/python-kasa: 🏠🤖 Python API for TP-Link smarthome products","url":"https://github.com/python-kasa/python-kasa"},{"title":"tplink-cloud-api · PyPI","url":"https://pypi.org/project/tplink-cloud-api/"},{"title":"Using python-kasa to control a smart plug | Hackaday.io","url":"https://hackaday.io/project/189196-using-python-kasa-to-control-a-smart-plug"},{"title":"KP400 | Kasa Smart Outdoor Plug | TP-Link","url":"https://www.tp-link.com/us/home-networking/smart-plug/kp400/v1/"},{"title":"python-kasa — python-kasa documentation","url":"https://python-kasa.readthedocs.io/"},{"title":"TP-Link Kasa KP400 2-outlet support? - Page 2 - Configuration - Home Assistant Community","url":"https://community.home-assistant.io/t/tp-link-kasa-kp400-2-outlet-support/113135?page=2"},{"title":"User Guide Kasa Smart Wi-Fi Outdoor Plug KP400 1910012470 REV1.0.0","url":"https://files.bbystatic.com/ss6GELNjorx%2BYMN6kznk7w==/bd607d59-1361-40df-9caa-976ac4b1729b.pdf"},{"title":"python-kasa 0.7.0.dev0","url":"https://pypi.org/project/python-kasa/0.7.0.dev0/"},{"title":"Kasa TP-Link, Kasa Smart Home, 2-Outlet Smart Outdoor Plug, Black KP400 - The Home Depot","url":"https://www.homedepot.com/p/Kasa-TP-Link-Kasa-Smart-Home-2-Outlet-Smart-Outdoor-Plug-Black-KP400/311991271"}]
Based on the search results, here's what I found about python-kasa KP400 support for local control:
The KP400 is listed as a supported device in python-kasa, which is a Python library designed for controlling TP-Link smart home devices.
Regarding local control specifically, python-kasa communicates with devices over your local network, making it suitable for local control scenarios. If you need local control on the same network as your devices, python-kasa is described as a great option.
The KP400 is a 2-outlet smart outdoor plug, and it has been confirmed to work with local control systems like Home Assistant for power on/off functions. The library provides the ability to control these smart plugs locally without requiring cloud connectivity or internet access when operating on the same network.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.Web search results for query: "National Weather Service API free weather forecast api.weather.gov usage"
Links: [{"title":"api.weather.gov: General FAQs","url":"https://weather-gov.github.io/api/general-faqs"},{"title":"weather.gov: A Truly Free Weather API | Adafruit Playground","url":"https://adafruit-playground.com/u/CGrover/pages/weather-gov-a-truly-free-weather-api"},{"title":"Web Services Documentation","url":"https://www.weather.gov/documentation"},{"title":"Weather.gov API - Free Official US Forecasts & Alerts | FreeAPIHub","url":"https://freeapihub.com/apis/weather-gov-api"},{"title":"Weather.gov | Documentation | Postman API Network","url":"https://www.postman.com/api-evangelist/national-oceanic-and-atmospheric-administration-noaa/documentation/9eu7ygi/weather-gov"},{"title":"National Weather Service","url":"https://rapidapi.com/theapiguy/api/national-weather-service"},{"title":"Free Weather Data with National Weather Service API – Clojure Civitas","url":"https://clojurecivitas.org/scittle/weather/weather_nws_integration.html"},{"title":"🌦️ Exploring Weather Service Api | Wes's Webzone","url":"https://wesmattison.com/posts/exploring-weather-service-api/"},{"title":"api.weather.gov: Community discussion and documentation for the US National Weather Service API","url":"https://weather-gov.github.io/api/"}]
Here's what I found about the National Weather Service API:
## Overview
api.weather.gov represents the public face of the next generation of data services from the National Weather Service, offering public access to a wide range of essential weather data in a REST-style, JSON-based web service.
## Free Access & Usage Limits
The NWS API provides free access to alerts, observations, and forecasts without the need for a user account or API key. It is completely free with no API key, at about 5,000 requests per hour and is a US government service.
## Authentication Requirements
There is no API key, but the service requires a descriptive User-Agent header identifying your application and a contact. The Weather API doesn't require an API key but does require a User-Agent header for identification, which can be a string unique to your application with contact information encouraged.
## Basic Usage
The base URL is https://api.weather.gov, and the usual flow starts at /points/{lat},{lon}, which returns metadata including the URLs for that location's forecast, hourly forecast and observation station.
## Coverage & Data
It covers the United States only, so it is not a global weather source. A large range of services are available through the API including alerts, forecasts, aviation weather advisories, and summary report products.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.Web search results for query: "TP-Link Kasa cloud API official developer local protocol KLAP 2025 2026"
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":"Topics — python-kasa documentation","url":"https://python-kasa.readthedocs.io/en/latest/topics.html"},{"title":"TP-Link Cloud, Kasa, TP-Link Smart Products - Dev Cheatsheet","url":"https://docs.joshuatz.com/random/tp-link-kasa/"},{"title":"API Access Issue – “Account Not Found” While Using HS300 with Kasa Cloud API - Smart Home Community","url":"https://community.tp-link.com/en/smart-home/forum/topic/834138"},{"title":"New TP-Link protocol: KLAP · Issue #113 · petretiandrea/plugp100","url":"https://github.com/petretiandrea/plugp100/issues/113"},{"title":"tplink-cloud-api · PyPI","url":"https://pypi.org/project/tplink-cloud-api/"},{"title":"TP-link devices integration using Kasa Cloud API - Share your Projects! - Home Assistant Community","url":"https://community.home-assistant.io/t/tp-link-devices-integration-using-kasa-cloud-api/330087"},{"title":"GitHub - whitslack/kasa: Kasa Smart API documentation and tools · GitHub","url":"https://github.com/whitslack/kasa"},{"title":"Kasa / Tapo | SharpTools Docs","url":"https://docs.sharptools.io/bridge/integrations/kasa-tapo"}]
Based on the search results, here's what I found about TP-Link Kasa cloud API, local protocol, and KLAP:
## KLAP Protocol Overview
TP-Link Kasa hardware started moving to KLAP (KASA Local Authentication Protocol) with later versions. In 2021 TP-Link started updating the underlying communication transport used by Kasa devices, switching from a TCP connection with static XOR type of encryption to a transport called KLAP which communicates over http and uses handshakes to negotiate a dynamic encryption cipher.
## Cloud API
The TP-Link Cloud API (V2) uses HMAC-SHA1 request signing for authentication and automatically connects to both the Kasa and Tapo clouds in parallel, returning a unified device list. This allows control of TP-Link Kasa and Tapo smart home devices from anywhere over the internet using TP-Link's cloud API.
## Local Protocol Changes
In 2023 TP-Link started updating the underlying communication transport used by Tapo devices, switching from AES encryption via public key exchange to use KLAP encryption and negotiation, with the encryption cipher being the same as for Kasa KLAP but the handshake seeds slightly different.
## Official Developer Resources
The search results indicate that most API documentation available by the community relies on endpoints exposed by reverse-engineering the Kasa smartphone app, and TP-Link could crack down on suspicious API requests. There don't appear to be official TP-Link developer documents for the KLAP protocol or cloud API in the search results—most implementations appear to be community-developed based on reverse engineering.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.Here's the landscape. Short version: everything you described is not only possible, it's a well-trodden path — and the best approach doesn't need any cloud API at all.
The key discovery: there's a mature open-source library, python-kasa, that talks to Kasa devices directly over your local network — no internet, no TP-Link cloud, no reverse-engineered web login. The KP400 is explicitly supported, including its two independently-switchable outlets (each of your outlets shows up as a child you can toggle separately). This is exactly what powers the Home Assistant Kasa integration.
Why local is the right choice here:
- Reliability — an ice-protection system shouldn't depend on TP-Link's cloud being up, your account not getting rate-limited, or a firmware change breaking an unofficial web API. It just needs your LAN.
- Speed & simplicity — kasa --host 192.168.1.x on --index 0 from a shell, or a few lines of Python.
- No account fragility — TP-Link has no official public developer API; the cloud methods people use are reverse-engineered from the Kasa app, and TP-Link periodically changes them (they've been migrating newer devices to an encrypted "KLAP" transport). Your KP400 uses the older, stable, well-supported local protocol.
One caveat to plan around: give the KP400s static/reserved IPs (DHCP reservation in your router) so the controller always knows where to find them.
Cloud alternatives exist (tplink-cloud-api and similar) and would let you control the switches from outside your home network — but for a device sitting on your own roof controlled by a program on your own network, that's added fragility for no benefit. I'd only reach for cloud control if the controller can't be on the same LAN as the switches.
Since you're in Montana (US), the National Weather Service API (api.weather.gov) is the natural fit:
- Completely free, no API key, ~5,000 requests/hour — you'll use a handful per hour.
- Just requires a User-Agent header identifying your app + a contact email.
- Flow: hit /points/{lat},{lon} once to get your gridpoint, which gives you URLs for hourly forecast and current observations (temperature, precipitation, etc.).
The main limitation is US-only, which doesn't affect you. If you ever want richer data (snow depth, freezing rain probability, historical) you could supplement with a keyed service like OpenWeatherMap, but NWS alone is plenty to start.
This is the interesting part, and worth getting right so you're not just burning electricity all winter. Ice dams form from a melt-refreeze cycle: snow on the roof melts (from attic heat or sun), water runs to the cold eaves, and refreezes. So the danger zone isn't "very cold" — it's near and just below freezing, with snow present.
A reasonable first heuristic: - Turn ON when: there is snow on the roof (recent snowfall or snow on ground) AND temperature is in roughly the 15°F–38°F band (cold enough to refreeze at the eaves, warm enough for melt to occur). - Turn OFF when: well above freezing for a sustained period (snow sheds on its own) or no snowpack present. - Add hysteresis / minimum on-time (e.g., don't flip states more than every 30–60 min) so a temperature hovering at the threshold doesn't rapid-cycle the relays.
You can refine over your first season — some people simply run cables whenever there's snowpack and temps are below ~40°F, accepting some extra energy use for safety margin. Because the KP400 has two outlets, you could even zone it (e.g., north-facing eaves vs. south).
| Approach | What it looks like | Best if… |
|---|---|---|
| Standalone script + scheduler | A Python script (python-kasa + NWS) run every 15–30 min by cron/systemd timer on a Raspberry Pi or always-on machine at home |
You want the simplest thing that works and full control over the logic |
| Home Assistant | Install HA on a Pi; it has built-in Kasa + weather integrations; express the logic as an automation | You might want a dashboard, history graphs, manual override, and other home automation later |
| Cloud-run script | Script on a VPS using the cloud API | Only if you can't run anything on your home network |
For a focused, single-purpose system like this, I'd lean toward the standalone script on a small always-on device at home — it's the least moving parts, and a Raspberry Pi (or any machine that's always on) sitting on your LAN can reach the switches directly. Home Assistant is the better pick if you anticipate this being the first of several automations.
Important safety layer regardless of approach: the automation should fail safe. Think about what happens if the controller crashes, the network drops, or the NWS API is down during a storm — you likely want a "when in doubt during winter, turn them ON" bias, plus the ability to manually override via the Kasa app (which keeps working independently). The KP400's own schedule/manual control remains as a fallback.
A sensible path forward:
1. Confirm you can reach a KP400 locally — install python-kasa and run kasa discover on your home network to see the switches and their two outlets.
2. Pull a live forecast from api.weather.gov for your coordinates.
3. Write the decision logic + a scheduler, starting conservative on the "on" conditions.
If you'd like, I can help you build this — e.g., start by scaffolding a small Python project that discovers your KP400s and pulls your local NWS forecast, so we can verify both halves work before writing the icing logic. Want me to go ahead and set that up?
Sources: python-kasa · python-kasa docs · KP400 product page · NWS API docs · NWS API FAQ · tplink-cloud-api
ls -la && echo "---python---" && (python3 --version; which python3) && echo "---pip---" && (python3 -m pip --version 2>&1 | head -1)
total 20 drwxr-xr-x 4 david david 4096 Jul 10 17:15 . drwxr-xr-x 10 david david 4096 Jul 10 17:10 .. drwxr-xr-x 2 david david 4096 Jul 10 17:15 .claude drwxr-xr-x 8 david david 4096 Jul 10 17:10 .git -rw-r--r-- 1 david david 16 Jul 10 17:10 README.md ---python--- Python 3.12.3 /usr/bin/python3 ---pip--- /usr/bin/python3: No module named pip
cat README.md; echo "---venv---"; python3 -m venv --help >/dev/null 2>&1 && echo "venv OK" || echo "venv MISSING"; echo "---git---"; git log --oneline -5
Empty repo, Python 3.12, venv available. I'll build a proper installable package. Here's the structure I'm creating:
heatwires/ — package with weather (NWS), switches (python-kasa), decision logic, state, controller, and a CLIconfig.example.yaml — all tunables (location, switch IPs, icing thresholds, fail-safe)pyproject.toml, .gitignore, updated README.mdLet me write it all.
[project]
name = "heatwires"
version = "0.1.0"
description = "Automated weather-based control of roof heat-cable switches (TP-Link Kasa KP400)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"python-kasa>=0.7,<0.12",
"requests>=2.28",
"PyYAML>=6.0",
]
[project.scripts]
heatwires = "heatwires.cli:main"
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["heatwires"]
File created successfully at: /home/david/projects/play/heatwires-opus/pyproject.toml (file state is current in your context — no need to Read it back)
# Local config and runtime state (never commit these) config.yaml state.json # Python __pycache__/ *.py[cod] *.egg-info/ .eggs/ build/ dist/ .venv/ venv/
File created successfully at: /home/david/projects/play/heatwires-opus/.gitignore (file state is current in your context — no need to Read it back)
# Copy this to config.yaml and edit for your site: cp config.example.yaml config.yaml
# --- Location -----------------------------------------------------------------
# Decimal degrees for your roof. (Default below is Bozeman, MT.)
location:
latitude: 45.6770
longitude: -111.0429
# --- Weather (US National Weather Service, api.weather.gov) --------------------
weather:
# NWS requires a User-Agent identifying you. An email is ideal.
user_agent_contact: "you@example.com"
# How long to cache the NWS gridpoint lookup (it never really changes).
points_cache_seconds: 86400
# --- Switches -----------------------------------------------------------------
# Your Kasa KP400 switch(es). Give each a reserved/static IP in your router so
# the address never changes. Run `heatwires -c config.yaml discover` to find them.
switches:
- host: "192.168.1.50"
outlets: all # "all", or a list of outlet indexes, e.g. [0] or [0, 1]
# - host: "192.168.1.51"
# outlets: [0]
# --- Icing logic --------------------------------------------------------------
# Ice dams form in a melt/refreeze window near freezing WITH snow present.
icing:
temp_on_low_f: 15.0 # below this: too cold to melt/refreeze — cables OFF
temp_on_high_f: 38.0 # above this: snow sheds on its own — cables OFF
hysteresis_f: 3.0 # once ON, widen the band by this much to avoid flapping
require_snow: true # only run when frozen precip is present/forecast
min_switch_minutes: 30 # minimum dwell time between state changes
# --- Fail-safe ----------------------------------------------------------------
# If the weather can't be fetched, bias toward safety during winter months.
fail_safe:
enabled: true
action: "on" # "on" (safe) or "off"
months: [11, 12, 1, 2, 3, 4] # only fail-safe during these months
# --- Misc ---------------------------------------------------------------------
# Relative paths are resolved next to this config file.
state_file: "state.json"
logging:
level: "INFO"
File created successfully at: /home/david/projects/play/heatwires-opus/config.example.yaml (file state is current in your context — no need to Read it back)
"""Automated weather-based control of roof heat-cable switches.""" __version__ = "0.1.0"
File created successfully at: /home/david/projects/play/heatwires-opus/heatwires/__init__.py (file state is current in your context — no need to Read it back)
"""Load and validate the YAML configuration."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Union
import yaml
@dataclass
class Location:
latitude: float
longitude: float
@dataclass
class WeatherConfig:
user_agent_contact: str
points_cache_seconds: int = 86400
@dataclass
class SwitchConfig:
host: str
outlets: Union[str, List[int]] = "all" # "all" or a list of outlet indexes
@dataclass
class IcingConfig:
temp_on_low_f: float = 15.0
temp_on_high_f: float = 38.0
hysteresis_f: float = 3.0
require_snow: bool = True
min_switch_minutes: int = 30
@dataclass
class FailSafeConfig:
enabled: bool = True
action: str = "on" # "on" or "off"
months: List[int] = field(default_factory=lambda: [11, 12, 1, 2, 3, 4])
@dataclass
class Config:
location: Location
weather: WeatherConfig
switches: List[SwitchConfig]
icing: IcingConfig
fail_safe: FailSafeConfig
state_file: Path
log_level: str
path: Path
class ConfigError(Exception):
"""Raised when the configuration is missing or invalid."""
def _require(mapping: dict, key: str, where: str):
if key not in mapping:
raise ConfigError(f"missing required '{key}' under {where}")
return mapping[key]
def _parse_outlets(value) -> Union[str, List[int]]:
if value in (None, "all"):
return "all"
if isinstance(value, int):
return [value]
if isinstance(value, list) and all(isinstance(i, int) for i in value):
return list(value)
raise ConfigError(f"'outlets' must be 'all' or a list of integers, got {value!r}")
def load_config(path: Union[str, Path]) -> Config:
path = Path(path).expanduser().resolve()
if not path.exists():
raise ConfigError(
f"config file not found: {path}\n"
"Create one with: cp config.example.yaml config.yaml"
)
raw = yaml.safe_load(path.read_text()) or {}
base = path.parent
loc = _require(raw, "location", "top level")
location = Location(
latitude=float(_require(loc, "latitude", "location")),
longitude=float(_require(loc, "longitude", "location")),
)
wx = raw.get("weather", {}) or {}
weather = WeatherConfig(
user_agent_contact=str(
_require(wx, "user_agent_contact", "weather")
),
points_cache_seconds=int(wx.get("points_cache_seconds", 86400)),
)
raw_switches = _require(raw, "switches", "top level")
if not isinstance(raw_switches, list) or not raw_switches:
raise ConfigError("'switches' must be a non-empty list")
switches = [
SwitchConfig(
host=str(_require(s, "host", "switches[]")),
outlets=_parse_outlets(s.get("outlets", "all")),
)
for s in raw_switches
]
ic = raw.get("icing", {}) or {}
icing = IcingConfig(
temp_on_low_f=float(ic.get("temp_on_low_f", 15.0)),
temp_on_high_f=float(ic.get("temp_on_high_f", 38.0)),
hysteresis_f=float(ic.get("hysteresis_f", 3.0)),
require_snow=bool(ic.get("require_snow", True)),
min_switch_minutes=int(ic.get("min_switch_minutes", 30)),
)
if icing.temp_on_low_f >= icing.temp_on_high_f:
raise ConfigError("icing.temp_on_low_f must be below icing.temp_on_high_f")
fs = raw.get("fail_safe", {}) or {}
fail_safe = FailSafeConfig(
enabled=bool(fs.get("enabled", True)),
action=str(fs.get("action", "on")).lower(),
months=list(fs.get("months", [11, 12, 1, 2, 3, 4])),
)
if fail_safe.action not in ("on", "off"):
raise ConfigError("fail_safe.action must be 'on' or 'off'")
state_file = Path(raw.get("state_file", "state.json"))
if not state_file.is_absolute():
state_file = (base / state_file).resolve()
log_level = str((raw.get("logging", {}) or {}).get("level", "INFO")).upper()
return Config(
location=location,
weather=weather,
switches=switches,
icing=icing,
fail_safe=fail_safe,
state_file=state_file,
log_level=log_level,
path=path,
)
File created successfully at: /home/david/projects/play/heatwires-opus/heatwires/config.py (file state is current in your context — no need to Read it back)
"""Fetch current/near-term conditions from the US National Weather Service.
api.weather.gov is free, keyless, US-only, and requires a descriptive
User-Agent header. Flow:
/points/{lat},{lon} -> forecastHourly + observationStations URLs
observationStations -> nearest station -> observations/latest
forecastHourly -> next hour's forecast (fallback + snow signal)
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Optional
import requests
log = logging.getLogger("heatwires.weather")
NWS_BASE = "https://api.weather.gov"
# Words in a conditions/forecast string that indicate frozen precipitation.
SNOW_WORDS = (
"snow",
"sleet",
"ice",
"icy",
"wintry",
"flurr",
"freezing",
"blizzard",
)
@dataclass
class WeatherSnapshot:
temp_f: Optional[float]
snow_signal: bool
description: str
ok: bool
source: str = ""
def summary(self) -> str:
if not self.ok:
return f"UNAVAILABLE ({self.description})"
t = f"{self.temp_f:.0f}°F" if self.temp_f is not None else "?"
snow = "snow/ice" if self.snow_signal else "no snow signal"
return f"{t}, {snow} — {self.description or 'n/a'} [{self.source}]"
def _c_to_f(celsius: float) -> float:
return celsius * 9.0 / 5.0 + 32.0
class WeatherClient:
def __init__(
self,
latitude: float,
longitude: float,
user_agent_contact: str,
points_cache_seconds: int = 86400,
):
self.lat = latitude
self.lon = longitude
self.session = requests.Session()
self.session.headers.update(
{
"User-Agent": f"heatwires/0.1 ({user_agent_contact})",
"Accept": "application/geo+json",
}
)
self._points_cache_seconds = points_cache_seconds
self._hourly_url: Optional[str] = None
self._stations_url: Optional[str] = None
self._points_at = 0.0
def _get(self, url: str, tries: int = 3) -> dict:
last_exc: Optional[Exception] = None
for attempt in range(1, tries + 1):
try:
resp = self.session.get(url, timeout=15)
resp.raise_for_status()
return resp.json()
except Exception as exc: # noqa: BLE001 - retry on any transient failure
last_exc = exc
log.debug("GET %s failed (attempt %d/%d): %s", url, attempt, tries, exc)
if attempt < tries:
time.sleep(1.5 * attempt)
assert last_exc is not None
raise last_exc
def _ensure_points(self) -> None:
fresh = self._hourly_url and (
time.time() - self._points_at
) < self._points_cache_seconds
if fresh:
return
data = self._get(f"{NWS_BASE}/points/{self.lat},{self.lon}")
props = data["properties"]
self._hourly_url = props["forecastHourly"]
self._stations_url = props["observationStations"]
self._points_at = time.time()
def _latest_observation(self) -> dict:
stations = self._get(self._stations_url)
features = stations.get("features") or []
if not features:
raise RuntimeError("no observation stations for this location")
station_url = features[0]["id"] # e.g. .../stations/KBZN
return self._get(f"{station_url}/observations/latest")["properties"]
def fetch(self) -> WeatherSnapshot:
"""Return a snapshot; never raises — failures come back as ok=False."""
try:
self._ensure_points()
temp_f: Optional[float] = None
obs_desc = ""
source = "observation"
# Current observation is the most authoritative temperature.
try:
obs = self._latest_observation()
celsius = (obs.get("temperature") or {}).get("value")
if celsius is not None:
temp_f = _c_to_f(celsius)
obs_desc = obs.get("textDescription") or ""
except Exception as exc: # noqa: BLE001 - fall back to forecast
log.debug("observation fetch failed, using forecast: %s", exc)
source = ""
# Hourly forecast: fills in temp if observation lacked it, and
# contributes to the snow/ice signal.
hourly = self._get(self._hourly_url)
period = hourly["properties"]["periods"][0]
if temp_f is None:
t = period.get("temperature")
if t is not None:
temp_f = (
_c_to_f(float(t))
if period.get("temperatureUnit") == "C"
else float(t)
)
source = "forecast"
fc_desc = period.get("shortForecast", "") or ""
blob = f"{obs_desc} {fc_desc}".lower()
snow = any(word in blob for word in SNOW_WORDS)
return WeatherSnapshot(
temp_f=temp_f,
snow_signal=snow,
description=(obs_desc or fc_desc).strip(),
ok=temp_f is not None,
source=source or "forecast",
)
except Exception as exc: # noqa: BLE001 - report as unavailable
log.warning("weather fetch failed: %s", exc)
return WeatherSnapshot(
temp_f=None,
snow_signal=False,
description=f"fetch failed: {exc}",
ok=False,
)
File created successfully at: /home/david/projects/play/heatwires-opus/heatwires/weather.py (file state is current in your context — no need to Read it back)
"""The icing decision: given weather, should the cables be on?"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from .config import IcingConfig
from .weather import WeatherSnapshot
@dataclass
class Decision:
want_on: bool
reason: str
def decide(
snapshot: WeatherSnapshot,
icing: IcingConfig,
currently_on: bool,
) -> Optional[Decision]:
"""Return the desired state, or None if weather is unavailable.
Ice dams form in a melt/refreeze window near freezing with snow present,
so the cables run when the temperature is inside [low, high] AND (if
required) frozen precipitation is present. Once on, the band is widened by
``hysteresis_f`` on each side to avoid rapid cycling around a threshold.
"""
if snapshot.temp_f is None:
return None
margin = icing.hysteresis_f if currently_on else 0.0
low = icing.temp_on_low_f - margin
high = icing.temp_on_high_f + margin
temp = snapshot.temp_f
in_band = low <= temp <= high
snow_ok = snapshot.snow_signal or not icing.require_snow
want_on = in_band and snow_ok
if want_on:
snow_note = (
"with snow/ice signal"
if snapshot.snow_signal
else "(snow not required)"
)
reason = f"{temp:.0f}°F within [{low:.0f}, {high:.0f}]°F icing band {snow_note}"
elif not in_band:
if temp < low:
reason = f"{temp:.0f}°F below {low:.0f}°F — too cold for melt/refreeze"
else:
reason = f"{temp:.0f}°F above {high:.0f}°F — snow sheds naturally"
else:
reason = f"{temp:.0f}°F in band but no snow/ice signal"
return Decision(want_on=want_on, reason=reason)
File created successfully at: /home/david/projects/play/heatwires-opus/heatwires/logic.py (file state is current in your context — no need to Read it back)
"""Tiny JSON persistence for run-to-run state (last known switch state, timing)."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
log = logging.getLogger("heatwires.state")
def load_state(path: Path) -> dict:
try:
return json.loads(Path(path).read_text())
except FileNotFoundError:
return {}
except Exception as exc: # noqa: BLE001 - corrupt state shouldn't crash a run
log.warning("could not read state %s (%s); starting fresh", path, exc)
return {}
def save_state(path: Path, state: dict) -> None:
Path(path).write_text(json.dumps(state, indent=2, sort_keys=True))
def parse_ts(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
return None
File created successfully at: /home/david/projects/play/heatwires-opus/heatwires/state.py (file state is current in your context — no need to Read it back)
"""Control TP-Link Kasa switches (e.g. KP400) directly over the LAN.
Uses python-kasa's discovery handshake so we don't need to hard-code the
device class. The KP400 exposes its two outlets as ``children``; single-outlet
plugs have no children and are controlled as a whole.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import List, Optional, Sequence
from kasa import Discover
from .config import SwitchConfig
log = logging.getLogger("heatwires.switches")
@dataclass
class OutletState:
index: int
alias: str
is_on: bool
@dataclass
class SwitchState:
host: str
ok: bool
alias: str = ""
model: str = ""
error: str = ""
outlets: List[OutletState] = field(default_factory=list)
def summary(self) -> str:
if not self.ok:
return f"{self.host}: ERROR ({self.error})"
parts = [
f"[{o.index}] {o.alias}={'ON' if o.is_on else 'off'}" for o in self.outlets
]
label = self.alias or self.model or "device"
return f"{self.host} ({label}): " + ", ".join(parts)
def _selected(children: Sequence, outlets) -> List:
if not children:
return []
if outlets == "all":
return list(children)
picked = []
for i in outlets:
if 0 <= i < len(children):
picked.append(children[i])
else:
log.warning("outlet index %d out of range (device has %d)", i, len(children))
return picked
async def _read_device(dev) -> SwitchState:
children = getattr(dev, "children", None) or []
if children:
outlets = [
OutletState(index=i, alias=c.alias or f"outlet{i}", is_on=bool(c.is_on))
for i, c in enumerate(children)
]
else:
outlets = [OutletState(index=0, alias=dev.alias or "outlet", is_on=bool(dev.is_on))]
return SwitchState(
host=dev.host,
ok=True,
alias=dev.alias or "",
model=getattr(dev, "model", "") or "",
outlets=outlets,
)
async def _apply_one(sw: SwitchConfig, turn_on: Optional[bool]) -> SwitchState:
"""Set (or, if turn_on is None, just read) a single switch."""
try:
dev = await Discover.discover_single(sw.host)
await dev.update()
if turn_on is not None:
children = getattr(dev, "children", None) or []
targets = _selected(children, sw.outlets) if children else [dev]
for target in targets:
if turn_on:
await target.turn_on()
else:
await target.turn_off()
await dev.update()
return await _read_device(dev)
except Exception as exc: # noqa: BLE001 - surface per-switch, don't abort others
log.error("switch %s failed: %s", sw.host, exc)
return SwitchState(host=sw.host, ok=False, error=str(exc))
async def _apply_all(
switches: Sequence[SwitchConfig], turn_on: Optional[bool]
) -> List[SwitchState]:
return await asyncio.gather(*(_apply_one(s, turn_on) for s in switches))
def set_switches(
switches: Sequence[SwitchConfig], turn_on: bool
) -> List[SwitchState]:
"""Turn all configured switches/outlets on or off. Returns resulting state."""
return asyncio.run(_apply_all(switches, turn_on))
def read_switches(switches: Sequence[SwitchConfig]) -> List[SwitchState]:
"""Read current state without changing anything."""
return asyncio.run(_apply_all(switches, None))
def discover(timeout: int = 8) -> List[SwitchState]:
"""Discover all Kasa devices on the LAN."""
async def _run() -> List[SwitchState]:
found = await Discover.discover(discovery_timeout=timeout)
states = []
for dev in found.values():
try:
await dev.update()
states.append(await _read_device(dev))
except Exception as exc: # noqa: BLE001
states.append(SwitchState(host=dev.host, ok=False, error=str(exc)))
return states
return asyncio.run(_run())
File created successfully at: /home/david/projects/play/heatwires-opus/heatwires/switches.py (file state is current in your context — no need to Read it back)
"""Orchestrate one control cycle: weather -> decision -> switches -> state."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List, Optional
from .config import Config
from .logic import decide
from .state import load_state, parse_ts, save_state
from .switches import SwitchState, read_switches, set_switches
from .weather import WeatherClient, WeatherSnapshot
log = logging.getLogger("heatwires.controller")
@dataclass
class CycleResult:
snapshot: WeatherSnapshot
want_on: bool
changed: bool
reason: str
applied: bool
switch_states: List[SwitchState] = field(default_factory=list)
def _failsafe_target(config: Config, now: datetime) -> Optional[bool]:
"""Desired state when weather is unavailable, or None to hold current."""
fs = config.fail_safe
if not fs.enabled or now.month not in fs.months:
return None
return fs.action == "on"
def run_cycle(config: Config, apply: bool = True) -> CycleResult:
now = datetime.now(timezone.utc)
client = WeatherClient(
latitude=config.location.latitude,
longitude=config.location.longitude,
user_agent_contact=config.weather.user_agent_contact,
points_cache_seconds=config.weather.points_cache_seconds,
)
snapshot = client.fetch()
state = load_state(config.state_file)
currently_on = state.get("state") == "on"
decision = decide(snapshot, config.icing, currently_on)
forced = False
if decision is None:
target = _failsafe_target(config, now)
if target is None:
want_on = currently_on
reason = "weather unavailable; holding current state (fail-safe inactive)"
else:
want_on = target
forced = True
reason = f"weather unavailable; fail-safe -> {'ON' if want_on else 'OFF'}"
else:
want_on = decision.want_on
reason = decision.reason
# Enforce minimum dwell time between changes (skipped when fail-safe forces).
changed = want_on != currently_on
if changed and not forced:
last_change = parse_ts(state.get("last_change"))
if last_change is not None:
elapsed_min = (now - last_change).total_seconds() / 60.0
if elapsed_min < config.icing.min_switch_minutes:
want_on = currently_on
changed = False
reason += (
f" [held {elapsed_min:.0f}m < {config.icing.min_switch_minutes}m "
"min dwell]"
)
switch_states: List[SwitchState] = []
if apply:
switch_states = set_switches(config.switches, want_on)
unreachable = [s.host for s in switch_states if not s.ok]
if unreachable:
log.error("switches unreachable: %s", ", ".join(unreachable))
else:
switch_states = read_switches(config.switches)
new_state = dict(state)
new_state["state"] = "on" if want_on else "off"
new_state["last_run"] = now.isoformat()
new_state["last_reason"] = reason
new_state["last_temp_f"] = snapshot.temp_f
if changed or "last_change" not in new_state:
new_state["last_change"] = now.isoformat()
if apply:
save_state(config.state_file, new_state)
log.info(
"weather: %s | decision: %s -> %s%s",
snapshot.summary(),
"ON" if want_on else "OFF",
"changed" if changed else "unchanged",
f" | {reason}",
)
return CycleResult(
snapshot=snapshot,
want_on=want_on,
changed=changed,
reason=reason,
applied=apply,
switch_states=switch_states,
)
File created successfully at: /home/david/projects/play/heatwires-opus/heatwires/controller.py (file state is current in your context — no need to Read it back)
"""Command-line entry point.
heatwires -c config.yaml discover # find Kasa devices on the LAN
heatwires -c config.yaml status # show weather + switches + decision (no change)
heatwires -c config.yaml run # one control cycle (for cron)
heatwires -c config.yaml run --dry-run # decide + read switches, but don't change them
heatwires -c config.yaml loop -i 900 # run forever, every 900s (for systemd)
"""
from __future__ import annotations
import argparse
import logging
import sys
import time
from .config import ConfigError, load_config
from .controller import run_cycle
from .logic import decide
from .switches import discover as discover_switches
from .switches import read_switches
from .weather import WeatherClient
def _setup_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level, logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
def _cmd_discover(args) -> int:
print(f"Discovering Kasa devices (timeout {args.timeout}s)...")
devices = discover_switches(timeout=args.timeout)
if not devices:
print("No devices found. Ensure this machine is on the same LAN/subnet.")
return 1
for dev in devices:
print(" " + dev.summary())
return 0
def _cmd_status(args) -> int:
config = load_config(args.config)
client = WeatherClient(
latitude=config.location.latitude,
longitude=config.location.longitude,
user_agent_contact=config.weather.user_agent_contact,
points_cache_seconds=config.weather.points_cache_seconds,
)
snapshot = client.fetch()
print(f"Weather: {snapshot.summary()}")
states = read_switches(config.switches)
print("Switches:")
for st in states:
print(" " + st.summary())
any_on = any(o.is_on for st in states if st.ok for o in st.outlets)
decision = decide(snapshot, config.icing, currently_on=any_on)
if decision is None:
print("Decision: weather unavailable")
else:
print(
f"Decision: cables should be "
f"{'ON' if decision.want_on else 'OFF'} — {decision.reason}"
)
return 0
def _cmd_run(args) -> int:
config = load_config(args.config)
result = run_cycle(config, apply=not args.dry_run)
verb = "would be" if args.dry_run else "set"
print(
f"Cables {verb} {'ON' if result.want_on else 'OFF'} "
f"({'changed' if result.changed else 'unchanged'}) — {result.reason}"
)
for st in result.switch_states:
print(" " + st.summary())
# Non-zero exit if any switch was unreachable, so cron/systemd can alert.
return 0 if all(st.ok for st in result.switch_states) else 2
def _cmd_loop(args) -> int:
config = load_config(args.config)
log = logging.getLogger("heatwires.loop")
log.info("starting loop, interval %ds", args.interval)
while True:
try:
run_cycle(config, apply=True)
except Exception as exc: # noqa: BLE001 - keep the loop alive
log.exception("cycle failed: %s", exc)
try:
time.sleep(args.interval)
except KeyboardInterrupt:
log.info("interrupted, exiting")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="heatwires",
description="Weather-based control of roof heat-cable switches.",
)
parser.add_argument(
"-c",
"--config",
default="config.yaml",
help="path to config file (default: config.yaml)",
)
sub = parser.add_subparsers(dest="command", required=True)
p_disc = sub.add_parser("discover", help="find Kasa devices on the LAN")
p_disc.add_argument("-t", "--timeout", type=int, default=8)
p_disc.set_defaults(func=_cmd_discover)
p_status = sub.add_parser(
"status", help="show weather, switch states, and the decision (no change)"
)
p_status.set_defaults(func=_cmd_status)
p_run = sub.add_parser("run", help="run one control cycle (for cron)")
p_run.add_argument(
"--dry-run",
action="store_true",
help="decide and read switches but do not change them",
)
p_run.set_defaults(func=_cmd_run)
p_loop = sub.add_parser("loop", help="run continuously (for systemd)")
p_loop.add_argument(
"-i", "--interval", type=int, default=900, help="seconds between cycles"
)
p_loop.set_defaults(func=_cmd_loop)
return parser
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
# `discover` doesn't need a config; everything else does.
if args.command != "discover":
_setup_logging(load_config(args.config).log_level)
else:
_setup_logging("INFO")
return args.func(args)
except ConfigError as exc:
print(f"Config error: {exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
raise SystemExit(main())
File created successfully at: /home/david/projects/play/heatwires-opus/heatwires/cli.py (file state is current in your context — no need to Read it back)
python3 -m venv .venv && .venv/bin/python -m ensurepip -U >/dev/null 2>&1; .venv/bin/python -m pip install --quiet --upgrade pip 2>&1 | tail -2; echo "=== installing project ==="; .venv/bin/python -m pip install --quiet -e . 2>&1 | tail -25; echo "EXIT=$?"
Installed cleanly. Let me verify the installed python-kasa API matches what I used, and that the CLI loads.
cd /home/david/projects/play/heatwires-opus
.venv/bin/python -c "import kasa, requests, yaml; print('python-kasa', kasa.__version__)"
echo "=== Discover API surface ==="
.venv/bin/python -c "from kasa import Discover; print('discover_single' , hasattr(Discover,'discover_single')); print('discover', hasattr(Discover,'discover'))"
echo "=== CLI help ==="
.venv/bin/heatwires --help 2>&1 | head -20python-kasa 0.10.2
=== Discover API surface ===
discover_single True
discover True
=== CLI help ===
usage: heatwires [-h] [-c CONFIG] {discover,status,run,loop} ...
Weather-based control of roof heat-cable switches.
positional arguments:
{discover,status,run,loop}
discover find Kasa devices on the LAN
status show weather, switch states, and the decision (no
change)
run run one control cycle (for cron)
loop run continuously (for systemd)
options:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
path to config file (default: config.yaml)Let me verify the exact kasa API signatures I depend on (child control, discovery kwargs) against 0.10.2.
cd /home/david/projects/play/heatwires-opus
.venv/bin/python -c "
import inspect
from kasa import Discover, Device
print('discover_single:', inspect.signature(Discover.discover_single))
print('discover:', inspect.signature(Discover.discover))
print('Device.turn_on coroutine:', inspect.iscoroutinefunction(Device.turn_on))
print('Device has children attr:', 'children' in dir(Device))
print('Device has alias:', 'alias' in dir(Device))
print('Device has model:', 'model' in dir(Device))
"discover_single: (host: 'str', *, discovery_timeout: 'int' = 5, port: 'int | None' = None, timeout: 'int | None' = None, credentials: 'Credentials | None' = None, username: 'str | None' = None, password: 'str | None' = None, on_discovered_raw: 'OnDiscoveredRawCallable | None' = None, on_unsupported: 'OnUnsupportedCallable | None' = None) -> 'Device | None' discover: (*, target: 'str' = '255.255.255.255', on_discovered: 'OnDiscoveredCallable | None' = None, on_discovered_raw: 'OnDiscoveredRawCallable | None' = None, discovery_timeout: 'int' = 5, discovery_packets: 'int' = 3, interface: 'str | None' = None, on_unsupported: 'OnUnsupportedCallable | None' = None, credentials: 'Credentials | None' = None, username: 'str | None' = None, password: 'str | None' = None, port: 'int | None' = None, timeout: 'int | None' = None) -> 'DeviceDict' Device.turn_on coroutine: True Device has children attr: True Device has alias: True Device has model: True
All APIs match. One gap: discover_single can return None (device not found) — my code would then hit an ugly NoneType error. Let me handle that explicitly.
try:
dev = await Discover.discover_single(sw.host)
await dev.update() try:
dev = await Discover.discover_single(sw.host)
if dev is None:
raise RuntimeError("no Kasa device answered at this host")
await dev.update()The file /home/david/projects/play/heatwires-opus/heatwires/switches.py has been updated successfully. (file state is current in your context — no need to Read it back)
Now let me write tests for the two parts I can verify without hardware — the decision logic and the NWS parsing (with a mocked HTTP session) — and run them.
"""Offline tests for the icing decision logic."""
from heatwires.config import IcingConfig
from heatwires.logic import decide
from heatwires.weather import WeatherSnapshot
def snap(temp_f, snow=True):
return WeatherSnapshot(
temp_f=temp_f, snow_signal=snow, description="test", ok=temp_f is not None
)
ICING = IcingConfig(
temp_on_low_f=15.0,
temp_on_high_f=38.0,
hysteresis_f=3.0,
require_snow=True,
min_switch_minutes=30,
)
def test_on_inside_band_with_snow():
d = decide(snap(30, snow=True), ICING, currently_on=False)
assert d.want_on is True
def test_off_too_warm():
d = decide(snap(45, snow=True), ICING, currently_on=False)
assert d.want_on is False
assert "sheds" in d.reason
def test_off_too_cold():
d = decide(snap(5, snow=True), ICING, currently_on=False)
assert d.want_on is False
assert "too cold" in d.reason
def test_off_in_band_but_no_snow_when_required():
d = decide(snap(30, snow=False), ICING, currently_on=False)
assert d.want_on is False
assert "no snow" in d.reason
def test_on_in_band_no_snow_when_not_required():
cfg = IcingConfig(**{**ICING.__dict__, "require_snow": False})
d = decide(snap(30, snow=False), cfg, currently_on=False)
assert d.want_on is True
def test_hysteresis_keeps_on_just_above_high():
# 40°F is above 38 but within the widened band (38+3=41) once already on.
off_state = decide(snap(40, snow=True), ICING, currently_on=False)
on_state = decide(snap(40, snow=True), ICING, currently_on=True)
assert off_state.want_on is False
assert on_state.want_on is True
def test_hysteresis_keeps_on_just_below_low():
# 13°F is below 15 but within the widened band (15-3=12) once already on.
assert decide(snap(13, snow=True), ICING, currently_on=False).want_on is False
assert decide(snap(13, snow=True), ICING, currently_on=True).want_on is True
def test_none_when_weather_unavailable():
assert decide(snap(None), ICING, currently_on=False) is None
File created successfully at: /home/david/projects/play/heatwires-opus/tests/test_logic.py (file state is current in your context — no need to Read it back)
"""Offline tests for NWS response parsing, using a fake HTTP session."""
from heatwires.weather import WeatherClient
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
pass
def json(self):
return self._payload
class FakeSession:
"""Routes GET calls to canned payloads keyed by URL substring."""
def __init__(self, routes, fail_on=None):
self.headers = {}
self._routes = routes
self._fail_on = fail_on or ()
def get(self, url, timeout=None):
for needle in self._fail_on:
if needle in url:
raise RuntimeError(f"boom on {needle}")
for needle, payload in self._routes.items():
if needle in url:
return FakeResponse(payload)
raise AssertionError(f"unexpected URL: {url}")
POINTS = {
"properties": {
"forecastHourly": "https://api.weather.gov/gridpoints/X/1,1/forecast/hourly",
"observationStations": "https://api.weather.gov/gridpoints/X/1,1/stations",
}
}
STATIONS = {"features": [{"id": "https://api.weather.gov/stations/KBZN"}]}
def make_client(routes, fail_on=None):
client = WeatherClient(45.0, -111.0, "test@example.com")
client.session = FakeSession(routes, fail_on=fail_on)
return client
def test_uses_observation_temperature_and_detects_snow():
routes = {
"/points/": POINTS,
"/stations": STATIONS,
"observations/latest": {
"properties": {
"temperature": {"value": 0.0}, # 0°C -> 32°F
"textDescription": "Light Snow",
}
},
"forecast/hourly": {
"properties": {"periods": [{"temperature": 33, "shortForecast": "Snow"}]}
},
}
snap = make_client(routes).fetch()
assert snap.ok is True
assert round(snap.temp_f) == 32
assert snap.snow_signal is True
assert snap.source == "observation"
def test_falls_back_to_forecast_when_observation_fails():
routes = {
"/points/": POINTS,
"/stations": STATIONS,
"forecast/hourly": {
"properties": {
"periods": [{"temperature": 28, "shortForecast": "Partly Cloudy"}]
}
},
}
snap = make_client(routes, fail_on=("observations/latest",)).fetch()
assert snap.ok is True
assert round(snap.temp_f) == 28
assert snap.snow_signal is False
assert snap.source == "forecast"
def test_reports_unavailable_when_points_fails():
snap = make_client({}, fail_on=("/points/",)).fetch()
assert snap.ok is False
assert snap.temp_f is None
def test_celsius_forecast_is_converted():
routes = {
"/points/": POINTS,
"/stations": STATIONS,
"forecast/hourly": {
"properties": {
"periods": [
{"temperature": 0, "temperatureUnit": "C", "shortForecast": "Ice"}
]
}
},
}
snap = make_client(routes, fail_on=("observations/latest",)).fetch()
assert round(snap.temp_f) == 32
assert snap.snow_signal is True # "Ice" matches
File created successfully at: /home/david/projects/play/heatwires-opus/tests/test_weather.py (file state is current in your context — no need to Read it back)
cd /home/david/projects/play/heatwires-opus .venv/bin/python -m pip install --quiet pytest 2>&1 | tail -2 .venv/bin/python -m pytest -q tests/ 2>&1 | tail -25
def test_uses_observation_temperature_and_detects_snow():
routes = {
"/points/": POINTS,
"/stations": STATIONS,
"observations/latest": {
"properties": {
"temperature": {"value": 0.0}, # 0°C -> 32°F
"textDescription": "Light Snow",
}
},
"forecast/hourly": {
"properties": {"periods": [{"temperature": 33, "shortForecast": "Snow"}]}
},
}
snap = make_client(routes).fetch()
assert snap.ok is True
> assert round(snap.temp_f) == 32
E AssertionError: assert 33 == 32
E + where 33 = round(33.0)
E + where 33.0 = WeatherSnapshot(temp_f=33.0, snow_signal=True, description='Snow', ok=True, source='forecast').temp_f
tests/test_weather.py:66: AssertionError
=========================== short test summary info ============================
FAILED tests/test_weather.py::test_uses_observation_temperature_and_detects_snow
1 failed, 11 passed in 13.57sThe failure is in my test fake, not the code: the routing matched /stations inside the longer .../stations/KBZN/observations/latest URL. I'll make the fake prefer the most-specific (longest) match.
for needle in self._fail_on:
if needle in url:
raise RuntimeError(f"boom on {needle}")
for needle, payload in self._routes.items():
if needle in url:
return FakeResponse(payload)
raise AssertionError(f"unexpected URL: {url}") for needle in self._fail_on:
if needle in url:
raise RuntimeError(f"boom on {needle}")
# Prefer the most-specific (longest) matching route so that, e.g.,
# ".../stations/KBZN/observations/latest" matches the observation
# route rather than the shorter "/stations" route.
matches = [(n, p) for n, p in self._routes.items() if n in url]
if matches:
return FakeResponse(max(matches, key=lambda kv: len(kv[0]))[1])
raise AssertionError(f"unexpected URL: {url}")The file /home/david/projects/play/heatwires-opus/tests/test_weather.py has been updated successfully. (file state is current in your context — no need to Read it back)
All 12 pass. Now a live end-to-end smoke test of the real NWS integration (the part unit tests can't cover). I'll make a real config.yaml and run status — weather should come back live; the switch at the placeholder IP should fail gracefully (it's not on this machine's LAN), which also verifies the error handling.
cd /home/david/projects/play/heatwires-opus cp config.example.yaml config.yaml # use the user's real contact for the NWS User-Agent sed -i 's/you@example.com/david@bozemanpass.com/' config.yaml echo "=== heatwires status (live NWS, placeholder switch) ===" .venv/bin/heatwires -c config.yaml status 2>&1 | grep -v "^2026"
=== heatwires status (live NWS, placeholder switch) === Weather: 88°F, no snow signal — Mostly Clear [observation] Switches: 192.168.1.50: ERROR (Timed out getting discovery response for 192.168.1.50) Decision: cables should be OFF — 88°F above 38°F — snow sheds naturally
Live NWS works (real Bozeman conditions), decision logic is correct, and the unreachable placeholder switch degrades gracefully instead of crashing. Let me confirm run --dry-run writes state correctly and the exit code signals the unreachable switch.
cd /home/david/projects/play/heatwires-opus echo "=== run --dry-run ===" .venv/bin/heatwires -c config.yaml run --dry-run 2>&1 | grep -v "^2026" echo "exit=$?" echo "=== state.json ===" cat state.json 2>/dev/null || echo "(dry-run wrote no state, as intended)"
=== run --dry-run === Cables would be OFF (unchanged) — 88°F above 38°F — snow sheds naturally 192.168.1.50: ERROR (Timed out getting discovery response for 192.168.1.50) exit=0 === state.json === (dry-run wrote no state, as intended)
Everything works end-to-end. (The exit=0 there is grep's, not heatwires — the run command returns exit 2 on an unreachable switch so cron/systemd can alert.) Now let me write a proper README.
# heatwires Automated, weather-based control of roof **heat cables** (de-icing / ice-dam prevention) via **TP-Link Kasa KP400** smart outdoor plugs. It queries the US National Weather Service for your location, decides whether icing conditions warrant running the cables, and switches your Kasa plugs **directly over the LAN** (no cloud account, no reverse-engineered web API). ## How it decides Ice dams form in a **melt/refreeze window near freezing with snow present**: snow melts, water runs to the cold eaves, and refreezes. So the cables run when: - the temperature is inside a configurable band (default **15–38 °F**), **and** - frozen precipitation is present/forecast (can be disabled). Extras for real-world robustness: - **Hysteresis** widens the band once the cables are on, to avoid flapping. - **Minimum dwell time** prevents rapid on/off cycling. - **Fail-safe**: if weather can't be fetched during winter months, bias to a safe state (default: ON). - **Graceful degradation**: an unreachable switch is logged and reported (and makes `run` exit non-zero) without aborting the rest of the cycle. ## Install Requires Python 3.10+. Run this on an always-on machine **on the same LAN** as the switches (e.g. a Raspberry Pi). ```bash python3 -m venv .venv source .venv/bin/activate pip install -e . ``` ## Configure ```bash cp config.example.yaml config.yaml ``` Then edit `config.yaml`: - `location` — your roof's latitude/longitude (decimal degrees). - `weather.user_agent_contact` — an email; NWS requires an identifying User-Agent. - `switches` — the IP(s) of your KP400(s) and which `outlets` to control. - `icing` — the temperature band and behavior tunables. Give each KP400 a **reserved/static IP** in your router so its address never changes. Find the switches on your network with: ```bash heatwires discover ``` `config.yaml` and `state.json` are git-ignored (they're host-specific). ## Use ```bash heatwires -c config.yaml discover # list Kasa devices on the LAN heatwires -c config.yaml status # show weather + switch state + decision (no change) heatwires -c config.yaml run # run one control cycle (turns cables on/off) heatwires -c config.yaml run --dry-run # decide + read switches, but don't change them heatwires -c config.yaml loop -i 900 # run continuously, every 900s ``` ## Deploy **Option A — cron** (runs one cycle every 15 minutes): ```cron */15 * * * * /path/to/.venv/bin/heatwires -c /path/to/config.yaml run >> /path/to/heatwires.log 2>&1 ``` **Option B — systemd service** (long-running loop, auto-restart): ```ini # /etc/systemd/system/heatwires.service [Unit] Description=Roof heat-cable weather controller After=network-online.target Wants=network-online.target [Service] ExecStart=/path/to/.venv/bin/heatwires -c /path/to/config.yaml loop -i 900 Restart=always RestartSec=30 User=pi [Install] WantedBy=multi-user.target ``` ```bash sudo systemctl enable --now heatwires journalctl -u heatwires -f ``` The Kasa app keeps working independently as a manual override at any time. ## Develop ```bash pip install pytest pytest -q # offline tests for the decision logic and NWS parsing ``` ## Layout | File | Purpose | |---|---| | `heatwires/weather.py` | NWS client → temperature + snow/ice signal | | `heatwires/logic.py` | the icing decision (band + snow + hysteresis) | | `heatwires/switches.py` | Kasa control over the LAN (python-kasa) | | `heatwires/controller.py` | one cycle: weather → decision → switches → state | | `heatwires/state.py` | run-to-run state (last state, dwell timing) | | `heatwires/config.py` | YAML config loading/validation | | `heatwires/cli.py` | `discover` / `status` / `run` / `loop` commands |
<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
# heatwires Automated, weather-based control of roof **heat cables** (de-icing / ice-dam prevention) via **TP-Link Kasa KP400** smart outdoor plugs. It queries the US National Weather Service for your location, decides whether icing conditions warrant running the cables, and switches your Kasa plugs **directly over the LAN** (no cloud account, no reverse-engineered web API). ## How it decides Ice dams form in a **melt/refreeze window near freezing with snow present**: snow melts, water runs to the cold eaves, and refreezes. So the cables run when: - the temperature is inside a configurable band (default **15–38 °F**), **and** - frozen precipitation is present/forecast (can be disabled). Extras for real-world robustness: - **Hysteresis** widens the band once the cables are on, to avoid flapping. - **Minimum dwell time** prevents rapid on/off cycling. - **Fail-safe**: if weather can't be fetched during winter months, bias to a safe state (default: ON). - **Graceful degradation**: an unreachable switch is logged and reported (and makes `run` exit non-zero) without aborting the rest of the cycle. ## Install Requires Python 3.10+. Run this on an always-on machine **on the same LAN** as the switches (e.g. a Raspberry Pi). ```bash python3 -m venv .venv source .venv/bin/activate pip install -e . ``` ## Configure ```bash cp config.example.yaml config.yaml ``` Then edit `config.yaml`: - `location` — your roof's latitude/longitude (decimal degrees). - `weather.user_agent_contact` — an email; NWS requires an identifying User-Agent. - `switches` — the IP(s) of your KP400(s) and which `outlets` to control. - `icing` — the temperature band and behavior tunables. Give each KP400 a **reserved/static IP** in your router so its address never changes. Find the switches on your network with: ```bash heatwires discover ``` `config.yaml` and `state.json` are git-ignored (they're host-specific). ## Use ```bash heatwires -c config.yaml discover # list Kasa devices on the LAN heatwires -c config.yaml status # show weather + switch state + decision (no change) heatwires -c config.yaml run # run one control cycle (turns cables on/off) heatwires -c config.yaml run --dry-run # decide + read switches, but don't change them heatwires -c config.yaml loop -i 900 # run continuously, every 900s ``` ## Deploy **Option A — cron** (runs one cycle every 15 minutes): ```cron */15 * * * * /path/to/.venv/bin/heatwires -c /path/to/config.yaml run >> /path/to/heatwires.log 2>&1 ``` **Option B — systemd service** (long-running loop, auto-restart): ```ini # /etc/systemd/system/heatwires.service [Unit] Description=Roof heat-cable weather controller After=network-online.target Wants=network-online.target [Service] ExecStart=/path/to/.venv/bin/heatwires -c /path/to/config.yaml loop -i 900 Restart=always RestartSec=30 User=pi [Install] WantedBy=multi-user.target ``` ```bash sudo systemctl enable --now heatwires journalctl -u heatwires -f ``` The Kasa app keeps working independently as a manual override at any time. ## Develop ```bash pip install pytest pytest -q # offline tests for the decision logic and NWS parsing ``` ## Layout | File | Purpose | |---|---| | `heatwires/weather.py` | NWS client → temperature + snow/ice signal | | `heatwires/logic.py` | the icing decision (band + snow + hysteresis) | | `heatwires/switches.py` | Kasa control over the LAN (python-kasa) | | `heatwires/controller.py` | one cycle: weather → decision → switches → state | | `heatwires/state.py` | run-to-run state (last state, dwell timing) | | `heatwires/config.py` | YAML config loading/validation | | `heatwires/cli.py` | `discover` / `status` / `run` / `loop` commands |
The file /home/david/projects/play/heatwires-opus/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
git status --short && echo "--- tracked-file tree ---" && git status --porcelain | awk '{print $2}' | sortM README.md ?? .claude/ ?? .gitignore ?? config.example.yaml ?? heatwires/ ?? pyproject.toml ?? tests/ --- tracked-file tree --- .claude/ .gitignore README.md config.example.yaml heatwires/ pyproject.toml tests/
Done. Built a working, installable Python package and verified it end-to-end.
heatwires/
weather.py NWS client → temperature + snow/ice signal (keyless api.weather.gov)
logic.py the icing decision: band + snow + hysteresis
switches.py Kasa control over the LAN via python-kasa (KP400 dual-outlet aware)
controller.py one cycle: weather → decision → switches → state
state.py run-to-run state (last state, dwell timing)
config.py YAML load/validate
cli.py discover / status / run / loop
config.example.yaml pyproject.toml .gitignore tests/ README.md
The decision matches what we discussed: cables run when the temp is in a 15–38 °F band and frozen precip is present, with hysteresis (no flapping), a minimum dwell time, a winter fail-safe (default ON if weather can't be fetched), and graceful per-switch failure so one unreachable plug doesn't abort the run.
heatwires status pulled real Bozeman weather (88°F, no snow signal — Mostly Clear), correctly decided OFF ("snow sheds naturally"), and the placeholder switch IP failed cleanly rather than crashing.discover_single/discover/turn_on/children signatures directly).pip install -e .heatwires discover to find your KP400s' IPs, then put them (and reserve those IPs in your router) into config.yaml.location coordinates — I seeded config.yaml with default Bozeman coords and your email for the NWS contact; adjust the lat/long to your actual roof.heatwires -c config.yaml status to sanity-check, then deploy via cron or the systemd unit (both in the README).Note: config.yaml and state.json are git-ignored as host-specific. I have not committed anything — want me to make an initial commit on a branch?
One tuning caveat for your first winter: the snow signal is inferred from NWS text (keywords like "snow"/"freezing"), not actual roof snowpack — so watch the first few storms and adjust the band or require_snow to taste.