← 206YALL.COM

HA.206YALL.COM

Home Assistant Projects


Nanoleaf Room Clock (Linear Bar)

Part of pwnkw_ha — Kenneth’s collection of Home Assistant projects.

Status: v1.0 — running daily on real hardware. See Known limitations.

A Nanoleaf Lines bar turned into a linear clock: seven light bars, mounted in a straight row, showing minutes-within-the-hour as they fill left to right. The seventh bar does double duty as an outdoor-temperature gauge and a night heartbeat.

It also takes over the whole bar for a few special jobs — a meeting countdown that drains the bar to empty exactly at meeting time, a partner bedtime wind-down, and a Pride rainbow.

The countdown is the one built for a specific problem: working from home. If your work calendar is the thing that governs your day, you want an ambient “the next meeting is coming” signal in the rooms where you don’t have a screen — readable from across the room, at a glance, without picking up a phone or alt-tabbing to a calendar. The bar goes orange at fifteen minutes out, marches down through the last fourteen, and is completely dark at the moment the meeting starts. You learn to read it without thinking about it.

🚿 The origin story, which is also the actual design constraint: this bar lives in a bathroom. The countdown exists because of one specific, recurring, entirely self-inflicted failure — you’re in the shower, you have a 9:00, and you have no idea whether it’s 8:47 or 8:58. Your phone is in another room. Your hands are full of shampoo. A watch is not an option and neither, frankly, is squinting at a phone screen through steam.

So the wall tells you instead. Orange means start wrapping up. The marching zone means you are committed. When the bar goes dark, you are not “about to be late” — you are late, and you will be joining with wet hair. It is the only clock in this house that has ever successfully changed anyone’s behavior.

Driven by a custom AppDaemon app that talks to the Nanoleaf over its local REST API — not through a Home Assistant light entity, because per-zone animation frames aren’t expressible through the light domain.

Behavior What you see
Normal Minutes fill the bar; completed minutes solid, the active zone a lighter shade, future minutes dim
Color arc Warm reds/ambers overnight → sky blue-white at solar noon → violet at dusk
Line 7 right Outdoor temperature, as a stepped color scale
Line 7 left Slow 1s-up/1s-down heartbeat, overnight and only when the room is occupied
Meeting countdown Whole bar goes orange, then a marching zone, then drains to empty at meeting time — an ambient WFH warning that your next work-calendar event is imminent
Post-meeting Solid pink, one line per minute, for six minutes
Partner wind-down Signals the hour before a shift-derived bedtime
Pride Full-bar ROYGBIV — a fallback when the clock is switched off, never an override

How it works

Home Assistant entities ──► AppDaemon app ──► Nanoleaf local REST API ──► the bar
  (sun, motion, temp,        (mode select +      (PUT /effects,
   calendars, toggles)        frame render)       animData frames)

The app polls once a minute (plus a 30s tick) and pushes a complete frame describing all 14 zones. Mode selection is a strict priority ladder in get_mode():

  1. meeting_countdown — workday only, 07:30–17:00, qualifying calendar event ≤15 min out
  2. post_meeting — the first 6 minutes after a meeting’s start time
  3. partner_winddown — the hour before a bedtime derived from the partner’s shift
  4. normal — the within-hour clock

Hardware

Two things that will cost you an evening if you don’t know them:

Panel IDs are per-device and change when you replace a bar, so yours will differ. Discover your own with the tools in tools/ — see Panel discovery.

Requirements

Install

  1. Get a Nanoleaf auth token — hold the device’s power button ~5–7s until the LED flashes, then within 30s: curl -X POST http://<device-ip>:16021/api/v1/new
  2. Copy the app — put nanoleaf_clock_bar.py in your AppDaemon apps directory, and merge apps.yaml.example into that directory’s apps.yaml. ⚠️ Read the app_dir section first — “your apps directory” is the single most common place this goes wrong.
  3. Set your device details — edit the constants at the top of nanoleaf_clock_bar.py: NANOLEAF_IP and NANOLEAF_TOKEN. (See apps.yaml.example for moving these into !secret instead, which is recommended.)
  4. Point it at your entities — edit the entity constants (table below) to match your system. There is no autodiscovery; these are yours to set.
  5. Discover your panel IDs and replace the ARMS map — see below.
  6. Create the two helpers: input_boolean.nano_clock_display (master on/off) and input_boolean.pride_mode.
  7. Restart AppDaemon. Watch its log for NanoleafClock initialized.

Entities to configure

Constant Placeholder shipped What it’s for
OCCUPANCY_SENSOR binary_sensor.room_motion Brightness (occupied vs empty); gates the heartbeat
TEMP_SENSOR sensor.outdoor_temperature Line 7b temperature gauge (°F)
SUN_ENTITY sun.sun Day/night brightness and the color arc (uses the elevation attribute)
WORKDAY_SENSOR binary_sensor.workday_sensor Gates the meeting countdown
MY_CALENDAR calendar.your_work_calendar Meeting countdown / post-meeting
PARTNER_CALENDAR calendar.partner_work_calendar Bedtime wind-down; shift time parsed from the event title as HHMM / HHMM
CLOCK_ENABLED input_boolean.nano_clock_display Master kill switch
PRIDE_ENABLED input_boolean.pride_mode Pride rainbow fallback

Meeting titles containing any of MEETING_EXCLUDE (lunch, dns, out, dr, medical — case-insensitive) are skipped by the countdown.

Panel discovery

Run these from a machine on the same LAN (the device isn’t reachable remotely):

python3 tools/nanoleaf_orient.py --discover     # dump panel IDs + x/y/orientation
python3 tools/nanoleaf_guided_walk.py           # lights each node; you type 1a, 7b, or x
python3 tools/nanoleaf_orient.py --rainbow      # ROYGBIV left→right sanity check

nanoleaf_guided_walk.py is the reliable one: it lights a single node at a time and you tell it what you’re looking at, which is how the verified map gets built. Put the result in the ARMS structure near the top of the app. MEETING_MARCH and EXTINGUISH_ORDER are derived from ARMS, so they follow automatically.


Gotchas

These are the two things that actually cost real time. Both are written up because they’re non-obvious and generalize beyond this project.

Gotcha 1: AppDaemon reads a different /config than you do

Symptom: you put your app in Home Assistant’s config/apps/ folder, AppDaemon starts cleanly, and your app never loads. Or you set app_dir and nothing changes.

On the Home Assistant OS add-on, AppDaemon runs appdaemon -c /config, and the add-on declares map: [addon_config:rw, homeassistant_config:rw, ...]. Supervisor mounts those at different places, so inside the AppDaemon container:

Path inside the container What it actually is
/config the add-on’s own config dir — /addon_configs/a0d7b954_appdaemon/
/homeassistant Home Assistant’s config dir — what your file editor shows as /config

So the two directories that look identically named are not the same place:

Studio Code Server shows          AppDaemon sees
  /config/apps/            ───►     /homeassistant/apps/
  /addon_configs/a0d7b954_appdaemon/apps/  ───►  /config/apps/

Consequences:

Confirm which one is live — AppDaemon prints it on every start:

INFO AppDaemon: Configuration read from: /config/appdaemon.yaml
INFO AppDaemon: Using /homeassistant/apps as app_dir

A failed import helpfully prints its search path too, which is a second confirmation:

ModuleNotFoundError: No module named 'hello'
  Import paths:
    /homeassistant/apps

Reference: Supervisor defines PATH_HOMEASSISTANT_CONFIG = /homeassistant and PATH_PUBLIC_CONFIG = /config.

Related: AppDaemon does not inherit latitude / longitude / elevation / time_zone from Home Assistant — its docs mark all four required, and the add-on ships with Amsterdam sample values. Set them, and note elevation is in metres in both AppDaemon and Home Assistant regardless of your unit system (HA takes a bare integer and hands it to astral, which documents metres). 250 ft is 76, not 250.

Gotcha 2: Nanoleaf silently drops writes while a loop is running

The device ignores a new effect write while an effect loop is already playing. Both loop → loop and loop → static transitions get dropped: the HTTP PUT returns success and the bar simply freezes on the previous frame.

This is brutal to debug, because every layer reports success.

Rules that follow:


Files

nanoleaf_clock_bar.py       # the app — class NanoleafClock, module nanoleaf_clock_bar
apps.yaml.example           # AppDaemon app registration (+ !secret hardening notes)
appdaemon.example.yaml      # minimal appdaemon.yaml, with the app_dir trap documented
tools/
  nanoleaf_orient.py        # --discover panel IDs/geometry, --rainbow, --blocks
  nanoleaf_guided_walk.py   # interactive per-node identifier; builds the panel map
  nanoleaf_countdown_test.py# standalone countdown player; the static-writes pattern

Known limitations

This is v1.0 — honest about what isn’t proven:

Ideas for later

Credits & Attribution