Skip to content
Merged
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
34 changes: 29 additions & 5 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:<its version>, 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
Expand Down Expand Up @@ -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"

21 changes: 18 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<token>/`: 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<version>` tag,
the `main` build publishes that image version, tags the commit and creates the GitHub release
from `docs/releases/<version>.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
Expand Down Expand Up @@ -217,5 +227,10 @@ reaches every install. Keep every input pinned:
`afraley/dapple:<branch>` 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.
65 changes: 63 additions & 2 deletions DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>`, 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/<token>/`, 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://<hostname>: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` +
Expand All @@ -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/<version>.md`, written for people running Dapple, not
developers. CI fails on a PR whose version has no tag and no notes file.

Expand All @@ -387,6 +423,31 @@ image as `latest`, `<version>` and `<major>.<minor>`, then tags the merge commit
GitHub release `Dapple <version>` from the notes file. A PR that doesn't bump the version
publishes only `main` and `sha-<commit>`; 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 <id>
```

**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
Expand Down
16 changes: 16 additions & 0 deletions HOME_ASSISTANT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/`.

Expand Down Expand Up @@ -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:
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -562,6 +566,6 @@ async def missing_ui() -> HTMLResponse:
return HTMLResponse(
"<h1>Dapple</h1><p>No built UI found. Run <code>npm --prefix frontend "
"install &amp;&amp; npm --prefix frontend run build</code>, or use the "
"Docker image. The API is up at <a href='/docs'>/docs</a>.</p>",
"Docker image. The API is up at <a href='docs'>/docs</a>.</p>",
status_code=200,
)
Loading
Loading