Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions badge/apps/solar_buddy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
solar_buddy_secrets.json
settings.json
118 changes: 118 additions & 0 deletions badge/apps/solar_buddy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Solar Buddy

An animated solar companion that reads Home Assistant power sensors and recommends appliances after stable surplus.

This app targets the GitHub Universe 2026 Badgeware runtime at 320 x 240. Copy the
folder into `/system/apps/solar_buddy` through USB mass-storage mode, leaving out
`solar_buddy_secrets.json` and `__pycache__` (see below).

It starts with simulated data and requires no network in demo mode. Left/right
switch screens, up/down select a scenario or appliance, Select opens appliance
details or toggles demo/live in Settings, Back returns to Buddy, Menu opens Settings,
and Home returns to the launcher. Shake briefly wakes the face.

Copy `settings.example.json` to `settings.json` to customize thresholds, estimated
appliance loads, polling, freshness, and the 30-second stability period. The default
safety reserve is 100 W. All recommendations are read-only and apply to one load
at a time; they do not guarantee a solar-only appliance cycle.

An explicit offline/stale state suppresses advice when data is missing or invalid.
The app assumes there is no home battery. Only Solar Superstar is implemented;
daily statistics achievements need additional sensors.

## Home Assistant credentials

Live mode reads a private JSON file from `/state/solar_buddy_secrets.json` on the
badge or in the canvas. It never reads credentials from the app folder, and Wi-Fi
uses the badge's existing Wi-Fi settings (`/system/secrets.py` or the Settings app).

**On your computer**, keep the private copy at
`badge\apps\solar_buddy\solar_buddy_secrets.json`. The app's `.gitignore` excludes
it from Git; check with `git check-ignore -v badge/apps/solar_buddy/solar_buddy_secrets.json`.
Start from the committed, credential-free template and fill in your Home Assistant
URL, IPv4 address, long-lived access token, and three instantaneous power sensor
IDs. Replace the template's example sensor IDs if they do not match your setup;
never put a real token in the `.example.json` file.

```powershell
Copy-Item badge\apps\solar_buddy\solar_buddy_secrets.example.json `
badge\apps\solar_buddy\solar_buddy_secrets.json
```

Create the token under your Home Assistant profile, **Security**, **Long-lived
access tokens**. Use three different instantaneous power sensors reported in W, kW,
or MW (find their IDs in **Developer tools**, **States**). `connect_ip` is required
unless `base_url` already uses an IPv4 address, so the badge avoids blocking DNS.
`allow_insecure_http` must be `true` for `http://` because the token travels
unencrypted; use it only on a trusted LAN. For `https://` on the badge, copy the
CA certificate to the badge (for example `/state/solar_buddy_ca.pem`), set
`"ca_file"` to that path, and make sure the badge clock is correct.

**On the badge**, copy the file over USB serial (`/state` is writable; `/system`
is not), then reset. Run one `mpremote` command at a time:

```powershell
mpremote devs
mpremote connect COM7 fs cp badge\apps\solar_buddy\solar_buddy_secrets.json :/state/solar_buddy_secrets.json
mpremote connect COM7 reset
```

Replace `COM7` with the port from `mpremote devs`. If `/state` is missing, run
`mpremote connect COM7 fs mkdir :/state` first. Do not copy
`solar_buddy_secrets.json` into `apps/solar_buddy` on the BADGER volume: the
Badge Simulator **Deploy** button and `deploy_app.py` copy the whole folder, so
delete `apps/solar_buddy/solar_buddy_secrets.json` from BADGER if it was copied.
Open Solar Buddy, press Menu, then Select to switch to live mode.

**In the Badge Simulator canvas**, the private file in the app folder is staged
(read-only) at `/system/apps/solar_buddy/solar_buddy_secrets.json`. Ask Copilot
to copy it to `/state`, or run this with the canvas `exec` action. It validates
the private copy made from the example above and does not print its contents:

```python
import json, os, config
with open("/system/apps/solar_buddy/solar_buddy_secrets.json") as f:
data = f.read()
config.validate_secrets(json.loads(data))
try:
os.mkdir("/state")
except OSError:
pass
with open(config.DEVICE_SECRETS, "w") as f:
f.write(data)
print("Copied to", config.DEVICE_SECRETS)
```

Then reload the app and switch to live mode in Settings. Repeat the copy after a
full canvas runtime restart (for example after files are added to, removed from,
or renamed in the app folder) or after editing the private file. A missing copy
shows `Live setup: No /state/solar_buddy_secrets.json` and Offline. See the next
section for canvas networking requirements.

## Badge Simulator canvas

Live mode automatically uses asynchronous browser HTTP on the canvas's
WebAssembly runtime. Physical badges and the desktop preview retain the
socket client. Requests remain read-only, time out, reject redirects, limit
responses to 16 KiB, and can be cancelled without blocking badge controls.

`/state/solar_buddy_secrets.json` lives in the canvas's **virtual** filesystem,
not on Windows; copy it there as described above. `settings.json` contains only
app settings, not connection details.

Browser networking uses `base_url` and browser DNS, not `connect_ip` (which is
still validated). `localhost`
means the computer hosting the canvas. Allow the canvas's exact origin (including
its port, shown in the canvas URL) in Home Assistant's `http.cors_allowed_origins`.
Browsers hide the HTTP `Date` header unless the server sends
`Access-Control-Expose-Headers: Date`, which Home Assistant's CORS settings
cannot configure. When `Date` is hidden, the canvas checks sensor freshness
against the computer's UTC clock instead and prints a one-time console notice.
Keep the computer and Home Assistant clocks synchronised (for example with NTP):
a skewed clock makes readings look stale, or rejects them as being ahead of the
clock. The physical badge always uses Home Assistant's `Date` header.
CORS, unreachable servers, authentication failures, and stale readings
are reported as errors; they never fall back to fabricated live data.
HTTPS uses browser certificate trust; `ca_file` is not supported in the canvas.
See [Home Assistant HTTP configuration](https://www.home-assistant.io/integrations/http/)
for CORS configuration. Do not disable browser security or TLS verification.
68 changes: 68 additions & 0 deletions badge/apps/solar_buddy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Universe 2026 Badgeware entry point. Desktop users run preview.py."""

import os
import sys

APP_DIRS = ("/remote/apps/solar_buddy", "/system/apps/solar_buddy",
"/apps/solar_buddy", "/solar_buddy")
APP_DIR = next(path for path in APP_DIRS if is_dir(path))
os.chdir(APP_DIR)
sys.path.insert(0, APP_DIR)

from badgeware import State
from clock import ticks_ms
from config import DEVICE_SECRETS, load_secrets, load_settings
from controller import App
from hardware import Feedback
from ui import BadgePainter, UI

badge.mode(HIRES | VSYNC)
screen.font = font.sins
settings = load_settings("settings.json")
feedback = Feedback(badge)
saved = {"mode": settings["mode"], "scenario": 0, "superstar": False}
State.load("solar_buddy", saved)


def live_source():
credentials = load_secrets(
DEVICE_SECRETS, "No " + DEVICE_SECRETS +
". See README: Home Assistant credentials.")
from ha_client import BadgeWifi, HomeAssistant
return HomeAssistant(settings, credentials, wifi=BadgeWifi())


def save_state(value):
try:
State.save("solar_buddy", value)
except OSError as exc:
app.hardware_note = "Cannot save preferences: " + str(exc)
print("Solar Buddy: " + app.hardware_note)


app = App(settings, live_source, saved, save_state)
ui = UI(BadgePainter(screen, shape, color))
buttons = ((BUTTON_LEFT, "left"), (BUTTON_RIGHT, "right"), (BUTTON_UP, "up"),
(BUTTON_DOWN, "down"), (BUTTON_SELECT, "select"), (BUTTON_BACK, "back"),
(BUTTON_MENU, "menu"))


def update():
now = ticks_ms()
for button, action in buttons:
if badge.pressed(button):
app.input(action, now)
app.tick(now)
feedback.update(app, now)
ui.draw(app, now, badge.ticks_delta)


def on_exit():
app.close()
feedback.close()


try:
run(update)
finally:
on_exit()
45 changes: 45 additions & 0 deletions badge/apps/solar_buddy/advisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Advice is based on a continuous series of fresh samples, never a UI timer."""

from clock import elapsed


class Advisor:
def __init__(self, settings):
self.settings = settings
self.items = settings["appliances"]
self.max_gap_ms = settings["poll_interval_ms"] + 3 * settings["request_timeout_ms"] + 1000
self.invalidate()

def invalidate(self):
self.since = [None] * len(self.items)
self.statuses = ["UNKNOWN"] * len(self.items)
self.last_sample = None

def update(self, sample, now):
if sample is None:
self.invalidate()
return
if self.last_sample is not None:
gap = elapsed(now, self.last_sample)
if gap <= 0:
return
if gap > self.max_gap_ms:
self.invalidate()
self.last_sample = now
usable = sample["surplus_w"] - self.settings["reserve_w"]
importing = sample["import_w"] > self.settings["import_threshold_w"]
for index, item in enumerate(self.items):
if importing or usable < item["watts"]:
self.since[index] = None
self.statuses[index] = "WAIT"
elif self.since[index] is None:
self.since[index] = now
self.statuses[index] = "WAIT"
elif elapsed(now, self.since[index]) >= self.settings["stability_ms"]:
self.statuses[index] = "READY"

def remaining_s(self, index, now):
if self.since[index] is None:
return None
left = self.settings["stability_ms"] - elapsed(now, self.since[index])
return max(0, (left + 999) // 1000)
140 changes: 140 additions & 0 deletions badge/apps/solar_buddy/browser_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Incremental Home Assistant GETs through the canvas worker's browser fetch."""

import json

from clock import elapsed
from config import parse_url, validate_secrets


# Keep network work in the worker's event loop so badge frames never wait on I/O.
FETCH = r"""
(function (url, headers, timeoutMs) {
const controller = new AbortController();
const job = {
state: "pending", status: 0, headers: "{}", body: "", error: "",
clock: "server",
cancel() {
if (this.state === "pending") this.state = "cancelled";
clearTimeout(timer);
controller.abort();
}
};
function fail(message) {
if (job.state !== "pending") return;
job.error = message;
job.state = "error";
clearTimeout(timer);
controller.abort();
}
const timer = setTimeout(
() => fail("Home Assistant request timed out"), timeoutMs);
(async () => {
try {
const response = await fetch(url, {
method: "GET", headers: JSON.parse(headers),
signal: controller.signal, redirect: "error",
credentials: "omit", cache: "no-store"
});
if (job.state !== "pending") return;
job.status = response.status;
if (response.status !== 200) {
job.state = "done";
controller.abort();
return;
}
const exposedHeaders = {};
let headerSize = 0;
response.headers.forEach((value, key) => {
exposedHeaders[key.toLowerCase()] = value;
headerSize += key.length + value.length + 4;
});
if (headerSize > 4096) return fail("HTTP headers exceed the memory limit");
if (!exposedHeaders.date) {
// Home Assistant's CORS setup cannot expose Date to browsers.
exposedHeaders.date = new Date().toUTCString();
job.clock = "browser";
}
job.headers = JSON.stringify(exposedHeaders);
const decoder = new TextDecoder("utf-8", {fatal: true});
const reader = response.body.getReader();
let size = 0;
while (job.state === "pending") {
const chunk = await reader.read();
if (job.state !== "pending") return;
if (chunk.done) {
job.body += decoder.decode();
job.state = "done";
return;
}
size += chunk.value.byteLength;
if (size > 16384) return fail("HTTP body exceeds the memory limit");
job.body += decoder.decode(chunk.value, {stream: true});
}
} catch (_error) {
fail("Canvas HTTP request failed; check the URL, CORS and browser certificate trust");
} finally {
clearTimeout(timer);
}
})();
return job;
})
"""


class HttpClient:
def __init__(self, credentials, timeout_ms):
import js

validate_secrets(credentials)
if credentials.get("ca_file"):
raise ValueError(
"The canvas uses browser certificate trust, not ca_file")
scheme, _host, _port, prefix, authority = parse_url(credentials["base_url"])
self.base_url = scheme + "://" + authority + prefix
self.headers = json.dumps({
"Authorization": "Bearer " + credentials["token"],
"Accept": "application/json",
})
self.timeout_ms = timeout_ms
self.request = js.eval(FETCH)
self.job = None
self.browser_clock = False

def start(self, path, now):
if self.job is not None:
raise RuntimeError("An HTTP request is already in progress")
self.started = now
self.job = self.request(self.base_url + path, self.headers, self.timeout_ms)

def step(self, now):
if self.job is None:
return None
if elapsed(now, self.started) >= self.timeout_ms:
self.close()
raise OSError("Home Assistant request timed out")
state = str(self.job.state)
if state == "pending":
return None
try:
if state == "error":
raise OSError(str(self.job.error))
if state != "done":
raise OSError("Home Assistant request was cancelled")
code = int(self.job.status)
if code != 200:
hint = {401: "check token", 403: "sensor access denied",
404: "check entity ID", 429: "rate limited",
503: "Home Assistant unavailable"}.get(code, "request failed")
raise ValueError("HTTP %d: %s" % (code, hint))
if str(self.job.clock) == "browser" and not self.browser_clock:
self.browser_clock = True
print("Solar Buddy: CORS hides HTTP Date; "
"checking freshness with the browser clock")
return json.loads(str(self.job.body)), json.loads(str(self.job.headers))
finally:
self.close()

def close(self):
job, self.job = self.job, None
if job is not None:
job.cancel()
Loading