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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 22:48:50 -06:00
parent 2d16ded0c1
commit 6e94a0c998
22 changed files with 1083 additions and 18 deletions
+1 -1
View File
@@ -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 (`<key>.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 <that value>`). **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 (`<key>.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 <that value>`). **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
@@ -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.
@@ -1 +1 @@
1.3.0
1.4.0
@@ -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<string>();
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<Pick<Settings, "calendars" | "photos">>, note: string) {
export function saveSettings(patch: Partial<Pick<Settings, "calendars" | "photos" | "tvs">>, 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;
});
@@ -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<unknown> = Promise.resolve();
function serialized<T>(task: () => Promise<T>) {
const run = queue.then(task, task);
queue = run.catch(() => undefined);
return run;
}
export async function readTvStatus(): Promise<Record<string, TvStatus>> {
try {
const raw = JSON.parse(await readFile(statusPath(), "utf8")) as Record<string, TvStatus>;
return raw && typeof raw === "object" ? raw : {};
} catch {
return {};
}
}
async function writeTvStatus(next: Record<string, TvStatus>) {
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<TvEvent, "at">,
knownIds: Set<string> = 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 };
@@ -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<Tab, string> = { calendars: "Calendars", photos: "Photos", tvs: "TVs", account: "Account" };
async function api<T>(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 (
<label className="flex cursor-pointer items-center gap-2 text-sm font-semibold">
<input type="checkbox" className="h-4 w-4 accent-cyan-300" checked={checked} onChange={(event) => onChange(event.target.checked)} />
{label}
</label>
);
}
function TvStatusLine({ tv, checkInMinutes }: { tv: TvView; checkInMinutes: number }) {
const status = tv.status;
if (!status) return <span className="text-amber-300">Never checked in</span>;
const applied = status.appliedVersion === tv.version;
const seenMinutes = minutesSince(status.lastSeenAt);
return (
<span className="space-x-2">
{applied ? (
<span className="text-emerald-300">✓ Settings are on the TV</span>
) : (
<span className="text-amber-300">Waiting for the TV to pick up changes (it checks every {checkInMinutes} min)</span>
)}
<span className={seenMinutes > checkInMinutes * 3 ? "text-rose-300" : "text-slate-500"}>· last check-in {ago(status.lastSeenAt)}</span>
</span>
);
}
function TvsTab() {
const [view, setView] = useState<TvsView | null>(null);
const [drafts, setDrafts] = useState<TvDraft[]>([]);
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<TvsView>("/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<TvsView>("/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<TvsView>("/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 <p className="text-sm text-slate-400">Loading…</p>;
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 (
<div className="space-y-5">
<p className="text-sm text-slate-400">
Morning routine for each TV, run by the <b>Hobbs Family Photos</b> 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.
</p>
{drafts.map((draft) => {
const tv = byId.get(draft.id);
const status = tv?.status ?? view.found.find((entry) => entry.id === draft.id) ?? null;
return (
<section key={draft.id} className="space-y-4 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-1">
<input className={inputClass} value={draft.name} maxLength={40} placeholder="TV name" onChange={(event) => update(draft.id, (d) => ({ ...d, name: event.target.value }))} />
<p className="text-xs text-slate-500">
{status ? `${status.deviceName || status.model} · ${status.ip} · photo app ${status.appVersion}` : draft.id}
</p>
<p className="text-xs">{tv ? <TvStatusLine tv={tv} checkInMinutes={view.checkInMinutes} /> : <span className="text-amber-300">New — save to send settings</span>}</p>
{status?.lastEvent && (
<p className={`text-xs ${status.lastEvent.ok ? "text-slate-400" : "text-rose-300"}`}>
Last morning routine {ago(status.lastEvent.at)}: {status.lastEvent.ok ? "✓ " : "✗ "}
{status.lastEvent.detail}
</p>
)}
</div>
<button type="button" className={dangerButton} onClick={() => setDrafts((current) => current.filter((entry) => entry.id !== draft.id))}>
Remove
</button>
</div>
<div className="space-y-2 border-t border-white/5 pt-3">
<Toggle label="Wake up and show the calendar" checked={draft.wake.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, wake: { ...d.wake, enabled: value } }))} />
<div className={`flex flex-wrap items-center gap-2 pl-6 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<input
type="time"
className={inputClass.replace("w-full", "w-36")}
value={draft.wake.time}
disabled={!draft.wake.enabled}
onChange={(event) => 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 (
<button
key={label}
type="button"
disabled={!draft.wake.enabled}
onClick={() =>
update(draft.id, (d) => ({
...d,
wake: { ...d.wake, days: on ? d.wake.days.filter((entry) => entry !== day) : [...d.wake.days, day].sort() },
}))
}
className={`rounded-md px-2 py-1 text-xs font-semibold ${on ? "bg-cyan-300 text-slate-950" : "bg-white/5 text-slate-400"}`}
>
{label}
</button>
);
})}
</div>
</div>
<div className={`space-y-2 border-t border-white/5 pt-3 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<Toggle label="Play music" checked={draft.music.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, music: { ...d.music, enabled: value } }))} />
<div className="pl-6">
<input
className={inputClass}
placeholder="YouTube Music playlist link (Share → Copy link)"
value={draft.music.playlist}
onChange={(event) => update(draft.id, (d) => ({ ...d, music: { ...d.music, playlist: event.target.value } }))}
/>
</div>
</div>
<div className={`space-y-2 border-t border-white/5 pt-3 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<Toggle label="Set the volume" checked={draft.volume.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, volume: { ...d.volume, enabled: value } }))} />
<div className="flex items-center gap-3 pl-6">
<input
type="range"
min={0}
max={100}
step={5}
className="flex-1 accent-cyan-300"
value={draft.volume.level}
disabled={!draft.volume.enabled}
onChange={(event) => update(draft.id, (d) => ({ ...d, volume: { ...d.volume, level: Number(event.target.value) } }))}
/>
<span className="w-12 text-right text-sm tabular-nums">{draft.volume.level}%</span>
</div>
<p className="pl-6 text-xs text-slate-500">This is the Shield&apos;s own volume; the TV&apos;s volume still applies on top.</p>
</div>
</section>
);
})}
{!drafts.length && <p className="text-sm text-slate-400">No TVs set up yet.</p>}
{found.length > 0 && (
<section className="space-y-2 rounded-xl border border-dashed border-white/15 p-4">
<h2 className="text-sm font-bold">TVs found on the network</h2>
{found.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 text-sm">
<span className="flex-1">
{entry.deviceName || entry.model} <span className="text-slate-500">· {entry.ip} · checked in {ago(entry.lastSeenAt)}</span>
</span>
<button type="button" className={ghostButton} onClick={() => add(entry)}>
Add
</button>
</div>
))}
</section>
)}
{!found.length && !drafts.length && (
<p className="text-xs text-slate-500">TVs appear here once the Hobbs Family Photos app (version 1.2 or later) has checked in.</p>
)}
<div className="flex items-center gap-2">
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
{busy ? "Saving…" : "Save and send to TVs"}
</button>
{dirty && (
<button type="button" className={ghostButton} onClick={() => apply(view)}>
Discard
</button>
)}
</div>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
</div>
);
}
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() {
</div>
</header>
<nav className="mb-6 flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
{(["calendars", "photos", "account"] as Tab[]).map((name) => (
{(Object.keys(TAB_LABELS) as Tab[]).map((name) => (
<button
key={name}
type="button"
onClick={() => selectTab(name)}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold capitalize ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
>
{name}
{TAB_LABELS[name]}
</button>
))}
</nav>
{tab === "calendars" && <CalendarsTab />}
{tab === "photos" && <PhotosTab />}
{tab === "tvs" && <TvsTab />}
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
</>
)}
@@ -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;
@@ -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));
}
@@ -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,
@@ -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=<android id>&name=<device name>&model=<model>&app=<app version>&applied=<config version>
* 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));
}
@@ -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<Report>(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 });
}
@@ -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
@@ -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",
@@ -1,6 +1,6 @@
{
"name": "hobbs-family-organizer",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"engines": {
"node": ">=22.13.0"
@@ -16,8 +16,8 @@ android {
applicationId "com.dayline.photos"
minSdk 26
targetSdk 35
versionCode 2
versionName "1.1"
versionCode 4
versionName "1.2.1"
buildConfigField "String", "DEFAULT_URL", "\"http://192.168.86.100:8340/photo\""
}
@@ -2,6 +2,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Morning routine (TvAgent): exact wake alarms, re-arm after reboot, start other apps
from the background (SYSTEM_ALERT_WINDOW is granted over ADB: appops set ... allow). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Android 11 package visibility: the apps the morning routine opens. -->
<queries>
<package android:name="com.dayline.tv" />
<package android:name="com.google.android.youtube.tvmusic" />
</queries>
<uses-feature
android:name="android.software.leanback"
@@ -48,6 +60,29 @@
android:name="android.service.dream"
android:resource="@xml/dream_info" />
</service>
<!-- Morning routine: wakes the screen, sets the volume, starts the music. -->
<activity
android:name=".MorningActivity"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleInstance"
android:noHistory="true"
android:screenOrientation="landscape"
android:showWhenLocked="true"
android:turnScreenOn="true" />
<receiver
android:name=".AgentReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="com.dayline.photos.action.CHECK_IN" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -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();
}
}
@@ -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");
}
}
@@ -18,6 +18,7 @@ public final class PhotoActivity extends Activity {
hideSystemUi();
frame.view().requestFocus();
frame.load();
TvAgent.checkInAsync(this);
}
@Override
@@ -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)) {
@@ -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;
}
}