diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3e18570..f8f844b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,8 +9,10 @@ name: Build and publish on: push: # Every branch builds, so a feature branch can be pulled and tried on real - # strands before it merges. Only main moves `latest`. - branches: ["**"] + # strands before it merges. Only main moves `latest`. `stable` is left out: + # the release job moves it to a commit main already built, with a deploy + # key, and unlike the workflow's own token that push starts a run. + branches: ["**", "!stable"] tags: ["v*"] pull_request: workflow_dispatch: @@ -42,6 +44,12 @@ jobs: echo "::error::pyproject.toml says $version but frontend/package.json says $ui. Bump both." exit 1 fi + # The add-on pulls afraley/dapple:, so it has to name this release. + addon=$(sed -n 's/^version: *"\{0,1\}\([^"]*\)"\{0,1\} *$/\1/p' home-assistant/dapple/config.yaml) + if [ "$version" != "$addon" ]; then + echo "::error::pyproject.toml says $version but home-assistant/dapple/config.yaml says ${addon:-nothing}. Bump both." + exit 1 + fi release=false if ! git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null; then release=true @@ -149,15 +157,31 @@ jobs: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.test.outputs.release == 'true' && needs.image.outputs.pushed == 'true' runs-on: ubuntu-latest + # Holds STABLE_DEPLOY_KEY, and only runs on main. + environment: release permissions: contents: write steps: + # The deploy key, not the workflow's token: that token may not push a + # commit that changes .github/workflows/, and moving stable does. Full + # history so the push below has every commit it names. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ssh-key: ${{ secrets.STABLE_DEPLOY_KEY }} + fetch-depth: 0 + # Skipped if it already exists, so a re-run gets on to moving stable. - name: Tag v${{ needs.test.outputs.version }} and publish the release env: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.test.outputs.version }} - run: >- - gh release create "v$VERSION" --target "$GITHUB_SHA" - --title "Dapple $VERSION" --notes-file "docs/releases/$VERSION.md" + run: | + gh release view "v$VERSION" >/dev/null 2>&1 && exit 0 + gh release create "v$VERSION" --target "$GITHUB_SHA" \ + --title "Dapple $VERSION" --notes-file "docs/releases/$VERSION.md" + # Home Assistant reads the add-on from `stable`, so it only sees a new + # version once that version's image exists. After the release, so a + # failure here can't hold it up for Docker users. No --force: git refuses + # anything but a fast-forward, and pushing the same commit again is a no-op. + - name: Move stable to this release, for the Home Assistant add-on + run: git push origin "$GITHUB_SHA:refs/heads/stable" diff --git a/CLAUDE.md b/CLAUDE.md index 896d142..8f5981f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ the API reference and internals. Keep them in their lanes — don't put REST tab ## Commands ```bash -.venv/bin/python -m pytest -q # 329 tests, no network, no strands +.venv/bin/python -m pytest -q # 341 tests, no network, no strands .venv/bin/pre-commit run --all-files # Black + Prettier; the git hook runs this on staged files npm --prefix frontend test # JS pattern port vs Python fixtures npm --prefix frontend run build # required before the Docker build picks up UI changes @@ -102,6 +102,16 @@ keeps the saved one. make Home Assistant retry a successful operation. A failed *config* write does 500 and rolls back, because there nothing happened and the user needs to know. This asymmetry is deliberate. +**The UI never uses an absolute path.** The Home Assistant add-on serves it under +`/api/hassio_ingress//`: API calls are `api/...`, icons `favicon.svg`, Vite `base: './'`. +A leading `/` works everywhere except the sidebar, so it won't show up in local testing. + +**The Supervisor's broker is filled in, never switched on.** At startup `sync_mqtt` +(`app/supervisor.py`) saves the Mosquitto login with `enabled=False` when no broker is saved. +While the saved host, port and username are still the Supervisor's, it refreshes the password +(Mosquitto reissues it on reinstall, and the user never sees it), touching nothing else. Any +other broker is the user's and is left alone. Without `SUPERVISOR_TOKEN` it does nothing. + ## API shape Every mutating route names a group — there is no whole-house apply or off. The user chose this @@ -143,7 +153,7 @@ This repo is open source; `data/` and `.env` are the only places real details ma - Never copy anything out of `data/`, `.env` or live API responses into tracked files. That covers strand IPs, broker addresses and logins, hostnames, MACs, serials, device names from the Twinkly app, and timestamps. - Example addresses are `192.168.40.21`, `.22`, `.30` in docs and UI placeholders (matching - `data.example/config.yaml`), `192.168.1.x` for the machine running Dapple (README, + `docs/example-config.yaml`), `192.168.1.x` for the machine running Dapple (README, HOME_ASSISTANT.md), and `10.0.0.x` in tests. Don't invent new ranges. - Screenshots in `docs/` come from a throwaway instance, never the live app or container: `scripts/screenshot.sh [page] [out.png] [width] [height]` starts one on a temp data dir with @@ -181,7 +191,7 @@ reaches every install. Keep every input pinned: - Merging to `main` is what releases. If `pyproject.toml` has a version with no `v` tag, the `main` build publishes that image version, tags the commit and creates the GitHub release from `docs/releases/.md`. So a release is just a PR that bumps the version in - `pyproject.toml` and `frontend/package.json`, refreshes both lock files (`uv lock`, and + `pyproject.toml`, `frontend/package.json` and `home-assistant/dapple/config.yaml`, refreshes both lock files (`uv lock`, and `npm --prefix frontend install --package-lock-only`) and adds the notes file. DEVELOPING.md's *Releases* section has the details. - Release notes are for people running Dapple, in the voice of README.md: what changed for them @@ -217,5 +227,10 @@ reaches every install. Keep every input pinned: `afraley/dapple:` for testing on the real strands. Only a release moves `latest`. `docker-compose.yml` must stay usable on its own (users download only that file), so anything that needs the source goes in the override. + The Home Assistant add-on (`home-assistant/dapple/`) runs that same image and is read by the + Supervisor from the `stable` branch, which only the release job moves, with the + `STABLE_DEPLOY_KEY` deploy key (a ruleset refuses anything else). Don't push to it. + The Supervisor treats every `config.yaml`/`config.json` in the repo as an add-on, so don't add + one outside `home-assistant/`. Check `git status` before committing: `data/`, `.env`, `frontend/dist/` and `*.egg-info/` must stay untracked. diff --git a/DEVELOPING.md b/DEVELOPING.md index a78ca74..8d10e2a 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -355,6 +355,41 @@ starts at its own LED 0. That's a bug. --- +## Home Assistant add-on + +`repository.yaml` makes this repository a Home Assistant add-on repository (Home Assistant's +menus now call add-ons *apps*). The add-on is `home-assistant/dapple/`: `config.yaml`, the +Documentation tab (`DOCS.md`), the store blurb (`README.md`) and `icon.png`. There's no +Dockerfile there. The Supervisor pulls `afraley/dapple:`, the same image Docker users +run. + +- **Users add `https://github.com/andrewfraley/dapple#stable`**, not `main`. See + [Releases](#releases) for why and how `stable` moves. +- **The Supervisor treats every `config.json`/`config.yaml` in the repository as an add-on** and + logs a warning for each one that doesn't validate. Don't add another file with that name + outside `home-assistant/`. The example config is `docs/example-config.yaml` for this reason. +- **Ingress.** Home Assistant shows the UI in its sidebar under + `/api/hassio_ingress//`, so the frontend must never use an absolute path: API calls + are `api/...`, icons are `favicon.svg`, and Vite builds with `base: './'`. Routing is by + `#hash`, so the page itself is always at the prefix root. +- **MQTT.** `services: [mqtt:want]` lets Dapple ask the Supervisor for the Mosquitto add-on's + address and a login (`app/supervisor.py`). At startup, if no broker is saved yet, Dapple + saves that one, switched off. If the saved broker is still that one (same host, port and + username), it takes the current password, since reinstalling Mosquitto issues a new one the + user never sees. Any other broker is left alone, and outside an add-on (`SUPERVISOR_TOKEN` + unset) it asks nothing. +- **No host port by default** (`8080/tcp: null`). Ingress reaches the container directly, and + Home Assistant reaches it as `http://:8080`, the hostname being on the add-on's + Info page. +- **Image user.** Add-ons start as root with a root-owned `/data`. `scripts/entrypoint.sh` + chowns it to `PUID` (1000) and drops privileges, as it does on Docker. + +**Testing a branch on Home Assistant OS.** Use a *local* add-on, so nothing about the +repository changes: copy `home-assistant/dapple/` to `/addons/dapple/` on the Home Assistant box +(the Samba or SSH app can reach it), change `version` in the copy to the branch's image tag +(e.g. `mqtt-discovery`), then App store → ⋮ → **Check for updates**. It appears under *Local +apps*. Reinstall it to pull a newer build of the branch. + ## Releases `.github/workflows/docker.yml` checks formatting and runs the Python and JS tests, then builds a `linux/amd64` + @@ -376,9 +411,10 @@ a PR, and wait for CI and a review. **Releasing is a pull request that bumps the version.** In that PR: -1. Set the new version in `pyproject.toml` and `frontend/package.json`. Then run +1. Set the new version in `pyproject.toml`, `frontend/package.json` and + `home-assistant/dapple/config.yaml`. Then run `npm --prefix frontend install --package-lock-only` and `uv lock` so both lock files follow. - CI fails if the two versions differ or `uv.lock` is stale. + CI fails if the versions differ or `uv.lock` is stale. 2. Add the release notes as `docs/releases/.md`, written for people running Dapple, not developers. CI fails on a PR whose version has no tag and no notes file. @@ -387,6 +423,31 @@ image as `latest`, `` and `.`, then tags the merge commit GitHub release `Dapple ` from the notes file. A PR that doesn't bump the version publishes only `main` and `sha-`; its changes reach users with the next release. +**The `stable` branch is `latest` for the Home Assistant add-on.** The Supervisor reads the +add-on's `config.yaml` straight from git, so if it read `main` it would offer a new version the +moment the PR merged, minutes before that image reached Docker Hub. Once the image is pushed and +the GitHub release created, the release job pushes the merge commit to `stable`. + +- It pushes with a deploy key, because the workflow's own token may not push a commit that + changes `.github/workflows/`. The private key is the `STABLE_DEPLOY_KEY` secret in the + `release` environment, which only `main` can use. +- Two rulesets guard `stable`. "Only the release job moves stable" lets nothing but a deploy key + update it. "Protect stable", which nothing bypasses, refuses deletion, force pushes, unsigned + commits and commits without passing `test` and `image` checks. +- `stable` is left out of branch builds, since a deploy-key push starts a workflow run. +- If moving `stable` fails, re-run the job. It skips the existing release, and pushing the same + commit again does nothing. + +To rotate the key: + +```bash +ssh-keygen -t ed25519 -N "" -C "dapple release job: stable branch" -f stable +gh repo deploy-key add stable.pub --allow-write --title "Release job: move stable" +gh secret set STABLE_DEPLOY_KEY --env release < stable +shred -u stable stable.pub +gh repo deploy-key list # then delete the old one: gh repo deploy-key delete +``` + **Dependabot PRs** can't bump the version, so they merge without releasing. Read the lock diff before merging one. Their changes ship in the next release PR, whose notes mention anything a user would notice. When Dependabot flags a *security* update, open a release soon after merging diff --git a/HOME_ASSISTANT.md b/HOME_ASSISTANT.md index 98651af..108ca41 100644 --- a/HOME_ASSISTANT.md +++ b/HOME_ASSISTANT.md @@ -11,6 +11,15 @@ over MQTT and sets up the lights itself. It's off until you set it up. ## Setting it up +**Running Dapple as a Home Assistant app** (see the README's +[On Home Assistant OS](README.md#on-home-assistant-os))**, with the Mosquitto broker installed?** +Dapple has already filled in the broker's address and a login of its own. Open its Home +Assistant tab, turn on **Connect to Home Assistant** and press **Save**. That's all. If you +install Mosquitto after Dapple, or reinstall it, restart the Dapple app so it picks up the +login. + +Otherwise: + 1. **Give Home Assistant an MQTT broker, if it doesn't have one.** Install the **Mosquitto broker** add-on and start it. Home Assistant then offers to set up the **MQTT** integration: accept. If you already use MQTT (for Zigbee2MQTT, say), skip this step. @@ -110,6 +119,8 @@ is stopped. ## Dapple's own page in Home Assistant +Running Dapple as a Home Assistant app? It's in the sidebar already; skip this. + To open Dapple's editor from the Home Assistant sidebar, go to Settings → Dashboards → Add dashboard → **Webpage**, and enter Dapple's address, e.g. `http://192.168.1.10:8080/`. @@ -148,6 +159,11 @@ If you'd rather not run a broker, Home Assistant's `rest_command` can call Dappl lose the automatic lights and the state updates, and have to write the YAML yourself. Put this in `configuration.yaml`, replacing `dapple.lan:8080` with Dapple's address: +- Running Dapple with Docker, that's the machine's address and port, e.g. `192.168.1.10:8080`. +- Running it as a Home Assistant app, Home Assistant reaches it by its hostname, which the app's + Info page lists: `b68bc6ef-dapple:8080` if you added it with the README's link. Nothing needs + turning on in the app's Network tab for this. + ```yaml rest_command: dapple_preset: diff --git a/README.md b/README.md index 3f33107..50a86f5 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,9 @@ You'll need three things: to each strand directly. 3. **Docker** on the computer that will run Dapple — a NAS, a Raspberry Pi, a spare laptop, whatever stays on. If you don't have it, install - [Docker Desktop](https://docs.docker.com/get-started/get-docker/). + [Docker Desktop](https://docs.docker.com/get-started/get-docker/). Or, if you run Home + Assistant OS, Dapple can run inside Home Assistant instead: + see [On Home Assistant OS](#on-home-assistant-os). You'll also need each strand's address. The Twinkly app shows it under the device's settings, or your router's device list will have it. It looks like `192.168.1.50`. @@ -88,6 +90,28 @@ Assistant connection, so keep it on your home network and don't open the port on --- +## On Home Assistant OS + +If your Home Assistant runs Home Assistant OS (a Home Assistant Green or Yellow does, and so do +most Raspberry Pi installs), Dapple can run as a Home Assistant app, also called an add-on. +There's no other computer and no Docker to set up. + +1. Click + **[Add Dapple to Home Assistant](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fandrewfraley%2Fdapple%23stable)** + and confirm. Or, in Home Assistant, go to Settings → Apps → App store, open the ⋮ menu → + **Repositories** and add `https://github.com/andrewfraley/dapple#stable`. +2. Find **Dapple** in the App store, then click **Install** and **Start**. +3. Turn on **Show in sidebar**, and click **Dapple** in the sidebar. + +Dapple opens inside Home Assistant, behind its login. New versions show up under Settings → +Updates, like any other app's. If you use the Mosquitto broker, Dapple has already filled in +its Home Assistant tab: see [HOME_ASSISTANT.md](HOME_ASSISTANT.md). + +Home Assistant installed some other way (in Docker, say) doesn't have apps. Run Dapple with +Docker as above instead. + +--- + ## Adding your lights Click the **Strands** tab, then **Add strand**, and type the address of one strand. That's all @@ -244,6 +268,9 @@ each. Then `docker compose up -d` keeps running that version until you change th To stop it: `docker compose down`. To start it again: `docker compose up -d`. +Running Dapple as a Home Assistant app? The same files live inside the app, and Home +Assistant's own backups include them. Updates arrive under Settings → Updates. + --- ## Going further diff --git a/app/main.py b/app/main.py index 38eadb2..3816bdc 100644 --- a/app/main.py +++ b/app/main.py @@ -54,6 +54,7 @@ from app.pattern import led_colors from app.presets import PresetError, PresetStorageError, PresetStore from app.state import StateStore, live_state +from app.supervisor import sync_mqtt logging.basicConfig( level=os.environ.get("DAPPLE_LOG_LEVEL", "INFO").upper(), @@ -116,6 +117,9 @@ async def lifespan(app: FastAPI): # Strands that are simply switched off shouldn't hold up startup. The # reference is kept so the task can't be garbage-collected mid-run. startup = asyncio.create_task(_startup_refresh(app)) + # Running as a Home Assistant add-on: offer the Mosquitto login, switched + # off, or refresh it if Mosquitto has issued a new one. + await asyncio.to_thread(sync_mqtt, app.state.strands) app.state.mqtt.start() yield startup.cancel() @@ -562,6 +566,6 @@ async def missing_ui() -> HTMLResponse: return HTMLResponse( "

Dapple

No built UI found. Run npm --prefix frontend " "install && npm --prefix frontend run build, or use the " - "Docker image. The API is up at /docs.

", + "Docker image. The API is up at /docs.

", status_code=200, ) diff --git a/app/supervisor.py b/app/supervisor.py new file mode 100644 index 0000000..3aab5b4 --- /dev/null +++ b/app/supervisor.py @@ -0,0 +1,102 @@ +"""Home Assistant's Supervisor, for when Dapple runs as a Home Assistant add-on. + +The Supervisor hands an add-on that declares ``services: [mqtt:want]`` the +Mosquitto add-on's address and a login made for it. Dapple writes those into +the Home Assistant tab so there's nothing to type, but leaves the bridge +switched off: MQTT only ever starts because someone turned it on. + +Reinstalling Mosquitto issues a new password the user never sees, so the login +is refreshed on every start for as long as it's still the one Mosquitto made. +""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import replace +import urllib.request +from collections.abc import Callable, Mapping + +from app.config import ConfigError, ConfigStorageError, ConfigStore, MqttConfig, normalize_mqtt + +log = logging.getLogger(__name__) + +SUPERVISOR_URL = "http://supervisor" +TIMEOUT_SECONDS = 5.0 + +Fetch = Callable[[str, str], dict] + + +def fetch_json(url: str, token: str) -> dict: + request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: + return json.load(response) + + +def mqtt_service( + env: Mapping[str, str] | None = None, fetch: Fetch = fetch_json +) -> MqttConfig | None: + """The broker the Supervisor offers, switched off, or None outside an add-on.""" + env = os.environ if env is None else env + token = env.get("SUPERVISOR_TOKEN") + if not token: + return None + try: + # A 400 here means no add-on provides MQTT, i.e. Mosquitto isn't installed. + service = fetch(f"{SUPERVISOR_URL}/services/mqtt", token).get("data") or {} + except Exception as exc: # any failure just means "type it in yourself" + log.info("Home Assistant has no MQTT broker to offer (%s)", exc) + return None + if service.get("ssl"): + log.info("Home Assistant's MQTT broker requires TLS, which Dapple doesn't support yet") + return None + try: + return normalize_mqtt( + host=service.get("host"), + port=service.get("port") or 1883, + username=service.get("username"), + password=service.get("password"), + enabled=False, + ) + except (ConfigError, TypeError, ValueError) as exc: + log.warning("Ignoring the MQTT broker Home Assistant offered: %s", exc) + return None + + +def _same_login(saved: MqttConfig, offered: MqttConfig) -> bool: + """Whether the saved broker is the one the Supervisor offers, password aside.""" + return (saved.host, saved.port, saved.username) == ( + offered.host, + offered.port, + offered.username, + ) + + +def sync_mqtt( + store: ConfigStore, env: Mapping[str, str] | None = None, fetch: Fetch = fetch_json +) -> bool: + """Save or refresh the Supervisor's broker, never a user's own. True if saved.""" + offered = mqtt_service(env, fetch) + if offered is None: + return False + saved = store.config.mqtt + if saved is None: + settings = offered + message = ( + "Filled in Home Assistant's MQTT broker (%s:%d). Turn on Connect to Home " + "Assistant on the Home Assistant tab to start using it." + ) + elif _same_login(saved, offered) and saved.password != offered.password: + # Only the password: switched on or off, and the prefixes, are the user's. + settings = replace(saved, password=offered.password) + message = "Mosquitto issued Dapple a new password (%s:%d); saved it." + else: + return False # unchanged, or a broker or login the user chose + try: + store.set_mqtt(settings) + except ConfigStorageError as exc: + log.warning("Couldn't save Home Assistant's MQTT broker: %s", exc) + return False + log.info(message, settings.host, settings.port) + return True diff --git a/data.example/config.yaml b/docs/example-config.yaml similarity index 100% rename from data.example/config.yaml rename to docs/example-config.yaml diff --git a/docs/releases/0.6.0.md b/docs/releases/0.6.0.md new file mode 100644 index 0000000..9dbe752 --- /dev/null +++ b/docs/releases/0.6.0.md @@ -0,0 +1,16 @@ +Dapple can now run inside Home Assistant, as an app. + +## What's changed + +- **Run Dapple on Home Assistant OS.** If your Home Assistant runs Home Assistant OS, you can install Dapple from Home Assistant's App store instead of setting up Docker on another computer. It opens in Home Assistant's sidebar, behind Home Assistant's login, and new versions arrive under Settings → Updates like any other app's. The README's [On Home Assistant OS](https://github.com/andrewfraley/dapple/blob/main/README.md#on-home-assistant-os) section has a one-click link to add it. +- **Less to type when connecting to Home Assistant.** Running as an app with the Mosquitto broker installed, Dapple fills in the broker's address and a login on its Home Assistant tab by itself. It still starts switched off: turn on **Connect to Home Assistant** and press **Save**. + +Nothing changes if you run Dapple with Docker. To move an existing Docker install over to the app, add your strands again in the app (or copy `config.yaml` and `presets.json` across), then stop the Docker one so the two don't both control your lights. + +## Upgrading + +`docker compose up -d` picks this up if you have `pull_policy: always`. Otherwise run `docker compose pull && docker compose up -d`. Your settings and saved presets carry over. To stay on this release, set the image line to: + +```yaml + image: afraley/dapple:0.6.0 +``` diff --git a/frontend/index.html b/frontend/index.html index e7aa1ea..61bc063 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,9 +4,9 @@ - - - + + + Dapple diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4e45120..ad749ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "dapple-ui", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dapple-ui", - "version": "0.5.1", + "version": "0.6.0", "license": "MIT", "dependencies": { "@emotion/react": "^11.13.3", diff --git a/frontend/package.json b/frontend/package.json index 6ab69b3..c321a38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "dapple-ui", "private": true, "license": "MIT", - "version": "0.5.1", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 827e63b..41eecbf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -81,7 +81,7 @@ export default function App() { > - + {/* Room for three tabs on a phone; the logo still says whose app it is. */} Dapple diff --git a/frontend/src/api.js b/frontend/src/api.js index bc8dd27..0a354e9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,4 +1,5 @@ -// Thin wrapper over /api. Every call surfaces the server's `detail` message, +// Thin wrapper over the API. Paths are relative so the UI also works under a +// path prefix (Home Assistant ingress). Every call surfaces the server's `detail` message, // which is what the status line shows. async function request(path, options = {}) { @@ -23,98 +24,98 @@ async function request(path, options = {}) { return body } -export const ping = () => request('/api/ping') -export const getDevices = () => request('/api/devices') -export const refreshDevices = () => request('/api/devices/refresh', { method: 'POST' }) -export const getPresets = () => request('/api/presets') -export const getConfig = () => request('/api/config') +export const ping = () => request('api/ping') +export const getDevices = () => request('api/devices') +export const refreshDevices = () => request('api/devices/refresh', { method: 'POST' }) +export const getPresets = () => request('api/presets') +export const getConfig = () => request('api/config') // ---- groups: what each one is showing, and changing it --------------------- -export const getGroups = () => request('/api/groups') +export const getGroups = () => request('api/groups') // Read from the strands, so it notices changes made outside Dapple; slower than getGroups. -export const getGroupLive = (id) => request(`/api/groups/${encodeURIComponent(id)}/live`) +export const getGroupLive = (id) => request(`api/groups/${encodeURIComponent(id)}/live`) export const applyToGroup = (id, pattern) => - request(`/api/groups/${encodeURIComponent(id)}/apply`, { + request(`api/groups/${encodeURIComponent(id)}/apply`, { method: 'POST', body: JSON.stringify(pattern), }) // The preset name goes in the body, not the path — names have spaces in them. export const applyPresetToGroup = (id, name) => - request(`/api/groups/${encodeURIComponent(id)}/preset`, { + request(`api/groups/${encodeURIComponent(id)}/preset`, { method: 'POST', body: JSON.stringify({ name }), }) export const setGroupBrightness = (id, value) => - request(`/api/groups/${encodeURIComponent(id)}/brightness`, { + request(`api/groups/${encodeURIComponent(id)}/brightness`, { method: 'POST', body: JSON.stringify({ value }), }) export const turnGroupOn = (id) => - request(`/api/groups/${encodeURIComponent(id)}/on`, { method: 'POST' }) + request(`api/groups/${encodeURIComponent(id)}/on`, { method: 'POST' }) export const turnGroupOff = (id) => - request(`/api/groups/${encodeURIComponent(id)}/off`, { method: 'POST' }) + request(`api/groups/${encodeURIComponent(id)}/off`, { method: 'POST' }) // ---- presets --------------------------------------------------------------- export const savePreset = (name, pattern) => - request(`/api/presets/${encodeURIComponent(name)}`, { + request(`api/presets/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(pattern), }) export const deletePreset = (name) => - request(`/api/presets/${encodeURIComponent(name)}`, { method: 'DELETE' }) + request(`api/presets/${encodeURIComponent(name)}`, { method: 'DELETE' }) // ---- configuration --------------------------------------------------------- export const createGroup = (name) => - request('/api/config/groups', { method: 'POST', body: JSON.stringify({ name }) }) + request('api/config/groups', { method: 'POST', body: JSON.stringify({ name }) }) export const renameGroup = (id, name) => - request(`/api/config/groups/${encodeURIComponent(id)}`, { + request(`api/config/groups/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ name }), }) export const deleteGroup = (id) => - request(`/api/config/groups/${encodeURIComponent(id)}`, { method: 'DELETE' }) + request(`api/config/groups/${encodeURIComponent(id)}`, { method: 'DELETE' }) export const reorderGroups = (ids) => - request('/api/config/groups/order', { method: 'PUT', body: JSON.stringify({ ids }) }) + request('api/config/groups/order', { method: 'PUT', body: JSON.stringify({ ids }) }) export const reorderGroupStrands = (id, hosts) => - request(`/api/config/groups/${encodeURIComponent(id)}/strands/order`, { + request(`api/config/groups/${encodeURIComponent(id)}/strands/order`, { method: 'PUT', body: JSON.stringify({ hosts }), }) export const addStrand = (strand) => - request('/api/config/strands', { method: 'POST', body: JSON.stringify(strand) }) + request('api/config/strands', { method: 'POST', body: JSON.stringify(strand) }) export const updateStrand = (host, strand) => - request(`/api/config/strands/${encodeURIComponent(host)}`, { + request(`api/config/strands/${encodeURIComponent(host)}`, { method: 'PUT', body: JSON.stringify(strand), }) export const moveStrand = (host, group, index) => - request(`/api/config/strands/${encodeURIComponent(host)}/group`, { + request(`api/config/strands/${encodeURIComponent(host)}/group`, { method: 'PUT', body: JSON.stringify(index === undefined ? { group } : { group, index }), }) export const deleteStrand = (host) => - request(`/api/config/strands/${encodeURIComponent(host)}`, { method: 'DELETE' }) + request(`api/config/strands/${encodeURIComponent(host)}`, { method: 'DELETE' }) // ---- home assistant -------------------------------------------------------- -export const getMqtt = () => request('/api/config/mqtt') +export const getMqtt = () => request('api/config/mqtt') // Leave `password` out to keep the saved one; the server never sends it back. export const saveMqtt = (settings) => - request('/api/config/mqtt', { method: 'PUT', body: JSON.stringify(settings) }) + request('api/config/mqtt', { method: 'PUT', body: JSON.stringify(settings) }) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index bb12666..de69488 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,6 +5,9 @@ import react from '@vitejs/plugin-react' // uvicorn, so the frontend can be worked on without rebuilding the container. export default defineConfig({ plugins: [react()], + // Relative asset URLs: Home Assistant's ingress serves the UI under + // /api/hassio_ingress//, so nothing may assume it lives at /. + base: './', server: { port: 5173, proxy: { diff --git a/home-assistant/dapple/DOCS.md b/home-assistant/dapple/DOCS.md new file mode 100644 index 0000000..1dc26d8 --- /dev/null +++ b/home-assistant/dapple/DOCS.md @@ -0,0 +1,35 @@ +# Dapple + +Dapple sets static multi-color patterns on Twinkly lights. Click **Dapple** in the sidebar to +open it. If it isn't there, turn on **Show in sidebar** on this app's Info tab. + +## Getting started + +1. On the **Strands** tab, add each strand by its address. The Twinkly app shows it under the + device's settings. Give each strand a fixed address in your router first (a "DHCP + reservation"), or Dapple may lose track of it one day. +2. On the **Pattern** tab, pick colors and press **Apply**. + +The [README](https://github.com/andrewfraley/dapple/blob/main/README.md) covers groups, +patterns and presets in full. + +## Home Assistant lights + +If the Mosquitto broker app is installed, Dapple has already filled in its **Home Assistant** +tab. Turn on **Connect to Home Assistant** and press **Save**, and each group appears as a +light, with your presets as its effects. If you install Mosquitto later, or reinstall it, +restart this app so Dapple picks up its login. + +[Dapple with Home Assistant](https://github.com/andrewfraley/dapple/blob/main/HOME_ASSISTANT.md) +has the details, including automations. + +## Network + +Dapple needs no port to show in the sidebar. To reach its page or REST API from elsewhere on +your network, set a port on the **Network** tab. Dapple has no login of its own, so only do +this on a network you trust. + +## Your settings + +Your strands, groups and presets live inside this app, and Home Assistant's backups include +them. Uninstalling the app deletes them. diff --git a/home-assistant/dapple/README.md b/home-assistant/dapple/README.md new file mode 100644 index 0000000..6ac2d0e --- /dev/null +++ b/home-assistant/dapple/README.md @@ -0,0 +1,5 @@ +# Dapple + +Static multi-color patterns for Twinkly lights: candy cane stripes, Halloween, team colors. +Each group of strands becomes a light in Home Assistant, with your saved patterns as its +effects. diff --git a/home-assistant/dapple/config.yaml b/home-assistant/dapple/config.yaml new file mode 100644 index 0000000..5da5ac7 --- /dev/null +++ b/home-assistant/dapple/config.yaml @@ -0,0 +1,29 @@ +# The Dapple add-on. The Supervisor pulls afraley/dapple:, the same +# image Docker users run, so `version` must match pyproject.toml (CI checks). +name: Dapple +version: "0.6.0" +slug: dapple +description: Static multi-color patterns for Twinkly lights +url: https://github.com/andrewfraley/dapple +arch: + - amd64 + - aarch64 +image: afraley/dapple +# The image has its own entrypoint, which fixes up /data and drops root. +init: false +startup: application +boot: auto +ingress: true +ingress_port: 8080 +panel_icon: mdi:string-lights +panel_title: Dapple +# Off by default: the sidebar needs no port. Turn it on in the Network tab to +# reach Dapple's page or REST API from elsewhere on the network. +ports: + 8080/tcp: null +ports_description: + 8080/tcp: Dapple's page and REST API, outside Home Assistant +# Gives Dapple the Mosquitto add-on's login, so the Home Assistant tab is +# filled in. It still starts switched off. +services: + - mqtt:want diff --git a/home-assistant/dapple/icon.png b/home-assistant/dapple/icon.png new file mode 100644 index 0000000..53151d4 Binary files /dev/null and b/home-assistant/dapple/icon.png differ diff --git a/pyproject.toml b/pyproject.toml index f51a12f..236dc91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dapple" -version = "0.5.1" +version = "0.6.0" description = "Static multi-color patterns for Twinkly light strands, with a REST API for Home Assistant" requires-python = ">=3.12" dependencies = [ diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..22783bc --- /dev/null +++ b/repository.yaml @@ -0,0 +1,6 @@ +# Makes this repository a Home Assistant add-on repository. Users add it by +# URL with the #stable branch, which the release job moves only once a +# release's image is on Docker Hub. The add-on itself is in home-assistant/. +name: Dapple +url: https://github.com/andrewfraley/dapple +maintainer: Dapple contributors diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py new file mode 100644 index 0000000..0034a06 --- /dev/null +++ b/tests/test_supervisor.py @@ -0,0 +1,155 @@ +"""Filling in the MQTT broker from Home Assistant's Supervisor, add-on installs only.""" + +import urllib.error +from dataclasses import replace + +import pytest + +from app.config import AppConfig, ConfigStore, MqttConfig, load_config +from app.supervisor import SUPERVISOR_URL, mqtt_service, sync_mqtt + +ADDON = {"SUPERVISOR_TOKEN": "token"} + + +class FakeSupervisor: + """Answers /services/mqtt like the Supervisor does, and records who asked.""" + + def __init__(self, service=None, error=None): + self.service = service + self.error = error + self.requests = [] + + def __call__(self, url, token): + self.requests.append((url, token)) + if self.error: + raise self.error + return {"result": "ok", "data": self.service} + + +MOSQUITTO = { + "host": "core-mosquitto", + "port": 1883, + "ssl": False, + "protocol": "3.1.1", + "username": "addons", + "password": "secret", + "addon": "core_mosquitto", +} + + +@pytest.fixture +def store(tmp_path): + return ConfigStore(AppConfig(data_dir=tmp_path, config_path=tmp_path / "config.yaml")) + + +def test_an_add_on_gets_the_mosquitto_login_filled_in(store): + """The whole point: no broker address or login to type on the Home Assistant tab.""" + supervisor = FakeSupervisor(MOSQUITTO) + + assert sync_mqtt(store, ADDON, supervisor) + + assert supervisor.requests == [(f"{SUPERVISOR_URL}/services/mqtt", "token")] + mqtt = store.config.mqtt + assert (mqtt.host, mqtt.port, mqtt.username, mqtt.password) == ( + "core-mosquitto", + 1883, + "addons", + "secret", + ) + + +def test_the_filled_in_broker_starts_switched_off(store): + """MQTT is off until someone turns it on, even when Dapple found the broker itself.""" + sync_mqtt(store, ADDON, FakeSupervisor(MOSQUITTO)) + + assert store.config.mqtt.enabled is False + + +def test_the_filled_in_broker_survives_a_restart(store, tmp_path): + """It's written to config.yaml like one typed in, so the password stays write-only.""" + sync_mqtt(store, ADDON, FakeSupervisor(MOSQUITTO)) + + assert load_config(tmp_path, env={}).mqtt == store.config.mqtt + + +def test_a_broker_the_user_set_up_is_left_alone(store): + """Their own broker, or one they switched off on purpose, must never be overwritten.""" + theirs = MqttConfig(host="10.0.0.50", enabled=True) + store.set_mqtt(theirs) + + assert not sync_mqtt(store, ADDON, FakeSupervisor(MOSQUITTO)) + + assert store.config.mqtt == theirs + + +def test_the_users_own_login_on_mosquitto_is_left_alone(store): + """Same broker, but a login they made themselves: the Supervisor's isn't theirs.""" + theirs = MqttConfig(host="core-mosquitto", username="dapple", password="mine") + store.set_mqtt(theirs) + + assert not sync_mqtt(store, ADDON, FakeSupervisor(MOSQUITTO)) + + assert store.config.mqtt == theirs + + +def test_a_new_mosquitto_password_replaces_the_old_one(store): + """Reinstalling Mosquitto issues a new password the user never saw and can't type in.""" + sync_mqtt(store, ADDON, FakeSupervisor(MOSQUITTO)) + store.set_mqtt(replace(store.config.mqtt, enabled=True, topic_prefix="dapple-tree")) + + assert sync_mqtt(store, ADDON, FakeSupervisor({**MOSQUITTO, "password": "reissued"})) + + mqtt = store.config.mqtt + assert mqtt.password == "reissued" + assert (mqtt.enabled, mqtt.topic_prefix) == (True, "dapple-tree") + + +class CountingStore(ConfigStore): + def __init__(self, config): + super().__init__(config) + self.writes = 0 + + def set_mqtt(self, mqtt): + self.writes += 1 + return super().set_mqtt(mqtt) + + +def test_an_unchanged_login_is_not_written_again(tmp_path): + """Every start asks the Supervisor; config.yaml shouldn't be rewritten each time.""" + store = CountingStore(AppConfig(data_dir=tmp_path, config_path=tmp_path / "config.yaml")) + sync_mqtt(store, ADDON, FakeSupervisor(MOSQUITTO)) + + assert not sync_mqtt(store, ADDON, FakeSupervisor(MOSQUITTO)) + + assert store.writes == 1 + + +def test_outside_an_add_on_the_supervisor_is_never_asked(store): + """Docker installs have no Supervisor; asking would just stall startup on DNS.""" + supervisor = FakeSupervisor(MOSQUITTO) + + assert not sync_mqtt(store, {}, supervisor) + + assert supervisor.requests == [] + assert store.config.mqtt is None + + +def test_without_mosquitto_nothing_is_filled_in(store): + """The Supervisor answers 400 when no add-on provides MQTT.""" + missing = urllib.error.HTTPError(f"{SUPERVISOR_URL}/services/mqtt", 400, "", {}, None) + + assert not sync_mqtt(store, ADDON, FakeSupervisor(error=missing)) + + assert store.config.mqtt is None + + +def test_a_broker_that_needs_tls_is_not_filled_in(store): + """Dapple connects without TLS, so these settings could only ever fail to connect.""" + assert mqtt_service(ADDON, FakeSupervisor({**MOSQUITTO, "ssl": True})) is None + + +def test_a_nonsense_answer_is_ignored(store): + """A Supervisor bug shouldn't stop Dapple from starting.""" + assert not sync_mqtt(store, ADDON, FakeSupervisor({"host": "not a host!"})) + + assert store.config.mqtt is None diff --git a/uv.lock b/uv.lock index d5c45f9..4fe27a0 100644 --- a/uv.lock +++ b/uv.lock @@ -393,7 +393,7 @@ wheels = [ [[package]] name = "dapple" -version = "0.5.1" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "aiomqtt" },