Reference
Configuration
This page is a reference for power users. Home Screens stores everything as JSON files, but you almost never need to touch them, the editor manages all of this for you. This page exists to document the schema for scripting, external tooling, or debugging.
Home Screens stores all configuration as JSON files on disk. The main config file is data/config.json; a few feature-specific data files (meals, chores, rewards) live alongside it. There is no database, every file is read and written directly by the API with atomic writes (temp file + rename) to prevent corruption during power loss.
Data files
| File | Purpose | API |
|---|---|---|
data/config.json | Screens, modules, profiles, global settings, multi-display registry | /api/config |
data/secrets.json | API keys for external integrations (weather, calendar, photos, etc.) | /api/secrets |
data/auth.json | Password hash and session secret for editor authentication | (internal) |
data/meals.json | Meal library, weekly plan, checked-off grocery items, household meal settings | /api/meals/data |
data/family.json | Shared family members, family groups, and legacy identity aliases | /api/family |
data/chores.json | Chore definitions | /api/chores/data |
data/chore-completions.json | Chore completion history (last 90 days) | /api/chores |
data/rewards.json | Reward definitions, point balances, redemption history | /api/rewards/data |
data/google-tokens.json | Google Calendar OAuth tokens | (internal) |
data/icloud-accounts.json | iCloud account credentials (app-specific passwords) for calendar sync | /api/icloud/accounts |
data/todos.json | Shared to-do lists (items, due days, people, repeats) | /api/todo/lists |
data/timetables.json | School timetables: schools and bell times, the shared subject list, one week per person | /api/timetables |
data/school-holidays.json | Last-good copy of the school and public holiday dates looked up for a region | /api/timetables/holidays |
data/routines.json | Saved timer routines (the steps, not a running timer) | /api/timers/routines |
data/timer-session.json | The one timer running right now, as a snapshot plus timestamps so displays can count down on their own | /api/timers/session |
data/google-picker-tokens.json | Google Photos Picker tokens, kept separate from the Calendar tokens above | (internal) |
data/onedrive-tokens.json | OneDrive photo sign-in tokens | (internal) |
data/backup-state.json | Last-backup and last-dismissed timestamps behind the backup reminder | /api/backup |
data/port.conf | Custom server port (preserved across upgrades) | (internal) |
data/plugins/ | Installed plugin bundles and manifests | /api/plugins/* |
data/plugin-tokens/ | Per-plugin account tokens from server-side auth adapters | /api/plugins/auth/* |
data/plugin-secrets/ | Per-plugin secrets you enter yourself, kept outside data/plugins/ because a plugin upgrade replaces that folder wholesale | /api/plugins/secrets |
data/custom-icons/ | The family's own icons: index.json lists them, and each picture sits beside it as a .webp named by its content | /api/custom-icons |
Chore definitions and chore completions are two separate files. Copying only data/chores.json leaves every completion (and therefore every earned point) behind.
Other files under data/ are written and managed by the app and are not meant to be edited by hand: backups/, thumbnails/, kiosk.conf, telemetry.json, background-cache.json, weather-today.json, audit.log, the update records (auto-update-setting.json, auto-update-state.json, update-notification-state.json, upgrade-failed.json, restart-needed.json), the Pi program records (system-packages.json, system-package-holds), the change journal behind family and restore writes (family-transaction.json), and the records of the one-time move to a shared family list (family-migration.json, family-migrations/).
The main config is read via GET /api/config and written via PUT /api/config. If you have set an editor password, both endpoints require an hs-session cookie: PUT accepts nothing else, and GET also accepts a display bearer token. With no password set, authentication is off and both are open on your local network.
Close the editor tab before hand-editing data/config.json. The editor loads the whole config into memory when the page opens and writes the whole file back when you save, so any change you made on disk in between is silently overwritten. PUT /api/config works the same way; it replaces the file wholesale rather than merging your changes into it.
API Keys & Credentials
API keys and credentials are managed through the editor UI under Settings > API keys and stored server-side in data/secrets.json via the /api/secrets endpoint. They are not stored in .env.local or in the config file.
Supported secret keys:
| Key | Used By |
|---|---|
openweathermap_key | Weather (OpenWeatherMap provider), Air Quality |
weatherapi_key | Weather (WeatherAPI provider) |
pirateweather_key | Weather (Pirate Weather provider) |
metoffice_key | Weather (Met Office provider) |
unsplash_access_key | Background rotation (Unsplash) |
todoist_token | Todoist module |
google_maps_key | Traffic module (Google Routes) |
tomtom_key | Traffic module (TomTom) |
google_client_id | Google Calendar, only with your own Google app (TVs and Limited Input devices client) |
google_client_secret | Google Calendar, only with your own Google app |
google_web_client_id | Google Photos import, only with your own Google app (web application client) |
google_web_client_secret | Google Photos import, only with your own Google app |
microsoft_client_id | OneDrive photos in the Photo Slideshow and Full-Screen Photo Viewer (the Application ID of your own Microsoft app) |
nasa_api_key | Background rotation (NASA APOD) |
immich_url | Immich server URL (e.g. http://192.168.1.50:2283) |
immich_api_key | Immich API key (Account Settings → API Keys) |
Environment variables
A few behaviors are set in the Home Screens service's environment rather than in the editor. Add them with sudo systemctl edit home-screens and then restart the service (see Networking), because upgrades rewrite the service file itself. PORT is the exception.
| Variable | Effect |
|---|---|
PORT | The port the server listens on. Set it in data/port.conf, not with an override: setup-system writes it into the service file from there and the kiosk browser reads the same file, so an override alone leaves the screen on the old port. See Networking |
HS_GOOGLE_HOSTED=0 | Turns off Home Screens' own Google apps, so Google Calendar and Google Photos only sign in with a Google app you saved under Settings > API keys |
HS_DISABLE_AUTO_UPDATE=1 | Never installs updates on a schedule, whatever Install updates automatically says |
HS_DISABLE_TELEMETRY=1 | Never sends the anonymous usage report, whatever the setting says |
HS_TRUSTED_PROXIES | Comma-separated proxy addresses whose forwarded client address is believed; see Restrict access by IP |
HS_ALLOWED_ORIGINS | Other sites allowed to send changes to the API; see Requests from other websites |
HOME_SCREENS_DIR | Where an install that is not under /opt/home-screens/current lives, so updates and the data folder are found |
Schema
The configuration has the following structure:
- ScreenConfiguration contains a
versionnumber, a single GlobalSettings object, zero or more Screen objects, and zero or more Profile objects. - Each Screen contains zero or more ModuleInstance objects.
- Each ModuleInstance has a ModuleStyle object, an optional ModuleSchedule, and a module-specific config object.
- Each Profile has an optional ModuleSchedule for auto-activation.
Top Level
| Field | Type | Default | Description |
|---|---|---|---|
version | number | 16 | Config schema version. The app updates it when it upgrades an older file; never edit it by hand |
settings | GlobalSettings | see GlobalSettings | Settings shared by every display (see GlobalSettings) |
screens | Screen[] | one entry | The screens shown in single-display mode. Once displays is set, each display owns its own screens instead |
profiles? | Profile[] | unset | Named groups of screens in single-display mode (see Profile) |
rules? | DisplayRule[] | unset | Display rules for legacy single-display mode (multi-display rules live on each DisplayNode). |
displays? | DisplayNode[] | unset | The multi-display registry (see DisplayNode). Omitted or empty means single-display mode |
The displays field is opt-in. When it is undefined or empty, Home Screens runs in single-display mode and renders screens directly, this is the default for fresh installs and the unchanged behavior for any existing config that predates the multi-display feature. When displays is populated, each entry has its own owned screens, dimensions, and rotation; see DisplayNode below and the Multi-display guide for the full multi-display flow.
GlobalSettings
| Field | Type | Default | Description |
|---|---|---|---|
rotationIntervalMs | number | 30000 | How long each screen shows before the next one, in milliseconds |
displayWidth | number | 1080 | Canvas width in pixels |
displayHeight | number | 1920 | Canvas height in pixels |
displayTransform? | string | "90" | Screen rotation: normal, 90, 180, or 270. 90 and 270 turn the screen to portrait |
touchMatrix? | number[] | unset | Six numbers that line touch up with the picture on the hub's own screen. Leave unset and touch follows the screen rotation; [1, 0, 0, 0, 1, 0] leaves touch as it is |
latitude | number | 0 | Household latitude |
longitude | number | 0 | Household longitude |
locationName? | string | unset | Name of the location, as shown in settings |
timezone? | string | unset | IANA timezone the displays keep time in (e.g. America/Chicago) |
googleApp? | string | "own" | Which Google app signs the household in when it has saved its own: own (its own app) or hosted (Home Screens' app, keeping the saved details for later). Without saved details, Home Screens' app is used either way |
weather | WeatherSettings | see WeatherSettings | Weather source, location, and units (see WeatherSettings) |
calendar | CalendarSettings | see CalendarSettings | Calendar sources and options (see CalendarSettings) |
sleep? | SleepSettings | unset | Dimming and sleep (see SleepSettings) |
screensaver? | ScreensaverSettings | unset | What a dimmed display shows (see ScreensaverSettings) |
cursorHideSeconds? | number | 3 | Seconds without mouse movement before the pointer hides |
activeProfile? | string | unset | ID of the profile used whenever no scheduled profile matches |
transitionEffect? | string | unset | How one screen changes to the next (see TransitionEffect). One of fade, slide, slide-up, zoom, flip, blur, crossfade, none |
transitionDuration? | number | 0.6 | Length of the screen transition in seconds |
updateChannel? | string | "stable" | Which builds the update check offers: stable, rc, beta, or nightly. Each channel also sees everything more stable than itself |
advancedMode? | boolean | false | Show the developer settings: the Test builds choice, build details, and the Plugins > Developer tab |
alerts? | AlertSettings | unset | Alert overlay (see AlertSettings) |
telemetryEnabled? | boolean | true | Send anonymous usage statistics |
fullscreenTheme? | string | unset | Theme for every full-screen module that does not set its own: linen, paper, mist, sandstone, vellum, or bloom (light), charcoal, midnight, slate, aurora, obsidian, or horizon (dark) |
pauseEnabled? | boolean | true | Double-tap the active pagination dot to pause screen rotation |
pauseTimeoutSeconds? | number | 300 | Seconds before a paused display starts rotating again on its own; 0 = never |
swipeEnabled? | boolean | true | Flick left or right on a touchscreen to change screens |
setupHintEnabled? | boolean | true | Show a faint note with the editor address on a display that has no screens to show. Turn it off to keep a deliberately blank display blank |
showRotationProgress? | boolean | true | Draw a thin line under the active pagination dot that fills while the screen is showing |
showPaginationDots? | boolean | true | Show the row of screen dots at the bottom of the display |
backupReminder? | BackupReminderSettings | unset | Backup reminder (see BackupReminderSettings) |
updateNotification? | UpdateNotificationSettings | unset | New-release banner (see UpdateNotificationSettings) |
autoUpdate? | AutoUpdateSettings | unset | Install updates by itself at a set time (see AutoUpdateSettings). Hub only |
locale? | string | "en-US" | Language for every display, as a BCP-47 tag (e.g. en-US, de-DE). Also sets date and number formatting unless formattingLocale overrides it |
formattingLocale? | string | unset | A BCP-47 tag that changes only date and number formatting, leaving the language alone. Unset follows locale |
timeFormat? | string | unset | Household 12/24-hour preference: 12h or 24h. Every module that shows a time follows it; clocks do when their hourFormat is inherit, and the meal planner unless its own time format is set. Unset follows the usual clock of the formatting language |
SleepSettings
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | unset | Turn dimming and sleep on |
idleDimEnabled? | boolean | true | Dim, then sleep, after a stretch with no touches. The schedules below run either way |
dimAfterMinutes | number | unset | Minutes without a touch before the display dims |
sleepAfterMinutes | number | unset | Minutes without a touch before the display sleeps |
dimBrightness | number | unset | Brightness while dimmed, 0 to 100 |
dimSchedule? | { startTime: string; endTime: string; } | unset | A daily window to stay dimmed: startTime and endTime as HH:MM |
schedule? | { startTime: string; endTime: string; } | unset | A daily window to sleep: startTime and endTime as HH:MM |
wakeHoldMinutes? | number | 5 | How long a wake-up during a scheduled dim or sleep window keeps the display on, in minutes; 0 = not at all |
panelPowerOff? | boolean | false | Also switch the screen's power off while asleep (Raspberry Pi kiosks only) |
ScreensaverSettings
| Field | Type | Description |
|---|---|---|
mode | string | What a dimmed display shows: clock, blank, or off |
AlertSettings
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | unset | Show alerts sent to the display |
position | string | unset | Where alerts appear: top or bottom |
maxVisible | number | unset | Most alerts shown at once |
defaultDuration | number | unset | How long an alert stays up, in milliseconds; 0 = each alert type's own default |
scale? | number | 1 | Size of the alerts, 0.75 to 2 |
BackupReminderSettings
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Remind you when a backup is overdue |
intervalDays | number | 7 | Days between reminders |
UpdateNotificationSettings
| Field | Type | Description |
|---|---|---|
enabled | boolean | Show a banner when a new release is available |
WeatherSettings
| Field | Type | Default | Description |
|---|---|---|---|
provider | string | "open-meteo" | Weather data source. One of openweathermap, weatherapi, pirateweather, noaa, open-meteo, yr, smhi, metoffice, envcanada |
latitude | number | 0 | Latitude the forecast is fetched for |
longitude | number | 0 | Longitude the forecast is fetched for |
units | string | "imperial" | metric or imperial |
radarServerUrl? | string | unset | Rain map radar server: any LibreWXR server. Blank uses the public one |
CalendarSettings
| Field | Type | Default | Description |
|---|---|---|---|
googleCalendarId | string | "" | A single Google calendar ID, from before several could be picked; googleCalendarIds replaced it |
googleCalendarIds | string[] | [] | Google calendars to show |
icalSources | ICalSource[] | [] | iCal (ICS) feeds (see ICalSource) |
icloudSources? | ICloudSource[] | unset | iCloud calendars picked from connected accounts (see ICloudSource) |
people? | CalendarPerson[] | unset | An older list of people and their calendars, kept only until it has been moved onto the family list |
personSources? | Record<string, string[]> | unset | Whose calendars are whose: a family member ID to the calendar source IDs that belong to them. A calendar nobody claims is shared by the household |
daysAhead | number | 7 | How many days ahead to fetch events |
holidayCountry? | string | unset | Country for public holidays, as a two-letter ISO code (e.g. US) |
hideDeclined? | boolean | unset | Google only: skip events the signed-in account declined |
The family grid and free time views join personSources against data/family.json. Manage people under Settings > Family and their calendars under Settings > Calendar > Whose calendars? A calendar that no person claims is shared by the whole household. Legacy people records are preserved by the schema upgrade until the coordinated family migration folds them safely.
ICalSource
| Field | Type | Description |
|---|---|---|
id | string | Unique ID |
type | "ical" | Always ical |
name | string | Name shown in the editor and in calendar legends |
url | string | Feed address |
color | string | Color for this calendar's events |
enabled | boolean | Whether the feed is shown |
homeNetwork? | boolean | Let the hub fetch this feed from an address on the home network, for a self-hosted calendar server |
ICloudSource
| Field | Type | Description |
|---|---|---|
id | string | Unique ID |
accountId | string | The connected iCloud account, from data/icloud-accounts.json. Passwords never live in the config |
kind | string | calendar for a calendar, birthdays for your contacts' birthdays |
url | string | The calendar's address; empty for birthdays |
name | string | Calendar name |
color | string | Calendar color, kept from iCloud |
enabled | boolean | Whether the calendar is shown |
Account credentials (Apple ID + app-specific password) are not stored in the config file, they live in data/icloud-accounts.json and are referenced by accountId.
TransitionEffect
One of fade, slide, slide-up, zoom, flip, blur, crossfade, none.
Screen
| Field | Type | Default | Description |
|---|---|---|---|
id | string | unset | Unique ID |
name | string | unset | Name shown in the editor |
enabled? | boolean | true | Whether the screen is in the rotation |
backgroundImage | string | unset | Background image path; empty for none |
backgroundRotation? | BackgroundRotation | unset | Rotating background images (see BackgroundRotation) |
shade? | BackgroundShade | unset | Dim/vignette overlay drawn over the background (see BackgroundShade) |
modules | ModuleInstance[] | unset | The modules on this screen (see ModuleInstance) |
rotationDurationMs? | number | unset | How long this screen shows, in milliseconds. Unset uses the display's rotation interval; 0 keeps the screen up until someone moves on |
schedule? | ModuleSchedule | unset | Only put this screen in the rotation during these days and times (see ModuleSchedule). When no scheduled screen matches, every enabled screen shows |
BackgroundRotation
| Field | Type | Description |
|---|---|---|
enabled | boolean | Turn rotation on |
source? | string | Where the images come from. One of unsplash, nasa-apod, immich, icloud |
query | string | Unsplash search words; other sources ignore it |
intervalMinutes | number | Minutes between images |
immichAlbumId? | string | Immich album to use |
immichPersonId? | string | Immich person (face) to use |
immichFavoritesOnly? | boolean | Only use Immich favorites |
icloudAlbumUrl? | string | Public iCloud shared album link, or just its token |
BackgroundShade
| Field | Type | Description |
|---|---|---|
enabled | boolean | Turn the shade on |
style | string | How the shade is shaped over the background. One of even, topBottom, edges, both |
strength | number | Overlay intensity, 0-100 |
color | string | Shade color (hex or rgb()) |
ModuleInstance
| Field | Type | Default | Description |
|---|---|---|---|
id | string | unset | Unique ID |
enabled? | boolean | true | Set to false to hide the module on the display without deleting it; the editor still shows it, dimmed |
type | string | unset | Module type, such as clock, or plugin:<name> for an add-on. One of clock, calendar, weather, countdown, dad-joke, text, image, video, quote, todo, sticky-note, greeting, news, fullscreen-news, stock-ticker, crypto, word-of-day, history, moon-phase, sunrise-sunset, photo-slideshow, qr-code, year-progress, traffic, sports, air-quality, todoist, rain-map, multi-month, garbage-day, standings, affirmations, date, display-control, meal-planner, iframe, icon, shape, chore-chart, timetable, fullscreen-calendar, fullscreen-chore-chart, fullscreen-meal-planner, fullscreen-photo, fullscreen-weather |
position | ModulePosition | unset | Top-left corner in pixels: { x, y } |
size | ModuleSize | unset | Width and height in pixels: { w, h } |
zIndex | number | unset | Stacking order; higher draws on top |
config | Record<string, unknown> | unset | The module's own settings, listed module by module in the Module Reference |
style | ModuleStyle | unset | Card styling (see ModuleStyle) |
schedule? | ModuleSchedule | unset | Only show the module during these days and times (see ModuleSchedule) |
visibility? | ModuleVisibility | unset | Only show the module while live values meet these conditions (see ModuleVisibility) |
backgroundProvider? | boolean | unset | Never draw the module; run it in the background instead, so the values it publishes keep updating while other screens show |
The three visibility gates (enabled, schedule, visibility) are AND-combined: a module renders only when it is enabled, its schedule window matches, and its visibility conditions are met.
ModuleSchedule
Controls when a module (or profile) is active based on day of week and time window.
| Field | Type | Description |
|---|---|---|
daysOfWeek? | number[] | Days the window opens, 0 = Sunday through 6 = Saturday. Omit for every day |
startTime? | string | Start, as HH:MM. Omit to start at midnight |
endTime? | string | End, as HH:MM. Omit to end at midnight. An end earlier than the start closes the next morning; an end equal to the start runs a full 24 hours |
endDayOffset? | number | Days after the start day that the window closes, 0 to 6, for one long stretch such as Monday 08:00 until Thursday 20:00 |
invert? | boolean | Hide during the window instead of showing |
ModuleVisibility
Shows or hides a module based on values published to the shared state bus. Most values come from plugins, via the SDK's publishState. Marking a plugin instance backgroundProvider keeps it publishing across screen rotation; the flag has no state-publishing effect on built-in modules, which do not publish anything of their own. Home Screens itself publishes two small sets of built-in values that need no module on screen and survive rotation. calendar.* comes from the display's shared calendar fetch (next event, how many events today, whether something is on right now) whenever a calendar source is configured. weather.* comes from the default weather provider whenever a location is set:
weather.condition:clear,clouds,rain,drizzle,snow,thunderstorm,fogorwind, read from the current hourweather.temperature: the current reading in the household's unit, whole degreesweather.precip_chance_now: chance of rain or snow this hour, 0 to 100weather.precip_chance_next_6h: the highest chance in the next six hoursweather.precip_chance_today: the highest chance for the rest of today, on the display's calendar day, including the forecast's own figure for the dayweather.alert_active:truewhile a weather warning that has not expired is in force, otherwisefalseweather.alert_severity:minor,moderate,severeorextremefor the most serious active warning, blank when there is noneweather.alert_title: that warning's name, such as "Winter Storm Warning", blank when there is none
A chance key is blank when the provider does not report one, which a numeric condition treats as not met. Only Pirate Weather and NOAA send warnings, so the three warning keys appear in the key picker only while the household's default weather provider is one of those; on every other provider they are still published, with weather.alert_active at false, so a condition saved earlier keeps evaluating. Conditions follow Home Assistant-style semantics.
| Field | Type | Default | Description |
|---|---|---|---|
conditions | VisibilityCondition[] | unset | Conditions that must all hold for the module to show (see VisibilityCondition) |
whenUnknown? | string | "hide" | What to do while a value the conditions read has not been published yet: hide or show |
VisibilityCondition
Each condition is one of these shapes, told apart by its kind:
kind: "state": Compares a published value as text
| Field | Type | Description |
|---|---|---|
sourceKey | string | Key of the published value |
equals? | string | string[] | Holds when the value is this text, or one of these |
notEquals? | string | string[] | Holds when the value is not this text, nor any of these |
kind: "numeric": Compares a published value as a number
| Field | Type | Description |
|---|---|---|
sourceKey | string | Key of the published value |
above? | number | Holds when the value is greater than this |
aboveInclusive? | boolean | Count a value equal to above as holding |
below? | number | Holds when the value is less than this |
belowInclusive? | boolean | Count a value equal to below as holding |
kind: "time": A day-and-time window on the display's clock, with the same fields as ModuleSchedule. It reads no published value, so it never waits on one
| Field | Type | Description |
|---|---|---|
daysOfWeek? | number[] | Days the window opens, 0 = Sunday through 6 = Saturday. Omit for every day |
startTime? | string | Start, as HH:MM. Omit to start at midnight |
endTime? | string | End, as HH:MM. Omit to end at midnight |
endDayOffset? | number | Days after the start day that the window closes, as in ModuleSchedule |
kind: "and": Holds when every condition inside it holds
| Field | Type | Description |
|---|---|---|
conditions | VisibilityCondition[] | The conditions in the group |
kind: "or": Holds when any condition inside it holds
| Field | Type | Description |
|---|---|---|
conditions | VisibilityCondition[] | The conditions in the group |
kind: "not": Holds when none of the conditions inside it hold
| Field | Type | Description |
|---|---|---|
conditions | VisibilityCondition[] | The conditions in the group |
sourceKey references a published state key (plugin keys are prefixed plugin:<id>:, built-in ones are not). Conditions are edited visually in the module's Conditions section in the editor; the key picker lists the built-in values first, under Built-in, followed by the keys plugins declare in their manifest's providesState field or compute from their config via a deriveProvidedKeys export. Check the Or equal to box next to a numeric bound to make it inclusive. See the Plugins guide for the publishing side.
Save-time limits: at most 32 conditions per module (leaves and groups combined) and 5 levels of and / or / not nesting, and a group condition must have at least one child. Exceeding any of these makes PUT /api/config fail with a 400 rather than saving.
Profile
Named groups of screens that can be activated manually or on a schedule.
| Field | Type | Description |
|---|---|---|
id | string | Unique ID |
name | string | Name shown in the editor (e.g. Morning) |
screenIds | string[] | IDs of the screens this profile shows |
schedule? | ModuleSchedule | When the profile turns itself on (see ModuleSchedule) |
Profiles support overnight windows (e.g. 23:00–06:00). Scheduled profiles take precedence: at each tick the first profile in list order whose schedule matches, and that still resolves to at least one screen, wins. settings.activeProfile is the fallback used when no scheduled profile matches. If neither produces screens, all screens are shown.
DisplayRule
A condition → action rule owned by a display. Rules reuse the VisibilityCondition tree and evaluator unchanged, but are edge-triggered: a rule fires only on the false→true transition of its conditions, never while they merely stay true, so a reboot or a restarting state producer never slams the display onto an alert screen for a condition that has been true for hours. Rules live under Settings > Automation > Rules and are per-display in multi-display setups.
| Field | Type | Default | Description |
|---|---|---|---|
id | string | unset | Unique ID |
name | string | unset | Name shown in the editor, e.g. Doorbell → front camera |
enabled? | boolean | true | Set to false to switch the rule off |
when | VisibilityCondition[] | unset | Conditions that must all become true for the rule to fire (see VisibilityCondition) |
action | RuleAction | unset | What happens when it fires (see RuleAction) |
cooldownSeconds? | number | 0 | Seconds after firing before the rule can fire again |
When multiple rules could fire at once, the first one in list order wins; reorder rules by dragging their cards. In multi-display setups, a rule can be copied to another display, since screens are per-display, a copied showScreen action arrives with its target screen cleared, ready to point at a screen on the new display.
Save-time limit: at most 64 rules per display. The when tree obeys the same condition and nesting limits as ModuleVisibility above.
RuleAction
kind: "showScreen": Show one screen
| Field | Type | Description |
|---|---|---|
screenId | string | The screen to show, from the rule's own display |
mode | string | while keeps the screen up as long as the conditions hold (at least 5 seconds); for shows it for seconds, then rotation carries on |
seconds? | number | How long to show the screen; required when mode is for |
kind: "wake": Wake the display; nothing happens if it is already awake
kind: "sleep": Put the display to sleep, like the remote sleep command, and end any screen a rule is holding
DisplayNode (multi-display)
A named display device. Each display owns its own list of screens, designed at its own resolution and orientation. Used in multi-display deployments where one server drives multiple Pi displays. See the Multi-display guide for the install and adoption flow.
| Field | Type | Description |
|---|---|---|
id | string | URL-safe ID, which is also the display's address: /display/<id> |
name | string | Name shown in the editor |
screens | Screen[] | This display's own screens, laid out at its resolution |
displayWidth? | number | Canvas width in pixels, instead of the shared one |
displayHeight? | number | Canvas height in pixels, instead of the shared one |
displayTransform? | string | Screen rotation for this display: normal, 90, 180, or 270 |
touchMatrix? | number[] | Six numbers that line touch up with the picture, instead of following the screen rotation |
profiles? | Profile[] | This display's own profiles. Their screenIds point at this display's screens |
activeProfile? | string | Profile used when no scheduled one matches; unset uses the shared activeProfile |
settings? | DisplayNodeSettings | Settings this display overrides (see DisplayNodeSettings) |
rules? | DisplayRule[] | This display's own rules (see DisplayRule). There is no shared set in multi-display mode |
Like screens, the profiles field is owned by the display: owned profile screenIds reference the display's own screens, not the global pool. When the first additional display is added to a single-display install, the existing config.profiles and config.settings.activeProfile migrate onto the auto-created main display alongside its screens; subsequent displays start with profiles: [] so they build fresh against their own screens. In multi-display mode profiles are always per-display, there is no "shared pool" escape hatch, because a pool profile's screenIds would silently diverge from each display's owned screens as soon as either one is edited.
DisplayNodeSettings is a subset of GlobalSettings that can be overridden per display. Nested objects (sleep, screensaver, alerts) are full-replacement, not deep-merged, override the whole object or omit it:
| Field | Type | Description |
|---|---|---|
displayWidth? | number | Canvas width in pixels |
displayHeight? | number | Canvas height in pixels |
displayTransform? | string | Screen rotation. One of normal, 90, 180, 270 |
rotationIntervalMs? | number | How long each screen shows, in milliseconds |
transitionEffect? | string | How one screen changes to the next. One of fade, slide, slide-up, zoom, flip, blur, crossfade, none |
transitionDuration? | number | Length of the transition in seconds |
sleep? | SleepSettings | Dimming and sleep, replacing the shared object as a whole |
screensaver? | ScreensaverSettings | What a dimmed display shows, replacing the shared object as a whole |
fullscreenTheme? | string | Theme for full-screen modules |
cursorHideSeconds? | number | Seconds before the pointer hides |
pauseEnabled? | boolean | Double-tap the active dot to pause rotation |
pauseTimeoutSeconds? | number | Seconds before a pause ends on its own |
swipeEnabled? | boolean | Swipe to change screens |
setupHintEnabled? | boolean | Show the setup note on a display with no screens |
showRotationProgress? | boolean | Show the rotation progress line |
showPaginationDots? | boolean | Show the row of screen dots |
alerts? | AlertSettings | Alert overlay, replacing the shared object as a whole |
Per-display location overrides (latitude, longitude, locationName, timezone) are intentionally not available: the weather, air-quality, and calendar API routes read location via readConfig() directly rather than through filterConfigForDisplay, so a per-display override would only affect client rendering while the upstream fetch still used the hub's coordinates.
Per-display dimension fields (top-level on the DisplayNode) override the equivalents nested inside settings. Per-display settings override the global settings on a per-key basis. Rotation is authoritative for canvas orientation: the hub sorts the (width, height) pair so the long edge points along the landscape axis when the rotation is normal/180 and along the portrait axis when it's 90/270.
Validation rules (enforced when the config is written):
| Rule | Limit |
|---|---|
| Display ID format | URL-safe slug, lowercase letters, digits, hyphens; must start with a letter or digit |
| Display ID length | ≤ 64 characters |
| Reserved IDs | all is refused, because the command endpoints use it to mean every display |
| IDs must be unique | Yes |
| Maximum displays | 64 per config |
| Maximum screens per display | 256 |
| Dimensions | Whole numbers from 320 to 16384 |
| Owned profile IDs | Must be unique within the display's profiles list |
Owned profile screenIds | Must reference the display's own screens (not the global pool) |
activeProfile references | When owned profiles are present, must be a member of profiles; otherwise must reference the global profile list |
ModuleType
There are 45 built-in module types. Plugin modules use the plugin:<name> format.
One of clock, calendar, weather, countdown, dad-joke, text, image, video, quote, todo, sticky-note, greeting, news, fullscreen-news, stock-ticker, crypto, word-of-day, history, moon-phase, sunrise-sunset, photo-slideshow, qr-code, year-progress, traffic, sports, air-quality, todoist, rain-map, multi-month, garbage-day, standings, affirmations, date, display-control, meal-planner, iframe, icon, shape, chore-chart, timetable, fullscreen-calendar, fullscreen-chore-chart, fullscreen-meal-planner, fullscreen-photo, fullscreen-weather.
ModuleStyle
| Field | Type | Default | Description |
|---|---|---|---|
opacity | number | 1 | Module opacity, 0 to 1 |
borderRadius | number | 12 | Corner rounding in pixels |
padding | number | 16 | Inner padding in pixels |
backgroundColor | string | "rgba(0, 0, 0, 0.4)" | Card background, any CSS color (e.g. rgba(0,0,0,0.4)) |
textColor | string | "#ffffff" | Text color, any CSS color |
fontFamily | string | "inter" | Font id from the built-in list below |
fontSize | number | 16 | Text size in pixels (the smallest size, on modules that fit their text to their box). Stands as it is while textScale is unset |
textScale? | number | unset | Text size as a percent, 10 to 450, of what the module shows on its own. Unset leaves fontSize as it is |
fontWeight? | number | unset | One weight, 100 to 900, for all of the module's text. Unset keeps each module's own weights |
title? | string | unset | Centered title strip above the module content; empty or unset = no strip |
titleFontSize? | number | unset | Title strip text size in pixels; unset uses fontSize |
backdropBlur | number | 12 | Blur behind the card, in pixels |
borderWidth | number | 1 | Border width in pixels |
borderColor | string | "rgba(255, 255, 255, 0.15)" | Border color, any CSS color |
shadowSize | number | 8 | Shadow size in pixels |
title and titleFontSize only apply to modules that render the standard card frame. Plugin modules and Display Control draw their content without the card, so a title strip can never appear on them, the editor hides the Card Title fields for those modules, and both keys are dropped from a plugin manifest's defaultStyle when a module is placed.
fontFamily stores a font registry id, not a raw CSS stack. The available ids are inter, roboto, poppins, system-ui, playfair, lora, dm-serif, georgia, jetbrains, mono, bebas, caveat, and pacifico. The fonts themselves are bundled at build time, so only these ids are guaranteed to load. A raw CSS stack is still accepted for backward compatibility, but anything the registry does not recognize is passed to the browser verbatim and will fall back to a system font.
Module Configs
Each ModuleInstance.config object holds the fields for its module type. Those fields, with their defaults and allowed values, are documented one table per module in the Module Reference: that page is the source of truth for module options, and the stored JSON matches it exactly.
Shared data files
Several features deliberately keep their data outside config.json: the four below, and the timetables, routines and running timer listed in Data files. The per-module config holds display options only; the data itself lives in a shared file so every module instance stays in sync, and so a save from the editor can never clobber something changed from /remote or tapped on a display.
data/meals.json
The meal library, weekly plan, grocery check-offs, and household-wide planning settings, written atomically via /api/meals/data. Settings live here rather than on each module so /remote and every meal-planner instance agree.
| Field | Type | Description |
|---|---|---|
savedMeals | SavedMeal[] | The meal library (see SavedMeal below) |
plan | PlannedMeal[] | Planned meals, one entry per day and slot (see PlannedMeal) |
groceryChecked | GroceryChecked | Ingredient names checked off each week's grocery list, keyed by the date that week starts on. The list itself is built from the week's planned meals and is not stored |
settings | MealSettings | Household meal settings (see MealSettings) |
timeFormatLegacyStripped? | boolean | Set once the app has cleared an old time-format default from settings; leave it alone |
SavedMeal
Each entry in savedMeals:
| Field | Type | Description |
|---|---|---|
id | string | Unique ID |
name | string | Meal name |
emoji? | string | Emoji shown with the meal |
category? | string | Kind of dish. One of main, side, dessert, drink, snack |
tags? | string[] | Free-form tags |
prepTime? | number | Prep time in minutes |
cookTime? | number | Cook time in minutes |
difficulty? | string | How hard it is to make. One of easy, medium, hard |
servings? | number | How many people it serves |
ingredients? | MealIngredient[] | Ingredients, for the grocery list (see MealIngredient) |
recipeUrl? | string | Link to the recipe, which a display can open |
notes? | string | Notes |
rating? | number | Rating, 1 to 5 |
isFavorite? | boolean | Marked as a favorite |
Each of its ingredients:
| Field | Type | Description |
|---|---|---|
name | string | Ingredient |
amount? | string | Amount, as text (e.g. 2 cups) |
category? | string | Grocery aisle. One of produce, dairy, meat, seafood, bakery, pantry, frozen, beverages, other |
MealSettings is edited from the /remote Meals tab so every meal module on every display stays consistent:
| Field | Type | Default | Description |
|---|---|---|---|
enabledSlots | MealSlotType[] | ["breakfast","lunch","dinner"] | Which meals of the day are planned |
weekStartDay | string | "sunday" | First day of the planning week. One of sunday, monday |
defaultSlotTimes | Partial<Record<MealSlotType, string>> | {} | Default serving time per slot, as 24-hour HH:MM. A planned meal's own time wins |
timeFormat? | string | unset | 12h or 24h for meal times. Unset follows the household time format |
timeFormat is optional: when omitted (the default), meal times follow the household GlobalSettings.timeFormat; an explicit '12h'/'24h' wins everywhere meals are shown. Versions before this setting existed wrote '12h' into every file whether or not the user had touched the picker, so on first read after upgrading, a stored '12h' is treated as never-configured and removed once (a timeFormatLegacyStripped marker in the file keeps a later deliberate '12h' pick from being stripped again). A stored '24h' was always deliberate and survives as an explicit override.
Each PlannedMeal uses an ISO date string to support multi-week planning:
| Field | Type | Description |
|---|---|---|
date | string | Day, as YYYY-MM-DD |
slot | string | Which meal of the day. One of breakfast, lunch, dinner, snack |
mealId? | string | The saved meal, by ID |
customText? | string | Text instead of a saved meal (e.g. Eating out) |
notes? | string | Notes |
time? | string | Serving time, as 24-hour HH:MM, instead of the slot's default for this one day |
Old configs that used day: number (day-of-week index) are automatically migrated to ISO date format on first read.
data/family.json
The shared household roster, edited under Settings > Family or /remote > Settings > Family. /api/family returns its members and groups and a revision required for writes; groups are edited through /api/family/groups.
| Field | Type | Description |
|---|---|---|
members | FamilyMember[] | The household, in display order (see the member table below) |
groups? | FamilyGroup[] | Named sets of members, used by the calendar's color key and people filter |
migrated? | boolean | Set once older chore and calendar people have been folded into this list |
aliasIds? | Record<string, string> | Older calendar person IDs, mapped to the member each one became |
Each entry in members:
| Field | Type | Description |
|---|---|---|
id | string | Unique ID |
name | string | Name |
color | string | Color used for this person everywhere |
emoji? | string | Emoji shown with the name |
createdAt | string | When the person was added (ISO timestamp) |
updatedAt | string | When the person was last changed (ISO timestamp) |
Chore identities survive migration and legacy restore by ID. Calendar identities are matched by ID or alias first, then by a unique normalized name. Existing rosters over 64 people and long legacy names are retained. The authoring API limits new additions to 64 people and new names to 40 characters.
data/chores.json
Chore definitions, served by /api/chores/data and edited from the /remote Chores tab. Family members are read separately from /api/family.
| Field | Type | Description |
|---|---|---|
chores | ChoreDefinition[] | Chore definitions (see ChoreDefinition) |
settings? | ChoreSettings | Household chore settings (see ChoreSettings). Missing = the defaults |
| Field | Type | Description |
|---|---|---|
id | string | Unique ID |
name | string | Chore name |
emoji | string | Emoji shown with the chore |
points | number | Tickets earned for doing it; 0 or more (0 = no reward) |
frequency | string | How often it comes around. One of daily, weekly, biweekly, once |
daysOfWeek | number[] | Days it happens, 0 = Sunday through 6 = Saturday |
specificDate? | string | The day, as YYYY-MM-DD, when frequency is once |
timeOfDay | string | When in the day it belongs. One of morning, afternoon, evening, anytime |
assigneeIds | string[] | Family members it can go to |
assigneeGroupIds? | string[] | Family groups it goes to, expanded to their members when the chore is resolved |
rotation | string | How it is shared among the people it goes to: everyone every time (fixed), taking turns by day or by week, or by schedule, where each person and each group has its own days. One of fixed, rotate-daily, rotate-weekly, schedule |
schedule? | Record<string, number[]> | With rotation set to schedule: each member ID to the days (0 to 6) they have the chore |
groupSchedule? | Record<string, number[]> | With rotation set to schedule: each family group ID to the days (0 to 6) everyone in it has the chore |
bonus? | ChoreBonus | Set on a bonus chore: an extra that only earns tickets and never counts against anyone |
Completions live in a separate file, data/chore-completions.json, served by /api/chores:
| Field | Type | Description |
|---|---|---|
completions | ChoreCompletion[] | Chores done, one entry per person per day (see ChoreCompletion). Entries older than 90 days are removed |
grabs? | ChoreGrab[] | Up-for-grabs chores someone is holding (see ChoreGrab). Lapsed ones are ignored when read and dropped after 90 days |
bonusResets? | Record<string, string> | When a grown-up last put each "when I put it back" bonus chore back, by chore ID, as an ISO timestamp |
Each entry in completions:
| Field | Type | Description |
|---|---|---|
choreId | string | The chore |
memberId | string | Who did it |
date | string | The day it was done, as YYYY-MM-DD |
status? | "skipped" | skipped marks a chore a grown-up set to "not today" for this person: it pays nothing and leaves every count. Omitted = done. |
at? | string | When it was marked, as an ISO timestamp. Missing on entries saved before it was recorded. |
endedGrab? | string | The day of the person's own grab this finished, as YYYY-MM-DD. Un-ticking hands that grab back, with its own date, so a tick and an un-tick never restart how long it lasts; un-ticking a tick nobody had grabbed for (a grown-up's slip) hands nothing back. |
data/todos.json
The family's shared to-do lists (see Lists). Each list has a name, a colour, an optional start-fresh repeat, and its items with done state, due day and who it is for. A To-Do module only stores which list it shows, so editing a screen never touches a list and the same list can sit on any number of screens. The phone, the wall and the editor all write through /api/todo/lists.
Display Resolution Presets
In the editor you pick a resolution and an orientation separately: four presets, labelled 720p HD, 1080p Full HD, 1440p QHD, and 4K UHD, each with a Portrait/Landscape toggle that swaps the two dimensions. That gives eight combinations:
| Preset | Width | Height |
|---|---|---|
| Portrait 720p | 720 | 1280 |
| Portrait 1080p | 1080 | 1920 |
| Portrait 1440p | 1440 | 2560 |
| Portrait 4K | 2160 | 3840 |
| Landscape 720p | 1280 | 720 |
| Landscape 1080p | 1920 | 1080 |
| Landscape 1440p | 2560 | 1440 |
| Landscape 4K | 3840 | 2160 |
Config Migrations
Config files include a version number. When the schema changes between releases, migrations in src/lib/migrations/ automatically transform older configs to the current format on load. The current schema version is 16.
Migration runs when the config is read and the result is written back to disk automatically, so version in data/config.json updates itself the first time newer code reads an older config. If a migration fails, the un-migrated config is returned as-is rather than falling back to defaults, so a bad upgrade can never quietly replace your setup with an empty one.
Do not hand-edit version. A config marked with a version newer than the running code is left alone; there is no downgrade path.
Validation CLI
Home Screens ships a standalone validator for data/config.json that you can run without starting the dev server. It checks the schema version, module types, screen and module structure, profile references, multi-display registry constraints, and settings bounds, then reports a typed list of diagnostics with colored output.
npm run config:check
A clean config exits with status 0 and a "Config is valid" summary. Any errors, unknown module types, duplicate screen IDs, profile references to non-existent screens, out-of-range display dimensions, etc., exit with a non-zero status and a list of diagnostic entries, making the CLI safe to wire into a pre-commit hook or CI step on a server that mounts data/. The same validation rules are exposed programmatically from src/lib/validate-config.ts if you want to reuse them from your own tooling.
Backup & Restore
- Save a backup (under Save a copy in Settings > Backups & data) in the editor, or Backup All Data in the remote's Settings sheet, downloads a backup as JSON. The bundle contains your config, family, chores, chore completions, rewards, meals, routines, to-do lists and school timetables, plus your icons when Include your icons is on.
- Import replaces everything the uploaded file carries (available in both the editor and the remote); a section the file leaves out, such as the icons, is kept as it is
- A configurable backup reminder shows a toast in the editor and a banner on the remote when you haven't backed up recently (Settings > Backups & data)
Backing up your keys
By default a backup carries no keys at all. This part needs an editor password (Settings > Security): without one, anything on your network can ask the hub for your keys, and there is no way for it to tell you apart from anyone else. Until you set one, the checkbox below stays switched off.
In the editor, Settings > Backups & data > Save a copy has two checkboxes:
- Include my keys and connected accounts adds your weather and map keys, your Google, iCloud, Immich and OneDrive sign-ins, your plugin logins, and your editor password to the file. Both boxes start unticked every time you open the page, so an accidental tick never becomes your standing default.
- Protect them with a password locks that part of the file. You choose the password, and you need it again to put the keys back.
A backup that carries keys is named home-screens-backup-with-keys-<date>.json, so you can tell it apart in a downloads folder.
There is no way to recover the password. If you lose it you can still restore the backup, choose Restore without my keys at the prompt, and everything except the keys comes back. Losing the password costs you the keys, not your setup.
Restoring a backup that includes your editor password signs you out, because the password on this device becomes the one from the backup. Sign in again with that password. If the backup also had network address rules turned on and this device isn't on the restored list, the rules are left switched off so you can't lock yourself out; turn them back on in Settings once you've added this device.
The remote control's Settings sheet has no password prompt. Restoring a key-carrying backup there restores everything except the keys and tells you so, finish that part in the editor.
Keep a key-carrying backup private. Without the password option, anyone who opens the file can use your accounts. With it, the keys are sealed and only your password opens them.
Plugin bundles themselves are still not in the backup, so a restored Pi comes up with its plugins missing (their saved logins return once you reinstall them). For a genuinely complete copy, take the whole data/ directory instead. Copying data/config.json on its own is narrower still: it captures none of your chores, completions, meals, or rewards.
Example
{
"version": 16,
"settings": {
"rotationIntervalMs": 30000,
"displayWidth": 1080,
"displayHeight": 1920,
"latitude": 40.0150,
"longitude": -105.2705,
"timezone": "America/Denver",
"weather": {
"provider": "pirateweather",
"latitude": 40.0150,
"longitude": -105.2705,
"units": "imperial"
},
"calendar": {
"googleCalendarId": "",
"googleCalendarIds": ["primary"],
"icalSources": [],
"daysAhead": 7
}
},
"screens": [
{
"id": "abc-123",
"name": "Main",
"backgroundImage": "/backgrounds/sunset.jpg",
"modules": [
{
"id": "mod-1",
"type": "clock",
"position": { "x": 20, "y": 40 },
"size": { "w": 1040, "h": 220 },
"zIndex": 1,
"config": {
"view": "classic",
"format24h": false,
"showSeconds": true,
"showDate": true
},
"style": {
"opacity": 1,
"borderRadius": 12,
"padding": 16,
"backgroundColor": "rgba(0,0,0,0.4)",
"textColor": "#ffffff",
"fontFamily": "inter",
"fontSize": 16,
"backdropBlur": 12,
"borderWidth": 1,
"borderColor": "rgba(255,255,255,0.15)",
"shadowSize": 8
}
}
]
}
]
}