0.3.2 — refresh rate, custom widget data & Docker fixes

This commit is contained in:
wojo
2026-04-12 16:13:30 +00:00
parent 001190a716
commit bf274b0edc
21 changed files with 363 additions and 367 deletions
+3
View File
@@ -147,6 +147,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
# Node.js binary for Prisma CLI (Bun's baseline mode crashes on non-AVX2 hardware)
COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node
# Puppeteer configuration
ENV PUPPETEER_EXECUTABLE_PATH=/opt/chrome-headless-shell-linux64/chrome-headless-shell
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
+1 -1
View File
@@ -1,6 +1,6 @@
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://buymeacoffee.com/wojo_o)
# Inker v0.3.1
# Inker v0.3.2
Self-hosted e-ink device management server built for the homelab community. Works with [TRMNL](https://usetrmnl.com/) devices (supports firmware 1.7.8) and any BYOD e-ink display. Design screens, create custom widgets with live data from your local network, and manage your displays from a modern web interface.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "inker-backend",
"version": "0.3.1",
"version": "0.3.2",
"description": "Inker Server Backend - E-ink Device Management",
"main": "dist/main.js",
"scripts": {
+1
View File
@@ -32,6 +32,7 @@ model Device {
lastSeenAt DateTime? @map("last_seen_at")
refreshPending Boolean @default(false) @map("refresh_pending")
lastScreenId String? @map("last_screen_id") // Track last displayed screen for ghosting prevention
screenStartedAt DateTime? @map("screen_started_at") // When the current screen started displaying
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+2 -2
View File
@@ -211,7 +211,7 @@ export class ApiController {
// Parse RSSI to integer
const wifi = rssiStr ? parseInt(rssiStr, 10) : undefined;
this.logger.debug(`[DISPLAY] Extracted deviceApiKey: ${deviceApiKey}, battery: ${battery}%, wifi: ${wifi} dBm, fw: ${firmwareVersion}`);
this.logger.debug(`[DISPLAY] Extracted deviceApiKey: ${deviceApiKey}, battery: ${batteryVoltageStr}V → ${battery}%, wifi: ${wifi} dBm, fw: ${firmwareVersion}`);
if (!deviceApiKey) {
this.logger.error(`[DISPLAY] Missing HTTP_ID header. All headers: ${this.sanitizeHeaders(headers)}`);
@@ -497,7 +497,7 @@ export class ApiController {
* Using linear approximation for simplicity
*/
private voltageToPercentage(voltage: number): number {
const minVoltage = 3.5; // 0% battery - device low-voltage cutoff
const minVoltage = 3.0; // 0% battery - LiPo low-voltage cutoff
const maxVoltage = 4.2; // 100% battery
if (voltage >= maxVoltage) return 100;
@@ -95,8 +95,8 @@ export class DefaultScreenService implements OnModuleInit {
const welcomeConfig = await this.settingsService.getWelcomeScreenConfig();
const svg = this.createDefaultScreenSvg(width, height, welcomeConfig.title, welcomeConfig.subtitle);
// Convert SVG to e-ink optimized 1-bit PNG (same pipeline as designed screens)
// TRMNL OG firmware requires dithered, negated, 1-bit palette PNG
// Convert SVG to e-ink optimized grayscale PNG (same pipeline as designed screens)
// Standard 8-bit grayscale — firmware handles display color mapping
const grayBuffer = await sharp(Buffer.from(svg))
.grayscale()
.normalise()
@@ -110,8 +110,7 @@ export class DefaultScreenService implements OnModuleInit {
await sharp(dithered, {
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
})
.negate()
.png({ compressionLevel: 9, palette: true, colours: 2 })
.png({ compressionLevel: 9 })
.toFile(this.defaultScreenPath);
this.logger.log(`Default screen saved to: ${this.defaultScreenPath}`);
@@ -255,8 +254,7 @@ export class DefaultScreenService implements OnModuleInit {
await sharp(dithered, {
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
})
.negate()
.png({ compressionLevel: 9, palette: true, colours: 2 })
.png({ compressionLevel: 9 })
.toFile(outputPath);
return outputPath;
@@ -319,14 +317,12 @@ export class DefaultScreenService implements OnModuleInit {
}
/**
* Get the default screen as a buffer for browser preview (un-negated)
* The on-disk PNG is negated for e-ink devices, so we negate it back for display
* Get the default screen as a buffer for browser preview
*/
async getDefaultScreenPreviewBuffer(): Promise<Buffer> {
await this.ensureDefaultScreenExists();
return sharp(this.defaultScreenPath)
.negate()
.png()
.toBuffer();
}
+66 -78
View File
@@ -32,84 +32,75 @@ describe('DisplayService', () => {
});
describe('getCurrentScreen (private)', () => {
const getCurrentScreen = (items: any[]) =>
(service as any).getCurrentScreen(items);
const getCurrentScreen = (items: any[], lastScreenId: string | null = null, screenStartedAt: Date | null = null) =>
(service as any).getCurrentScreen(items, lastScreenId, screenStartedAt);
it('should return null for empty items', () => {
expect(getCurrentScreen([])).toBeNull();
expect(getCurrentScreen(null)).toBeNull();
expect(getCurrentScreen([], null, null)).toBeNull();
expect(getCurrentScreen(null as any, null, null)).toBeNull();
});
it('should default duration to 60 when duration is 0 (falsy)', () => {
// duration 0 is falsy, so `item.duration || 60` defaults to 60
const items = [{ id: 1, duration: 0 }];
const result = getCurrentScreen(items);
expect(result.item.id).toBe(1);
expect(result.remainingTime).toBeGreaterThan(0);
expect(result.remainingTime).toBeLessThanOrEqual(60);
});
it('should return single item with calculated remaining time', () => {
it('should return single item without screen change', () => {
const items = [{ id: 1, duration: 60 }];
const result = getCurrentScreen(items);
expect(result.item.id).toBe(1);
expect(result.remainingTime).toBeGreaterThan(0);
expect(result.remainingTime).toBeLessThanOrEqual(60);
expect(result.screenChanged).toBe(false);
});
it('should default duration to 60 when not set', () => {
const items = [{ id: 1 }];
const result = getCurrentScreen(items);
expect(result.item.id).toBe(1);
expect(result.remainingTime).toBeGreaterThan(0);
expect(result.remainingTime).toBeLessThanOrEqual(60);
});
it('should rotate through multiple screens based on time', () => {
it('should start at first item when no previous screen', () => {
const items = [
{ id: 1, duration: 30 },
{ id: 2, duration: 30 },
{ id: 1, screenDesign: { id: 1 }, duration: 300 },
{ id: 2, screenDesign: { id: 2 }, duration: 60 },
];
const result = getCurrentScreen(items);
expect(result).not.toBeNull();
expect([1, 2]).toContain(result.item.id);
expect(result.remainingTime).toBeGreaterThan(0);
expect(result.remainingTime).toBeLessThanOrEqual(30);
});
});
describe('hasTimeSensitiveWidgets (private)', () => {
const hasTimeSensitive = (design: any) =>
(service as any).hasTimeSensitiveWidgets(design);
it('should return false for null/empty design', () => {
expect(hasTimeSensitive(null)).toBe(false);
expect(hasTimeSensitive({})).toBe(false);
expect(hasTimeSensitive({ widgets: [] })).toBe(false);
const result = getCurrentScreen(items, null, null);
expect(result.item.id).toBe(1);
expect(result.screenChanged).toBe(true);
});
it('should return true for clock widget', () => {
expect(hasTimeSensitive({
widgets: [{ template: { name: 'clock' } }],
})).toBe(true);
it('should keep current screen when duration not expired', () => {
const items = [
{ id: 1, screenDesign: { id: 1 }, duration: 300 },
{ id: 2, screenDesign: { id: 2 }, duration: 60 },
];
// Screen started 100 seconds ago, duration is 300
const startedAt = new Date(Date.now() - 100_000);
const result = getCurrentScreen(items, 'design-1', startedAt);
expect(result.item.id).toBe(1);
expect(result.screenChanged).toBe(false);
});
it('should return true for countdown widget', () => {
expect(hasTimeSensitive({
widgets: [{ template: { name: 'countdown' } }],
})).toBe(true);
it('should advance to next screen when duration expired', () => {
const items = [
{ id: 1, screenDesign: { id: 1 }, duration: 300 },
{ id: 2, screenDesign: { id: 2 }, duration: 60 },
];
// Screen started 301 seconds ago, duration is 300
const startedAt = new Date(Date.now() - 301_000);
const result = getCurrentScreen(items, 'design-1', startedAt);
expect(result.item.id).toBe(2);
expect(result.screenChanged).toBe(true);
});
it('should return true for date widget', () => {
expect(hasTimeSensitive({
widgets: [{ template: { name: 'date' } }],
})).toBe(true);
it('should wrap around to first screen after last', () => {
const items = [
{ id: 1, screenDesign: { id: 1 }, duration: 300 },
{ id: 2, screenDesign: { id: 2 }, duration: 60 },
];
// On screen 2, duration expired
const startedAt = new Date(Date.now() - 61_000);
const result = getCurrentScreen(items, 'design-2', startedAt);
expect(result.item.id).toBe(1);
expect(result.screenChanged).toBe(true);
});
it('should return false for non-time widgets', () => {
expect(hasTimeSensitive({
widgets: [{ template: { name: 'text' } }, { template: { name: 'weather' } }],
})).toBe(false);
it('should start at first item when lastScreenId not found in playlist', () => {
const items = [
{ id: 1, screenDesign: { id: 1 }, duration: 300 },
{ id: 2, screenDesign: { id: 2 }, duration: 60 },
];
const result = getCurrentScreen(items, 'design-99', new Date());
expect(result.item.id).toBe(1);
expect(result.screenChanged).toBe(true);
});
});
@@ -131,60 +122,57 @@ describe('DisplayService', () => {
});
describe('getRefreshRateForScreen (private)', () => {
const getRate = (screen: any, deviceRate: number, immediate: boolean, remaining: number) =>
(service as any).getRefreshRateForScreen(screen, deviceRate, immediate, remaining);
const getRate = (screen: any, deviceRate: number, immediate: boolean) =>
(service as any).getRefreshRateForScreen(screen, deviceRate, immediate);
it('should return 1 when shouldRefreshImmediately is true', () => {
expect(getRate({}, 900, true, 60)).toBe(1);
expect(getRate({}, 900, true)).toBe(1);
});
it('should return device refresh rate for normal screens', () => {
const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } };
expect(getRate(screen, 900, false, 1000)).toBe(900);
expect(getRate(screen, 900, false)).toBe(900);
});
it('should return 60 for time-sensitive (non-clock) widgets', () => {
it('should return device refresh rate for countdown widgets (no override)', () => {
const screen = { screenDesign: { widgets: [{ template: { name: 'countdown' } }] } };
expect(getRate(screen, 900, false, 1000)).toBe(60);
expect(getRate(screen, 900, false)).toBe(900);
});
it('should return device refresh rate for date widgets (not time-sensitive)', () => {
const screen = { screenDesign: { widgets: [{ template: { name: 'date' } }] } };
expect(getRate(screen, 900, false)).toBe(900);
});
it('should calculate clock refresh based on seconds until next minute', () => {
const screen = { screenDesign: { widgets: [{ template: { name: 'clock' } }] } };
const rate = getRate(screen, 900, false, 1000);
// Should be between 4 (0 seconds into minute + 3 buffer) and 63 (59 seconds + 3 + cap)
const rate = getRate(screen, 900, false);
expect(rate).toBeGreaterThanOrEqual(4);
expect(rate).toBeLessThanOrEqual(63);
});
it('should cap at remaining time when remaining is smaller', () => {
it('should enforce 10 second floor', () => {
const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } };
expect(getRate(screen, 900, false, 30)).toBe(30);
expect(getRate(screen, 5, false)).toBe(10);
});
});
describe('getNextRefreshTimestamp', () => {
it('should return ~1 second from now when immediate', () => {
const screen = {};
const ts = service.getNextRefreshTimestamp(screen, 900, true, 60);
const ts = service.getNextRefreshTimestamp(screen, 900, true);
expect(ts).not.toBeNull();
expect(ts! - Date.now()).toBeLessThanOrEqual(2000);
});
it('should return device refresh rate ms from now for normal screens', () => {
const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } };
const ts = service.getNextRefreshTimestamp(screen, 900, false, 1000);
const ts = service.getNextRefreshTimestamp(screen, 900, false);
const diff = ts! - Date.now();
// Should be approximately 900 seconds from now
expect(diff).toBeGreaterThan(899000);
expect(diff).toBeLessThan(901000);
});
it('should cap by remaining time', () => {
const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } };
const ts = service.getNextRefreshTimestamp(screen, 900, false, 30);
const diff = ts! - Date.now();
expect(diff).toBeLessThanOrEqual(31000);
});
});
describe('getFirmwareUpdateUrl (private)', () => {
+149 -180
View File
@@ -8,8 +8,7 @@ import { ConfigService } from '@nestjs/config';
import { DefaultScreenService } from './default-screen.service';
import { ScreenRendererService } from '../../screen-designer/services/screen-renderer.service';
import { PluginsService } from '../../plugins/plugins.service';
import * as fs from 'fs';
import * as path from 'path';
import { SetupService } from '../setup/setup.service';
/**
* Device metrics from headers
@@ -29,6 +28,7 @@ export class DisplayService {
private defaultScreenService: DefaultScreenService,
private screenRendererService: ScreenRendererService,
private pluginsService: PluginsService,
private setupService: SetupService,
) {}
/**
@@ -51,7 +51,7 @@ export class DisplayService {
const apiUrl = baseUrl || this.config.get<string>('api.url', 'http://localhost:3002');
// Find device by MAC address (id header) or API key (access-token header)
// The Ruby version looks up by MAC address for better compatibility
const device = await this.prisma.device.findFirst({
let device = await this.prisma.device.findFirst({
where: {
OR: [
{ macAddress: macAddressOrApiKey },
@@ -90,24 +90,64 @@ export class DisplayService {
});
if (!device) {
// Return reset signal instead of 404 - tells device to factory reset
// Device will clear its API key and return to setup mode
// Include ALL expected fields so ArduinoJson on ESP32 doesn't fail parsing
this.logger.log(`Device not found for key ${macAddressOrApiKey} - sending factory reset signal`);
return {
status: 0,
image_url: '',
filename: '',
image_url_timeout: 0,
firmware_url: '',
update_firmware: false,
refresh_rate: 0,
reset_firmware: true,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
message: 'Device removed from server',
};
// Auto-provision unknown devices instead of factory resetting
// This handles devices connecting to a new/rebuilt server that still have
// a stored api_key — they skip /api/setup and call /api/display directly
const macRegex = /^([0-9A-Fa-f]{2}[:-]?){5}([0-9A-Fa-f]{2})$/;
const isBlocked = macRegex.test(macAddressOrApiKey) && await this.prisma.blockedDevice.findUnique({
where: { macAddress: macAddressOrApiKey },
});
if (macRegex.test(macAddressOrApiKey) && !isBlocked) {
this.logger.log(`Auto-provisioning unknown device with MAC ${macAddressOrApiKey}`);
try {
await this.setupService.provisionDevice(
macAddressOrApiKey,
firmwareVersion,
metrics,
baseUrl,
);
// Re-fetch the newly created device to continue with display logic
device = await this.prisma.device.findFirst({
where: { macAddress: macAddressOrApiKey },
include: {
model: true,
playlist: {
include: {
items: {
include: {
screen: true,
screenDesign: { include: { widgets: { include: { template: true } } } },
pluginInstance: { include: { plugin: true } },
},
orderBy: { order: 'asc' },
},
},
},
},
});
} catch (err) {
this.logger.error(`Auto-provision failed for ${macAddressOrApiKey}: ${err.message}`);
}
}
// If still not found after auto-provision attempt, send reset
if (!device) {
this.logger.log(`Device not found for key ${macAddressOrApiKey} - sending factory reset signal`);
return {
status: 0,
image_url: '',
filename: '',
image_url_timeout: 0,
firmware_url: '',
update_firmware: false,
refresh_rate: 0,
reset_firmware: true,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
message: 'Device removed from server',
};
}
}
// Check if device has a pending refresh (playlist just changed)
@@ -197,8 +237,12 @@ export class DisplayService {
};
}
// Get current screen from playlist rotation
const currentScreenResult = this.getCurrentScreen(device.playlist.items);
// Get current screen from playlist rotation (per-device tracking)
const currentScreenResult = this.getCurrentScreen(
device.playlist.items,
device.lastScreenId,
device.screenStartedAt,
);
if (!currentScreenResult) {
this.logger.log(`Device ${device.name} playlist has no valid screens - serving default screen`);
@@ -235,44 +279,50 @@ export class DisplayService {
};
}
const { item: currentScreen, remainingTime } = currentScreenResult;
const { item: currentScreen, screenChanged, idealStartTime } = currentScreenResult;
// Generate unique screen ID to detect screen changes
// Generate unique screen ID for tracking
const currentScreenId = currentScreen.screenDesign
? `design-${currentScreen.screenDesign.id}`
: currentScreen.screen
? `screen-${currentScreen.screen.id}`
: null;
: currentScreen.pluginInstance?.plugin
? `plugin-${currentScreen.pluginInstance.id}`
: null;
// Detect screen change for ghosting prevention (full refresh on e-ink)
const screenChanged = currentScreenId && device.lastScreenId !== currentScreenId;
if (screenChanged) {
// Update screen tracking when screen changes
// screenStartedAt tracks when this screen began displaying (for duration-based rotation)
// maximum_compatibility = true forces full e-ink refresh to prevent ghosting artifacts
if (screenChanged && currentScreenId) {
this.logger.debug(
`Screen changed for device ${device.name}: ${device.lastScreenId} -> ${currentScreenId} (will trigger full refresh)`,
);
// Update lastScreenId in database
await this.prisma.device.update({
where: { id: device.id },
data: { lastScreenId: currentScreenId },
data: { lastScreenId: currentScreenId, screenStartedAt: idealStartTime || new Date() },
});
}
// Calculate refresh rate based on current screen content and remaining playlist time
// Time-sensitive widgets (clock, countdown, date) get 60s refresh instead of device default
// But never exceed the remaining time for the current screen in the playlist
// For multi-screen playlists, cap refresh rate at current screen's duration
// so the device calls back in time for rotation
const screenDuration = currentScreen.duration || 60;
const effectiveDeviceRate = device.playlist.items.length > 1
? Math.min(device.refreshRate, screenDuration)
: device.refreshRate;
// Calculate refresh rate based on current screen content
// Clock widgets get minute-synced refresh; otherwise uses device's configured rate
const effectiveRefreshRate = this.getRefreshRateForScreen(
currentScreen,
device.refreshRate,
effectiveDeviceRate,
shouldRefreshImmediately,
remainingTime,
);
// Calculate the next refresh timestamp for minute-synchronized clock updates
const nextRefreshAt = this.getNextRefreshTimestamp(
currentScreen,
device.refreshRate,
effectiveDeviceRate,
shouldRefreshImmediately,
remainingTime,
);
// Handle both regular screens and designed screens
@@ -298,64 +348,16 @@ export class DisplayService {
reset_firmware: false,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
maximum_compatibility: screenChanged,
refresh_at: nextRefreshAt,
battery: updatedDevice.battery,
wifi: updatedDevice.wifi,
};
} else if (currentScreen.screenDesign) {
// Designed screen - check for pre-captured pixel-perfect image first
const captureFilename = `capture_${currentScreen.screenDesign.id}.png`;
const capturePath = path.join(process.cwd(), 'uploads', 'captures', captureFilename);
const captureExists = fs.existsSync(capturePath);
// Designed screen - always render fresh via the render endpoint
// This ensures consistent URLs and up-to-date content for all widget types
const timestamp = Date.now();
// Check if screen has dynamic widgets that need fresh rendering
// Dynamic widgets: clock, date, countdown, weather (change over time)
const dynamicTemplateNames = ['clock', 'date', 'countdown', 'weather'];
const hasDynamicWidgets = currentScreen.screenDesign.widgets?.some(
(widget: { template?: { name?: string } }) =>
widget.template?.name && dynamicTemplateNames.includes(widget.template.name)
);
if (captureExists && !hasDynamicWidgets) {
// USE CAPTURE FILE - exact pixels from designer (pixel-perfect)
// Only for static screens without dynamic widgets
const captureUrl = `${apiUrl}/uploads/captures/${captureFilename}?t=${timestamp}`;
const dynamicFilename = `capture-${currentScreen.screenDesign.id}-${timestamp}.png`;
this.logger.debug(
`Serving CAPTURED screen "${currentScreen.screenDesign.name}" to device ${device.name} (pixel-perfect, refresh: ${effectiveRefreshRate}s)`,
);
return {
status: 0,
image_url: captureUrl,
filename: dynamicFilename,
image_url_timeout: 0,
image_data: undefined,
firmware_url: firmwareUrl,
update_firmware: !!firmwareUrl,
refresh_rate: effectiveRefreshRate,
reset_firmware: false,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
refresh_at: nextRefreshAt,
battery: updatedDevice.battery,
wifi: updatedDevice.wifi,
};
}
// RENDER FRESH: No capture, has dynamic widgets, or needs current time
// Dynamic widgets (clock, countdown, weather) must be rendered fresh each time
if (hasDynamicWidgets) {
this.logger.debug(
`Screen "${currentScreen.screenDesign.name}" has dynamic widgets - rendering fresh`,
);
}
const queryParams = new URLSearchParams({
t: timestamp.toString(),
battery: (updatedDevice.battery ?? 0).toString(),
@@ -374,7 +376,7 @@ export class DisplayService {
const dynamicFilename = `design-${currentScreen.screenDesign.id}-${timestamp}.png`;
this.logger.debug(
`Serving RENDERED screen "${currentScreen.screenDesign.name}" to device ${device.name} (no capture, refresh: ${effectiveRefreshRate}s, next_at: ${nextRefreshAt ? new Date(nextRefreshAt).toISOString() : 'N/A'})`,
`Serving screen "${currentScreen.screenDesign.name}" to device ${device.name} (refresh: ${effectiveRefreshRate}s, next_at: ${nextRefreshAt ? new Date(nextRefreshAt).toISOString() : 'N/A'})`,
);
return {
@@ -389,7 +391,7 @@ export class DisplayService {
reset_firmware: false,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
maximum_compatibility: screenChanged,
refresh_at: nextRefreshAt,
battery: updatedDevice.battery,
wifi: updatedDevice.wifi,
@@ -418,7 +420,7 @@ export class DisplayService {
reset_firmware: false,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
maximum_compatibility: screenChanged,
refresh_at: nextRefreshAt,
battery: updatedDevice.battery,
wifi: updatedDevice.wifi,
@@ -451,57 +453,65 @@ export class DisplayService {
}
/**
* Get current screen from playlist items using simple rotation
* Get current screen from playlist items using per-device rotation
*
* Returns: { item, remainingTime } - the current playlist item and seconds until it should rotate
* Each device tracks which screen it's showing and when it started.
* When the screen's duration expires, it advances to the next screen.
* This ensures each screen shows for exactly its configured duration.
*/
private getCurrentScreen(items: any[]): { item: any; remainingTime: number } | null {
private getCurrentScreen(
items: any[],
lastScreenId: string | null,
screenStartedAt: Date | null,
): { item: any; screenChanged: boolean; idealStartTime?: Date } | null {
if (!items || items.length === 0) {
return null;
}
// SINGLE SCREEN: Respect duration for refresh timing
// SINGLE SCREEN: No rotation needed
if (items.length === 1) {
const itemDuration = items[0].duration || 60;
// Duration 0 = never refresh (static content, save battery)
if (itemDuration === 0) {
return { item: items[0], remainingTime: Infinity };
}
// Calculate remaining time in current cycle based on duration
const currentSecond = Math.floor(Date.now() / 1000);
const remainingTime = itemDuration - (currentSecond % itemDuration);
return { item: items[0], remainingTime };
return { item: items[0], screenChanged: false };
}
// Multiple screens: rotation based on current time
// Each screen shows for its duration, then rotates to next
const totalDuration = items.reduce((sum, item) => sum + (item.duration || 60), 0);
// Find the screen ID for a playlist item
const getItemScreenId = (item: any): string | null =>
item.screenDesign ? `design-${item.screenDesign.id}`
: item.screen ? `screen-${item.screen.id}`
: item.pluginInstance?.plugin ? `plugin-${item.pluginInstance.id}`
: null;
// Guard against division by zero (shouldn't happen, but defensive)
if (totalDuration <= 0) {
const firstItemDuration = items[0].duration || 60;
return { item: items[0], remainingTime: firstItemDuration };
// Find the current item by lastScreenId
let currentIndex = -1;
if (lastScreenId) {
currentIndex = items.findIndex(item => getItemScreenId(item) === lastScreenId);
}
const currentSecond = Math.floor(Date.now() / 1000);
const positionInCycle = currentSecond % totalDuration;
let elapsed = 0;
for (const item of items) {
const itemDuration = item.duration || 60;
elapsed += itemDuration;
if (positionInCycle < elapsed) {
// Calculate remaining time for this screen
const remainingTime = elapsed - positionInCycle;
return { item, remainingTime };
}
// If no previous screen or it's no longer in the playlist, start at first item
if (currentIndex === -1) {
return { item: items[0], screenChanged: true };
}
// Fallback to first item
const firstItemDuration = items[0].duration || 60;
return { item: items[0], remainingTime: firstItemDuration };
// Check if the current screen's duration has expired
const currentItem = items[currentIndex];
const duration = currentItem.duration || 60;
// If screenStartedAt is null (e.g. existing device before migration),
// treat as screen change so the timestamp gets initialized
if (!screenStartedAt) {
return { item: currentItem, screenChanged: true };
}
const elapsedSeconds = (Date.now() - screenStartedAt.getTime()) / 1000;
if (elapsedSeconds >= duration) {
// Duration expired — advance to next screen
// Use ideal start time (previous start + duration) to prevent drift accumulation
const nextIndex = (currentIndex + 1) % items.length;
const idealStartTime = new Date(screenStartedAt.getTime() + duration * 1000);
return { item: items[nextIndex], screenChanged: true, idealStartTime };
}
// Duration not expired — keep showing current screen
return { item: currentItem, screenChanged: false };
}
/**
@@ -558,25 +568,6 @@ export class DisplayService {
return undefined;
}
/**
* Check if a screen design contains time-sensitive widgets (clock, countdown, date)
* These widgets require more frequent refresh to stay accurate
*/
private hasTimeSensitiveWidgets(screenDesign: any): boolean {
if (!screenDesign?.widgets || !Array.isArray(screenDesign.widgets)) {
return false;
}
// Widget template names that are time-sensitive and need frequent refresh
const timeSensitiveWidgets = ['clock', 'countdown', 'date'];
return screenDesign.widgets.some(
(widget: any) =>
widget.template &&
timeSensitiveWidgets.includes(widget.template.name),
);
}
/**
* Check if a screen design contains a clock widget
*/
@@ -591,16 +582,14 @@ export class DisplayService {
}
/**
* Get the appropriate refresh rate based on screen content and playlist rotation
* Get the appropriate refresh rate based on screen content
* For clock widgets, returns the exact seconds until next minute boundary
* This ensures the device wakes up exactly when the minute changes
* BUT never exceeds the remaining time for the current screen in the playlist
*/
private getRefreshRateForScreen(
currentScreen: any,
deviceRefreshRate: number,
shouldRefreshImmediately: boolean,
remainingTime: number,
): number {
// If refresh is pending, return 1 second to force immediate update
if (shouldRefreshImmediately) {
@@ -629,21 +618,11 @@ export class DisplayService {
this.logger.debug(
`Clock widget - calculated refresh ${refreshRate}s (${secondsUntilNextMinute}s until minute + ${bufferSeconds}s buffer)`,
);
} else if (currentScreen?.screenDesign && this.hasTimeSensitiveWidgets(currentScreen.screenDesign)) {
// For other time-sensitive widgets (date, countdown), use 60 second refresh
refreshRate = 60;
this.logger.debug(
`Screen design "${currentScreen.screenDesign.name}" has time-sensitive widgets - using 60s refresh`,
);
}
// IMPORTANT: Cap refresh rate by remaining time in playlist rotation
// This ensures screens rotate according to their duration in the playlist
if (remainingTime > 0 && remainingTime < refreshRate) {
this.logger.debug(
`Capping refresh rate from ${refreshRate}s to ${remainingTime}s (playlist rotation)`,
);
refreshRate = remainingTime;
// Floor: never go below 10 seconds to prevent rapid polling from edge cases
if (refreshRate < 10) {
refreshRate = 10;
}
return refreshRate;
@@ -653,13 +632,11 @@ export class DisplayService {
* Calculate the exact timestamp when the device should refresh next
* For clock widgets, this is synchronized to the next minute boundary
* This ensures the clock updates exactly when the minute changes (e.g., 20:00 -> 20:01)
* BUT never exceeds the remaining time for the current screen in the playlist
*/
getNextRefreshTimestamp(
currentScreen: any,
deviceRefreshRate: number,
shouldRefreshImmediately: boolean,
remainingTime: number,
): number | null {
// If refresh is pending, refresh immediately
if (shouldRefreshImmediately) {
@@ -684,18 +661,6 @@ export class DisplayService {
this.logger.debug(
`Clock widget detected - calculated refresh in ${Math.round(refreshMs / 1000)}s (after minute boundary)`,
);
} else if (currentScreen?.screenDesign && this.hasTimeSensitiveWidgets(currentScreen.screenDesign)) {
// For other time-sensitive widgets, use 60 second intervals
refreshMs = 60 * 1000;
}
// IMPORTANT: Cap by remaining time in playlist rotation
const remainingTimeMs = remainingTime * 1000;
if (remainingTimeMs > 0 && remainingTimeMs < refreshMs) {
this.logger.debug(
`Capping next refresh timestamp from ${Math.round(refreshMs / 1000)}s to ${remainingTime}s (playlist rotation)`,
);
refreshMs = remainingTimeMs;
}
return Date.now() + refreshMs;
@@ -748,8 +713,12 @@ export class DisplayService {
return this.defaultScreenService.getDefaultScreenPreviewBuffer();
}
// Get current screen from playlist rotation
const currentScreenResult = this.getCurrentScreen(device.playlist.items);
// Get current screen from playlist rotation (preview uses device state too)
const currentScreenResult = this.getCurrentScreen(
device.playlist.items,
device.lastScreenId,
device.screenStartedAt,
);
if (!currentScreenResult) {
return this.defaultScreenService.getDefaultScreenPreviewBuffer();
@@ -88,8 +88,7 @@ export class SetupScreenService implements OnModuleInit {
await sharp(dithered, {
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
})
.negate()
.png({ compressionLevel: 9, palette: true, colours: 2 })
.png({ compressionLevel: 9 })
.toFile(this.setupScreenPath);
this.logger.log(`Setup screen saved to: ${this.setupScreenPath}`);
+1 -1
View File
@@ -157,7 +157,7 @@ export class SetupService {
const setupScreenUrl = this.setupScreenService.getSetupScreenUrl();
return {
status: 200, // TRMNL firmware expects status field
status: 200, // Firmware 1.7.8 setup parser checks status == 200
api_key: device.apiKey, // CRITICAL: Must be 'api_key' not 'uuid'
friendly_id: device.friendlyId, // Friendly name for the device
image_url: `${apiUrl}${setupScreenUrl}`, // Setup screen image
@@ -35,6 +35,10 @@ export interface FieldMeta {
export class DataSourcesService {
private readonly logger = new Logger(DataSourcesService.name);
// Deduplicates concurrent external API fetches for the same data source
// When multiple widgets share a data source, only one fetch runs at a time
private pendingFetches = new Map<number, Promise<unknown>>();
constructor(
private prisma: PrismaService,
private settingsService: SettingsService,
@@ -860,29 +864,49 @@ export class DataSourcesService {
dataSource.refreshInterval * 1000;
if (isStale || !dataSource.lastData) {
// Deduplicate concurrent fetches — if a fetch for this ID is already
// in-flight, reuse that Promise instead of making another API call
const pending = this.pendingFetches.get(id);
if (pending) {
return pending;
}
const fetchPromise = this.fetchAndCache(id, dataSource);
this.pendingFetches.set(id, fetchPromise);
try {
const data = await this.fetchDataFromSource({
...dataSource,
headers: dataSource.headers as object | null,
});
await this.prisma.dataSource.update({
where: { id },
data: {
lastData: data as object,
lastFetchedAt: new Date(),
lastError: null,
},
});
return data;
} catch (error) {
// Return cached data if available, even if stale
if (dataSource.lastData) {
return dataSource.lastData;
}
throw error;
return await fetchPromise;
} finally {
this.pendingFetches.delete(id);
}
}
return dataSource.lastData;
}
/**
* Fetch data from external source and update DB cache
*/
private async fetchAndCache(id: number, dataSource: any): Promise<unknown> {
try {
const data = await this.fetchDataFromSource({
...dataSource,
headers: dataSource.headers as object | null,
});
await this.prisma.dataSource.update({
where: { id },
data: {
lastData: data as object,
lastFetchedAt: new Date(),
lastError: null,
},
});
return data;
} catch (error) {
// Return cached data if available, even if stale
if (dataSource.lastData) {
return dataSource.lastData;
}
throw error;
}
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ async function bootstrap() {
const config = new DocumentBuilder()
.setTitle('Inker API')
.setDescription('API documentation for Inker e-ink device management server')
.setVersion('0.3.1')
.setVersion('0.3.2')
.addBearerAuth()
.addApiKey({ type: 'apiKey', name: 'X-Device-Key', in: 'header' }, 'device-key')
.build();
@@ -677,7 +677,7 @@ export class ScreenDesignerController {
output[i] = Math.max(0, Math.min(255, Math.round(pixels[i])));
}
// Create 1-bit PNG using palette mode (black and white only)
// Create grayscale PNG (dithered to black/white values)
buffer = await sharp(output, {
raw: {
width: info.width,
@@ -685,11 +685,7 @@ export class ScreenDesignerController {
channels: 1,
},
})
.png({
compressionLevel: 9,
palette: true,
colours: 2, // 1-bit: 2 colors (black/white)
})
.png({ compressionLevel: 9 })
.toBuffer();
attempts++;
@@ -348,26 +348,16 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
// Apply Floyd-Steinberg dithering
const ditheredBuffer = this.applyFloydSteinbergDithering(data, info.width, info.height, threshold);
// Create 1-bit PNG
let sharpInstance = sharp(ditheredBuffer, {
// Output as standard 8-bit grayscale PNG (no palette mode)
// Firmware 1.7.8 handles display color mapping — palette PNGs cause scrambled display
let buffer = await sharp(ditheredBuffer, {
raw: {
width: info.width,
height: info.height,
channels: 1,
},
});
// Apply negate for device mode (TRMNL expects inverted colors)
if (negate) {
sharpInstance = sharpInstance.negate();
}
let buffer = await sharpInstance
.png({
compressionLevel: 9,
palette: true,
colours: 2,
})
})
.png({ compressionLevel: 9 })
.toBuffer();
// If still too large, scale down and re-dither
@@ -399,29 +389,19 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
threshold,
);
let scaledSharp = sharp(scaledDithered, {
buffer = await sharp(scaledDithered, {
raw: {
width: scaledGray.info.width,
height: scaledGray.info.height,
channels: 1,
},
});
if (negate) {
scaledSharp = scaledSharp.negate();
}
buffer = await scaledSharp
.png({
compressionLevel: 9,
palette: true,
colours: 2,
})
})
.png({ compressionLevel: 9 })
.toBuffer();
}
this.logger.debug(
`E-ink processing complete: ${buffer.length} bytes, 1-bit, negate=${negate}`,
`E-ink processing complete: ${buffer.length} bytes, grayscale dithered`,
);
return buffer;
@@ -2575,7 +2555,24 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
this.logger.debug(` Widget ${i}: ${w.template.name} at (${w.x}, ${w.y}) size ${w.width}x${w.height}${crossesEdge ? ' [CROSSES EDGE]' : ''}`);
});
// Generate HTML for all widgets
// Pre-fetch data for custom widgets to avoid duplicate API calls
// When multiple custom widgets share a data source, this ensures only one external fetch
const seenDataSources = new Set<number>();
for (const widget of widgets) {
if (widget.template.name === 'custom-widget-base') {
const cwId = (widget.config as any)?.customWidgetId as number | undefined;
if (cwId) {
const cw = await this.customWidgetsService.findOne(cwId).catch(() => null);
if (cw?.dataSourceId && !seenDataSources.has(cw.dataSourceId)) {
seenDataSources.add(cw.dataSourceId);
// getWithData warms the data source cache in DB
await this.customWidgetsService.getWithData(cwId).catch(() => null);
}
}
}
}
// Generate HTML for all widgets (custom widget data is already cached)
const widgetsHtml = await Promise.all(
widgets.map(widget => this.generateWidgetHtml(widget, deviceContext))
);
+28
View File
@@ -102,6 +102,34 @@ server {
add_header Cache-Control "no-store";
}
# Device API endpoints strip security headers for ESP32 devices
# (ESP32 has ~8-16KB HTTP buffer; Helmet headers can overflow it)
location ~ ^/api/(setup|display|log)(/?)$ {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_scheme;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Strict-Transport-Security;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-DNS-Prefetch-Control;
proxy_hide_header X-Frame-Options;
proxy_hide_header X-XSS-Protection;
proxy_hide_header Cross-Origin-Opener-Policy;
proxy_hide_header Cross-Origin-Resource-Policy;
proxy_hide_header Origin-Agent-Cluster;
proxy_hide_header X-Download-Options;
proxy_hide_header X-Permitted-Cross-Domain-Policies;
proxy_hide_header Referrer-Policy;
proxy_hide_header Permissions-Policy;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-store";
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://127.0.0.1:3002/api/;
+2 -1
View File
@@ -23,9 +23,10 @@ chown -R inker:inker /app/uploads /app/logs /tmp/inker-home
export HOME=/tmp/inker-home
# Run database migrations as non-root user
# Uses node instead of bunx to avoid Bun WASM crash on non-AVX2 hardware (Synology, older CPUs)
cd /app
echo "[backend] Running database migrations..."
s6-setuidgid inker bunx prisma db push --skip-generate 2>&1 || echo "[backend] Warning: Database migration had issues, check logs"
s6-setuidgid inker node ./node_modules/prisma/build/index.js db push --skip-generate 2>&1 || echo "[backend] Warning: Database migration had issues, check logs"
# Start backend as non-root user
echo "[backend] Starting Inker backend..."
+7
View File
@@ -1,2 +1,9 @@
#!/command/with-contenv sh
# Wait for backend to be ready before accepting connections
# Prevents 502 errors when devices connect during startup
echo "[nginx] Waiting for backend..."
until bun -e "const r=await fetch('http://127.0.0.1:3002/health');process.exit(r.ok?0:1)" 2>/dev/null; do
sleep 1
done
echo "[nginx] Backend is ready, starting nginx"
exec nginx -g "daemon off;"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "inker-frontend",
"private": true,
"version": "0.3.1",
"version": "0.3.2",
"type": "module",
"scripts": {
"dev": "bunx --bun vite",
@@ -1504,7 +1504,7 @@ export function CustomWidgetForm() {
</code>
<div className="flex items-center gap-1">
{field.isImageUrl && (
<span className="text-pink-500 text-xs">🖼</span>
<span className="text-pink-500 text-xs font-bold">IMG</span>
)}
{field.isLink && !field.isImageUrl && (
<span className="text-accent text-xs">🔗</span>
+21 -28
View File
@@ -103,28 +103,6 @@ export function AddDevice() {
</h2>
<div className="space-y-5">
{/* API URL Display */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Server URL for your device
</label>
<div className="flex items-center space-x-2">
<code className="flex-1 text-sm bg-bg-muted px-4 py-3 rounded-lg border border-border-default font-mono">
{deviceApiUrl}
</code>
<button
type="button"
onClick={handleCopyUrl}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-semibold text-white rounded-xl transition-all duration-200"
style={{ backgroundColor: copied ? '#22c55e' : '#3b82f6' }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = copied ? '#16a34a' : '#2563eb'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = copied ? '#22c55e' : '#3b82f6'; }}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
{/* Status Warning */}
{!status.isOnline && (
<div className="p-4 bg-status-warning-bg border border-status-warning-border rounded-lg">
@@ -160,18 +138,33 @@ export function AddDevice() {
<code className="bg-bg-muted px-1 rounded">192.168.4.1</code> in your browser.
</p>
</li>
<li>
<strong>Enter the server URL</strong>
<p className="ml-6 mt-1 text-text-muted">
Paste: <code className="bg-bg-muted px-1 rounded">{deviceApiUrl}</code>
</p>
</li>
<li>
<strong>Enter your WiFi credentials</strong>
<p className="ml-6 mt-1 text-text-muted">
Select your home/office WiFi and enter the password.
</p>
</li>
<li>
<strong>Set the Server URL in Advanced Settings</strong>
<p className="ml-6 mt-1 text-text-muted">
Open <strong>Advanced Settings</strong> in the captive portal and paste this URL into the <strong>API Server</strong> field:
</p>
<div className="ml-6 mt-2 flex items-center space-x-2">
<code className="flex-1 text-sm bg-bg-muted px-4 py-3 rounded-lg border border-border-default font-mono">
{deviceApiUrl}
</code>
<button
type="button"
onClick={handleCopyUrl}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-semibold text-white rounded-xl transition-all duration-200"
style={{ backgroundColor: copied ? '#22c55e' : '#3b82f6' }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = copied ? '#16a34a' : '#2563eb'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = copied ? '#22c55e' : '#3b82f6'; }}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</li>
<li>
<strong>Done!</strong>
<p className="ml-6 mt-1 text-text-muted">

Some files were not shown because too many files have changed in this diff Show More