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
2 changes: 1 addition & 1 deletion apps/signage/DEBUGGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ makes the display request use `?preview=true`.
| Takeover scheduled but not showing | A takeover with no valid media does not start. Compare the playlist and media `valid_from` / `valid_until` with `schedule.now` |
| Trigger did not start a takeover | A trigger fires only when its value changes to true. A display update does not replay a trigger that is already true, and a trigger is ignored while another override plays |
| Stuck on old content | `poll.last_success` and `poll.next_due`; run `signage.poll()` |
| Not picking up new content | `poll.last_success` vs now; if stale, look for `Display poll failed` in the console |
| Not picking up new content | `poll.last_success` is when the backend last answered; if stale, look for `Failed to fetch display details` or `Display poll failed` |
| Media never appears | `media_cache.files` for that URL — `invalidated` means the download failed; `failed_sync_attempts` shows the backoff |
| Old version running | `updates.new_version`, `updates.reload_pending` (a reload waits for the network and for play-through content to finish), `updates.last_check` |
| Blank screen after a reboot | Likely offline boot — check `online`, then whether cached credentials exist |
Expand Down
10 changes: 6 additions & 4 deletions apps/signage/USER_STORIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device

- The app calls the PlaceOS signage endpoint for the active display ID.
- Requests include preview context when debug mode is enabled and include the currently playing item ID when available.
- After a successful response, later requests send its `ETag` and `Last-Modified` values as `If-None-Match` and `If-Modified-Since`.
- After a response has been applied, later requests send its `ETag` and `Last-Modified` values as `If-None-Match` and `If-Modified-Since`. A response that fails to apply is requested again in full.
- Display requests bypass the browser cache, and a `304 Not Modified` response keeps the current display configuration.
- The latest display configuration is cached in localStorage under a display-specific `PlaceOS.SIGNAGE.display_details.<display_id>` key.
- Legacy cached configuration under `PlaceOS.SIGNAGE.display_details` can still be used as a fallback.
- If the API request fails, the app falls back to the cached display configuration only when it matches the active display ID.
- A cached copy that cannot be read or saved does not stop the display configuration from loading.
- Unchanged responses keep the current parsed display, trigger bindings, media cache, and playlist state.
- Display configuration refreshes every 60 seconds.

Expand Down Expand Up @@ -321,9 +322,10 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device
**Acceptance Criteria:**

- A scheduled playlist with `play_takeover` disabled is included in normal playback only while its schedule is active.
- `play_at` schedules support Unix timestamps in seconds or milliseconds.
- `play_at` schedules use a Unix timestamp in seconds.
- `play_at_local` schedules play once at a wall-clock time (for example `2027-01-01T00:00:00`) in the display's timezone.
- `play_cron` schedules support recurring cron-based activation.
- `play_cron` schedules support recurring cron-based activation in the display's local time.
- When clocks go back, a repeated cron time runs once, at its first occurrence. A cron time skipped when clocks go forward does not run.
- `play_period` controls the active window in minutes.
- When `play_period` is missing, the default active window is 24 hours.
- Schedule activation is re-evaluated every 15 seconds, or faster while debug time is accelerated.
Expand Down Expand Up @@ -536,7 +538,7 @@ The Signage app is a kiosk-style digital signage player. It bootstraps a device
- Empty metrics are not posted.
- Non-empty metrics are posted to `/api/engine/v2/signage/:display_id/metrics`.
- Metric posting is delayed by a random offset of up to 60 seconds to avoid synchronized device traffic.
- Metrics are cleared only after a successful post.
- Metrics recorded while a post is in flight are kept for the next post.
- Failed posts leave metrics available for the next posting attempt.

---
Expand Down
125 changes: 94 additions & 31 deletions apps/signage/src/app/cron-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function createScheduleMaskFilter(cron: string, schedule: ScheduleMask) {
slots.push(hour * 60 + minute);
}
}
const calendar_parts = ['*', '*', ...parts.slice(2)];
const calendar_parts = cronDayParts(parts);
const first_day = new Date(anchor);
first_day.setHours(0, 0, 0, 0);
const month_totals = new Map<number, number[]>();
Expand Down Expand Up @@ -238,6 +238,66 @@ export function createScheduleMaskFilter(cron: string, schedule: ScheduleMask) {

/** Search limit below which a lookup is too cheap and too precise to memoise */
const MIN_CACHEABLE_SEARCH_LIMIT_SECONDS = 60;
/**
* Longest search, so a lookup always ends whatever limit it is given. A search
* as long as a play period must find the run that started it, and the manager
* sets no upper limit on play periods, so this is ten years: far above any
* real period. Days the cron cannot match are skipped whole, so a search costs
* at most about 3,700 day checks plus the minutes of the days it does match.
*/
const MAX_SEARCH_LIMIT_SECONDS = 10 * 366 * 24 * 60 * 60;
const MINUTE_MS = 60_000;

/**
* Whether `date` is the first time its local wall-clock minute occurs. When the
* clocks go back, the repeated hour only counts once, at its first occurrence,
* as in the signage manager and the schedule mask count.
*/
function isFirstLocalOccurrence(date: Date) {
const wall_clock = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes(),
);
return wall_clock.getTime() === date.getTime();
}

/** Whether a cron runs at `date`, a whole minute, in local time */
function isCronRun(cron_parts: string[], date: Date) {
return doesCronMatchDate(cron_parts, date) && isFirstLocalOccurrence(date);
}

/** Whether a cron can run at some time of day; `61 * * * *` never can */
function hasCronTimeOfDay([minute_part, hour_part]: string[]) {
for (let hour = 0; hour < 24; hour++) {
if (!matchesCronPart(hour, hour_part)) continue;
for (let minute = 0; minute < 60; minute++) {
if (matchesCronPart(minute, minute_part)) return true;
}
}
return false;
}

/** Cron fields that match any time on the days the cron runs */
function cronDayParts(cron_parts: string[]) {
return ['*', '*', ...cron_parts.slice(2)];
}

/** Start of the local day `offset_days` from the day of `date` */
function localDayStart(date: Date, offset_days = 0) {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate() + offset_days,
).getTime();
}

/** Search limit in milliseconds, capped to `MAX_SEARCH_LIMIT_SECONDS` */
function searchLimitMs(search_limit_in_seconds: number) {
return Math.min(search_limit_in_seconds, MAX_SEARCH_LIMIT_SECONDS) * 1000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Long cron takeovers stop early

If a cron takeover has a play_period longer than 366 days, the new search cap can hide its start while the run is still active. For example, a February 29 run with a 368-day period is still active on March 2, 2025, but the player cannot find it and stops showing it. The search needs to cover the configured period.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 209e804. The search limit is now the requested period, capped at 10 years. To keep long searches cheap, both searches skip local days the cron cannot match (one check per day), and crons that can never run at any time of day return at once. Measured: a 10-year search for 0 0 31 2 * takes about 5 ms. Test: "finds a run that started more than a year ago" (0 0 29 2 *, 368-day period, checked on 2 Mar 2025).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remaining case is a deliberate limit, not an oversight. The search now covers the requested period, with a 10-year ceiling. Without a ceiling, a corrupt or extreme play_period (the manager sets no maximum) would make each schedule tick walk an unbounded number of days on low-power display hardware. A run is now missed only if its period exceeds 10 years and it started more than 10 years ago. For signage, a takeover that long is effectively permanent, and a background schedule is the right tool for it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That addresses the concern. Using the requested play period preserves long-running takeovers, while the 10-year ceiling is a reasonable defensive bound against an unbounded walk on display hardware. Given the stated behavior for periods beyond the ceiling and the fallback to background schedules for effectively permanent content, I’m withdrawing this finding.

}

const CRON_LOOKUP_CACHE = new Map<string, number | null>();
let cron_lookup_second = 0;
Expand Down Expand Up @@ -297,21 +357,25 @@ export function getNextCronRunTimestampInRange(
const mask_key = JSON.stringify([schedule.valid_from, schedule.mask]);
const key = `next|${cron_string}|${search_limit_in_seconds}|${mask_key}`;
return cachedCronLookup(key, now, search_limit_in_seconds, () => {
const searchLimitDate = new Date(now + search_limit_in_seconds * 1000);
const start_time = new Date(now);
start_time.setSeconds(0, 0);
start_time.setMinutes(start_time.getMinutes() + 1);

const current_date = new Date(start_time.getTime());

while (current_date <= searchLimitDate) {
if (
doesCronMatchDate(parts, current_date) &&
allows(current_date)
) {
return Math.floor(current_date.getTime() / 1000);
// Steps in UTC, not local time: a local step resolves the hour that
// repeats when the clocks go back to its first occurrence, which
// would move the search an hour into the past.
if (!hasCronTimeOfDay(parts)) return null;
const day_parts = cronDayParts(parts);
const limit = now + searchLimitMs(search_limit_in_seconds);
const start = Math.floor(now / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
const current_date = new Date(start);
// Every step moves forwards, by a minute or to the next day
for (let time = start; time <= limit; ) {
current_date.setTime(time);
if (!doesCronMatchDate(day_parts, current_date)) {
time = localDayStart(current_date, 1);
continue;
}
current_date.setMinutes(current_date.getMinutes() + 1);
if (isCronRun(parts, current_date) && allows(current_date)) {
return Math.floor(time / 1000);
}
time += MINUTE_MS;
}
return null;
});
Expand Down Expand Up @@ -345,24 +409,23 @@ export function getLastCronRunTimestampInRange(
const mask_key = JSON.stringify([schedule.valid_from, schedule.mask]);
const key = `last|${cron_string}|${search_limit_in_seconds}|${mask_key}`;
return cachedCronLookup(key, now, search_limit_in_seconds, () => {
const search_limit_date = new Date(
now - search_limit_in_seconds * 1000,
);
const current_date = new Date(now);
current_date.setSeconds(0, 0);

while (current_date >= search_limit_date) {
if (doesCronMatchDate(parts, current_date)) {
return allows(current_date)
? Math.floor(current_date.getTime() / 1000)
: null;
// Steps in UTC for the same reason as the forwards search.
if (!hasCronTimeOfDay(parts)) return null;
const day_parts = cronDayParts(parts);
const limit = now - searchLimitMs(search_limit_in_seconds);
const start = Math.floor(now / MINUTE_MS) * MINUTE_MS;
const current_date = new Date(start);
// Every step moves backwards, by a minute or to the day before
for (let time = start; time >= limit; ) {
current_date.setTime(time);
if (!doesCronMatchDate(day_parts, current_date)) {
time = localDayStart(current_date) - MINUTE_MS;
continue;
}
const previous = current_date.getTime();
current_date.setMinutes(current_date.getMinutes() - 1);
// A missing local hour can normalise a backwards step forwards.
if (current_date.getTime() >= previous) {
current_date.setTime(previous - 60_000);
if (isCronRun(parts, current_date)) {
return allows(current_date) ? Math.floor(time / 1000) : null;
}
time -= MINUTE_MS;
}
return null;
});
Expand Down
Loading
Loading