From 6e94a0c998402d5c9bbf8b0f34adfd573b84ce42 Mon Sep 17 00:00:00 2001 From: jeremy Date: Wed, 4 Nov 2026 22:48:50 -0600 Subject: [PATCH] feat(hobbs-organizer): v1.4.0 per-TV morning routine (admin TVs tab) + photo app 1.2.1 - Admin TVs tab: wake time/days, YouTube Music playlist, Shield volume per TV, pushed via 5-min TV check-ins (/api/tv/config, /api/tv/report, LAN-only) - com.dayline.photos 1.2.1: MorningActivity (screen on, volume, playlist), calendar alarm at wake time, Doze-safe chained check-ins - Both TVs configured for 06:30 daily with the family playlist Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3 --- CLAUDE.md | 2 +- .../RELEASE-NOTES.md | 19 ++ .../VERSION | 2 +- .../app/_lib/store.ts | 71 +++- .../app/_lib/tvs.ts | 125 ++++++++ .../app/admin/page.tsx | 265 ++++++++++++++- .../app/api/admin/history/route.ts | 2 +- .../app/api/admin/tvs/route.ts | 72 +++++ .../app/api/health/route.ts | 2 +- .../app/api/tv/config/route.ts | 26 ++ .../app/api/tv/report/route.ts | 23 ++ .../compose.yaml | 2 +- .../package-lock.json | 4 +- .../package.json | 2 +- .../shield/Hobbs-Family-Photos-1.2.1.apk | Bin 0 -> 24858 bytes .../shield/photos-source/app/build.gradle | 4 +- .../app/src/main/AndroidManifest.xml | 35 ++ .../com/dayline/photos/AgentReceiver.java | 23 ++ .../com/dayline/photos/MorningActivity.java | 116 +++++++ .../com/dayline/photos/PhotoActivity.java | 1 + .../com/dayline/photos/PhotoFrameView.java | 2 +- .../main/java/com/dayline/photos/TvAgent.java | 303 ++++++++++++++++++ 22 files changed, 1083 insertions(+), 18 deletions(-) create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/tvs.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/tvs/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/tv/config/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/tv/report/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/Hobbs-Family-Photos-1.2.1.apk create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/AgentReceiver.java create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/MorningActivity.java create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/TvAgent.java diff --git a/CLAUDE.md b/CLAUDE.md index 17ae252..2ac5bdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD) ├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget) ├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation ├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS). -├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.3.0` since 2026-11-04 night; `1.0.0`/`1.1.0`/`1.2.0` kept for rollback; Watchtower disabled). **v1.3.0 adds `/photo/gallery`** (remote-driven grid: arrows move, OK opens full screen, ◀ ▶ browse, Back returns; Slideshow button; idle 10 min -> slideshow) and a 400px tile per photo (`.m.jpg`). Slideshow interval set to **15s** (was 20s) per user. **v1.2.0 adds the photo frame `/photo`** (slideshow of Google Photos shared albums, scraped from the share page because Google's Library API lost album access 2025-03-31, + admin uploads; synced every 6h into LOCAL `data/photos/`, ~0.5MB/photo; current album "TV screensaver", 46 photos). Photo health `GET /api/health/photos` = 503 when an album has not synced for 48h. **Shield photo app `com.dayline.photos` v1.1** (2026-11-04, separate APK: launcher tile opens `/photo/gallery` and takes D-pad input, Back steps back through WebView history; screensaver/DreamService loads `/photo`, non-interactive; default URL baked in, override via `files/base_url` with `run-as`). Built on .100 by `compose/hf-pdocker-02/hobbs-organizer/build-photos-apk.sh` (JDK17 container, SDK cached in volume `hobbs-android-sdk`). **Signing key** `/var/lib/hobbs-organizer/apk-signing/` (root 600) + NAS copy `/mnt/docker-data/configs/hobbs-organizer/apk-signing/` -- losing it means uninstall/reinstall for any update. Installed on **both Shields** (.186 canary, then .179 on 2026-11-04 after the user confirmed .186) and set as the screensaver on both (previous on each: `com.google.android.backdrop/.Backdrop`, saved in `~/hobbs-shield/screensaver-before-{186,179}.txt` on .100; rollback = `adb shell settings put secure screensaver_components `). **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it, and both have the daily 6:30 AM wake-and-launch enabled (flag `files/daily_wake_0630_enabled`; .186 added 2026-11-04). It wakes a sleeping Shield; the TV powers on only via HDMI-CEC. See memory `hobbs-family-organizer`. +├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.4.0` since 2026-11-04 late night; `1.0.0`–`1.3.0` kept for rollback; Watchtower disabled). **v1.4.0 adds the admin TVs tab**: per-TV morning routine (wake time + days, YouTube Music playlist, Shield volume) pushed to TVs by check-in (`GET /api/tv/config` every 5 min, `POST /api/tv/report`, both LAN-only; status in `data/tv-status.json`). TVs are identified by the photo app's per-app Android ID (.179 = `2f49d9d58ea552b4`, .186 "Jack's tv" = `cc516afd2195c2cc`). **v1.3.0 adds `/photo/gallery`** (remote-driven grid: arrows move, OK opens full screen, ◀ ▶ browse, Back returns; Slideshow button; idle 10 min -> slideshow) and a 400px tile per photo (`.m.jpg`). Slideshow interval set to **15s** (was 20s) per user. **v1.2.0 adds the photo frame `/photo`** (slideshow of Google Photos shared albums, scraped from the share page because Google's Library API lost album access 2025-03-31, + admin uploads; synced every 6h into LOCAL `data/photos/`, ~0.5MB/photo; current album "TV screensaver", 46 photos). Photo health `GET /api/health/photos` = 503 when an album has not synced for 48h. **Shield photo app `com.dayline.photos` v1.2.1** (2026-11-04; runs the morning routine: alarm at wake−30s -> MorningActivity turns screen on, sets volume, starts the playlist in YouTube Music (`music.youtube.com/watch?list=`); second alarm at the wake time opens `com.dayline.tv` on top. **Needs two ADB grants per TV, already done on both:** `appops set com.dayline.photos SYSTEM_ALERT_WINDOW allow` (start other apps from background) and `dumpsys deviceidle whitelist +com.dayline.photos` (network during Doze -- without it a sleeping TV never checks in). The old `com.dayline.tv` 6:30 alarm flag is still set on both as a backup -- **remove it once the new routine is proven**, or admin time changes will still get a 6:30 calendar launch; separate APK: launcher tile opens `/photo/gallery` and takes D-pad input, Back steps back through WebView history; screensaver/DreamService loads `/photo`, non-interactive; default URL baked in, override via `files/base_url` with `run-as`). Built on .100 by `compose/hf-pdocker-02/hobbs-organizer/build-photos-apk.sh` (JDK17 container, SDK cached in volume `hobbs-android-sdk`). **Signing key** `/var/lib/hobbs-organizer/apk-signing/` (root 600) + NAS copy `/mnt/docker-data/configs/hobbs-organizer/apk-signing/` -- losing it means uninstall/reinstall for any update. Installed on **both Shields** (.186 canary, then .179 on 2026-11-04 after the user confirmed .186) and set as the screensaver on both (previous on each: `com.google.android.backdrop/.Backdrop`, saved in `~/hobbs-shield/screensaver-before-{186,179}.txt` on .100; rollback = `adb shell settings put secure screensaver_components `). **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it, and both have the daily 6:30 AM wake-and-launch enabled (flag `files/daily_wake_0630_enabled`; .186 added 2026-11-04). It wakes a sleeping Shield; the TV powers on only via HDMI-CEC. See memory `hobbs-family-organizer`. ├── [COMPOSE] Farm Agent (:8889) ├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26) ├── [COMPOSE] Telegraf -- metrics collection to InfluxDB diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md index 533ae48..2bb8f92 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md @@ -1,3 +1,22 @@ +# 1.4.0 (2026-11-04) + +- Admin **TVs** tab: a morning routine for each TV. + - Wake time and days of the week. + - A YouTube Music playlist (paste any playlist link; the `si=` tracking part is dropped). + - The Shield's volume (0–100%). + - Settings are pushed to each TV on its next check-in (every 5 minutes). Each TV card shows when the TV has applied them and how its last morning routine went. +- TV endpoints, direct LAN only and refused through a proxy: + - `GET /api/tv/config?device=…` returns that TV's settings and records the check-in. + - `POST /api/tv/report` records the outcome of the morning routine. + - Check-in status lives in `/data/tv-status.json`, kept separate from the settings history. + - TVs that check in without being configured show up under "TVs found on the network" with an **Add** button. +- Settings history Restore leaves TV settings unchanged. +- Shield photo app `com.dayline.photos` 1.2.1 (versionCode 4) runs the routine: + - At the wake time minus 30 s, it turns the screen on (the TV follows over HDMI-CEC). After 5 s it sets the volume and starts the playlist in YouTube Music. + - At the wake time, a second alarm opens the calendar app (`com.dayline.tv`) on top while the music keeps playing (needs YouTube Music Premium). + - With music off, it opens the calendar straight away. + - It needs two one-time grants over ADB: `appops set com.dayline.photos SYSTEM_ALERT_WINDOW allow`, so it can open other apps from the background, and `dumpsys deviceidle whitelist +com.dayline.photos`, so check-ins keep network access while the Shield is in Doze. + # 1.3.0 (2026-11-04) - `/photo/gallery`: a remote-driven photo grid for the Shield photo app's launcher tile. diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION index f0bb29e..88c5fb8 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION @@ -1 +1 @@ -1.3.0 +1.4.0 diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/store.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/store.ts index 846199c..0e40653 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/store.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/store.ts @@ -44,11 +44,76 @@ export const DEFAULT_PHOTO_SETTINGS: PhotoSettings = { albums: [], }; +/** + * Per-TV morning routine, run by the Shield photo app (com.dayline.photos) on each TV: + * wake the TV, set the volume, start a YouTube Music playlist, then bring up the calendar. + * `id` is the TV's Android ID, reported by the app when it checks in. + */ +export type TvSetting = { + id: string; + name: string; + wake: { enabled: boolean; time: string; days: number[] }; + music: { enabled: boolean; playlistId: string }; + volume: { enabled: boolean; level: number }; +}; + +export const MAX_TVS = 10; +export const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]; + +export function validTvId(value: unknown) { + return typeof value === "string" && /^[a-f0-9]{8,32}$/.test(value) ? value : null; +} + +/** Accepts a YouTube / YouTube Music playlist link or a bare playlist ID; returns the ID. */ +export function validPlaylistId(value: unknown) { + const text = String(value ?? "").trim(); + if (/^[A-Za-z0-9_-]{10,64}$/.test(text)) return text; + let url: URL; + try { + url = new URL(text); + } catch { + return null; + } + if (url.protocol !== "https:" || !["music.youtube.com", "www.youtube.com", "youtube.com", "m.youtube.com"].includes(url.hostname)) return null; + const list = url.searchParams.get("list") ?? ""; + return /^[A-Za-z0-9_-]{10,64}$/.test(list) ? list : null; +} + +export const playlistUrl = (playlistId: string) => `https://music.youtube.com/playlist?list=${playlistId}`; + +function validTime(value: unknown) { + return typeof value === "string" && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : null; +} + +function normalizeTvs(raw: unknown): TvSetting[] { + const seen = new Set(); + return (Array.isArray(raw) ? raw : []) + .map((entry) => { + const id = validTvId(entry?.id); + if (!id || seen.has(id)) return null; + seen.add(id); + const days = Array.isArray(entry.wake?.days) + ? [...new Set((entry.wake.days as unknown[]).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort() + : ALL_DAYS; + const level = Number(entry.volume?.level); + return { + id, + name: typeof entry.name === "string" ? entry.name.trim().slice(0, 40) : "", + wake: { enabled: entry.wake?.enabled === true, time: validTime(entry.wake?.time) ?? "06:30", days }, + music: { enabled: entry.music?.enabled === true, playlistId: validPlaylistId(entry.music?.playlistId) ?? "" }, + volume: { enabled: entry.volume?.enabled === true, level: Number.isFinite(level) ? Math.min(100, Math.max(0, Math.round(level))) : 40 }, + } satisfies TvSetting; + }) + .filter((entry): entry is TvSetting => entry !== null) + .slice(0, MAX_TVS); +} + export type Settings = { schema: 1; updatedAt: string; calendars: CalendarSetting[]; photos: PhotoSettings; + tvs: TvSetting[]; }; /** Accepts photos.app.goo.gl short links and photos.google.com/share links. */ @@ -149,6 +214,7 @@ function normalizeSettings(raw: unknown): Settings { .filter((entry): entry is CalendarSetting => entry !== null) .slice(0, MAX_CALENDARS), photos: normalizePhotos(value.photos), + tvs: normalizeTvs(value.tvs), }; } @@ -188,16 +254,17 @@ async function pruneHistory() { } /** Saves new settings (only the parts given), first snapshotting the current ones into history. */ -export function saveSettings(patch: Partial>, note: string) { +export function saveSettings(patch: Partial>, note: string) { return serialized(async () => { const current = await readSettings(); const calendars = patch.calendars ?? current.calendars; const photos = patch.photos ?? current.photos; + const tvs = patch.tvs ?? current.tvs; const stamp = new Date().toISOString(); const snapshotId = `${stamp.replace(/[:.]/g, "-")}`; await writeJsonAtomic(path.join(historyDir(), `${snapshotId}.json`), { ...current, note }); await pruneHistory(); - const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars, photos }); + const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars, photos, tvs }); await writeJsonAtomic(settingsPath(), next); return next; }); diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/tvs.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/tvs.ts new file mode 100644 index 0000000..fcd188d --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/tvs.ts @@ -0,0 +1,125 @@ +import { createHash } from "node:crypto"; +import { readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { playlistUrl, type TvSetting } from "./store"; + +/* + * TV check-ins. Each Shield's photo app polls GET /api/tv/config every few minutes with its + * Android ID; the server answers with that TV's morning-routine settings and records what the + * TV last reported (so the admin page can show "applied" vs "waiting for the TV"). + * Status is runtime data, kept apart from settings.json so it never lands in settings history. + */ + +/** How often the app checks in (the app uses this value; keep in sync with the APK default). */ +export const CHECK_IN_MINUTES = 5; +/** Music starts this many seconds before the wake time; the calendar comes up at the wake time. */ +export const MUSIC_LEAD_SECONDS = 30; +const MAX_STATUS_ENTRIES = 20; + +export type TvEvent = { at: number; kind: string; ok: boolean; detail: string }; +export type TvStatus = { + id: string; + deviceName: string; + model: string; + ip: string; + appVersion: string; + lastSeenAt: number; + appliedVersion: string; + lastEvent: TvEvent | null; +}; + +const statusPath = () => path.join(process.env.ORGANIZER_DATA_DIR || "/data", "tv-status.json"); + +let queue: Promise = Promise.resolve(); +function serialized(task: () => Promise) { + const run = queue.then(task, task); + queue = run.catch(() => undefined); + return run; +} + +export async function readTvStatus(): Promise> { + try { + const raw = JSON.parse(await readFile(statusPath(), "utf8")) as Record; + return raw && typeof raw === "object" ? raw : {}; + } catch { + return {}; + } +} + +async function writeTvStatus(next: Record) { + const temporary = `${statusPath()}.${process.pid}.tmp`; + await writeFile(temporary, JSON.stringify(next), { mode: 0o600 }); + await rename(temporary, statusPath()); +} + +const clean = (value: unknown, max: number) => + String(value ?? "") + .replace(/[^\x20-\x7e]/g, "") + .trim() + .slice(0, max); + +/** Records a check-in (and optionally an event such as the morning routine's result). */ +export function recordTvCheckIn( + id: string, + report: { deviceName?: unknown; model?: unknown; appVersion?: unknown; appliedVersion?: unknown; ip: string }, + event?: Omit, + knownIds: Set = new Set(), +) { + return serialized(async () => { + const all = await readTvStatus(); + const previous = all[id]; + // Unknown TVs are remembered (so admin can add them) but capped, oldest dropped first. + if (!previous && Object.keys(all).length >= MAX_STATUS_ENTRIES) { + const evictable = Object.values(all) + .filter((entry) => !knownIds.has(entry.id)) + .sort((left, right) => left.lastSeenAt - right.lastSeenAt); + if (!evictable.length) return; + delete all[evictable[0].id]; + } + all[id] = { + id, + deviceName: clean(report.deviceName, 60) || previous?.deviceName || "", + model: clean(report.model, 60) || previous?.model || "", + ip: report.ip, + appVersion: clean(report.appVersion, 20) || previous?.appVersion || "", + lastSeenAt: Date.now(), + appliedVersion: clean(report.appliedVersion, 20) || previous?.appliedVersion || "", + lastEvent: event ? { ...event, kind: clean(event.kind, 20), detail: clean(event.detail, 200), at: Date.now() } : (previous?.lastEvent ?? null), + }; + await writeTvStatus(all); + }); +} + +export function forgetTvStatus(id: string) { + return serialized(async () => { + const all = await readTvStatus(); + if (!all[id]) return; + delete all[id]; + await writeTvStatus(all); + }); +} + +/** What the TV app receives. Unknown TVs get everything disabled. */ +export function tvConfig(setting: TvSetting | undefined) { + const body = { + registered: Boolean(setting), + name: setting?.name ?? "", + wake: setting?.wake ?? { enabled: false, time: "06:30", days: [] as number[] }, + music: { + enabled: Boolean(setting?.music.enabled && setting.music.playlistId), + // watch?list= starts playback; playlist?list= would only open the playlist page. + url: setting?.music.playlistId ? `https://music.youtube.com/watch?list=${setting.music.playlistId}` : "", + }, + volume: setting?.volume ?? { enabled: false, level: 40 }, + musicLeadSeconds: MUSIC_LEAD_SECONDS, + checkInMinutes: CHECK_IN_MINUTES, + calendarPackage: "com.dayline.tv", + }; + return { ...body, version: configVersion(body) }; +} + +export function configVersion(body: unknown) { + return createHash("sha1").update(JSON.stringify(body)).digest("hex").slice(0, 12); +} + +export { playlistUrl }; diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx index e83aa20..8f791f9 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx @@ -27,7 +27,8 @@ type DraftCalendar = { test?: TestResult | "running"; }; type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string }; -type Tab = "calendars" | "photos" | "account"; +type Tab = "calendars" | "photos" | "tvs" | "account"; +const TAB_LABELS: Record = { calendars: "Calendars", photos: "Photos", tvs: "TVs", account: "Account" }; async function api(path: string, init?: RequestInit & { body?: string }) { const response = await fetch(path, { @@ -372,6 +373,10 @@ type UploadView = { key: string; thumb: string; src: string }; type PhotosView = { display: PhotoDisplay; albums: AlbumView[]; uploads: UploadView[] }; type AlbumDraft = { key: string; id?: string; name: string; enabled: boolean; shareUrl: string; view?: AlbumView; check?: string }; +function minutesSince(timestamp: number) { + return (Date.now() - timestamp) / 60_000; +} + function ago(timestamp: number | null) { if (!timestamp) return "never"; const minutes = Math.round((Date.now() - timestamp) / 60_000); @@ -693,6 +698,255 @@ function PhotosTab() { ); } +type TvStatusView = { + deviceName: string; + model: string; + ip: string; + appVersion: string; + lastSeenAt: number; + appliedVersion: string; + lastEvent: { at: number; kind: string; ok: boolean; detail: string } | null; +}; +type TvView = { + id: string; + name: string; + wake: { enabled: boolean; time: string; days: number[] }; + music: { enabled: boolean; playlistId: string }; + volume: { enabled: boolean; level: number }; + playlistUrl: string; + version: string; + status: (TvStatusView & { id: string }) | null; +}; +type TvsView = { checkInMinutes: number; tvs: TvView[]; found: (TvStatusView & { id: string })[] }; +type TvDraft = { + id: string; + name: string; + wake: { enabled: boolean; time: string; days: number[] }; + music: { enabled: boolean; playlist: string }; + volume: { enabled: boolean; level: number }; +}; + +const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function tvDraft(tv: TvView): TvDraft { + return { id: tv.id, name: tv.name, wake: { ...tv.wake }, music: { enabled: tv.music.enabled, playlist: tv.playlistUrl }, volume: { ...tv.volume } }; +} + +function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) { + return ( + + ); +} + +function TvStatusLine({ tv, checkInMinutes }: { tv: TvView; checkInMinutes: number }) { + const status = tv.status; + if (!status) return Never checked in; + const applied = status.appliedVersion === tv.version; + const seenMinutes = minutesSince(status.lastSeenAt); + return ( + + {applied ? ( + ✓ Settings are on the TV + ) : ( + Waiting for the TV to pick up changes (it checks every {checkInMinutes} min) + )} + checkInMinutes * 3 ? "text-rose-300" : "text-slate-500"}>· last check-in {ago(status.lastSeenAt)} + + ); +} + +function TvsTab() { + const [view, setView] = useState(null); + const [drafts, setDrafts] = useState([]); + const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null); + const [busy, setBusy] = useState(false); + + const apply = useCallback((next: TvsView) => { + setView(next); + setDrafts(next.tvs.map(tvDraft)); + }, []); + + useEffect(() => { + void (async () => { + const result = await api("/api/admin/tvs"); + if (result.ok) apply(result.data); + })(); + // Keep check-in status fresh without touching unsaved edits. + const timer = window.setInterval(async () => { + const result = await api("/api/admin/tvs"); + if (result.ok) setView(result.data); + }, 15000); + return () => window.clearInterval(timer); + }, [apply]); + + const saved = JSON.stringify(view?.tvs.map(tvDraft) ?? []); + const dirty = view !== null && JSON.stringify(drafts) !== saved; + + const update = (id: string, change: (draft: TvDraft) => TvDraft) => setDrafts((current) => current.map((draft) => (draft.id === id ? change(draft) : draft))); + + const add = (found: TvsView["found"][number]) => + setDrafts((current) => [ + ...current, + { + id: found.id, + name: found.deviceName || found.model || "TV", + wake: { enabled: true, time: "06:30", days: [0, 1, 2, 3, 4, 5, 6] }, + music: { enabled: false, playlist: "" }, + volume: { enabled: false, level: 40 }, + }, + ]); + + const save = async () => { + setBusy(true); + setMessage(null); + const result = await api("/api/admin/tvs", { method: "PUT", body: JSON.stringify({ tvs: drafts }) }); + setBusy(false); + if (result.ok) { + apply(result.data); + setMessage({ tone: "ok", text: `Saved. Each TV picks up its settings within ${result.data.checkInMinutes} minutes; the status below turns green when it has.` }); + } else setMessage({ tone: "error", text: result.data.error || "Save failed." }); + }; + + if (!view) return

Loading…

; + const byId = new Map(view.tvs.map((tv) => [tv.id, tv])); + const draftIds = new Set(drafts.map((draft) => draft.id)); + const found = view.found.filter((entry) => !draftIds.has(entry.id)); + + return ( +
+

+ Morning routine for each TV, run by the Hobbs Family Photos app: wake the TV, set the volume, start the playlist, and bring up the + calendar at the wake time (music starts 30 seconds earlier). The TV turns on through HDMI-CEC. +

+ + {drafts.map((draft) => { + const tv = byId.get(draft.id); + const status = tv?.status ?? view.found.find((entry) => entry.id === draft.id) ?? null; + return ( +
+
+
+ update(draft.id, (d) => ({ ...d, name: event.target.value }))} /> +

+ {status ? `${status.deviceName || status.model} · ${status.ip} · photo app ${status.appVersion}` : draft.id} +

+

{tv ? : New — save to send settings}

+ {status?.lastEvent && ( +

+ Last morning routine {ago(status.lastEvent.at)}: {status.lastEvent.ok ? "✓ " : "✗ "} + {status.lastEvent.detail} +

+ )} +
+ +
+ +
+ update(draft.id, (d) => ({ ...d, wake: { ...d.wake, enabled: value } }))} /> +
+ update(draft.id, (d) => ({ ...d, wake: { ...d.wake, time: event.target.value } }))} + /> + {DAY_LABELS.map((label, day) => { + const on = draft.wake.days.includes(day); + return ( + + ); + })} +
+
+ +
+ update(draft.id, (d) => ({ ...d, music: { ...d.music, enabled: value } }))} /> +
+ update(draft.id, (d) => ({ ...d, music: { ...d.music, playlist: event.target.value } }))} + /> +
+
+ +
+ update(draft.id, (d) => ({ ...d, volume: { ...d.volume, enabled: value } }))} /> +
+ update(draft.id, (d) => ({ ...d, volume: { ...d.volume, level: Number(event.target.value) } }))} + /> + {draft.volume.level}% +
+

This is the Shield's own volume; the TV's volume still applies on top.

+
+
+ ); + })} + + {!drafts.length &&

No TVs set up yet.

} + + {found.length > 0 && ( +
+

TVs found on the network

+ {found.map((entry) => ( +
+ + {entry.deviceName || entry.model} · {entry.ip} · checked in {ago(entry.lastSeenAt)} + + +
+ ))} +
+ )} + {!found.length && !drafts.length && ( +

TVs appear here once the Hobbs Family Photos app (version 1.2 or later) has checked in.

+ )} + +
+ + {dirty && ( + + )} +
+ {message &&

{message.text}

} +
+ ); +} + function AccountTab({ onSignedOut }: { onSignedOut: () => void }) { const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); @@ -752,7 +1006,7 @@ export default function AdminPage() { // eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount void check(); const fromHash = window.location.hash.replace("#", "") as Tab; - if (["calendars", "photos", "account"].includes(fromHash)) setTab(fromHash); + if (fromHash in TAB_LABELS) setTab(fromHash); }, [check]); const selectTab = (next: Tab) => { @@ -777,19 +1031,20 @@ export default function AdminPage() { {tab === "calendars" && } {tab === "photos" && } + {tab === "tvs" && } {tab === "account" && setState("login")} />} )} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/history/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/history/route.ts index f72b9d3..ba33db8 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/history/route.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/history/route.ts @@ -10,7 +10,7 @@ export async function GET(request: Request) { return json({ history: await listHistory() }); } -/** Reverts calendars and photo settings to a snapshot. The current state is itself snapshotted first, so a revert can be undone. */ +/** Reverts calendars and photo settings to a snapshot (TV settings are left as they are). The current state is itself snapshotted first, so a revert can be undone. */ export async function POST(request: Request) { const denied = await requireAdmin(request, { mutating: true }); if (denied) return denied; diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/tvs/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/tvs/route.ts new file mode 100644 index 0000000..6cd03d9 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/tvs/route.ts @@ -0,0 +1,72 @@ +import { json, readJsonBody, requireAdmin } from "../../../_lib/auth"; +import { ALL_DAYS, MAX_TVS, playlistUrl, readSettings, saveSettings, type TvSetting, validPlaylistId, validTvId } from "../../../_lib/store"; +import { CHECK_IN_MINUTES, readTvStatus, tvConfig } from "../../../_lib/tvs"; + +export const dynamic = "force-dynamic"; + +async function view(tvs: TvSetting[]) { + const status = await readTvStatus(); + const known = new Set(tvs.map((tv) => tv.id)); + return { + checkInMinutes: CHECK_IN_MINUTES, + tvs: tvs.map((tv) => ({ + ...tv, + playlistUrl: tv.music.playlistId ? playlistUrl(tv.music.playlistId) : "", + version: tvConfig(tv).version, + status: status[tv.id] ?? null, + })), + // TVs running the photo app that have checked in but are not configured yet. + found: Object.values(status) + .filter((entry) => !known.has(entry.id)) + .sort((left, right) => right.lastSeenAt - left.lastSeenAt), + }; +} + +export async function GET(request: Request) { + const denied = await requireAdmin(request); + if (denied) return denied; + return json(await view((await readSettings()).tvs)); +} + +type IncomingTv = { + id?: string; + name?: string; + wake?: { enabled?: boolean; time?: string; days?: number[] }; + music?: { enabled?: boolean; playlist?: string }; + volume?: { enabled?: boolean; level?: number }; +}; + +export async function PUT(request: Request) { + const denied = await requireAdmin(request, { mutating: true }); + if (denied) return denied; + const body = await readJsonBody<{ tvs?: IncomingTv[] }>(request); + if (!body || !Array.isArray(body.tvs)) return json({ error: "Expected a list of TVs." }, 400); + if (body.tvs.length > MAX_TVS) return json({ error: `At most ${MAX_TVS} TVs.` }, 400); + + const tvs: TvSetting[] = []; + for (const [position, entry] of body.tvs.entries()) { + const label = entry.name?.trim() || `TV ${position + 1}`; + const id = validTvId(entry.id); + if (!id) return json({ error: `${label}: unknown TV id; reload the page.` }, 400); + const time = String(entry.wake?.time ?? ""); + if (entry.wake?.enabled && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) return json({ error: `${label}: pick a wake time.` }, 400); + const playlistText = String(entry.music?.playlist ?? "").trim(); + const playlistId = playlistText ? validPlaylistId(playlistText) : ""; + if (playlistText && !playlistId) return json({ error: `${label}: paste a YouTube Music playlist link (https://music.youtube.com/playlist?list=…).` }, 400); + if (entry.music?.enabled && !playlistId) return json({ error: `${label}: music is on but no playlist is set.` }, 400); + const days = Array.isArray(entry.wake?.days) ? entry.wake.days : ALL_DAYS; + if (entry.wake?.enabled && !days.length) return json({ error: `${label}: pick at least one day.` }, 400); + tvs.push({ + id, + name: (entry.name ?? "").trim().slice(0, 40), + wake: { enabled: entry.wake?.enabled === true, time: time || "06:30", days }, + music: { enabled: entry.music?.enabled === true, playlistId: playlistId || "" }, + volume: { enabled: entry.volume?.enabled === true, level: Number(entry.volume?.level ?? 40) }, + }); + } + if (new Set(tvs.map((tv) => tv.id)).size !== tvs.length) return json({ error: "The same TV is listed twice." }, 400); + + const saved = await saveSettings({ tvs }, `Saved TV settings (${tvs.length} TV(s))`); + console.log(`[admin] TV settings saved (${tvs.length} TV(s))`); + return json(await view(saved.tvs)); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts index 766a1f9..1787555 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts @@ -15,7 +15,7 @@ export async function GET() { { status: "ok", service: "hobbs-family-organizer", - version: "1.3.0", + version: "1.4.0", calendarConfigurationValid: calendars.some((calendar) => calendar.enabled), calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length, adminConfigured, diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/tv/config/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/tv/config/route.ts new file mode 100644 index 0000000..1097a95 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/tv/config/route.ts @@ -0,0 +1,26 @@ +import { clientIp, isAdminNetworkAllowed, json } from "../../../_lib/auth"; +import { readSettings, validTvId } from "../../../_lib/store"; +import { recordTvCheckIn, tvConfig } from "../../../_lib/tvs"; + +export const dynamic = "force-dynamic"; + +/** + * Called by the Shield photo app every few minutes: + * GET /api/tv/config?device=&name=&model=&app=&applied= + * Same network rule as admin: direct LAN only, never through a proxy. + */ +export async function GET(request: Request) { + if (!isAdminNetworkAllowed(request.headers)) return new Response("Not found", { status: 404 }); + const params = new URL(request.url).searchParams; + const id = validTvId(params.get("device")); + if (!id) return json({ error: "Missing or invalid device id." }, 400); + const settings = await readSettings(); + const setting = settings.tvs.find((tv) => tv.id === id); + await recordTvCheckIn( + id, + { deviceName: params.get("name"), model: params.get("model"), appVersion: params.get("app"), appliedVersion: params.get("applied"), ip: clientIp(request.headers) }, + undefined, + new Set(settings.tvs.map((tv) => tv.id)), + ); + return json(tvConfig(setting)); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/tv/report/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/tv/report/route.ts new file mode 100644 index 0000000..30f181a --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/tv/report/route.ts @@ -0,0 +1,23 @@ +import { clientIp, isAdminNetworkAllowed, json, readJsonBody } from "../../../_lib/auth"; +import { readSettings, validTvId } from "../../../_lib/store"; +import { recordTvCheckIn } from "../../../_lib/tvs"; + +export const dynamic = "force-dynamic"; + +type Report = { device?: string; kind?: string; ok?: boolean; detail?: string; applied?: string; app?: string }; + +/** The Shield photo app reports the outcome of its morning routine here (shown in admin). */ +export async function POST(request: Request) { + if (!isAdminNetworkAllowed(request.headers)) return new Response("Not found", { status: 404 }); + const body = await readJsonBody(request); + const id = validTvId(body?.device); + if (!body || !id) return json({ error: "Missing or invalid device id." }, 400); + const settings = await readSettings(); + await recordTvCheckIn( + id, + { appVersion: body.app, appliedVersion: body.applied, ip: clientIp(request.headers) }, + { kind: body.kind || "event", ok: body.ok === true, detail: body.detail || "" }, + new Set(settings.tvs.map((tv) => tv.id)), + ); + return json({ ok: true }); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml index 946f152..818d405 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml @@ -2,7 +2,7 @@ name: hobbs-family-organizer services: organizer: - image: hobbs-family-organizer:1.3.0 + image: hobbs-family-organizer:1.4.0 build: context: . dockerfile: Dockerfile diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json index 98d462c..438ea15 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json @@ -1,12 +1,12 @@ { "name": "hobbs-family-organizer", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hobbs-family-organizer", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "ical.js": "^2.2.1", "lucide-react": "^1.31.0", diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json index d588c72..4666084 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json @@ -1,6 +1,6 @@ { "name": "hobbs-family-organizer", - "version": "1.3.0", + "version": "1.4.0", "private": true, "engines": { "node": ">=22.13.0" diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/Hobbs-Family-Photos-1.2.1.apk b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/Hobbs-Family-Photos-1.2.1.apk new file mode 100644 index 0000000000000000000000000000000000000000..c9009470de248f77b201afb0d856991681b6d58a GIT binary patch literal 24858 zcmZ5{WlWt-)Gbn=cyWgoN^#DMySwXw;_mKl#frOgaCdiich`eE9Ngjd%l&cl-6xqR znaRv#?b&N5BYP>zz@%0A`oWxf;)_*n5e}(H`krPuDVvv#-2N>De01Rx6 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 aemTECp@ZD~(n15Hf%$ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/AgentReceiver.java b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/AgentReceiver.java new file mode 100644 index 0000000..5d972cc --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/AgentReceiver.java @@ -0,0 +1,23 @@ +package com.dayline.photos; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** Boot, app update, clock changes and the periodic check-in alarm all lead to a check-in. */ +public final class AgentReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Context app = context.getApplicationContext(); + // Re-arm from the cached settings at once (the server may be unreachable right after boot). + TvAgent.schedule(app, TvAgent.cachedConfig(app)); + PendingResult pending = goAsync(); + new Thread(() -> { + try { + TvAgent.checkIn(app); + } finally { + pending.finish(); + } + }, "tv-agent-check-in").start(); + } +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/MorningActivity.java b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/MorningActivity.java new file mode 100644 index 0000000..1eb5f83 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/MorningActivity.java @@ -0,0 +1,116 @@ +package com.dayline.photos; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.graphics.Color; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.WindowManager; +import android.widget.TextView; + +import org.json.JSONObject; + +/** + * Started by the morning alarm. Turns the screen on (the Shield then wakes the TV over HDMI-CEC), + * sets the volume and starts the playlist in YouTube Music. The calendar is opened at the wake + * time by a second alarm, or straight away from here when music is off. + */ +public final class MorningActivity extends Activity { + /** Give the TV a moment to power up and switch input before audio starts. */ + private static final long SETTLE_MS = 5_000; + + private final Handler handler = new Handler(Looper.getMainLooper()); + + @Override + @SuppressWarnings("deprecation") + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setTurnScreenOn(true); + setShowWhenLocked(true); + } + TextView text = new TextView(this); + text.setText("Good morning"); + text.setTextColor(Color.argb(170, 255, 255, 255)); + text.setTextSize(42); + text.setGravity(Gravity.CENTER); + text.setBackgroundColor(Color.BLACK); + setContentView(text); + + long wakeAt = getIntent().getLongExtra(TvAgent.EXTRA_WAKE_AT, System.currentTimeMillis()); + // Hold off re-arming until this morning's calendar alarm has fired. + TvAgent.prefs(this).edit().putLong(TvAgent.KEY_ROUTINE_UNTIL, Math.max(wakeAt, System.currentTimeMillis()) + 60_000).apply(); + handler.postDelayed(() -> run(wakeAt), SETTLE_MS); + } + + private void run(long wakeAt) { + JSONObject config = TvAgent.cachedConfig(this); + StringBuilder detail = new StringBuilder(); + boolean ok = true; + + JSONObject volume = config.optJSONObject("volume"); + if (volume != null && volume.optBoolean("enabled", false)) { + AudioManager audio = (AudioManager) getSystemService(AUDIO_SERVICE); + if (audio != null) { + int max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + int level = Math.round(Math.max(0, Math.min(100, volume.optInt("level", 40))) / 100f * max); + try { + audio.setStreamVolume(AudioManager.STREAM_MUSIC, level, 0); + detail.append("volume ").append(level).append('/').append(max).append("; "); + } catch (SecurityException denied) { + ok = false; + detail.append("volume refused; "); + } + } + } + + String calendarPackage = config.optString("calendarPackage", "com.dayline.tv"); + JSONObject music = config.optJSONObject("music"); + String url = music != null && music.optBoolean("enabled", false) ? music.optString("url", "") : ""; + if (!url.isEmpty()) { + Intent play = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setPackage(TvAgent.MUSIC_PACKAGE) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + startActivity(play); + detail.append("music started"); + } catch (ActivityNotFoundException missing) { + ok = false; + detail.append("YouTube Music not installed"); + openCalendar(calendarPackage, detail); + } + } else { + openCalendar(calendarPackage, detail); + } + + TvAgent.report(this, ok, detail.toString()); + // Re-arm for the next day once this morning's calendar alarm is past. + long delay = Math.max(0, wakeAt + 60_000 - System.currentTimeMillis()); + android.content.Context app = getApplicationContext(); + handler.postDelayed(() -> { + TvAgent.prefs(app).edit().remove(TvAgent.KEY_ROUTINE_UNTIL).apply(); + TvAgent.checkInAsync(app); + }, delay); + finish(); + } + + private void openCalendar(String calendarPackage, StringBuilder detail) { + Intent launch = TvAgent.calendarLaunchIntent(this, calendarPackage); + if (launch == null) { + detail.append("; calendar app not found"); + return; + } + startActivity(launch); + detail.append(detail.length() > 0 ? "; " : "").append("calendar opened"); + } +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoActivity.java b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoActivity.java index 4a6fa41..aca73eb 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoActivity.java +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoActivity.java @@ -18,6 +18,7 @@ public final class PhotoActivity extends Activity { hideSystemUi(); frame.view().requestFocus(); frame.load(); + TvAgent.checkInAsync(this); } @Override diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoFrameView.java b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoFrameView.java index bd254ea..140560f 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoFrameView.java +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoFrameView.java @@ -143,7 +143,7 @@ final class PhotoFrameView { } /** files/base_url (written over ADB run-as) overrides the built-in default. */ - private static String configuredUrl(Context context) { + static String configuredUrl(Context context) { File file = new File(context.getFilesDir(), BASE_URL_FILE); if (file.isFile() && file.length() > 0 && file.length() < 2048) { try (FileInputStream input = new FileInputStream(file)) { diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/TvAgent.java b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/TvAgent.java new file mode 100644 index 0000000..7012b87 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/TvAgent.java @@ -0,0 +1,303 @@ +package com.dayline.photos; + +import android.annotation.SuppressLint; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.os.SystemClock; +import android.provider.Settings; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Calendar; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Per-TV morning routine controlled from the organizer's admin page (TVs tab). + * + * Every few minutes the app checks in at GET /api/tv/config with this TV's Android ID and + * receives its settings: wake time + days, playlist, volume. It then arms two alarms: + * A) wake time minus the music lead: MorningActivity wakes the screen, sets the volume, starts music; + * B) the wake time itself: opens the calendar app (com.dayline.tv) on top. + * With music off only one alarm is used and MorningActivity opens the calendar itself. + */ +final class TvAgent { + static final String TAG = "HobbsTvAgent"; + static final String ACTION_CHECK_IN = "com.dayline.photos.action.CHECK_IN"; + static final String EXTRA_WAKE_AT = "wakeAt"; + static final String MUSIC_PACKAGE = "com.google.android.youtube.tvmusic"; + + private static final String PREFS = "tv_agent"; + private static final String KEY_CONFIG = "config"; + private static final String KEY_VERSION = "version"; + /** While a routine is running (music started, calendar alarm pending) do not re-arm. */ + static final String KEY_ROUTINE_UNTIL = "routineUntil"; + private static final int REQ_MORNING = 7001; + private static final int REQ_CALENDAR = 7002; + private static final int REQ_CHECK_IN = 7003; + private static final long DEFAULT_CHECK_IN_MINUTES = 5; + + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + + private TvAgent() { + } + + // ---------- identity / server ---------- + + @SuppressLint("HardwareIds") + static String deviceId(Context context) { + String id = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + return id == null ? "" : id.toLowerCase(); + } + + static String origin(Context context) { + Uri parsed = Uri.parse(PhotoFrameView.configuredUrl(context)); + return parsed.getScheme() + "://" + parsed.getAuthority(); + } + + private static String enc(String value) { + try { + return URLEncoder.encode(value == null ? "" : value, "UTF-8"); + } catch (IOException impossible) { + return ""; + } + } + + private static String deviceName(Context context) { + String name = Settings.Global.getString(context.getContentResolver(), "device_name"); + return name != null ? name : Build.MODEL; + } + + // ---------- cached config ---------- + + static SharedPreferences prefs(Context context) { + return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + static JSONObject cachedConfig(Context context) { + try { + return new JSONObject(prefs(context).getString(KEY_CONFIG, "{}")); + } catch (JSONException broken) { + return new JSONObject(); + } + } + + static String appliedVersion(Context context) { + return prefs(context).getString(KEY_VERSION, ""); + } + + // ---------- check-in ---------- + + static void checkInAsync(Context context) { + Context app = context.getApplicationContext(); + EXECUTOR.execute(() -> checkIn(app)); + } + + /** Blocking: fetch settings, re-arm alarms if they changed, confirm the applied version. */ + static void checkIn(Context context) { + try { + JSONObject config = fetchConfig(context, appliedVersion(context)); + String version = config.optString("version", ""); + boolean changed = !version.equals(appliedVersion(context)); + prefs(context).edit().putString(KEY_CONFIG, config.toString()).putString(KEY_VERSION, version).apply(); + schedule(context, config); + scheduleCheckIns(context, config.optLong("checkInMinutes", DEFAULT_CHECK_IN_MINUTES)); + // Tell the server at once that the new settings are in place (admin shows "on the TV"). + if (changed) fetchConfig(context, version); + } catch (IOException | JSONException error) { + Log.w(TAG, "Check-in failed: " + error); + // Keep the last known schedule. + schedule(context, cachedConfig(context)); + scheduleCheckIns(context, DEFAULT_CHECK_IN_MINUTES); + } + } + + private static JSONObject fetchConfig(Context context, String applied) throws IOException, JSONException { + String url = origin(context) + "/api/tv/config?device=" + enc(deviceId(context)) + + "&name=" + enc(deviceName(context)) + + "&model=" + enc(Build.MODEL) + + "&app=" + enc(BuildConfig.VERSION_NAME) + + "&applied=" + enc(applied); + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(10_000); + connection.setInstanceFollowRedirects(false); + try { + int code = connection.getResponseCode(); + if (code != 200) throw new IOException("HTTP " + code); + return new JSONObject(readAll(connection.getInputStream())); + } finally { + connection.disconnect(); + } + } + + /** Fire-and-forget report of the morning routine's outcome (shown in admin). */ + static void report(Context context, boolean ok, String detail) { + Context app = context.getApplicationContext(); + EXECUTOR.execute(() -> { + HttpURLConnection connection = null; + try { + JSONObject body = new JSONObject() + .put("device", deviceId(app)) + .put("kind", "morning") + .put("ok", ok) + .put("detail", detail) + .put("applied", appliedVersion(app)) + .put("app", BuildConfig.VERSION_NAME); + connection = (HttpURLConnection) new URL(origin(app) + "/api/tv/report").openConnection(); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(10_000); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + try (OutputStream out = connection.getOutputStream()) { + out.write(body.toString().getBytes(StandardCharsets.UTF_8)); + } + connection.getResponseCode(); + } catch (IOException | JSONException error) { + Log.w(TAG, "Report failed: " + error); + } finally { + if (connection != null) connection.disconnect(); + } + }); + } + + private static String readAll(InputStream input) throws IOException { + try (InputStream in = input; ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[4096]; + int read; + int total = 0; + while ((read = in.read(buffer)) != -1) { + total += read; + if (total > 64 * 1024) throw new IOException("Response too large"); + out.write(buffer, 0, read); + } + return out.toString("UTF-8"); + } + } + + // ---------- alarms ---------- + + private static PendingIntent checkInIntent(Context context) { + Intent intent = new Intent(context, AgentReceiver.class).setAction(ACTION_CHECK_IN); + return PendingIntent.getBroadcast(context, REQ_CHECK_IN, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } + + private static PendingIntent morningIntent(Context context, long wakeAt) { + Intent intent = new Intent(context, MorningActivity.class) + .putExtra(EXTRA_WAKE_AT, wakeAt) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return PendingIntent.getActivity(context, REQ_MORNING, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } + + static Intent calendarLaunchIntent(Context context, String calendarPackage) { + Intent launch = context.getPackageManager().getLeanbackLaunchIntentForPackage(calendarPackage); + if (launch == null) launch = context.getPackageManager().getLaunchIntentForPackage(calendarPackage); + if (launch == null) return null; + return launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + } + + private static PendingIntent calendarIntent(Context context, String calendarPackage) { + Intent launch = calendarLaunchIntent(context, calendarPackage); + if (launch == null) launch = new Intent().setComponent(new ComponentName(calendarPackage, calendarPackage + ".MainActivity")); + return PendingIntent.getActivity(context, REQ_CALENDAR, launch, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } + + static void scheduleCheckIns(Context context, long minutes) { + AlarmManager alarms = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarms == null) return; + long interval = Math.max(1, minutes) * 60_000L; + // One-shot and chained (every check-in arms the next): "allow while idle" alarms still fire + // while the Shield sleeps in Doze, where repeating alarms would wait for a maintenance window. + alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + interval, checkInIntent(context)); + } + + /** Arms (or cancels) the morning alarms from a config object. */ + static void schedule(Context context, JSONObject config) { + AlarmManager alarms = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarms == null) return; + if (System.currentTimeMillis() < prefs(context).getLong(KEY_ROUTINE_UNTIL, 0)) { + Log.i(TAG, "Routine in progress; re-arming after it ends"); + return; + } + String calendarPackage = config.optString("calendarPackage", "com.dayline.tv"); + alarms.cancel(morningIntent(context, 0)); + alarms.cancel(calendarIntent(context, calendarPackage)); + + JSONObject wake = config.optJSONObject("wake"); + if (wake == null || !wake.optBoolean("enabled", false)) { + Log.i(TAG, "Morning routine off"); + return; + } + long wakeAt = nextWake(wake.optString("time", "06:30"), wake.optJSONArray("days"), System.currentTimeMillis()); + if (wakeAt <= 0) return; + JSONObject music = config.optJSONObject("music"); + boolean musicOn = music != null && music.optBoolean("enabled", false) && !music.optString("url", "").isEmpty(); + long lead = musicOn ? config.optLong("musicLeadSeconds", 30) * 1000L : 0; + + // setAlarmClock: exact, wakes the device from idle, and may start activities. + PendingIntent morning = morningIntent(context, wakeAt); + alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(wakeAt - lead, morning), morning); + if (musicOn) { + alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(wakeAt, morning), calendarIntent(context, calendarPackage)); + } + Log.i(TAG, "Morning routine armed for " + wakeAt + (musicOn ? " (music " + lead / 1000 + "s earlier)" : "")); + } + + /** Next time at HH:MM (local) on one of the allowed days (0 = Sunday), strictly after now plus a minute. */ + static long nextWake(String time, JSONArray days, long now) { + String[] parts = time.split(":"); + int hour; + int minute; + try { + hour = Integer.parseInt(parts[0]); + minute = Integer.parseInt(parts[1]); + } catch (RuntimeException bad) { + return -1; + } + boolean[] allowed = new boolean[7]; + boolean any = false; + if (days == null) { + java.util.Arrays.fill(allowed, true); + any = true; + } else { + for (int i = 0; i < days.length(); i++) { + int day = days.optInt(i, -1); + if (day >= 0 && day <= 6) { + allowed[day] = true; + any = true; + } + } + } + if (!any) return -1; + Calendar candidate = Calendar.getInstance(); + candidate.setTimeInMillis(now); + candidate.set(Calendar.HOUR_OF_DAY, hour); + candidate.set(Calendar.MINUTE, minute); + candidate.set(Calendar.SECOND, 0); + candidate.set(Calendar.MILLISECOND, 0); + for (int i = 0; i < 8; i++) { + int day = candidate.get(Calendar.DAY_OF_WEEK) - 1; // Calendar.SUNDAY == 1 + if (candidate.getTimeInMillis() > now + 60_000 && allowed[day]) return candidate.getTimeInMillis(); + candidate.add(Calendar.DAY_OF_YEAR, 1); + } + return -1; + } +}