0.3.0 - firmware 1.7.8 support, bug fixes, base for Inker plugins

This commit is contained in:
wojo
2026-03-26 19:25:18 +00:00
parent b46b12dfe5
commit 6c7b657346
43 changed files with 5368 additions and 46 deletions
+18 -3
View File
@@ -1,8 +1,10 @@
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://buymeacoffee.com/wojo_o)
# Inker v0.2.1.1
# Inker v0.3.0
Self-hosted e-ink device management system for [TRMNL](https://usetrmnl.com/) devices and BYOD e-ink displays. Design screens, create custom widgets, and manage your displays from a modern web interface.
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.
Inker is heading in its own direction — focusing on homelab integrations like server monitoring, smart home dashboards, network stats, and self-hosted service displays. TRMNL device compatibility is maintained, but the plugin ecosystem will be Inker-native.
![Dashboard](https://github.com/user-attachments/assets/fd9affac-5c57-4448-9338-ea8f83add08a)
@@ -11,9 +13,11 @@ Self-hosted e-ink device management system for [TRMNL](https://usetrmnl.com/) de
- **Screen Designer** — Drag & drop widget placement, snap guides, freehand drawing, auto-fit zoom for any resolution
- **Built-in Widgets** — Clock, date, text, weather, countdown, days until, QR code, image, GitHub stars, battery, WiFi, device info
- **Custom Widgets** — Connect to any JSON API or RSS feed (including local network sources), JavaScript transformations, grid layouts
- **Plugins** — Coming soon — homelab-native integrations for server monitoring, smart home, network stats
- **Playlists** — Rotate multiple screens on devices automatically
- **Device Management** — Auto-provisioning, BYOD support with custom resolutions, real-time status, logs
- **Device Management** — Auto-provisioning, firmware 1.7.8 support, real-time status, logs
- **BYOD Support** — Register any e-ink device manually with custom screen resolution
- **Custom Ports** — Run on any port with Docker port mapping (e.g. `800:80`)
## Screenshots
@@ -101,6 +105,17 @@ cd inker
docker compose up -d --build
```
## Updating
```bash
docker compose pull
docker compose up -d
```
All data (screens, devices, playlists, settings) is preserved — database schema updates are applied automatically on startup.
> **Warning:** Never use `docker compose down -v` to update — the `-v` flag deletes all volumes and you will lose your data.
## Troubleshooting
If something isn't working after an update or on first run, reset the volumes and start fresh:
+5
View File
@@ -24,6 +24,7 @@
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"joi": "^17.11.0",
"liquidjs": "^10.25.1",
"nest-winston": "^1.9.4",
"passport": "^0.7.0",
"puppeteer": "^23.11.0",
@@ -819,6 +820,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"liquidjs": ["liquidjs@10.25.1", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-D+jsJvkGigFn8qNUgh8U6XNHhGFBp+p8Dk26ea/Hl+XrjFVSg9OXlN31hGAfS3MYQ3Kr8Xi9sEVBVQa/VTVSmg=="],
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1313,6 +1316,8 @@
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"liquidjs/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "inker-backend",
"version": "0.2.1.1",
"version": "0.3.0",
"description": "Inker Server Backend - E-ink Device Management",
"main": "dist/main.js",
"scripts": {
@@ -42,6 +42,7 @@
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"joi": "^17.11.0",
"liquidjs": "^10.25.1",
"nest-winston": "^1.9.4",
"passport": "^0.7.0",
"puppeteer": "^23.11.0",
@@ -0,0 +1,64 @@
-- CreateTable
CREATE TABLE "plugins" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"icon" TEXT,
"category" TEXT NOT NULL DEFAULT 'custom',
"data_strategy" TEXT NOT NULL DEFAULT 'polling',
"data_url" TEXT,
"data_method" TEXT NOT NULL DEFAULT 'GET',
"data_headers" JSONB,
"data_path" TEXT,
"data_transform" TEXT,
"refresh_interval" INTEGER NOT NULL DEFAULT 300,
"markup_full" TEXT,
"markup_half_horizontal" TEXT,
"markup_half_vertical" TEXT,
"markup_quadrant" TEXT,
"settings_schema" JSONB,
"oauth_provider" TEXT,
"oauth_scopes" TEXT,
"is_installed" BOOLEAN NOT NULL DEFAULT false,
"is_builtin" BOOLEAN NOT NULL DEFAULT false,
"source" TEXT NOT NULL DEFAULT 'inker',
"source_url" TEXT,
"source_hash" TEXT,
"version" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "plugins_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plugin_instances" (
"id" SERIAL NOT NULL,
"plugin_id" INTEGER NOT NULL,
"name" TEXT,
"settings" JSONB NOT NULL DEFAULT '{}',
"settings_encrypted" JSONB NOT NULL DEFAULT '{}',
"oauth_token" TEXT,
"oauth_refresh_token" TEXT,
"oauth_expires_at" TIMESTAMP(3),
"last_data" JSONB,
"last_fetched_at" TIMESTAMP(3),
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "plugin_instances_pkey" PRIMARY KEY ("id")
);
-- Add plugin_instance_id to playlist_items
ALTER TABLE "playlist_items" ADD COLUMN "plugin_instance_id" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "plugins_slug_key" ON "plugins"("slug");
-- AddForeignKey
ALTER TABLE "plugin_instances" ADD CONSTRAINT "plugin_instances_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "playlist_items" ADD CONSTRAINT "playlist_items_plugin_instance_id_fkey" FOREIGN KEY ("plugin_instance_id") REFERENCES "plugin_instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+81
View File
@@ -108,9 +108,12 @@ model PlaylistItem {
duration Int @default(60) // seconds
createdAt DateTime @default(now()) @map("created_at")
pluginInstanceId Int? @map("plugin_instance_id")
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
screen Screen? @relation(fields: [screenId], references: [id], onDelete: Cascade)
screenDesign ScreenDesign? @relation(fields: [screenDesignId], references: [id], onDelete: Cascade)
pluginInstance PluginInstance? @relation(fields: [pluginInstanceId], references: [id], onDelete: Cascade)
@@map("playlist_items")
}
@@ -294,3 +297,81 @@ model BlockedDevice {
@@map("blocked_devices")
}
// ========================
// Plugin System Models
// ========================
// Plugin - a reusable plugin definition (built-in from TRMNL or user-created)
model Plugin {
id Int @id @default(autoincrement())
name String
slug String @unique
description String?
icon String? // emoji or image URL
category String @default("custom") // news, productivity, weather, social, finance, system, custom
// Data fetching configuration
dataStrategy String @default("polling") @map("data_strategy") // polling | webhook | static
dataUrl String? @map("data_url") // supports {{setting}} interpolation
dataMethod String @default("GET") @map("data_method")
dataHeaders Json? @map("data_headers") // supports {{setting}} interpolation
dataPath String? @map("data_path") // JSONPath to extract from response
dataTransform String? @map("data_transform") // JS script to transform fetched data into locals
refreshInterval Int @default(300) @map("refresh_interval") // seconds
// Liquid templates for 4 layout sizes (matching TRMNL)
markupFull String? @map("markup_full")
markupHalfHorizontal String? @map("markup_half_horizontal")
markupHalfVertical String? @map("markup_half_vertical")
markupQuadrant String? @map("markup_quadrant")
// Settings schema: [{key, label, type, options?, required?, encrypted?, default?}]
settingsSchema Json? @map("settings_schema")
// OAuth (for plugins that require OAuth2 authentication)
oauthProvider String? @map("oauth_provider") // "google" | "spotify" | "strava" etc.
oauthScopes String? @map("oauth_scopes") // space-separated scopes
// Metadata
isInstalled Boolean @default(false) @map("is_installed")
isBuiltin Boolean @default(false) @map("is_builtin")
source String @default("inker") // "inker" | "trmnl"
sourceUrl String? @map("source_url") // GitHub URL of original plugin
sourceHash String? @map("source_hash") // hash for detecting updates
version String?
instances PluginInstance[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("plugins")
}
// Plugin Instance - a configured instance of a plugin (with user settings and cached data)
model PluginInstance {
id Int @id @default(autoincrement())
pluginId Int @map("plugin_id")
name String? // optional custom name for this instance
settings Json @default("{}") // user settings (non-sensitive)
settingsEncrypted Json @default("{}") @map("settings_encrypted") // encrypted settings (API keys)
// OAuth tokens (encrypted)
oauthToken String? @map("oauth_token") // encrypted access token
oauthRefreshToken String? @map("oauth_refresh_token") // encrypted refresh token
oauthExpiresAt DateTime? @map("oauth_expires_at")
// Cached data (the locals hash)
lastData Json? @map("last_data")
lastFetchedAt DateTime? @map("last_fetched_at")
lastError String? @map("last_error")
plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)
playlistItems PlaylistItem[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("plugin_instances")
}
+11 -2
View File
@@ -82,8 +82,17 @@ export class ApiController {
}
// Fallback: derive from request headers (for LAN access where IP varies)
const host = headers['host'] || headers['Host'] || 'localhost:3002';
let host = headers['host'] || headers['Host'] || 'localhost:3002';
const protocol = headers['x-forwarded-proto'] || 'http';
// Ensure INKER_PORT is used when it's non-standard (handles Docker port mapping)
const inkerPort = this.configService.get<number>('inkerPort', 80);
if (inkerPort && inkerPort !== 80) {
// Strip any existing port from host and replace with INKER_PORT
const hostname = host.split(':')[0];
host = `${hostname}:${inkerPort}`;
}
return `${protocol}://${host}`;
}
@@ -400,7 +409,7 @@ export class ApiController {
* Accepts log data from devices
*/
@Post('log')
@HttpCode(HttpStatus.CREATED)
@HttpCode(HttpStatus.OK)
@ApiHeader({
name: 'HTTP_ID',
description: 'Device API Key',
+2
View File
@@ -10,12 +10,14 @@ import { LogService } from './log/log.service';
import { PrismaModule } from '../prisma/prisma.module';
import { ScreenDesignerModule } from '../screen-designer/screen-designer.module';
import { SettingsModule } from '../settings/settings.module';
import { PluginsModule } from '../plugins/plugins.module';
@Module({
imports: [
PrismaModule,
ScreenDesignerModule,
SettingsModule,
PluginsModule,
// Serve static files from assets directory at /assets path
ServeStaticModule.forRoot({
rootPath: join(process.cwd(), 'assets'),
+77 -8
View File
@@ -7,6 +7,7 @@ import { PrismaService } from '../../prisma/prisma.service';
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';
@@ -27,6 +28,7 @@ export class DisplayService {
private config: ConfigService,
private defaultScreenService: DefaultScreenService,
private screenRendererService: ScreenRendererService,
private pluginsService: PluginsService,
) {}
/**
@@ -72,6 +74,11 @@ export class DisplayService {
},
},
},
pluginInstance: {
include: {
plugin: true,
},
},
},
orderBy: {
order: 'asc',
@@ -91,10 +98,14 @@ export class DisplayService {
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',
};
}
@@ -156,7 +167,7 @@ export class DisplayService {
await this.defaultScreenService.ensureDefaultScreenExists();
const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl();
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`;
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`;
// Get base64 if requested
let imageData: string | undefined;
@@ -171,12 +182,16 @@ export class DisplayService {
return {
status: 0,
image_url: fullDefaultUrl,
filename: 'default-screen.png',
filename: `default-screen-${Date.now()}.png`,
image_url_timeout: 0,
image_data: imageData,
firmware_url: firmwareUrl,
update_firmware: !!firmwareUrl,
refresh_rate: defaultRefreshRate,
reset_firmware: false,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
battery: updatedDevice.battery,
wifi: updatedDevice.wifi,
};
@@ -190,7 +205,7 @@ export class DisplayService {
await this.defaultScreenService.ensureDefaultScreenExists();
const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl();
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`;
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`;
// Get base64 if requested
let imageData: string | undefined;
@@ -205,12 +220,16 @@ export class DisplayService {
return {
status: 0,
image_url: fullDefaultUrl,
filename: 'default-screen.png',
filename: `default-screen-${Date.now()}.png`,
image_url_timeout: 0,
image_data: imageData,
firmware_url: firmwareUrl,
update_firmware: !!firmwareUrl,
refresh_rate: defaultRefreshRate,
reset_firmware: false,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
battery: updatedDevice.battery,
wifi: updatedDevice.wifi,
};
@@ -271,11 +290,15 @@ export class DisplayService {
status: 0,
image_url: imageUrl,
filename: this.getImageFilename(currentScreen.screen.imageUrl),
image_url_timeout: 0,
image_data: useBase64 ? await this.getBase64Image(currentScreen.screen.imageUrl) : 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,
@@ -310,11 +333,15 @@ export class DisplayService {
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,
@@ -354,32 +381,69 @@ export class DisplayService {
status: 0,
image_url: renderUrl,
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,
};
} else if (currentScreen.pluginInstance?.plugin) {
// Plugin instance - render via plugin engine
const pluginInstance = currentScreen.pluginInstance;
const timestamp = Date.now();
const renderUrl = `${apiUrl}/api/plugins/instances/${pluginInstance.id}/render?mode=device&t=${timestamp}`;
const dynamicFilename = `plugin-${pluginInstance.plugin.slug}-${timestamp}.png`;
this.logger.debug(
`Serving PLUGIN "${pluginInstance.plugin.name}" to device ${device.name} (refresh: ${effectiveRefreshRate}s)`,
);
return {
status: 0,
image_url: renderUrl,
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,
};
} else {
// Neither screen nor screenDesign - should not happen, but handle gracefully
this.logger.warn(`Playlist item ${currentScreen.id} has no screen or screenDesign`);
// Neither screen, screenDesign, nor plugin - handle gracefully
this.logger.warn(`Playlist item ${currentScreen.id} has no screen, screenDesign, or plugin`);
await this.defaultScreenService.ensureDefaultScreenExists();
const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl();
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`;
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`;
return {
status: 0,
image_url: fullDefaultUrl,
filename: 'default-screen.png',
filename: `default-screen-${Date.now()}.png`,
image_url_timeout: 0,
image_data: undefined,
firmware_url: firmwareUrl,
update_firmware: !!firmwareUrl,
refresh_rate: defaultRefreshRate,
reset_firmware: false,
special_function: '',
temperature_profile: 'default',
maximum_compatibility: false,
battery: updatedDevice.battery,
wifi: updatedDevice.wifi,
};
@@ -660,6 +724,11 @@ export class DisplayService {
},
},
},
pluginInstance: {
include: {
plugin: true,
},
},
},
orderBy: {
order: 'asc',
+2
View File
@@ -23,6 +23,7 @@ import { CommonModule } from './common/common.module';
import { ScreenDesignerModule } from './screen-designer/screen-designer.module';
import { EventsModule } from './events/events.module';
import { SettingsModule } from './settings/settings.module';
import { PluginsModule } from './plugins/plugins.module';
import { configuration } from './config/configuration';
import { validationSchema } from './config/validation.schema';
@@ -92,6 +93,7 @@ import { validationSchema } from './config/validation.schema';
ScreenDesignerModule,
EventsModule,
SettingsModule,
PluginsModule,
],
controllers: [],
providers: [
+7 -2
View File
@@ -1,4 +1,9 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { EncryptionService } from './services/encryption.service';
@Module({})
@Global()
@Module({
providers: [EncryptionService],
exports: [EncryptionService],
})
export class CommonModule {}
@@ -8,3 +8,9 @@
* This prevents collision with built-in template IDs (typically 1-100)
*/
export const CUSTOM_WIDGET_TEMPLATE_OFFSET = 10000;
/**
* Offset used for plugin virtual template IDs
* Installed plugins get IDs: PLUGIN_TEMPLATE_OFFSET + plugin.id
*/
export const PLUGIN_TEMPLATE_OFFSET = 20000;
@@ -0,0 +1,66 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const KEY_LENGTH = 32;
const SALT = 'inker-plugin-settings';
@Injectable()
export class EncryptionService {
private readonly logger = new Logger(EncryptionService.name);
private readonly key: Buffer;
constructor(private readonly config: ConfigService) {
const secret = config.get<string>('encryption.key') || config.get<string>('admin.pin') || 'inker-default-key';
this.key = scryptSync(secret, SALT, KEY_LENGTH);
}
encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
}
decrypt(ciphertext: string): string {
const parts = ciphertext.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted format');
}
const [ivB64, authTagB64, encryptedB64] = parts;
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const encrypted = Buffer.from(encryptedB64, 'base64');
const decipher = createDecipheriv(ALGORITHM, this.key, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
return decipher.update(encrypted) + decipher.final('utf8');
}
encryptObject(obj: Record<string, any>): Record<string, string> {
const encrypted: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined && value !== null) {
encrypted[key] = this.encrypt(String(value));
}
}
return encrypted;
}
decryptObject(obj: Record<string, any>): Record<string, string> {
const decrypted: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
try {
decrypted[key] = this.decrypt(String(value));
} catch {
this.logger.warn(`Failed to decrypt field "${key}"`);
decrypted[key] = '';
}
}
return decrypted;
}
}
+36
View File
@@ -6,6 +6,8 @@ export const configuration = () => ({
url: process.env.API_URL || `http://localhost:${process.env.PORT || '3002'}`,
},
inkerPort: parseInt(process.env.INKER_PORT || '80', 10),
database: {
url: process.env.DATABASE_URL,
},
@@ -33,6 +35,10 @@ export const configuration = () => ({
enabled: process.env.SCREEN_POLLER_ENABLED !== 'false',
interval: parseInt(process.env.SCREEN_POLLER_INTERVAL || '900000', 10), // 15 minutes
},
pluginSync: {
enabled: process.env.PLUGIN_SYNC_ENABLED !== 'false',
interval: parseInt(process.env.PLUGIN_SYNC_INTERVAL || '604800000', 10), // 7 days
},
},
// External APIs
@@ -65,6 +71,36 @@ export const configuration = () => ({
pin: process.env.ADMIN_PIN || '1111',
},
encryption: {
key: process.env.ENCRYPTION_KEY,
},
oauth: {
providers: {
google: {
clientId: process.env.OAUTH_GOOGLE_CLIENT_ID,
clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET,
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
scopes: 'https://www.googleapis.com/auth/calendar.readonly',
},
spotify: {
clientId: process.env.OAUTH_SPOTIFY_CLIENT_ID,
clientSecret: process.env.OAUTH_SPOTIFY_CLIENT_SECRET,
authUrl: 'https://accounts.spotify.com/authorize',
tokenUrl: 'https://accounts.spotify.com/api/token',
scopes: 'user-read-currently-playing user-read-recently-played',
},
strava: {
clientId: process.env.OAUTH_STRAVA_CLIENT_ID,
clientSecret: process.env.OAUTH_STRAVA_CLIENT_SECRET,
authUrl: 'https://www.strava.com/oauth/authorize',
tokenUrl: 'https://www.strava.com/oauth/token',
scopes: 'read,activity:read',
},
},
},
logging: {
level: process.env.LOG_LEVEL || 'info',
format: process.env.LOG_FORMAT || 'json',
+17 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Controller, Get, Post } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
@@ -57,4 +57,20 @@ export class DashboardController {
async getStats(): Promise<DashboardStatsDto> {
return this.dashboardService.getStats();
}
@Get('version-check')
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Check for available updates' })
@ApiResponse({ status: 200, description: 'Version check result' })
async checkForUpdate() {
return this.dashboardService.checkForUpdate();
}
@Post('update')
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Perform auto-update via Docker socket' })
@ApiResponse({ status: 200, description: 'Update result' })
async performUpdate() {
return this.dashboardService.performUpdate();
}
}
+149
View File
@@ -1,6 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DashboardStatsDto } from './dto/dashboard-stats.dto';
import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';
const CURRENT_VERSION = require('../../package.json').version;
/**
* Dashboard service
@@ -15,6 +20,8 @@ import { DashboardStatsDto } from './dto/dashboard-stats.dto';
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
private versionCache: { data: any; timestamp: number } | null = null;
private readonly CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
constructor(private readonly prisma: PrismaService) {}
@@ -136,4 +143,146 @@ export class DashboardService {
}
return lastSeenAt >= onlineThreshold ? 'online' : 'offline';
}
async checkForUpdate(): Promise<{
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
releaseUrl: string;
dockerAvailable: boolean;
}> {
const dockerAvailable = this.isDockerAvailable();
if (this.versionCache && Date.now() - this.versionCache.timestamp < this.CACHE_TTL) {
return { ...this.versionCache.data, dockerAvailable };
}
try {
const release = await this.fetchLatestRelease();
const latestVersion = (release.tag_name || '').replace(/^v/, '');
const updateAvailable = this.isNewerVersion(latestVersion, CURRENT_VERSION);
const data = {
currentVersion: CURRENT_VERSION,
latestVersion: latestVersion || CURRENT_VERSION,
updateAvailable,
releaseUrl: release.html_url || 'https://github.com/usetrmnl/inker/releases',
};
this.versionCache = { data, timestamp: Date.now() };
return { ...data, dockerAvailable };
} catch (error) {
this.logger.warn('Failed to check for updates', error);
return {
currentVersion: CURRENT_VERSION,
latestVersion: CURRENT_VERSION,
updateAvailable: false,
releaseUrl: 'https://github.com/usetrmnl/inker/releases',
dockerAvailable,
};
}
}
private fetchLatestRelease(): Promise<any> {
return new Promise((resolve, reject) => {
const req = https.get(
'https://api.github.com/repos/usetrmnl/inker/releases/latest',
{ headers: { 'User-Agent': 'Inker', Accept: 'application/vnd.github.v3+json' } },
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
reject(new Error('Invalid JSON from GitHub'));
}
});
},
);
req.on('error', reject);
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
});
}
private isNewerVersion(latest: string, current: string): boolean {
const parse = (v: string) => v.split('.').map(Number);
const l = parse(latest);
const c = parse(current);
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] || 0;
const cv = c[i] || 0;
if (lv > cv) return true;
if (lv < cv) return false;
}
return false;
}
isDockerAvailable(): boolean {
try {
fs.accessSync('/var/run/docker.sock');
return true;
} catch {
return false;
}
}
async performUpdate(): Promise<{ success: boolean; message: string }> {
if (!this.isDockerAvailable()) {
return { success: false, message: 'Docker socket not available' };
}
try {
// Pull new image
await this.dockerRequest('POST', '/images/create?fromImage=wojooo/inker&tag=latest');
// Get current container ID
const hostname = require('os').hostname();
// Inspect current container to get its config
const container = await this.dockerRequest('GET', `/containers/${hostname}/json`);
const config = JSON.parse(container);
// Create new container with same config
const createBody = JSON.stringify({
Image: 'wojooo/inker:latest',
Env: config.Config?.Env,
HostConfig: config.HostConfig,
ExposedPorts: config.Config?.ExposedPorts,
});
// Stop current container (will be replaced)
// The container needs to stop itself — use restart policy
await this.dockerRequest('POST', `/containers/${hostname}/restart`);
return { success: true, message: 'Update initiated. Container will restart with the new image.' };
} catch (error) {
this.logger.error('Update failed', error);
return { success: false, message: `Update failed: ${error.message}` };
}
}
private dockerRequest(method: string, path: string, body?: string): Promise<string> {
return new Promise((resolve, reject) => {
const options: http.RequestOptions = {
socketPath: '/var/run/docker.sock',
path: `/v1.41${path}`,
method,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`Docker API error ${res.statusCode}: ${data}`));
} else {
resolve(data);
}
});
});
req.on('error', reject);
req.setTimeout(120000, () => { req.destroy(); reject(new Error('Docker request timeout')); });
if (body) req.write(body);
req.end();
});
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ async function bootstrap() {
const config = new DocumentBuilder()
.setTitle('Inker API')
.setDescription('API documentation for Inker e-ink device management server')
.setVersion('0.2.1.1')
.setVersion('0.3.0')
.addBearerAuth()
.addApiKey({ type: 'apiKey', name: 'X-Device-Key', in: 'header' }, 'device-key')
.build();
@@ -0,0 +1,61 @@
import { IsString, IsOptional, IsNumber, IsArray, IsBoolean } from 'class-validator';
export class CreatePluginDto {
@IsString() name: string;
@IsString() slug: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() icon?: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() dataStrategy?: string;
@IsOptional() @IsString() dataUrl?: string;
@IsOptional() @IsString() dataMethod?: string;
@IsOptional() dataHeaders?: Record<string, string>;
@IsOptional() @IsString() dataPath?: string;
@IsOptional() @IsString() dataTransform?: string;
@IsOptional() @IsNumber() refreshInterval?: number;
@IsOptional() @IsString() markupFull?: string;
@IsOptional() @IsString() markupHalfHorizontal?: string;
@IsOptional() @IsString() markupHalfVertical?: string;
@IsOptional() @IsString() markupQuadrant?: string;
@IsOptional() @IsArray() settingsSchema?: any[];
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceUrl?: string;
@IsOptional() @IsString() version?: string;
}
export class UpdatePluginDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() icon?: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() dataUrl?: string;
@IsOptional() @IsString() dataMethod?: string;
@IsOptional() dataHeaders?: Record<string, string>;
@IsOptional() @IsString() dataPath?: string;
@IsOptional() @IsString() dataTransform?: string;
@IsOptional() @IsNumber() refreshInterval?: number;
@IsOptional() @IsString() markupFull?: string;
@IsOptional() @IsString() markupHalfHorizontal?: string;
@IsOptional() @IsString() markupHalfVertical?: string;
@IsOptional() @IsString() markupQuadrant?: string;
@IsOptional() @IsArray() settingsSchema?: any[];
@IsOptional() @IsString() version?: string;
}
export class CreatePluginInstanceDto {
@IsNumber() pluginId: number;
@IsOptional() @IsString() name?: string;
@IsOptional() settings?: Record<string, any>;
}
export class UpdatePluginInstanceDto {
@IsOptional() @IsString() name?: string;
@IsOptional() settings?: Record<string, any>;
}
+197
View File
@@ -0,0 +1,197 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
import { EncryptionService } from '../../common/services/encryption.service';
interface OAuthProviderConfig {
clientId: string;
clientSecret: string;
authUrl: string;
tokenUrl: string;
scopes?: string;
}
interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
}
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
private readonly providers: Record<string, OAuthProviderConfig>;
private readonly callbackUrl: string;
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
private readonly encryption: EncryptionService,
) {
this.providers = (config.get('oauth.providers') || {}) as Record<string, OAuthProviderConfig>;
const apiUrl = config.get<string>('api.url') || 'http://localhost:3002';
this.callbackUrl = `${apiUrl}/plugins/oauth/callback`;
}
getAvailableProviders(): string[] {
return Object.entries(this.providers)
.filter(([, cfg]) => cfg.clientId && cfg.clientSecret)
.map(([name]) => name);
}
getAuthorizationUrl(instanceId: number, provider: string): string {
const cfg = this.getProviderConfig(provider);
const state = this.encryption.encrypt(JSON.stringify({ instanceId, provider }));
const params = new URLSearchParams({
client_id: cfg.clientId,
redirect_uri: this.callbackUrl,
response_type: 'code',
scope: cfg.scopes || '',
state,
access_type: 'offline',
prompt: 'consent',
});
return `${cfg.authUrl}?${params.toString()}`;
}
async handleCallback(code: string, state: string): Promise<{ instanceId: number; provider: string }> {
let parsed: { instanceId: number; provider: string };
try {
parsed = JSON.parse(this.encryption.decrypt(state));
} catch {
throw new BadRequestException('Invalid OAuth state');
}
const { instanceId, provider } = parsed;
const cfg = this.getProviderConfig(provider);
const tokenResponse = await this.exchangeCode(code, cfg);
await this.storeTokens(instanceId, tokenResponse);
this.logger.log(`OAuth connected for instance ${instanceId} (provider: ${provider})`);
return parsed;
}
async getAccessToken(instanceId: number): Promise<string | null> {
const instance: any = await this.prisma.pluginInstance.findUnique({
where: { id: instanceId },
include: { plugin: true },
});
if (!instance?.oauthToken) return null;
// Check if token is expired (with 5-minute buffer)
if (instance.oauthExpiresAt && instance.oauthExpiresAt.getTime() < Date.now() + 5 * 60 * 1000) {
if (instance.oauthRefreshToken && instance.plugin.oauthProvider) {
return this.refreshToken(instanceId, instance.plugin.oauthProvider, instance.oauthRefreshToken);
}
return null;
}
try {
return this.encryption.decrypt(instance.oauthToken);
} catch {
return null;
}
}
async disconnectOAuth(instanceId: number): Promise<void> {
await (this.prisma.pluginInstance as any).update({
where: { id: instanceId },
data: {
oauthToken: null,
oauthRefreshToken: null,
oauthExpiresAt: null,
},
});
}
private async refreshToken(instanceId: number, provider: string, encryptedRefreshToken: string): Promise<string | null> {
const cfg = this.getProviderConfig(provider);
let refreshToken: string;
try {
refreshToken = this.encryption.decrypt(encryptedRefreshToken);
} catch {
this.logger.error(`Failed to decrypt refresh token for instance ${instanceId}`);
return null;
}
try {
const response = await fetch(cfg.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
this.logger.error(`Token refresh failed for instance ${instanceId}: ${response.status}`);
return null;
}
const tokenData: TokenResponse = await response.json();
await this.storeTokens(instanceId, tokenData);
return tokenData.access_token;
} catch (error) {
this.logger.error(`Token refresh error for instance ${instanceId}: ${error.message}`);
return null;
}
}
private async exchangeCode(code: string, cfg: OAuthProviderConfig): Promise<TokenResponse> {
const response = await fetch(cfg.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
code,
redirect_uri: this.callbackUrl,
grant_type: 'authorization_code',
}),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
const body = await response.text();
throw new BadRequestException(`OAuth token exchange failed: ${response.status} ${body}`);
}
return response.json();
}
private async storeTokens(instanceId: number, tokens: TokenResponse): Promise<void> {
const data: any = {
oauthToken: this.encryption.encrypt(tokens.access_token),
};
if (tokens.refresh_token) {
data.oauthRefreshToken = this.encryption.encrypt(tokens.refresh_token);
}
if (tokens.expires_in) {
data.oauthExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
}
await this.prisma.pluginInstance.update({
where: { id: instanceId },
data,
});
}
private getProviderConfig(provider: string): OAuthProviderConfig {
const cfg = this.providers[provider];
if (!cfg?.clientId || !cfg?.clientSecret) {
throw new BadRequestException(`OAuth provider "${provider}" is not configured. Set OAUTH_${provider.toUpperCase()}_CLIENT_ID and OAUTH_${provider.toUpperCase()}_CLIENT_SECRET environment variables.`);
}
return cfg;
}
}
@@ -0,0 +1,271 @@
import { Injectable, Logger } from '@nestjs/common';
import { Liquid } from 'liquidjs';
import * as sharp from 'sharp';
import { ScreenRendererService } from '../screen-designer/services/screen-renderer.service';
import { TRMNL_CSS } from './sync/trmnl-css';
export type PluginLayout = 'full' | 'half_horizontal' | 'half_vertical' | 'quadrant';
/**
* Plugin Renderer Service
* Renders plugin Liquid templates into e-ink PNG images using the existing
* Puppeteer-based rendering pipeline from ScreenRendererService.
*/
@Injectable()
export class PluginRendererService {
private readonly logger = new Logger(PluginRendererService.name);
private readonly liquid: Liquid;
constructor(
readonly screenRenderer: ScreenRendererService,
) {
this.liquid = new Liquid({
strictVariables: false,
strictFilters: false,
});
this.registerTrmnlFilters();
}
/**
* Register TRMNL-compatible Liquid filters (ported from trmnl-liquid gem)
*/
private registerTrmnlFilters() {
// number_with_delimiter: {{ 1234567 | number_with_delimiter }} → "1,234,567"
this.liquid.registerFilter('number_with_delimiter', (value: any, delimiter = ',', separator = '.') => {
if (value == null) return '';
const parts = String(value).split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, delimiter);
return parts.join(separator);
});
// number_to_currency: {{ 10420 | number_to_currency: "£" }} → "£10,420.00"
this.liquid.registerFilter('number_to_currency', (value: any, unit = '$', delimiter = ',', separator = '.', precision = 2) => {
if (value == null) return '';
const num = Number(value);
if (isNaN(num)) return String(value);
const fixed = num.toFixed(precision);
const parts = fixed.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, delimiter);
return `${unit}${parts.join(separator)}`;
});
// days_ago: {{ 3 | days_ago }} → "2026-03-23"
this.liquid.registerFilter('days_ago', (value: any, timezone = 'UTC') => {
const days = Number(value) || 0;
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().split('T')[0];
});
// pluralize: {{ "book" | pluralize: 2 }} → "2 books"
this.liquid.registerFilter('pluralize', (singular: any, count: any, options?: any) => {
const n = Number(count) || 0;
const plural = options?.plural || `${singular}s`;
return `${n} ${n === 1 ? singular : plural}`;
});
// group_by: {{ items | group_by: "category" }}
this.liquid.registerFilter('group_by', (collection: any, key: string) => {
if (!Array.isArray(collection)) return {};
const groups: Record<string, any[]> = {};
for (const item of collection) {
const k = String(item?.[key] ?? '');
if (!groups[k]) groups[k] = [];
groups[k].push(item);
}
return groups;
});
// find_by: {{ items | find_by: "name", "Ryan" }}
this.liquid.registerFilter('find_by', (collection: any, key: string, value: any, fallback?: any) => {
if (!Array.isArray(collection)) return fallback ?? null;
return collection.find(item => item?.[key] == value) ?? fallback ?? null;
});
// json: {{ data | json }}
this.liquid.registerFilter('json', (value: any) => {
try { return JSON.stringify(value); } catch { return ''; }
});
// parse_json: {% assign obj = data | parse_json %}
this.liquid.registerFilter('parse_json', (value: any) => {
try { return JSON.parse(String(value)); } catch { return null; }
});
// append_random: {{ "chart-" | append_random }} → "chart-a3f1"
this.liquid.registerFilter('append_random', (value: any) => {
const hex = Math.random().toString(16).slice(2, 6);
return `${value ?? ''}${hex}`;
});
// sample: {{ items | sample }} → random element
this.liquid.registerFilter('sample', (arr: any) => {
if (!Array.isArray(arr) || arr.length === 0) return null;
return arr[Math.floor(Math.random() * arr.length)];
});
// map_to_i: {{ "5, 4, 3" | split: ", " | map_to_i }}
this.liquid.registerFilter('map_to_i', (arr: any) => {
if (!Array.isArray(arr)) return arr;
return arr.map(v => parseInt(String(v), 10) || 0);
});
// ordinalize: {{ "2025-10-02" | ordinalize: "%A, %B <<ordinal_day>>, %Y" }}
this.liquid.registerFilter('ordinalize', (value: any, format?: string) => {
const d = new Date(String(value));
if (isNaN(d.getTime())) return String(value);
const day = d.getDate();
const suffix = [11,12,13].includes(day % 100) ? 'th'
: day % 10 === 1 ? 'st' : day % 10 === 2 ? 'nd' : day % 10 === 3 ? 'rd' : 'th';
if (!format) return `${day}${suffix}`;
const weekdays = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
return format
.replace('<<ordinal_day>>', `${day}${suffix}`)
.replace('%A', weekdays[d.getDay()])
.replace('%B', months[d.getMonth()])
.replace('%Y', String(d.getFullYear()))
.replace('%y', String(d.getFullYear()).slice(-2))
.replace('%m', String(d.getMonth() + 1).padStart(2, '0'))
.replace('%d', String(day).padStart(2, '0'));
});
// l_date: {{ "2025-01-11" | l_date: "%y %b" }}
this.liquid.registerFilter('l_date', (value: any, format?: string, locale = 'en') => {
let d: Date;
if (value === 'now' || value === 'today') d = new Date();
else if (typeof value === 'number' && value > 1e9) d = new Date(value * 1000);
else d = new Date(String(value));
if (isNaN(d.getTime())) return String(value);
if (!format) return d.toLocaleDateString(locale);
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const fullMonths = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
return format
.replace('%Y', String(d.getFullYear()))
.replace('%y', String(d.getFullYear()).slice(-2))
.replace('%B', fullMonths[d.getMonth()])
.replace('%b', months[d.getMonth()])
.replace('%A', ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.getDay()])
.replace('%a', weekdays[d.getDay()])
.replace('%m', String(d.getMonth() + 1).padStart(2, '0'))
.replace('%d', String(d.getDate()).padStart(2, '0'))
.replace('%H', String(d.getHours()).padStart(2, '0'))
.replace('%M', String(d.getMinutes()).padStart(2, '0'))
.replace('%S', String(d.getSeconds()).padStart(2, '0'))
.replace('%I', String(d.getHours() % 12 || 12).padStart(2, '0'))
.replace('%p', d.getHours() >= 12 ? 'PM' : 'AM');
});
// where_exp: {{ items | where_exp: "item", "item.active == true" }}
this.liquid.registerFilter('where_exp', (collection: any, variable: string, expression: string) => {
if (!Array.isArray(collection)) return [];
return collection.filter(item => {
try {
// Simple expression evaluator for common patterns
const expr = expression
.replace(new RegExp(`${variable}\\.`, 'g'), 'item.')
.replace(/\bnil\b/g, 'null')
.replace(/\band\b/g, '&&')
.replace(/\bor\b/g, '||')
.replace(/\bnot\b/g, '!')
.replace(/(?<!=)=(?!=)/g, '==');
const fn = new Function('item', `try { return !!(${expr}); } catch { return false; }`);
return fn(item);
} catch { return false; }
});
});
}
/**
* Render a plugin's Liquid template with data to HTML string
*/
async renderToHtml(
markup: string,
locals: Record<string, any>,
settings: Record<string, any> = {},
): Promise<string> {
try {
const context = { ...locals, settings };
const html = await this.liquid.parseAndRender(markup, context);
return html;
} catch (error) {
this.logger.warn(`Liquid render failed: ${error.message}`);
// Return a fallback HTML instead of throwing
return `<div style="padding:16px;font-family:sans-serif"><p style="font-size:14px;font-weight:bold;margin-bottom:8px">Template Error</p><p style="font-size:11px;color:#666">${error.message?.split('\n')[0] || 'Unknown error'}</p></div>`;
}
}
/**
* Render a plugin instance to a full HTML page (with CSS) ready for Puppeteer
*/
buildFullPage(innerHtml: string, width: number, height: number): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { width: ${width}px; height: ${height}px; }
${TRMNL_CSS}
</style>
</head>
<body>
${innerHtml}
</body>
</html>`;
}
/**
* Render a plugin to a PNG buffer using the existing Puppeteer pipeline
*/
async renderToPng(
markup: string,
locals: Record<string, any>,
settings: Record<string, any> = {},
width: number = 800,
height: number = 480,
mode: 'device' | 'preview' | 'einkPreview' = 'device',
): Promise<Buffer> {
const innerHtml = await this.renderToHtml(markup, locals, settings);
const fullPage = this.buildFullPage(innerHtml, width, height);
// Render HTML to raw PNG via Puppeteer
const rawPng = await this.screenRenderer.renderHtmlToPng(fullPage, width, height);
// For preview mode, return without e-ink processing
if (mode === 'preview') {
return rawPng;
}
// Apply e-ink processing (dithering + optional inversion)
const shouldNegate = mode === 'device';
const canvas = sharp(rawPng);
return this.screenRenderer.applyEinkProcessing(canvas, width, height, shouldNegate);
}
/**
* Select the appropriate template based on layout size
*/
selectMarkup(
plugin: {
markupFull?: string | null;
markupHalfHorizontal?: string | null;
markupHalfVertical?: string | null;
markupQuadrant?: string | null;
},
layout: PluginLayout = 'full',
): string | null {
switch (layout) {
case 'full':
return plugin.markupFull || null;
case 'half_horizontal':
return plugin.markupHalfHorizontal || plugin.markupFull || null;
case 'half_vertical':
return plugin.markupHalfVertical || plugin.markupFull || null;
case 'quadrant':
return plugin.markupQuadrant || plugin.markupFull || null;
default:
return plugin.markupFull || null;
}
}
}
File diff suppressed because it is too large Load Diff

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