0.3.1 — firmware 1.7.8 connectivity & display fixes

This commit is contained in:
wojo
2026-04-02 17:41:17 +00:00
parent e4cdd55fb1
commit d59b4a45f9
14 changed files with 158 additions and 130 deletions
+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();
}
@@ -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', () => {
+75 -36
View File
@@ -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<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 +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;
}
@@ -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
@@ -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;
+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/;
+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;"
@@ -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">
@@ -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() {
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB