mirror of
https://github.com/usetrmnl/inker.git
synced 2026-04-29 13:45:07 -07:00
272 lines
7.8 KiB
TypeScript
272 lines
7.8 KiB
TypeScript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import * as sharpModule from 'sharp';
|
|
// Handle both ESM and CJS imports for Bun compatibility
|
|
const sharp = (sharpModule as any).default || sharpModule;
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
|
|
/**
|
|
* Setup Screen Service
|
|
* Generates a welcome/setup screen for newly connected devices
|
|
* This screen is shown immediately after device setup before they get their first playlist content
|
|
*/
|
|
@Injectable()
|
|
export class SetupScreenService implements OnModuleInit {
|
|
private readonly logger = new Logger(SetupScreenService.name);
|
|
private readonly assetsDir: string;
|
|
private readonly setupScreenPath: string;
|
|
private setupScreenGenerated = false;
|
|
|
|
// Default TRMNL e-ink display dimensions (800x480)
|
|
private readonly DEFAULT_WIDTH = 800;
|
|
private readonly DEFAULT_HEIGHT = 480;
|
|
|
|
constructor() {
|
|
this.assetsDir = path.join(process.cwd(), 'assets');
|
|
this.setupScreenPath = path.join(this.assetsDir, 'setup.png');
|
|
}
|
|
|
|
/**
|
|
* Generate setup screen on module initialization
|
|
*/
|
|
async onModuleInit() {
|
|
await this.ensureSetupScreenExists();
|
|
}
|
|
|
|
/**
|
|
* Ensure the setup screen image exists
|
|
* Generates it if not present
|
|
*/
|
|
async ensureSetupScreenExists(): Promise<void> {
|
|
try {
|
|
// Create assets directory if it doesn't exist
|
|
await fs.mkdir(this.assetsDir, { recursive: true });
|
|
|
|
// Check if setup screen already exists
|
|
try {
|
|
await fs.access(this.setupScreenPath);
|
|
this.logger.log(`Setup screen already exists at: ${this.setupScreenPath}`);
|
|
this.setupScreenGenerated = true;
|
|
return;
|
|
} catch {
|
|
// File doesn't exist, generate it
|
|
}
|
|
|
|
await this.generateSetupScreen();
|
|
this.setupScreenGenerated = true;
|
|
this.logger.log(`Setup screen generated at: ${this.setupScreenPath}`);
|
|
} catch (error) {
|
|
this.logger.error('Failed to ensure setup screen exists:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate the setup screen image using Sharp
|
|
* Creates a clean, e-ink optimized welcome screen
|
|
*/
|
|
async generateSetupScreen(
|
|
width: number = this.DEFAULT_WIDTH,
|
|
height: number = this.DEFAULT_HEIGHT,
|
|
): Promise<string> {
|
|
this.logger.log(`Generating setup screen: ${width}x${height}`);
|
|
|
|
try {
|
|
// Create SVG with the welcome content
|
|
const svg = this.createSetupScreenSvg(width, height);
|
|
|
|
// Convert SVG to e-ink optimized 1-bit PNG (same pipeline as designed screens)
|
|
const grayBuffer = await sharp(Buffer.from(svg))
|
|
.grayscale()
|
|
.normalise()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
|
|
const dithered = this.applyFloydSteinbergDithering(
|
|
grayBuffer.data, grayBuffer.info.width, grayBuffer.info.height, 140,
|
|
);
|
|
|
|
await sharp(dithered, {
|
|
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
|
|
})
|
|
.png({ compressionLevel: 9 })
|
|
.toFile(this.setupScreenPath);
|
|
|
|
this.logger.log(`Setup screen saved to: ${this.setupScreenPath}`);
|
|
return this.setupScreenPath;
|
|
} catch (error) {
|
|
this.logger.error('Failed to generate setup screen:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create SVG content for the setup screen
|
|
* Shows welcome message and setup instructions
|
|
*/
|
|
private createSetupScreenSvg(width: number, height: number): string {
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
|
|
return `
|
|
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
<!-- White background -->
|
|
<rect width="100%" height="100%" fill="white"/>
|
|
|
|
<!-- Checkmark icon circle -->
|
|
<circle
|
|
cx="${centerX}"
|
|
cy="${centerY - 100}"
|
|
r="50"
|
|
fill="none"
|
|
stroke="black"
|
|
stroke-width="4"
|
|
/>
|
|
|
|
<!-- Checkmark inside circle -->
|
|
<polyline
|
|
points="${centerX - 20},${centerY - 100} ${centerX - 5},${centerY - 85} ${centerX + 25},${centerY - 120}"
|
|
fill="none"
|
|
stroke="black"
|
|
stroke-width="6"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
|
|
<!-- Main title: Device Connected -->
|
|
<text
|
|
x="${centerX}"
|
|
y="${centerY}"
|
|
font-family="Arial, Helvetica, sans-serif"
|
|
font-size="48"
|
|
font-weight="bold"
|
|
fill="black"
|
|
text-anchor="middle"
|
|
dominant-baseline="middle"
|
|
>Device Connected</text>
|
|
|
|
<!-- Subtitle: Setup complete -->
|
|
<text
|
|
x="${centerX}"
|
|
y="${centerY + 50}"
|
|
font-family="Arial, Helvetica, sans-serif"
|
|
font-size="24"
|
|
fill="black"
|
|
text-anchor="middle"
|
|
dominant-baseline="middle"
|
|
>Setup complete! Waiting for content...</text>
|
|
|
|
<!-- Instructions -->
|
|
<text
|
|
x="${centerX}"
|
|
y="${centerY + 100}"
|
|
font-family="Arial, Helvetica, sans-serif"
|
|
font-size="18"
|
|
fill="black"
|
|
text-anchor="middle"
|
|
dominant-baseline="middle"
|
|
>Assign a playlist in the Inker dashboard</text>
|
|
|
|
<!-- Decorative line below instructions -->
|
|
<line
|
|
x1="${centerX - 250}"
|
|
y1="${centerY + 140}"
|
|
x2="${centerX + 250}"
|
|
y2="${centerY + 140}"
|
|
stroke="black"
|
|
stroke-width="1"
|
|
/>
|
|
|
|
<!-- Inker branding -->
|
|
<text
|
|
x="${centerX}"
|
|
y="${centerY + 170}"
|
|
font-family="Arial, Helvetica, sans-serif"
|
|
font-size="14"
|
|
fill="black"
|
|
text-anchor="middle"
|
|
dominant-baseline="middle"
|
|
>Powered by Inker</text>
|
|
</svg>
|
|
`.trim();
|
|
}
|
|
|
|
/**
|
|
* Get the URL path for the setup screen
|
|
*/
|
|
getSetupScreenUrl(): string {
|
|
return '/assets/setup.png';
|
|
}
|
|
|
|
/**
|
|
* Get the filesystem path to the setup screen
|
|
*/
|
|
getSetupScreenPath(): string {
|
|
return this.setupScreenPath;
|
|
}
|
|
|
|
/**
|
|
* Check if setup screen has been generated
|
|
*/
|
|
isReady(): boolean {
|
|
return this.setupScreenGenerated;
|
|
}
|
|
|
|
/**
|
|
* Floyd-Steinberg dithering for 1-bit e-ink output
|
|
*/
|
|
private applyFloydSteinbergDithering(
|
|
data: Buffer, width: number, height: number, threshold: number,
|
|
): Buffer {
|
|
const pixels = new Float32Array(data.length);
|
|
for (let i = 0; i < data.length; i++) {
|
|
const val = data[i];
|
|
if (val > 200) pixels[i] = 255;
|
|
else if (val < 55) pixels[i] = 0;
|
|
else pixels[i] = val;
|
|
}
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const idx = y * width + x;
|
|
const oldPixel = pixels[idx];
|
|
const newPixel = oldPixel > threshold ? 255 : 0;
|
|
pixels[idx] = newPixel;
|
|
const error = oldPixel - newPixel;
|
|
if (x + 1 < width) pixels[idx + 1] += error * 7 / 16;
|
|
if (y + 1 < height) {
|
|
if (x - 1 >= 0) pixels[(y + 1) * width + (x - 1)] += error * 3 / 16;
|
|
pixels[(y + 1) * width + x] += error * 5 / 16;
|
|
if (x + 1 < width) pixels[(y + 1) * width + (x + 1)] += error * 1 / 16;
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = Buffer.alloc(data.length);
|
|
for (let i = 0; i < data.length; i++) {
|
|
result[i] = Math.max(0, Math.min(255, Math.round(pixels[i])));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Force regeneration of the setup screen
|
|
*/
|
|
async regenerate(
|
|
width: number = this.DEFAULT_WIDTH,
|
|
height: number = this.DEFAULT_HEIGHT,
|
|
): Promise<string> {
|
|
this.logger.log('Force regenerating setup screen...');
|
|
return this.generateSetupScreen(width, height);
|
|
}
|
|
|
|
/**
|
|
* Get the setup screen as base64 encoded string
|
|
*/
|
|
async getSetupScreenBase64(): Promise<string> {
|
|
await this.ensureSetupScreenExists();
|
|
|
|
const buffer = await fs.readFile(this.setupScreenPath);
|
|
return buffer.toString('base64');
|
|
}
|
|
}
|