Skip to content

Commit dc8a0e9

Browse files
ikraamgclaude
andcommitted
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>
1 parent bdb05af commit dc8a0e9

8 files changed

Lines changed: 90 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ docker run -d --name trmnl-ha \
5151
```
5252

5353
> **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.
54+
>
55+
> **Volume mount:** `-v ./trmnl-data:/data` is recommended. `-v ./trmnl-data:/app/data` also works.
5456
5557
Then open `http://localhost:10000` - that's it!
5658

trmnl-ha/DOCS.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ docker run -d --name trmnl-ha \
9191
ghcr.io/usetrmnl/trmnl-ha-amd64:latest
9292
```
9393

94+
**Data Persistence:**
95+
96+
Schedules and output screenshots are stored in a persistent data directory. Two volume mount options are supported:
97+
98+
| Mount | Example | Notes |
99+
|-------|---------|-------|
100+
| `/data` (recommended) | `-v ./trmnl-data:/data` | Matches docs, used by HA add-on |
101+
| `/app/data` (also works) | `-v ./trmnl-data:/app/data` | Legacy alternative |
102+
103+
Both options work — the app auto-detects which path is available. If you're already using `/app/data`, there's no need to change.
104+
94105
**Environment Variables:**
95106

96107
| Variable | Required | Description |
@@ -304,7 +315,7 @@ curl "http://192.168.1.x:10000/lovelace/0?viewport=800x480&dithering&palette=bw&
304315

305316
Create cron-based schedules via the Web UI for automatic captures.
306317

307-
**Storage:** `/data/schedules.json` (persists across restarts)
318+
**Storage:** `schedules.json` in the data directory (persists across restarts when a volume is mounted — see [Data Persistence](#home-assistant-container-docker))
308319

309320
**Manual Trigger:** Click **Send Now** to execute immediately.
310321

trmnl-ha/ha-trmnl/const.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
isValidTimezone,
4444
hasEnvConfig as checkEnvConfig,
4545
detectIsAddOn,
46+
detectDataDir,
4647
findBrowser,
4748
isNetworkError,
4849
parseOptionsFile,
@@ -196,6 +197,15 @@ if (isAddOn) {
196197
console.log('[Config] Running in standalone mode')
197198
}
198199

200+
/**
201+
* Persistent data directory for schedules, output screenshots, etc.
202+
* - HA add-on: /data (mounted by HA Supervisor)
203+
* - Standalone Docker: /data (user-mounted volume)
204+
* - Local dev: ./data (relative to cwd)
205+
*/
206+
export const DATA_DIR: string = detectDataDir(isAddOn, process.cwd())
207+
console.log(`[Config] Data directory: ${DATA_DIR}`)
208+
199209
/**
200210
* Whether to use mock Home Assistant for testing and local development
201211
* Set MOCK_HA=true environment variable to enable mock mode

trmnl-ha/ha-trmnl/lib/config-helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,26 @@ export function detectIsAddOn(
119119
)
120120
}
121121

122+
/**
123+
* Determine the data directory for persistence (schedules, output, etc.)
124+
*
125+
* Priority:
126+
* 1. HA add-on mode → /data (mounted by HA Supervisor)
127+
* 2. Standalone Docker with /data mount → /data (user-mounted volume)
128+
* 3. Local dev → ./data relative to cwd (no /data exists)
129+
*
130+
* @param isAddOn - Whether running as HA add-on
131+
* @param cwd - Current working directory (for local dev fallback)
132+
* @returns Absolute path to data directory
133+
*/
134+
export function detectDataDir(isAddOn: boolean, cwd: string): string {
135+
if (isAddOn) return '/data'
136+
// NOTE: In standalone Docker, users mount -v ./trmnl-data:/data
137+
// existsSync('/data') is true when a volume is mounted there
138+
if (existsSync('/data')) return '/data'
139+
return `${cwd}/data`
140+
}
141+
122142
/**
123143
* Parse boolean from environment variable string
124144
* @param value - String value from env var

trmnl-ha/ha-trmnl/lib/scheduleStore.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,17 @@
88
*/
99

1010
import fs from 'node:fs/promises'
11-
import { existsSync } from 'node:fs'
1211
import path from 'node:path'
13-
import { fileURLToPath } from 'node:url'
1412
import type {
1513
Schedule,
1614
ScheduleInput,
1715
ScheduleUpdate,
1816
} from '../types/domain.js'
17+
import { DATA_DIR } from '../const.js'
1918
import { schedulerLogger } from './logger.js'
2019

2120
const log = schedulerLogger()
2221

23-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
24-
2522
// =============================================================================
2623
// FILE LOCKING (prevents race conditions on concurrent writes)
2724
// =============================================================================
@@ -62,11 +59,7 @@ async function withLock<T>(
6259
}
6360
}
6461

65-
// NOTE: existsSync is used at startup only (sync is fine for config detection)
66-
const isAddOn = existsSync('/data/options.json')
67-
const DEFAULT_SCHEDULES_FILE = isAddOn
68-
? '/data/schedules.json'
69-
: path.join(__dirname, '..', 'data', 'schedules.json')
62+
const DEFAULT_SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json')
7063

7164
/**
7265
* Check if file exists (async)

trmnl-ha/ha-trmnl/scheduler.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,15 @@
1919

2020
import fs from 'node:fs'
2121
import path from 'node:path'
22-
import { fileURLToPath } from 'node:url'
2322
import { loadSchedules } from './lib/scheduleStore.js'
2423
import { ScheduleExecutor, type ScreenshotFunction, type ExecutionResult } from './lib/scheduler/schedule-executor.js'
2524
import { CronJobManager } from './lib/scheduler/cron-job-manager.js'
26-
import { SCHEDULER_RELOAD_INTERVAL_MS, SCHEDULER_OUTPUT_DIR_NAME } from './const.js'
25+
import { SCHEDULER_RELOAD_INTERVAL_MS, SCHEDULER_OUTPUT_DIR_NAME, DATA_DIR } from './const.js'
2726
import type { Schedule } from './types/domain.js'
2827
import { schedulerLogger } from './lib/logger.js'
2928

3029
const log = schedulerLogger()
3130

32-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
33-
3431
/**
3532
* High-level scheduler orchestrating cron jobs and screenshot execution.
3633
*/
@@ -46,7 +43,7 @@ export class Scheduler {
4643
* @param screenshotFn - Screenshot capture function (async)
4744
*/
4845
constructor(screenshotFn: ScreenshotFunction) {
49-
this.#outputDir = path.join(__dirname, SCHEDULER_OUTPUT_DIR_NAME)
46+
this.#outputDir = path.join(DATA_DIR, SCHEDULER_OUTPUT_DIR_NAME)
5047
this.#cronManager = new CronJobManager()
5148
this.#executor = new ScheduleExecutor(screenshotFn, this.#outputDir)
5249

trmnl-ha/ha-trmnl/scripts/docker-entrypoint.sh

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,19 @@
22
set -e
33

44
echo "Starting TRMNL HA..."
5-
mkdir -p logs output data
5+
6+
# Ensure data subdirectories exist for persistence.
7+
# Supports two mount points:
8+
# -v ./trmnl-data:/data (recommended, matches docs)
9+
# -v ./trmnl-data:/app/data (also works, legacy workaround)
10+
# In HA add-on mode, /data is managed by HA Supervisor.
11+
if [ -d "/data" ]; then
12+
mkdir -p /data/output
13+
else
14+
mkdir -p data/output
15+
fi
16+
17+
# Local app directories (logs stay app-local, not persisted)
18+
mkdir -p logs
19+
620
exec "$@"

trmnl-ha/ha-trmnl/tests/unit/config-helpers.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
isValidTimezone,
1818
hasEnvConfig,
1919
detectIsAddOn,
20+
detectDataDir,
2021
parseEnvBoolean,
2122
isNetworkError,
2223
parseOptionsFile,
@@ -204,6 +205,32 @@ describe('detectIsAddOn', () => {
204205
})
205206
})
206207

208+
// =============================================================================
209+
// Data Directory Detection
210+
// =============================================================================
211+
212+
describe('detectDataDir', () => {
213+
it('returns /data for HA add-on mode', () => {
214+
expect(detectDataDir(true, '/app')).toBe('/data')
215+
})
216+
217+
it('returns cwd/data when /data does not exist', () => {
218+
// Covers local dev AND the -v ./trmnl-data:/app/data workaround mount.
219+
// In Docker with WORKDIR /app, cwd/data resolves to /app/data.
220+
const result = detectDataDir(false, '/Users/dev/project')
221+
expect(result).toBe('/Users/dev/project/data')
222+
})
223+
224+
it('supports /app/data path in Docker (WORKDIR /app fallback)', () => {
225+
// Simulates Docker with WORKDIR /app and -v ./trmnl-data:/app/data.
226+
// Since /data doesn't exist on the host, falls through to cwd/data.
227+
// NOTE: In Docker with -v ./x:/data, existsSync('/data') is true → '/data'.
228+
// Both mount points are supported; this test covers the fallback branch.
229+
const result = detectDataDir(false, '/app')
230+
expect(['/data', '/app/data']).toContain(result)
231+
})
232+
})
233+
207234
// =============================================================================
208235
// Boolean Environment Variable Parsing
209236
// =============================================================================

0 commit comments

Comments
 (0)