- Sync with latest upstream dev branch - Include all map improvements: 2D/3D toggle, shadows, labels, overlays - Include platform updates: location API, audio echo cancellation - Preserve fork-specific re-exports (gltf, csg, test)
398 lines
13 KiB
JavaScript
Executable file
398 lines
13 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
import crypto from 'node:crypto';
|
|
import dgram from 'node:dgram';
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import tls from 'node:tls';
|
|
import zlib from 'node:zlib';
|
|
|
|
function arg(name, fallback = undefined) {
|
|
const eq = process.argv.find(v => v.startsWith(`--${name}=`));
|
|
if (eq) return eq.slice(name.length + 3);
|
|
const idx = process.argv.indexOf(`--${name}`);
|
|
if (idx >= 0 && idx + 1 < process.argv.length) return process.argv[idx + 1];
|
|
return fallback;
|
|
}
|
|
|
|
const host = arg('host', '10.0.0.182');
|
|
const mac = arg('mac', '04-E4-B6-F4-5A-8E');
|
|
const pin = arg('pin', '123456');
|
|
const displayId = Number(arg('display', '0'));
|
|
const timeoutMs = Number(arg('timeout-ms', '120000'));
|
|
const imageWidth = Number(arg('width', '800'));
|
|
const imageHeight = Number(arg('height', '480'));
|
|
|
|
function localIp() {
|
|
const explicit = arg('local-ip');
|
|
if (explicit) return explicit;
|
|
const addrs = Object.values(os.networkInterfaces())
|
|
.flat()
|
|
.filter(v => v && v.family === 'IPv4' && !v.internal)
|
|
.map(v => v.address);
|
|
return addrs.find(v => v.startsWith('10.')) || addrs[0] || '127.0.0.1';
|
|
}
|
|
|
|
function crc32(bytes) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of bytes) {
|
|
crc ^= byte;
|
|
for (let i = 0; i < 8; i++) {
|
|
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
|
}
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function u32be(value) {
|
|
const out = Buffer.alloc(4);
|
|
out.writeUInt32BE(value >>> 0);
|
|
return out;
|
|
}
|
|
|
|
function chunk(type, data = Buffer.alloc(0)) {
|
|
const typeBytes = Buffer.from(type, 'ascii');
|
|
return Buffer.concat([
|
|
u32be(data.length),
|
|
typeBytes,
|
|
data,
|
|
u32be(crc32(Buffer.concat([typeBytes, data]))),
|
|
]);
|
|
}
|
|
|
|
function randomPng(width, height) {
|
|
const seed = crypto.randomBytes(16);
|
|
const raw = Buffer.alloc((width * 4 + 1) * height);
|
|
for (let y = 0; y < height; y++) {
|
|
const row = y * (width * 4 + 1);
|
|
raw[row] = 0;
|
|
for (let x = 0; x < width; x++) {
|
|
const p = row + 1 + x * 4;
|
|
raw[p] = (x * 3 + y * 7 + seed[0]) & 255;
|
|
raw[p + 1] = (x * 11 + y * 5 + seed[1]) & 255;
|
|
raw[p + 2] = (x * 17 + y * 13 + seed[2]) & 255;
|
|
raw[p + 3] = 255;
|
|
}
|
|
}
|
|
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8;
|
|
ihdr[9] = 6;
|
|
ihdr[10] = 0;
|
|
ihdr[11] = 0;
|
|
ihdr[12] = 0;
|
|
|
|
return Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
chunk('IHDR', ihdr),
|
|
chunk('IDAT', zlib.deflateSync(raw)),
|
|
chunk('IEND'),
|
|
]);
|
|
}
|
|
|
|
function escapeJsonForEmdx(value) {
|
|
return JSON.stringify(value).replaceAll('/', '\\/');
|
|
}
|
|
|
|
function wakeOnLan(targetMac) {
|
|
if (!targetMac) return Promise.resolve();
|
|
const cleaned = targetMac.replace(/[^a-fA-F0-9]/g, '');
|
|
if (cleaned.length !== 12) throw new Error(`Invalid MAC: ${targetMac}`);
|
|
const macBytes = Buffer.from(cleaned, 'hex');
|
|
const packet = Buffer.concat([Buffer.alloc(6, 0xff), ...Array(16).fill(macBytes)]);
|
|
return new Promise((resolve, reject) => {
|
|
const socket = dgram.createSocket('udp4');
|
|
socket.once('listening', () => socket.setBroadcast(true));
|
|
socket.send(packet, 0, packet.length, 9, '255.255.255.255', err => {
|
|
socket.close();
|
|
err ? reject(err) : resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function mdcFrame(commandId, id, data) {
|
|
const payload = Buffer.from([commandId, id, data.length, ...data]);
|
|
const checksum = payload.reduce((sum, byte) => sum + byte, 0) % 256;
|
|
return Buffer.from([0xaa, ...payload, checksum]);
|
|
}
|
|
|
|
function parseMdcFrame(buffer) {
|
|
while (buffer.length > 0 && buffer[0] !== 0xaa) buffer = buffer.subarray(1);
|
|
if (buffer.length < 6 || buffer[1] !== 0xff) return [null, buffer];
|
|
const length = buffer[3];
|
|
const frameLength = 5 + length;
|
|
if (buffer.length < frameLength) return [null, buffer];
|
|
const frame = buffer.subarray(0, frameLength);
|
|
const checksum = frame[frame.length - 1];
|
|
const calc = frame.subarray(1, frame.length - 1).reduce((sum, byte) => sum + byte, 0) % 256;
|
|
if (checksum !== calc) return [null, buffer.subarray(1)];
|
|
return [{
|
|
displayId: frame[2],
|
|
ack: frame[4] === 0x41,
|
|
commandId: frame[5],
|
|
payload: frame.subarray(6, 6 + length - 2),
|
|
}, buffer.subarray(frameLength)];
|
|
}
|
|
|
|
function waitForPlainGreeting(socket) {
|
|
return new Promise((resolve, reject) => {
|
|
let text = '';
|
|
const timer = setTimeout(() => reject(new Error('Timed out waiting for MDC TLS greeting')), 10000);
|
|
socket.on('data', data => {
|
|
text += data.toString('utf8');
|
|
if (text.includes('MDCSTART<<TLS>>')) {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
}
|
|
});
|
|
socket.once('error', reject);
|
|
});
|
|
}
|
|
|
|
async function connectMdc() {
|
|
const tcp = net.connect({ host, port: 1515 });
|
|
await waitForPlainGreeting(tcp);
|
|
|
|
const secure = await new Promise((resolve, reject) => {
|
|
const socket = tls.connect({ socket: tcp, rejectUnauthorized: false }, () => resolve(socket));
|
|
socket.once('error', reject);
|
|
});
|
|
|
|
let text = '';
|
|
let binary = Buffer.alloc(0);
|
|
const pending = [];
|
|
secure.on('data', data => {
|
|
text += data.toString('utf8');
|
|
binary = Buffer.concat([binary, data]);
|
|
let parsed;
|
|
do {
|
|
[parsed, binary] = parseMdcFrame(binary);
|
|
if (parsed) pending.push(parsed);
|
|
} while (parsed);
|
|
});
|
|
secure.write(Buffer.from(pin));
|
|
|
|
const start = Date.now();
|
|
while (!text.includes('MDCAUTH<<PASS>>')) {
|
|
if (text.includes('MDCAUTH<<FAIL:0x01>>')) throw new Error('Authentication failed: incorrect PIN');
|
|
if (text.includes('MDCAUTH<<FAIL:0x02>>')) throw new Error('Authentication failed: blocked');
|
|
if (Date.now() - start > 10000) throw new Error('Timed out waiting for MDC auth');
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
|
|
return {
|
|
async sendCommand(commandId, data = Buffer.alloc(0)) {
|
|
secure.write(mdcFrame(commandId, displayId, data));
|
|
const start = Date.now();
|
|
while (Date.now() - start < 15000) {
|
|
const idx = pending.findIndex(v => v.commandId === commandId && v.displayId === displayId);
|
|
if (idx >= 0) {
|
|
const [response] = pending.splice(idx, 1);
|
|
if (!response.ack) throw new Error(`MDC NAK command 0x${commandId.toString(16)} payload: ${response.payload.toString('hex')}`);
|
|
return response.payload;
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
throw new Error(`Timed out waiting for MDC ACK for command 0x${commandId.toString(16)}`);
|
|
},
|
|
async setContentDownload(url) {
|
|
const urlBytes = Buffer.from(url);
|
|
if (urlBytes.length > 255) throw new Error(`URL too long: ${urlBytes.length} bytes`);
|
|
const data = Buffer.from([0x53, 0x80, urlBytes.length, ...urlBytes]);
|
|
await this.sendCommand(0xc7, data);
|
|
},
|
|
close() {
|
|
secure.end();
|
|
},
|
|
};
|
|
}
|
|
|
|
async function printMdcDiagnostics(device) {
|
|
const readString = async (label, commandId) => {
|
|
try {
|
|
const payload = await device.sendCommand(commandId);
|
|
console.log(`[diag] ${label}: ${payload.toString('utf8')} (${payload.toString('hex')})`);
|
|
} catch (err) {
|
|
console.log(`[diag] ${label}: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
await readString('serial', 0x0b);
|
|
await readString('software', 0x0e);
|
|
await readString('device name', 0x67);
|
|
|
|
const printPayload = async (label, commandId, data = Buffer.alloc(0)) => {
|
|
try {
|
|
const payload = await device.sendCommand(commandId, data);
|
|
console.log(`[diag] ${label}: ${payload.toString('hex')} "${payload.toString('utf8')}"`);
|
|
return payload;
|
|
} catch (err) {
|
|
console.log(`[diag] ${label}: ${err.message}`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const status = await printPayload('status', 0x00);
|
|
if (status && status.length >= 7) {
|
|
console.log(`[diag] status decoded: power=${status[0]}, volume=${status[1]}, mute=${status[2]}, input=0x${status[3].toString(16)}, aspect=0x${status[4].toString(16)}`);
|
|
}
|
|
const input = await printPayload('input source', 0x14);
|
|
if (input && input.length) {
|
|
console.log(`[diag] input decoded: 0x${input[0].toString(16)}`);
|
|
}
|
|
await printPayload('launcher play via', 0xc7, Buffer.from([0x81]));
|
|
await printPayload('launcher url', 0xc7, Buffer.from([0x82]));
|
|
await printPayload('network config', 0x1b, Buffer.from([0x82]));
|
|
await printPayload('network mode', 0x1b, Buffer.from([0x85]));
|
|
await printPayload('panel', 0xf9);
|
|
|
|
try {
|
|
const payload = await device.sendCommand(0x11);
|
|
const state = payload[0] === 0 ? 'Off' : payload[0] === 1 ? 'On' : payload[0] === 2 ? 'Reboot' : `Unknown ${payload[0]}`;
|
|
console.log(`[diag] power: ${state} (${payload.toString('hex')})`);
|
|
} catch (err) {
|
|
console.log(`[diag] power: ${err.message}`);
|
|
}
|
|
|
|
try {
|
|
const payload = await device.sendCommand(0x1b, Buffer.from([0x73]));
|
|
console.log(`[diag] battery payload: ${payload.toString('hex')}`);
|
|
if (payload.length >= 7) {
|
|
console.log(`[diag] battery: ${payload[4]}%, plugged=${payload[6] === 0x02}, warning=${payload[2] === 0x01}`);
|
|
}
|
|
} catch (err) {
|
|
console.log(`[diag] battery: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function listenOnAvailablePort(server, startPort) {
|
|
for (let port = startPort; port < startPort + 50; port++) {
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(port, '0.0.0.0', resolve);
|
|
});
|
|
return port;
|
|
} catch (err) {
|
|
server.removeAllListeners('error');
|
|
if (err.code !== 'EADDRINUSE') throw err;
|
|
}
|
|
}
|
|
throw new Error(`No available HTTP port near ${startPort}`);
|
|
}
|
|
|
|
function getUrl(url) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.get(url, res => {
|
|
const chunks = [];
|
|
res.on('data', chunk => chunks.push(chunk));
|
|
res.on('end', () => resolve({
|
|
statusCode: res.statusCode,
|
|
bytes: Buffer.concat(chunks).length,
|
|
}));
|
|
});
|
|
req.setTimeout(5000, () => {
|
|
req.destroy(new Error(`Timed out fetching ${url}`));
|
|
});
|
|
req.once('error', reject);
|
|
});
|
|
}
|
|
|
|
const ip = localIp();
|
|
const fileId = crypto.randomUUID().toUpperCase();
|
|
const png = randomPng(imageWidth, imageHeight);
|
|
const state = { contentRequests: 0, imageRequests: 0 };
|
|
let port;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
console.log(`[http] ${req.method} ${req.url}`);
|
|
if (req.url.startsWith('/content.json')) {
|
|
state.contentRequests++;
|
|
const content = escapeJsonForEmdx({
|
|
schedule: [{
|
|
start_date: '1970-01-01',
|
|
stop_date: '2999-12-31',
|
|
start_time: '00:00:00',
|
|
contents: [{
|
|
image_url: `http://${ip}:${port}/image`,
|
|
file_id: fileId,
|
|
file_path: `/home/owner/content/Downloads/vxtplayer/epaper/mobile/contents/${fileId}/${fileId}.png`,
|
|
duration: 91326,
|
|
file_size: `${png.length}`,
|
|
file_name: `${fileId}.png`,
|
|
}],
|
|
}],
|
|
name: 'node-samsung-emdx',
|
|
version: 1,
|
|
create_time: '2025-01-01 00:00:00',
|
|
id: fileId,
|
|
program_id: 'com.samsung.ios.ePaper',
|
|
content_type: 'ImageContent',
|
|
deploy_type: 'MOBILE',
|
|
});
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(content),
|
|
'Cache-Control': 'no-store',
|
|
Connection: 'close',
|
|
});
|
|
res.end(content);
|
|
return;
|
|
}
|
|
if (req.url.startsWith('/image')) {
|
|
state.imageRequests++;
|
|
res.writeHead(200, {
|
|
'Content-Type': 'image/png',
|
|
'Content-Length': png.length,
|
|
'Cache-Control': 'no-store',
|
|
Connection: 'close',
|
|
});
|
|
res.end(png);
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('not found');
|
|
});
|
|
|
|
port = await listenOnAvailablePort(server, Number(arg('port', '3000')));
|
|
const contentUrl = `http://${ip}:${port}/content.json`;
|
|
console.log(`[test] Serving ${png.length} byte PNG as ${fileId}`);
|
|
console.log(`[test] Content URL: ${contentUrl}`);
|
|
console.log(`[test] Target: ${host}, display ${displayId}`);
|
|
|
|
try {
|
|
const contentCheck = await getUrl(contentUrl);
|
|
const imageCheck = await getUrl(`http://${ip}:${port}/image`);
|
|
console.log(`[self] content.json ${contentCheck.statusCode}, ${contentCheck.bytes} bytes`);
|
|
console.log(`[self] image ${imageCheck.statusCode}, ${imageCheck.bytes} bytes`);
|
|
state.contentRequests = 0;
|
|
state.imageRequests = 0;
|
|
|
|
if (mac) {
|
|
console.log(`[mdc] Wake-on-LAN ${mac}`);
|
|
await wakeOnLan(mac);
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
console.log('[mdc] Connecting');
|
|
const device = await connectMdc();
|
|
console.log('[mdc] Connected/authenticated');
|
|
await printMdcDiagnostics(device);
|
|
console.log('[mdc] Sending setContentDownload');
|
|
await device.setContentDownload(contentUrl);
|
|
device.close();
|
|
console.log('[mdc] ACK received');
|
|
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs && state.imageRequests === 0) {
|
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
}
|
|
|
|
console.log(`[result] content.json requests: ${state.contentRequests}`);
|
|
console.log(`[result] image requests: ${state.imageRequests}`);
|
|
process.exitCode = state.imageRequests > 0 ? 0 : 2;
|
|
} finally {
|
|
server.close();
|
|
}
|