mirror of
https://github.com/usetrmnl/trmnl-home-assistant.git
synced 2026-04-29 13:44:17 -07:00
Fixed standalone Docker data persistence
Necessary to resolve issues where schedules and output were written to /app/data inside the container instead of the user-mounted /data volume. The app now auto-detects the data directory at startup and supports both mount points: - -v ./trmnl-data:/data (recommended, matches docs) - -v ./trmnl-data:/app/data (legacy workaround, also works) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bdb05afd55
commit
dc8a0e903f
@@ -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!
|
||||
|
||||
|
||||
+12
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user