Minimal BYOS implementation

This commit is contained in:
Andrii Reinvald
2025-05-25 09:59:23 +02:00
parent 1989343865
commit d1187421e2
7 changed files with 175 additions and 50 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
# example file for .env.local
SECRET_KEY=PUT_YOUR_UNIQIE_SECRET_KEY_HERE
SECRET_KEY=PUT_YOUR_UNIQUE_SECRET_KEY_HERE
URL_ORIGIN=http://localhost:3000
# If you want to use BYOS. Mac should be of your real TMNNL device
BYOS_DEVICE_MAC=
+12 -1
View File
@@ -28,7 +28,7 @@ from any source, and design it with HTML, JSX, CSS.
**Image** http://localhost:3000/image?secret_key=... <br>
-- can be used for preview and [Alias](https://help.usetrmnl.com/en/articles/10701448-alias-plugin) plugin
**API** http://localhost:3000/api?secret_key=... <br>
**API** http://localhost:3000/plugin/redirect?secret_key=... <br>
-- can be used for [Redirect](https://help.usetrmnl.com/en/articles/11035846-redirect-plugin) plugin
## JSX components
@@ -74,5 +74,16 @@ Fetch Screen Content as your device (Developer edition):
curl https://usetrmnl.com/api/display --header "access-token:xxxxxx"
```
## Bring your own server (BYOS)
This repo implements basic BYOS server for one device.<br>
You can setup it with those steps:
1. Put your device's MAC value to ENV (can be .env.local). If you don't know it: just put anything and check server logs.
2. Hold round button on your device for more than5 seconds - you should see connection instructions on screen.
3. Connect your phone to wifi called `TRMNL`
4. On setup choose `use your own server`
5. Check logs of server
6. If it still `wrong access-token value from device` - you may need to choose `Soft reset` on setup stage
---
Goal of this repo: simple and easy to customize.
+98
View File
@@ -0,0 +1,98 @@
import {Router} from 'express';
import {BYOS_DEVICE_MAC, PUBLIC_URL_ORIGIN, SECRET_KEY} from '../Config.js';
import {screenHash} from "../Utils/Screen.js";
import {sha256} from "../Utils/Sha256.js";
// all routes starts with /api/
export const BYOSRoutes = Router();
BYOSRoutes.post('/log', (req, res) => {
const macId = getMacId(req);
const data = req.body;
if (!data['log'] || !data['log']['logs_array']) {
res.status(204).send();
return;
}
data['log']['logs_array'].map(record => {
let ts = record['creation_timestamp'];
if (ts) {
ts = new Date(ts * 1000).toISOString();
}
console.log([
`[LOG]`,
`[${macId}]`,
`EVENT_TIME`,
ts,
record['log_message'],
'file:' + record['log_sourcefile'] + ':' + record['log_codeline']
].join(' '))
});
res.status(204).send();
});
BYOSRoutes.get('/display', async (req, res) => {
const macId = getMacId(req);
if (readDeviceKey(req) !== calcProperDeviceApiKey(macId)) {
console.error(`[DISPLAY] [${macId}] Wrong access-token value from device: ` + readDeviceKey(req));
res.status(403).send();
return;
}
batteryPercentage = calcBattery(req.headers['battery-voltage']);
res.json({
// screen wouldn't update if data is not changed
filename: 'custom-screen-' + await screenHash(),
image_url: PUBLIC_URL_ORIGIN + '/image?secret_key=' + SECRET_KEY,
refresh_rate: 60, // Seconds. Can be overridden by device settings.
});
});
BYOSRoutes.get('/setup', (req, res) => {
const macId = getMacId(req);
if (!BYOS_DEVICE_MAC || macId !== BYOS_DEVICE_MAC) {
console.error(`[SETUP] [${macId}] device is tried to connect with other MAC, that allowed - rejected`);
res.status(403).send();
return;
}
console.log(`[SETUP] [${macId}] device is trying to connect.`);
res.json({
"status": 200,
"api_key": calcProperDeviceApiKey(macId),
"friendly_id": "TRMNL",
"message": "Device successfully registered",
});
});
export let batteryPercentage = 0;
function calcBattery(voltage) {
const minVoltage = 0.45;
const maxVoltage = 4.05;
const minPercentage = 10;
const maxPercentage = 90;
if (voltage <= minVoltage) return minPercentage;
if (voltage > maxVoltage) return 100;
const percentage = (voltage - minVoltage) / (maxVoltage - minVoltage) *
(maxPercentage - minPercentage) + minPercentage;
return Math.round(percentage);
}
function getMacId(req): string {
if (typeof req.headers.id !== 'string') {
throw new Error('Missing id header');
}
return req.headers.id;
}
function calcProperDeviceApiKey(macID: string) {
return sha256(SECRET_KEY + macID);
}
function readDeviceKey(req): string {
if (typeof req.headers['access-token'] !== 'string') {
throw new Error('Missing access-token header');
}
return req.headers['access-token'];
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const SERVER_PORT = 3000;
export const SERVER_HOST = '127.0.0.1'; // use '0.0.0.0' for access via local router
export const TIMEZONE = 'Europe/Warsaw';
export const ASSETS_FOLDER = path.join(import.meta.dirname, '..', 'assets');
export const BYOS_DEVICE_MAC = process.env['BYOS_DEVICE_MAC'] && process.env['BYOS_DEVICE_MAC'].length > 5 ? process.env['BYOS_DEVICE_MAC'] : undefined;
function readEnvOrFail(key: string): string {
const value = process.env[key];
+40 -47
View File
@@ -1,70 +1,63 @@
import {JSXtoPNG} from "./Utils/JSXtoPNG.js";
import App from "./Template/App.js";
import express from "express";
import {prepareData} from "./Data/PrepareData.js";
import {SECRET_KEY, SERVER_HOST, SERVER_PORT, PUBLIC_URL_ORIGIN} from "./Config.js";
import {PNGto1BIT} from "./Utils/PNGto1BIT.js";
import crypto from 'crypto';
import {buildScreen, screenHash} from "./Utils/Screen.js";
import {BYOSRoutes} from "./BYOS/BYOSRoutes.js";
const app = express();
app.use((req, res, next) => {
if (req.path === '/') {
return next();
}
if (req.query['secret_key'] !== SECRET_KEY) {
res.setHeader('Content-Type', 'application/json');
res.status(401).send(JSON.stringify('Wrong or missing secret_key'));
return;
}
next();
});
app.get('/api', async (req, res) => {
const data = await prepareData();
const image = await JSXtoPNG(App(data));
const imageHash = crypto.createHash('sha256').update(image).digest('hex');
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify({
// screen wouldn't update if data is not changed
filename: 'custom-screen-' + imageHash,
url: PUBLIC_URL_ORIGIN + '/image?secret_key=' + SECRET_KEY,
refresh_rate: 60, // Seconds. Can be overridden by device settings.
}));
});
app.get('/image', async (req, res) => {
const data = await prepareData();
const image = await JSXtoPNG(App(data));
const image1bit = await PNGto1BIT(image);
res.setHeader('Content-Type', 'image/bmp');
res.send(image1bit);
})
app.use(express.json());
app.use('/api', BYOSRoutes); // comment this line to disable BYOS
app.get('/', (req, res) => {
res.send();
})
app.use((req, res) => {
res.status(404).send({
error: 'Not Found',
message: 'The requested path could not be found: ' + req.url
function isSecretKeyValid(req, res) {
if (req.query['secret_key'] !== SECRET_KEY) {
res.setHeader('Content-Type', 'application/json');
res.status(401).json('Wrong or missing secret_key');
return false;
}
return true;
}
app.get('/plugin/redirect', async (req, res) => {
if (!isSecretKeyValid(req, res)) {
return;
}
res.setHeader('Content-Type', 'application/json');
res.json({
// screen wouldn't update if data is not changed
filename: 'custom-screen-' + await screenHash(),
url: PUBLIC_URL_ORIGIN + '/image?secret_key=' + SECRET_KEY,
refresh_rate: 60, // Seconds. Can be overridden by device settings.
});
});
app.get('/image', async (req, res) => {
if (!isSecretKeyValid(req, res)) {
return;
}
const image1bit = await buildScreen();
res.setHeader('Content-Type', 'image/bmp');
res.send(image1bit);
})
app.use((req, res) => {
console.log(`[404] ${req.method} ${req.url}`);
res.status(404).json({error: 'Not Found', message: 'The requested path could not be found: ' + req.url});
});
app.use((err: Error, req, res, next) => {
console.error(err.stack);
res.status(500).send({
error: 'Internal Server Error',
message: 'Something went wrong!'
});
res.status(500).json({error: 'Internal Server Error', message: 'Something went wrong!'});
});
app.listen(SERVER_PORT, SERVER_HOST, (error) => {
if (error) {
throw error;
} else {
console.log(`Server started. Check it http://${SERVER_HOST}:${SERVER_PORT}/api?secret_key=... OR ${PUBLIC_URL_ORIGIN}/api?secret_key=...`);
console.log(`Server started. Check it http://${SERVER_HOST}:${SERVER_PORT}/image?secret_key=... OR ${PUBLIC_URL_ORIGIN}/image?secret_key=...`);
}
})
+16
View File
@@ -0,0 +1,16 @@
import {prepareData} from "../Data/PrepareData.js";
import {JSXtoPNG} from "./JSXtoPNG.js";
import App from "../Template/App.js";
import {PNGto1BIT} from "./PNGto1BIT.js";
import {sha256} from "./Sha256.js";
export async function buildScreen() {
const data = await prepareData();
const image = await JSXtoPNG(App(data));
return PNGto1BIT(image);
}
export async function screenHash() {
const screen = await buildScreen();
return sha256(screen);
}
+5
View File
@@ -0,0 +1,5 @@
import crypto from "crypto";
export function sha256(data: string | NodeJS.ArrayBufferView): string {
return crypto.createHash('sha256').update(data).digest('hex');
}