mirror of
https://github.com/usetrmnl/inker.git
synced 2026-04-29 13:45:07 -07:00
0.3.2 — refresh rate, custom widget data & Docker fixes
This commit is contained in:
@@ -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,6 +1,6 @@
|
||||
[](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,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": {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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/;
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user