diff --git a/README.md b/README.md index 5a94304..8ec77a1 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ docker run -d --name trmnl-ha \ ``` > **Note:** Replace `YOUR_HOST_IP` with your machine's IP (e.g., `192.168.1.100`). Container names like `homeassistant` won't work since HA uses host networking. +> +> **Volume mount:** `-v ./trmnl-data:/data` is recommended. `-v ./trmnl-data:/app/data` also works. Then open `http://localhost:10000` - that's it! diff --git a/trmnl-ha/DOCS.md b/trmnl-ha/DOCS.md index 8ceab11..ef05840 100644 --- a/trmnl-ha/DOCS.md +++ b/trmnl-ha/DOCS.md @@ -91,6 +91,17 @@ docker run -d --name trmnl-ha \ ghcr.io/usetrmnl/trmnl-ha-amd64:latest ``` +**Data Persistence:** + +Schedules and output screenshots are stored in a persistent data directory. Two volume mount options are supported: + +| Mount | Example | Notes | +|-------|---------|-------| +| `/data` (recommended) | `-v ./trmnl-data:/data` | Matches docs, used by HA add-on | +| `/app/data` (also works) | `-v ./trmnl-data:/app/data` | Legacy alternative | + +Both options work — the app auto-detects which path is available. If you're already using `/app/data`, there's no need to change. + **Environment Variables:** | Variable | Required | Description | @@ -304,7 +315,7 @@ curl "http://192.168.1.x:10000/lovelace/0?viewport=800x480&dithering&palette=bw& Create cron-based schedules via the Web UI for automatic captures. -**Storage:** `/data/schedules.json` (persists across restarts) +**Storage:** `schedules.json` in the data directory (persists across restarts when a volume is mounted — see [Data Persistence](#home-assistant-container-docker)) **Manual Trigger:** Click **Send Now** to execute immediately. diff --git a/trmnl-ha/ha-trmnl/const.ts b/trmnl-ha/ha-trmnl/const.ts index df914ed..8458eb7 100644 --- a/trmnl-ha/ha-trmnl/const.ts +++ b/trmnl-ha/ha-trmnl/const.ts @@ -43,6 +43,7 @@ import { isValidTimezone, hasEnvConfig as checkEnvConfig, detectIsAddOn, + detectDataDir, findBrowser, isNetworkError, parseOptionsFile, @@ -196,6 +197,15 @@ if (isAddOn) { console.log('[Config] Running in standalone mode') } +/** + * Persistent data directory for schedules, output screenshots, etc. + * - HA add-on: /data (mounted by HA Supervisor) + * - Standalone Docker: /data (user-mounted volume) + * - Local dev: ./data (relative to cwd) + */ +export const DATA_DIR: string = detectDataDir(isAddOn, process.cwd()) +console.log(`[Config] Data directory: ${DATA_DIR}`) + /** * Whether to use mock Home Assistant for testing and local development * Set MOCK_HA=true environment variable to enable mock mode diff --git a/trmnl-ha/ha-trmnl/lib/config-helpers.ts b/trmnl-ha/ha-trmnl/lib/config-helpers.ts index e86b9cd..9bcc713 100644 --- a/trmnl-ha/ha-trmnl/lib/config-helpers.ts +++ b/trmnl-ha/ha-trmnl/lib/config-helpers.ts @@ -119,6 +119,26 @@ export function detectIsAddOn( ) } +/** + * Determine the data directory for persistence (schedules, output, etc.) + * + * Priority: + * 1. HA add-on mode → /data (mounted by HA Supervisor) + * 2. Standalone Docker with /data mount → /data (user-mounted volume) + * 3. Local dev → ./data relative to cwd (no /data exists) + * + * @param isAddOn - Whether running as HA add-on + * @param cwd - Current working directory (for local dev fallback) + * @returns Absolute path to data directory + */ +export function detectDataDir(isAddOn: boolean, cwd: string): string { + if (isAddOn) return '/data' + // NOTE: In standalone Docker, users mount -v ./trmnl-data:/data + // existsSync('/data') is true when a volume is mounted there + if (existsSync('/data')) return '/data' + return `${cwd}/data` +} + /** * Parse boolean from environment variable string * @param value - String value from env var diff --git a/trmnl-ha/ha-trmnl/lib/scheduleStore.ts b/trmnl-ha/ha-trmnl/lib/scheduleStore.ts index 5b5efc1..a5b1583 100644 --- a/trmnl-ha/ha-trmnl/lib/scheduleStore.ts +++ b/trmnl-ha/ha-trmnl/lib/scheduleStore.ts @@ -8,20 +8,17 @@ */ import fs from 'node:fs/promises' -import { existsSync } from 'node:fs' import path from 'node:path' -import { fileURLToPath } from 'node:url' import type { Schedule, ScheduleInput, ScheduleUpdate, } from '../types/domain.js' +import { DATA_DIR } from '../const.js' import { schedulerLogger } from './logger.js' const log = schedulerLogger() -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - // ============================================================================= // FILE LOCKING (prevents race conditions on concurrent writes) // ============================================================================= @@ -62,11 +59,7 @@ async function withLock( } } -// NOTE: existsSync is used at startup only (sync is fine for config detection) -const isAddOn = existsSync('/data/options.json') -const DEFAULT_SCHEDULES_FILE = isAddOn - ? '/data/schedules.json' - : path.join(__dirname, '..', 'data', 'schedules.json') +const DEFAULT_SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json') /** * Check if file exists (async) diff --git a/trmnl-ha/ha-trmnl/scheduler.ts b/trmnl-ha/ha-trmnl/scheduler.ts index d7013df..b72732f 100644 --- a/trmnl-ha/ha-trmnl/scheduler.ts +++ b/trmnl-ha/ha-trmnl/scheduler.ts @@ -19,18 +19,15 @@ import fs from 'node:fs' import path from 'node:path' -import { fileURLToPath } from 'node:url' import { loadSchedules } from './lib/scheduleStore.js' import { ScheduleExecutor, type ScreenshotFunction, type ExecutionResult } from './lib/scheduler/schedule-executor.js' import { CronJobManager } from './lib/scheduler/cron-job-manager.js' -import { SCHEDULER_RELOAD_INTERVAL_MS, SCHEDULER_OUTPUT_DIR_NAME } from './const.js' +import { SCHEDULER_RELOAD_INTERVAL_MS, SCHEDULER_OUTPUT_DIR_NAME, DATA_DIR } from './const.js' import type { Schedule } from './types/domain.js' import { schedulerLogger } from './lib/logger.js' const log = schedulerLogger() -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - /** * High-level scheduler orchestrating cron jobs and screenshot execution. */ @@ -46,7 +43,7 @@ export class Scheduler { * @param screenshotFn - Screenshot capture function (async) */ constructor(screenshotFn: ScreenshotFunction) { - this.#outputDir = path.join(__dirname, SCHEDULER_OUTPUT_DIR_NAME) + this.#outputDir = path.join(DATA_DIR, SCHEDULER_OUTPUT_DIR_NAME) this.#cronManager = new CronJobManager() this.#executor = new ScheduleExecutor(screenshotFn, this.#outputDir) diff --git a/trmnl-ha/ha-trmnl/scripts/docker-entrypoint.sh b/trmnl-ha/ha-trmnl/scripts/docker-entrypoint.sh index 92c9521..f096a27 100755 --- a/trmnl-ha/ha-trmnl/scripts/docker-entrypoint.sh +++ b/trmnl-ha/ha-trmnl/scripts/docker-entrypoint.sh @@ -2,5 +2,19 @@ set -e echo "Starting TRMNL HA..." -mkdir -p logs output data + +# Ensure data subdirectories exist for persistence. +# Supports two mount points: +# -v ./trmnl-data:/data (recommended, matches docs) +# -v ./trmnl-data:/app/data (also works, legacy workaround) +# In HA add-on mode, /data is managed by HA Supervisor. +if [ -d "/data" ]; then + mkdir -p /data/output +else + mkdir -p data/output +fi + +# Local app directories (logs stay app-local, not persisted) +mkdir -p logs + exec "$@" diff --git a/trmnl-ha/ha-trmnl/tests/unit/config-helpers.test.ts b/trmnl-ha/ha-trmnl/tests/unit/config-helpers.test.ts index 487f6b9..de9ed17 100644 --- a/trmnl-ha/ha-trmnl/tests/unit/config-helpers.test.ts +++ b/trmnl-ha/ha-trmnl/tests/unit/config-helpers.test.ts @@ -17,6 +17,7 @@ import { isValidTimezone, hasEnvConfig, detectIsAddOn, + detectDataDir, parseEnvBoolean, isNetworkError, parseOptionsFile, @@ -204,6 +205,32 @@ describe('detectIsAddOn', () => { }) }) +// ============================================================================= +// Data Directory Detection +// ============================================================================= + +describe('detectDataDir', () => { + it('returns /data for HA add-on mode', () => { + expect(detectDataDir(true, '/app')).toBe('/data') + }) + + it('returns cwd/data when /data does not exist', () => { + // Covers local dev AND the -v ./trmnl-data:/app/data workaround mount. + // In Docker with WORKDIR /app, cwd/data resolves to /app/data. + const result = detectDataDir(false, '/Users/dev/project') + expect(result).toBe('/Users/dev/project/data') + }) + + it('supports /app/data path in Docker (WORKDIR /app fallback)', () => { + // Simulates Docker with WORKDIR /app and -v ./trmnl-data:/app/data. + // Since /data doesn't exist on the host, falls through to cwd/data. + // NOTE: In Docker with -v ./x:/data, existsSync('/data') is true → '/data'. + // Both mount points are supported; this test covers the fallback branch. + const result = detectDataDir(false, '/app') + expect(['/data', '/app/data']).toContain(result) + }) +}) + // ============================================================================= // Boolean Environment Variable Parsing // =============================================================================