Skip to content

Commit 4d03c59

Browse files
ikraamgclaude
andcommitted
Added color-7b and color-8a palettes with dynamic UI
New e-ink color palettes were missing from the UI dropdown despite being defined in code. This adds support for RTM1002 (7-color Cyan) and Spectra 6 (8-color) displays. Introduces single source of truth architecture: - palette-options.ts defines labels (browser-safe) - const.ts imports labels via helper, adds runtime data - /api/palettes endpoint serves options to UI - UI fetches palettes dynamically at startup Closes: #3 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5aab3a9 commit 4d03c59

9 files changed

Lines changed: 269 additions & 79 deletions

File tree

trmnl-ha/ha-trmnl/bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

trmnl-ha/ha-trmnl/const.ts

Lines changed: 59 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,22 @@ import type {
2323
ContentTypeMap,
2424
ColorPaletteDefinition,
2525
GrayscalePaletteDefinition,
26+
Palette,
27+
PaletteConfig,
2628
} from './types/domain.js'
29+
import { isColorPalette, isGrayscalePalette } from './types/domain.js'
30+
// Re-export browser-safe palette options (single source of truth for labels)
31+
export {
32+
PALETTE_OPTIONS,
33+
GRAYSCALE_OPTIONS,
34+
COLOR_OPTIONS,
35+
type PaletteOption,
36+
} from './html/js/palette-options.js'
37+
import { PALETTE_OPTIONS } from './html/js/palette-options.js'
38+
39+
/** Lookup label from palette-options (single source of truth) */
40+
const label = (value: string): string =>
41+
PALETTE_OPTIONS.find((p) => p.value === value)?.label ?? value
2742
import {
2843
isValidTimezone,
2944
hasEnvConfig as checkEnvConfig,
@@ -297,63 +312,56 @@ export const VALID_FORMATS: readonly ImageFormat[] = [
297312
export const VALID_ROTATIONS: readonly RotationAngle[] = [90, 180, 270] as const
298313

299314
/**
300-
* Color palette definitions for e-ink displays
315+
* Unified palette definitions - SINGLE SOURCE OF TRUTH
316+
*
317+
* Add new palettes here and they automatically appear in UI + dithering.
318+
* Order determines dropdown display order.
301319
*
302320
* @see https://www.eink.com/brand/detail/Spectra6
303321
* @see https://shop.pimoroni.com/products/inky-impression-7-3
304322
*/
305-
export const COLOR_PALETTES: ColorPaletteDefinition = {
306-
// 6-color: Basic RGB + Yellow + Black/White
307-
'color-6a': [
308-
'#000000', // Black
309-
'#FFFFFF', // White
310-
'#FF0000', // Red
311-
'#00FF00', // Green
312-
'#0000FF', // Blue
313-
'#FFFF00', // Yellow
314-
],
315-
// 7-color ACeP/Gallery with Orange (Waveshare, Pimoroni Inky Impression)
316-
'color-7a': [
317-
'#000000', // Black
318-
'#FFFFFF', // White
319-
'#FF0000', // Red
320-
'#00FF00', // Green
321-
'#0000FF', // Blue
322-
'#FFFF00', // Yellow
323-
'#FF8C00', // Orange (Dark Orange - per Pimoroni spec)
324-
],
325-
// 7-color with Cyan (for displays that have Cyan instead of Orange)
326-
'color-7b': [
327-
'#000000', // Black
328-
'#FFFFFF', // White
329-
'#FF0000', // Red
330-
'#00FF00', // Green
331-
'#0000FF', // Blue
332-
'#FFFF00', // Yellow
333-
'#00FFFF', // Cyan
334-
],
335-
// 8-color Spectra 6 T2000 (2025+) - has both Cyan AND Orange
336-
'color-8a': [
337-
'#000000', // Black
338-
'#FFFFFF', // White
339-
'#FF0000', // Red
340-
'#00FF00', // Green
341-
'#0000FF', // Blue
342-
'#FFFF00', // Yellow
343-
'#00FFFF', // Cyan
344-
'#FF8C00', // Orange
345-
],
323+
export const PALETTES: Record<Palette, PaletteConfig> = {
324+
// Grayscale palettes (labels from palette-options.ts)
325+
bw: { label: label('bw'), levels: 2 },
326+
'gray-4': { label: label('gray-4'), levels: 4 },
327+
'gray-16': { label: label('gray-16'), levels: 16 },
328+
'gray-256': { label: label('gray-256'), levels: 256 },
329+
// Color palettes (labels from palette-options.ts)
330+
'color-6a': {
331+
label: label('color-6a'),
332+
colors: ['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00'],
333+
},
334+
'color-7a': {
335+
label: label('color-7a'),
336+
colors: ['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF8C00'],
337+
},
338+
'color-7b': {
339+
label: label('color-7b'),
340+
colors: ['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#00FFFF'],
341+
},
342+
'color-8a': {
343+
label: label('color-8a'),
344+
colors: ['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#00FFFF', '#FF8C00'],
345+
},
346346
}
347347

348-
/**
349-
* Grayscale palette definitions (number of gray levels)
350-
*/
351-
export const GRAYSCALE_PALETTES: GrayscalePaletteDefinition = {
352-
bw: 2,
353-
'gray-4': 4,
354-
'gray-16': 16,
355-
'gray-256': 256,
356-
}
348+
// =============================================================================
349+
// DERIVED PALETTE CONSTANTS (backward compatibility)
350+
// =============================================================================
351+
352+
/** Color palettes (hex arrays) - derived from PALETTES */
353+
export const COLOR_PALETTES: ColorPaletteDefinition = Object.fromEntries(
354+
Object.entries(PALETTES)
355+
.filter(([_, config]) => isColorPalette(config))
356+
.map(([key, config]) => [key, (config as { colors: string[] }).colors])
357+
) as ColorPaletteDefinition
358+
359+
/** Grayscale palettes (gray levels) - derived from PALETTES */
360+
export const GRAYSCALE_PALETTES: GrayscalePaletteDefinition = Object.fromEntries(
361+
Object.entries(PALETTES)
362+
.filter(([_, config]) => isGrayscalePalette(config))
363+
.map(([key, config]) => [key, (config as { levels: number }).levels])
364+
) as GrayscalePaletteDefinition
357365

358366
/**
359367
* Default wait time after page load (milliseconds)

trmnl-ha/ha-trmnl/html/js/api-client.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
PresetsConfig,
1515
SendScheduleResponse,
1616
} from '../../types/domain.js'
17+
import type { PaletteOption } from './palette-options.js'
1718

1819
/**
1920
* Fetches all schedules from the API
@@ -126,6 +127,25 @@ export class LoadPresets {
126127
}
127128
}
128129

130+
/**
131+
* Loads palette options for UI dropdown
132+
*/
133+
export class LoadPalettes {
134+
baseUrl: string
135+
136+
constructor(baseUrl = './api/palettes') {
137+
this.baseUrl = baseUrl
138+
}
139+
140+
async call(): Promise<PaletteOption[]> {
141+
const response = await fetch(this.baseUrl)
142+
if (!response.ok) {
143+
throw new Error(`Failed to load palettes: ${response.statusText}`)
144+
}
145+
return response.json()
146+
}
147+
}
148+
129149
/**
130150
* Triggers immediate execution of a schedule.
131151
* Takes screenshot and optionally uploads to webhook.

trmnl-ha/ha-trmnl/html/js/app.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import { PreviewGenerator } from './preview-generator.js'
2323
import { CropModal } from './crop-modal.js'
2424
import { ConfirmModal } from './confirm-modal.js'
2525
import { DevicePresetsManager } from './device-presets.js'
26-
import { SendSchedule } from './api-client.js'
26+
import { SendSchedule, LoadPalettes } from './api-client.js'
27+
import type { PaletteOption } from './palette-options.js'
2728
import type {
2829
Schedule,
2930
CropRegion,
@@ -82,6 +83,7 @@ class App {
8283
#cropModal: CropModal
8384
#confirmModal: ConfirmModal
8485
#devicePresetsManager: DevicePresetsManager
86+
#paletteOptions: PaletteOption[] = []
8587

8688
constructor() {
8789
this.#scheduleManager = new ScheduleManager()
@@ -105,7 +107,13 @@ class App {
105107
// Show HA status banner if not connected
106108
this.#updateHAStatusBanner()
107109

108-
await this.#scheduleManager.loadAll()
110+
// Load palettes and schedules in parallel
111+
const [, palettes] = await Promise.all([
112+
this.#scheduleManager.loadAll(),
113+
new LoadPalettes().call(),
114+
])
115+
this.#paletteOptions = palettes
116+
109117
this.renderUI()
110118

111119
await this.#devicePresetsManager.loadAndRenderPresets()
@@ -513,7 +521,7 @@ class App {
513521
#renderScheduleContent(): void {
514522
const schedule = this.#scheduleManager.activeSchedule
515523
if (schedule) {
516-
new RenderScheduleContent(schedule).call()
524+
new RenderScheduleContent(schedule, this.#paletteOptions).call()
517525

518526
this.#devicePresetsManager.afterDOMRender(schedule)
519527

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Palette options - SINGLE SOURCE OF TRUTH for UI labels
3+
*
4+
* This file is browser-safe (no Node.js deps) so it can be:
5+
* 1. Served directly to browser for UI
6+
* 2. Imported by server-side const.ts for PALETTES
7+
*
8+
* @module html/js/palette-options
9+
*/
10+
11+
export interface PaletteOption {
12+
value: string
13+
label: string
14+
}
15+
16+
/** Grayscale palette options */
17+
export const GRAYSCALE_OPTIONS: PaletteOption[] = [
18+
{ value: 'bw', label: '1-bit (B&W)' },
19+
{ value: 'gray-4', label: '2-bit (4 grays)' },
20+
{ value: 'gray-16', label: '4-bit (16 grays)' },
21+
{ value: 'gray-256', label: '8-bit (256 grays)' },
22+
]
23+
24+
/** Color palette options */
25+
export const COLOR_OPTIONS: PaletteOption[] = [
26+
{ value: 'color-6a', label: '6-color (Inky 13.3)' },
27+
{ value: 'color-7a', label: '7-color (Inky 7.3)' },
28+
{ value: 'color-7b', label: '7-color Cyan (RTM1002)' },
29+
{ value: 'color-8a', label: '8-color (Spectra 6)' },
30+
]
31+
32+
/** All palette options (for UI dropdown) */
33+
export const PALETTE_OPTIONS: PaletteOption[] = [
34+
...GRAYSCALE_OPTIONS,
35+
...COLOR_OPTIONS,
36+
]

trmnl-ha/ha-trmnl/html/js/ui-renderer.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import type { Schedule } from '../../types/domain.js'
19+
import type { PaletteOption } from './palette-options.js'
1920

2021
/**
2122
* Renders the tab bar showing all schedules as clickable tabs.
@@ -89,9 +90,11 @@ export class RenderEmptyState {
8990
*/
9091
export class RenderScheduleContent {
9192
schedule: Schedule
93+
paletteOptions: PaletteOption[]
9294

93-
constructor(schedule: Schedule) {
95+
constructor(schedule: Schedule, paletteOptions: PaletteOption[]) {
9496
this.schedule = schedule
97+
this.paletteOptions = paletteOptions
9598
}
9699

97100
call(): void {
@@ -571,26 +574,14 @@ export class RenderScheduleContent {
571574
<select id="s_palette" class="w-full px-3 py-2 border rounded-md" style="border-color: var(--primary-light)"
572575
onchange="window.app.updateScheduleFromForm()"
573576
title="Color palette matching your e-ink display capabilities">
574-
<option value="bw" ${
575-
s.dithering?.palette === 'bw' ? 'selected' : ''
576-
}>1-bit (B&W)</option>
577-
<option value="gray-4" ${
578-
s.dithering?.palette === 'gray-4' ? 'selected' : ''
579-
}>2-bit (4 grays)</option>
580-
<option value="gray-16" ${
581-
s.dithering?.palette === 'gray-16' ? 'selected' : ''
582-
}>4-bit (16 grays)</option>
583-
<option value="gray-256" ${
584-
s.dithering?.palette === 'gray-256' ? 'selected' : ''
585-
}>8-bit (256 grays)</option>
586-
<option value="color-6a" ${
587-
s.dithering?.palette === 'color-6a' ? 'selected' : ''
588-
}>6-color (Inky 13.3)</option>
589-
<option value="color-7a" ${
590-
s.dithering?.palette === 'color-7a' ? 'selected' : ''
591-
}>7-color (Inky 7.3)</option>
577+
${this.paletteOptions.map(
578+
(p) =>
579+
`<option value="${p.value}" ${
580+
s.dithering?.palette === p.value ? 'selected' : ''
581+
}>${p.label}</option>`
582+
).join('\n ')}
592583
</select>
593-
<p class="text-xs text-gray-500 mt-1">Match your display: 1-bit (classic), 4-grays (TRMNL), 16-grays (high-res), or color (Pimoroni Inky)</p>
584+
<p class="text-xs text-gray-500 mt-1">Match your display: grayscale (TRMNL, classic e-ink) or color (Inky, Spectra, RTM1002)</p>
594585
</div>
595586
596587
<div class="flex items-center">

trmnl-ha/ha-trmnl/lib/http-router.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
deleteSchedule,
2323
} from './scheduleStore.js'
2424
import { loadPresets } from '../devices.js'
25+
import { PALETTE_OPTIONS } from '../const.js'
2526
import type { BrowserFacade } from './browserFacade.js'
2627
import type {
2728
ScheduleInput,
@@ -132,6 +133,10 @@ export class HttpRouter {
132133
return this.#handlePresetsAPI(response)
133134
}
134135

136+
if (pathname === '/api/palettes') {
137+
return this.#handlePalettesAPI(response)
138+
}
139+
135140
if (
136141
pathname.startsWith('/js/') ||
137142
pathname.startsWith('/css/') ||
@@ -288,6 +293,12 @@ export class HttpRouter {
288293
return true
289294
}
290295

296+
#handlePalettesAPI(response: ServerResponse): boolean {
297+
response.writeHead(200, { 'Content-Type': 'application/json' })
298+
response.end(toJson(PALETTE_OPTIONS))
299+
return true
300+
}
301+
291302
async #handleStaticFile(
292303
response: ServerResponse,
293304
pathname: string

0 commit comments

Comments
 (0)