diff --git a/backend/src/api/api.controller.ts b/backend/src/api/api.controller.ts index 59ff14f..3123858 100644 --- a/backend/src/api/api.controller.ts +++ b/backend/src/api/api.controller.ts @@ -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; diff --git a/backend/src/api/display/default-screen.service.ts b/backend/src/api/display/default-screen.service.ts index 17be11e..00c3b6d 100644 --- a/backend/src/api/display/default-screen.service.ts +++ b/backend/src/api/display/default-screen.service.ts @@ -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 { await this.ensureDefaultScreenExists(); return sharp(this.defaultScreenPath) - .negate() .png() .toBuffer(); } diff --git a/backend/src/api/display/display.service.test.ts b/backend/src/api/display/display.service.test.ts index 091e27b..12baab6 100644 --- a/backend/src/api/display/display.service.test.ts +++ b/backend/src/api/display/display.service.test.ts @@ -40,29 +40,25 @@ describe('DisplayService', () => { expect(getCurrentScreen(null)).toBeNull(); }); - it('should default duration to 60 when duration is 0 (falsy)', () => { - // duration 0 is falsy, so `item.duration || 60` defaults to 60 + it('should return Infinity remainingTime for single item (no rotation needed)', () => { 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); + expect(result.remainingTime).toBe(Infinity); }); - it('should return single item with calculated remaining time', () => { + it('should return single item with Infinity remainingTime', () => { 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.remainingTime).toBe(Infinity); }); - it('should default duration to 60 when not set', () => { + it('should return Infinity remainingTime when duration 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); + expect(result.remainingTime).toBe(Infinity); }); it('should rotate through multiple screens based on time', () => { diff --git a/backend/src/api/display/display.service.ts b/backend/src/api/display/display.service.ts index 7061780..4d88228 100644 --- a/backend/src/api/display/display.service.ts +++ b/backend/src/api/display/display.service.ts @@ -8,6 +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 { SetupService } from '../setup/setup.service'; import * as fs from 'fs'; import * as path from 'path'; @@ -29,6 +30,7 @@ export class DisplayService { private defaultScreenService: DefaultScreenService, private screenRendererService: ScreenRendererService, private pluginsService: PluginsService, + private setupService: SetupService, ) {} /** @@ -51,7 +53,7 @@ export class DisplayService { const apiUrl = baseUrl || this.config.get('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 +92,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) @@ -245,7 +287,8 @@ export class DisplayService { : null; // Detect screen change for ghosting prevention (full refresh on e-ink) - const screenChanged = currentScreenId && device.lastScreenId !== currentScreenId; + // maximum_compatibility = true forces slower full refresh, preventing artifacts + const screenChanged = !!(currentScreenId && device.lastScreenId !== currentScreenId); if (screenChanged) { this.logger.debug( `Screen changed for device ${device.name}: ${device.lastScreenId} -> ${currentScreenId} (will trigger full refresh)`, @@ -298,7 +341,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, @@ -341,7 +384,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, @@ -389,7 +432,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 +461,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, @@ -460,19 +503,10 @@ export class DisplayService { return null; } - // SINGLE SCREEN: Respect duration for refresh timing + // SINGLE SCREEN: No rotation needed — use device refresh rate + // Dynamic widgets (clock, countdown) are handled by getRefreshRateForScreen() 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], remainingTime: Infinity }; } // Multiple screens: rotation based on current time @@ -646,6 +680,11 @@ export class DisplayService { refreshRate = remainingTime; } + // Floor: never go below 10 seconds to prevent rapid polling from edge cases + if (refreshRate < 10) { + refreshRate = 10; + } + return refreshRate; } diff --git a/backend/src/api/setup/setup-screen.service.ts b/backend/src/api/setup/setup-screen.service.ts index d91eb8d..595e9b4 100644 --- a/backend/src/api/setup/setup-screen.service.ts +++ b/backend/src/api/setup/setup-screen.service.ts @@ -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}`); diff --git a/backend/src/api/setup/setup.service.ts b/backend/src/api/setup/setup.service.ts index 8857854..6fcf367 100644 --- a/backend/src/api/setup/setup.service.ts +++ b/backend/src/api/setup/setup.service.ts @@ -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 diff --git a/backend/src/screen-designer/screen-designer.controller.ts b/backend/src/screen-designer/screen-designer.controller.ts index b084f4c..910cbdd 100644 --- a/backend/src/screen-designer/screen-designer.controller.ts +++ b/backend/src/screen-designer/screen-designer.controller.ts @@ -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++; diff --git a/backend/src/screen-designer/services/screen-renderer.service.ts b/backend/src/screen-designer/services/screen-renderer.service.ts index e10db7e..800ca13 100644 --- a/backend/src/screen-designer/services/screen-renderer.service.ts +++ b/backend/src/screen-designer/services/screen-renderer.service.ts @@ -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; diff --git a/docker/nginx.conf b/docker/nginx.conf index e36da4d..09132ed 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -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/; diff --git a/docker/services.d/nginx/run b/docker/services.d/nginx/run index 2804283..698a2c7 100644 --- a/docker/services.d/nginx/run +++ b/docker/services.d/nginx/run @@ -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;" diff --git a/frontend/src/pages/custom-widgets/CustomWidgetForm.tsx b/frontend/src/pages/custom-widgets/CustomWidgetForm.tsx index f9b0d74..a715f80 100644 --- a/frontend/src/pages/custom-widgets/CustomWidgetForm.tsx +++ b/frontend/src/pages/custom-widgets/CustomWidgetForm.tsx @@ -1504,7 +1504,7 @@ export function CustomWidgetForm() {
{field.isImageUrl && ( - 🖼️ + IMG )} {field.isLink && !field.isImageUrl && ( 🔗 diff --git a/frontend/src/pages/devices/AddDevice.tsx b/frontend/src/pages/devices/AddDevice.tsx index 2ef8367..9fd8078 100644 --- a/frontend/src/pages/devices/AddDevice.tsx +++ b/frontend/src/pages/devices/AddDevice.tsx @@ -103,28 +103,6 @@ export function AddDevice() {
- {/* API URL Display */} -
- -
- - {deviceApiUrl} - - -
-
- {/* Status Warning */} {!status.isOnline && (
@@ -160,18 +138,33 @@ export function AddDevice() { 192.168.4.1 in your browser.

-
  • - Enter the server URL -

    - Paste: {deviceApiUrl} -

    -
  • Enter your WiFi credentials

    Select your home/office WiFi and enter the password.

  • +
  • + Set the Server URL in Advanced Settings +

    + Open Advanced Settings in the captive portal and paste this URL into the API Server field: +

    +
    + + {deviceApiUrl} + + +
    +
  • Done!

    diff --git a/frontend/src/pages/screens/ScreenDesigner.tsx b/frontend/src/pages/screens/ScreenDesigner.tsx index edaee76..d7924cd 100644 --- a/frontend/src/pages/screens/ScreenDesigner.tsx +++ b/frontend/src/pages/screens/ScreenDesigner.tsx @@ -22,12 +22,6 @@ const CUSTOM_WIDGET_TEMPLATE_OFFSET = 10000; const RESOLUTION_PRESETS = [ { label: 'TRMNL Standard', width: 800, height: 480, description: '800 x 480 px (landscape)' }, { label: 'TRMNL Portrait', width: 480, height: 800, description: '480 x 800 px (portrait)' }, - { label: 'Small Display', width: 400, height: 300, description: '400 x 300 px' }, - { label: 'Medium Display', width: 640, height: 384, description: '640 x 384 px' }, - { label: 'Large Display', width: 1024, height: 758, description: '1024 x 758 px' }, - { label: 'E-Paper 2.9"', width: 296, height: 128, description: '296 x 128 px' }, - { label: 'E-Paper 4.2"', width: 400, height: 300, description: '400 x 300 px' }, - { label: 'E-Paper 7.5"', width: 800, height: 480, description: '800 x 480 px' }, ]; export function ScreenDesigner() { diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..715eda1 Binary files /dev/null and b/preview.png differ