Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e93564ba | ||
|
|
5020d2c288 | ||
|
|
ac759a60fe | ||
|
|
75f9686533 | ||
|
|
daee65e0d5 | ||
|
|
69b62b6af3 | ||
|
|
6d38beaf26 |
+146
-3
@@ -229,10 +229,33 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.modal h2 {
|
.modal h2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
padding-right: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form */
|
/* Form */
|
||||||
@@ -512,6 +535,11 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.confirm-text {
|
.confirm-text {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -652,12 +680,31 @@
|
|||||||
<th>Requests</th>
|
<th>Requests</th>
|
||||||
<th>Last Seen</th>
|
<th>Last Seen</th>
|
||||||
<th>User Agent</th>
|
<th>User Agent</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="debug-devices-tbody"></tbody>
|
<tbody id="debug-devices-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="debug-section">
|
||||||
|
<h3>🚫 Banned IPs</h3>
|
||||||
|
<div style="overflow-x: auto">
|
||||||
|
<table class="debug-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Banned At</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Banned By</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="debug-banned-tbody"><tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -781,8 +828,11 @@
|
|||||||
|
|
||||||
function openModal(html) {
|
function openModal(html) {
|
||||||
document.getElementById("modal-container").innerHTML = `
|
document.getElementById("modal-container").innerHTML = `
|
||||||
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
<div class="modal-overlay">
|
||||||
<div class="modal">${html}</div>
|
<div class="modal">
|
||||||
|
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||||
|
${html}
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
}
|
}
|
||||||
@@ -877,7 +927,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Cache TTL (seconds)</label>
|
<label>Cache TTL (seconds)</label>
|
||||||
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
|
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
|
||||||
<div class="hint">How long a render stays in cache. 0 = always re-render. Each access extends TTL.</div>
|
<div class="hint">How long a render stays in cache. 0 = always re-render.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field" style="display:flex;gap:12px;">
|
<div class="field" style="display:flex;gap:12px;">
|
||||||
@@ -1138,6 +1188,7 @@
|
|||||||
document.getElementById("debug-devices-tbody").innerHTML = data.devices
|
document.getElementById("debug-devices-tbody").innerHTML = data.devices
|
||||||
.map((d) => {
|
.map((d) => {
|
||||||
const ago = Math.round((Date.now() - d.lastSeen) / 1000);
|
const ago = Math.round((Date.now() - d.lastSeen) / 1000);
|
||||||
|
const ipEnc = encodeURIComponent(d.ip);
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${esc(d.ip)}</td>
|
<td>${esc(d.ip)}</td>
|
||||||
@@ -1145,10 +1196,20 @@
|
|||||||
<td>${d.requestCount}</td>
|
<td>${d.requestCount}</td>
|
||||||
<td>${ago}s ago</td>
|
<td>${ago}s ago</td>
|
||||||
<td class="url-cell" title="${esc(d.userAgent)}">${esc(d.userAgent.slice(0, 60))}</td>
|
<td class="url-cell" title="${esc(d.userAgent)}">${esc(d.userAgent.slice(0, 60))}</td>
|
||||||
|
<td>
|
||||||
|
<div class="device-actions">
|
||||||
|
<button class="btn btn-xs btn-ghost" onclick="adminRefresh('${ipEnc}')" title="Force cache refresh">↺</button>
|
||||||
|
<button class="btn btn-xs btn-ghost" onclick="adminDisconnect('${ipEnc}')" title="Disconnect">⏏</button>
|
||||||
|
<button class="btn btn-xs btn-danger" onclick="adminBan('${ipEnc}', '${esc(d.ip)}')" title="Ban IP">🚫</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
// ── Banned IPs table ──
|
||||||
|
fetchBannedIPs();
|
||||||
|
|
||||||
if (!debugTickInterval) {
|
if (!debugTickInterval) {
|
||||||
debugTickInterval = setInterval(tickDebugValues, 500);
|
debugTickInterval = setInterval(tickDebugValues, 500);
|
||||||
}
|
}
|
||||||
@@ -1177,6 +1238,88 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// ADMIN ACTIONS
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function adminDisconnect(ipEnc) {
|
||||||
|
try {
|
||||||
|
await api("POST", "/api/admin/disconnect/" + ipEnc);
|
||||||
|
toast("Device disconnected");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminRefresh(ipEnc) {
|
||||||
|
try {
|
||||||
|
await api("POST", "/api/admin/refresh/" + ipEnc);
|
||||||
|
toast("Cache refreshed");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminBan(ipEnc, ipDisplay) {
|
||||||
|
openModal(`
|
||||||
|
<h2>Ban IP</h2>
|
||||||
|
<div class="confirm-text">Ban <strong>${esc(ipDisplay)}</strong>? Future connections will receive 403.</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Reason</label>
|
||||||
|
<input type="text" id="ban-reason" placeholder="Reason (optional)" value="Banned by administrator">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-danger" onclick="doAdminBan('${ipEnc}', '${esc(ipDisplay)}')">Ban</button>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAdminBan(ipEnc, ipDisplay) {
|
||||||
|
const reason = document.getElementById("ban-reason")?.value || "Banned by administrator";
|
||||||
|
try {
|
||||||
|
await api("POST", "/api/admin/ban/" + ipEnc, { reason });
|
||||||
|
closeModal();
|
||||||
|
toast(`Banned ${decodeURIComponent(ipEnc)}`);
|
||||||
|
fetchBannedIPs();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminUnban(ipEnc) {
|
||||||
|
try {
|
||||||
|
await api("POST", "/api/admin/unban/" + ipEnc);
|
||||||
|
toast("IP unbanned");
|
||||||
|
fetchBannedIPs();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBannedIPs() {
|
||||||
|
try {
|
||||||
|
const banned = await api("GET", "/api/banned");
|
||||||
|
const tbody = document.getElementById("debug-banned-tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!banned.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = banned.map((b) => {
|
||||||
|
const ipEnc = encodeURIComponent(b.ip);
|
||||||
|
const dt = new Date(b.banned_at * 1000).toLocaleString();
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(b.ip)}</td>
|
||||||
|
<td>${esc(dt)}</td>
|
||||||
|
<td>${esc(b.reason || "—")}</td>
|
||||||
|
<td>${esc(b.banned_by || "—")}</td>
|
||||||
|
<td><button class="btn btn-xs btn-ghost" onclick="adminUnban('${ipEnc}')">Unban</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join("");
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
// KEYBOARD
|
// KEYBOARD
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
|
|||||||
+346
-45
@@ -16,6 +16,7 @@ const CONFIG = {
|
|||||||
MAX_CONCURRENT_RENDERS: 16,
|
MAX_CONCURRENT_RENDERS: 16,
|
||||||
QUEUE_TIMEOUT_MS: 30000,
|
QUEUE_TIMEOUT_MS: 30000,
|
||||||
CACHE_CLEANUP_INTERVAL_MS: 30000,
|
CACHE_CLEANUP_INTERVAL_MS: 30000,
|
||||||
|
WARMUP_INTERVAL_MS: 60000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -115,6 +116,15 @@ db.run(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
banned_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
reason TEXT,
|
||||||
|
banned_by TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// Migrations for upgrading from older schema
|
// Migrations for upgrading from older schema
|
||||||
try {
|
try {
|
||||||
db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60");
|
db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60");
|
||||||
@@ -143,6 +153,30 @@ function rowToView(row: ViewRow): View {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed a default view on first run (only when table is empty)
|
||||||
|
const viewCount = (db.query("SELECT COUNT(*) as n FROM views").get() as { n: number }).n;
|
||||||
|
if (viewCount === 0) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
|
[
|
||||||
|
crypto.randomUUID(),
|
||||||
|
"Demo View",
|
||||||
|
JSON.stringify([
|
||||||
|
{ url: "https://www.google.com", durationSec: 30, forceDarkMode: false, bakeStyles: false },
|
||||||
|
{ url: "https://duck.ai", durationSec: 30, forceDarkMode: false, bakeStyles: false },
|
||||||
|
{ url: "https://www.msn.com", durationSec: 30, forceDarkMode: false, bakeStyles: false },
|
||||||
|
]),
|
||||||
|
"ssr",
|
||||||
|
0,
|
||||||
|
60,
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function dbLoadAll(): View[] {
|
function dbLoadAll(): View[] {
|
||||||
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
|
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
|
||||||
rowToView,
|
rowToView,
|
||||||
@@ -192,6 +226,35 @@ function dbDelete(id: string): boolean {
|
|||||||
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
|
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dbBanIP(ip: string, reason?: string): void {
|
||||||
|
db.run(
|
||||||
|
"INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)",
|
||||||
|
[ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbUnbanIP(ip: string): boolean {
|
||||||
|
return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbIsBanned(ip: string): boolean {
|
||||||
|
return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbGetBannedIPs(): Array<{
|
||||||
|
ip: string;
|
||||||
|
banned_at: number;
|
||||||
|
reason: string;
|
||||||
|
banned_by: string;
|
||||||
|
}> {
|
||||||
|
return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{
|
||||||
|
ip: string;
|
||||||
|
banned_at: number;
|
||||||
|
reason: string;
|
||||||
|
banned_by: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// IN-MEMORY VIEW CACHE
|
// IN-MEMORY VIEW CACHE
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -215,9 +278,16 @@ function viewCacheAll(): View[] {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const devices = new Map<string, DeviceEntry>();
|
const devices = new Map<string, DeviceEntry>();
|
||||||
|
const warmedUpViews = new Set<string>();
|
||||||
const DEVICE_TTL_MS = 120_000;
|
const DEVICE_TTL_MS = 120_000;
|
||||||
|
|
||||||
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void {
|
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): boolean {
|
||||||
|
// Check if IP is banned
|
||||||
|
if (dbIsBanned(ip)) {
|
||||||
|
console.warn(`[security] Blocked connection from banned IP: ${ip}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = devices.get(ip);
|
const existing = devices.get(ip);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.viewId = viewId;
|
existing.viewId = viewId;
|
||||||
@@ -227,7 +297,16 @@ function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: st
|
|||||||
existing.requestCount++;
|
existing.requestCount++;
|
||||||
} else {
|
} else {
|
||||||
devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 });
|
devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 });
|
||||||
|
|
||||||
|
// First user connecting to this view - trigger warmup
|
||||||
|
if (!warmedUpViews.has(viewId)) {
|
||||||
|
warmedUpViews.add(viewId);
|
||||||
|
warmupView(viewId).catch((err) => {
|
||||||
|
console.error(`[warmup] Failed to warm up view ${viewId}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function devicesPrune(): void {
|
function devicesPrune(): void {
|
||||||
@@ -247,7 +326,7 @@ function devicesSnapshot(): DeviceEntry[] {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function getDebugData() {
|
function getDebugData() {
|
||||||
const viewsDebug = viewCacheAll().map(v => {
|
const viewsDebug = viewCacheAll().map((v) => {
|
||||||
const idx = getCurrentURLIndex(v);
|
const idx = getCurrentURLIndex(v);
|
||||||
return {
|
return {
|
||||||
id: v.id,
|
id: v.id,
|
||||||
@@ -262,7 +341,7 @@ function getDebugData() {
|
|||||||
currentUrl: v.urls[idx]?.url ?? "",
|
currentUrl: v.urls[idx]?.url ?? "",
|
||||||
remainingSec: getRemainingSec(v),
|
remainingSec: getRemainingSec(v),
|
||||||
totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0),
|
totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0),
|
||||||
cacheEntries: cacheStats().filter(c => c.viewId === v.id),
|
cacheEntries: cacheStats().filter((c) => c.viewId === v.id),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -364,7 +443,9 @@ class PagePool {
|
|||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[page-pool] resetPage failed, discarding page: ${err}`);
|
console.warn(`[page-pool] resetPage failed, discarding page: ${err}`);
|
||||||
try { await page.close(); } catch {}
|
try {
|
||||||
|
await page.close();
|
||||||
|
} catch {}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,10 +520,7 @@ let pagePool: PagePool;
|
|||||||
// WEBSOCKET DEBUG CLIENTS
|
// WEBSOCKET DEBUG CLIENTS
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const debugClients = new Map<
|
const debugClients = new Map<any, { interval: number; timer: ReturnType<typeof setInterval> }>();
|
||||||
any,
|
|
||||||
{ interval: number; timer: ReturnType<typeof setInterval> }
|
|
||||||
>();
|
|
||||||
const DEBUG_DEFAULT_INTERVAL_MS = 1000;
|
const DEBUG_DEFAULT_INTERVAL_MS = 1000;
|
||||||
const DEBUG_MIN_INTERVAL_MS = 100;
|
const DEBUG_MIN_INTERVAL_MS = 100;
|
||||||
|
|
||||||
@@ -467,6 +545,64 @@ function cachePurgeExpired(): void {
|
|||||||
|
|
||||||
setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS);
|
setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS);
|
||||||
|
|
||||||
|
async function warmupView(viewId: string): Promise<void> {
|
||||||
|
const view = viewCacheGet(viewId);
|
||||||
|
if (!view) {
|
||||||
|
console.warn(`[warmup] View ${viewId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[warmup] Starting precache for view "${view.name}" (${view.urls.length} URLs)`);
|
||||||
|
|
||||||
|
for (let i = 0; i < view.urls.length; i++) {
|
||||||
|
const key = renderCacheKey(view.id, i);
|
||||||
|
const cached = renderCache.get(key);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!cached || now >= cached.expiresAt) {
|
||||||
|
try {
|
||||||
|
await getOrRender(view, i);
|
||||||
|
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[warmup] Precache complete for view "${view.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warmupCache(): Promise<void> {
|
||||||
|
const activeDevices = devicesSnapshot();
|
||||||
|
if (activeDevices.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[warmup] Starting periodic precache for ${activeDevices.length} active device(s)`);
|
||||||
|
|
||||||
|
const views = viewCacheAll();
|
||||||
|
for (const view of views) {
|
||||||
|
for (let i = 0; i < view.urls.length; i++) {
|
||||||
|
const key = renderCacheKey(view.id, i);
|
||||||
|
const cached = renderCache.get(key);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!cached || now >= cached.expiresAt) {
|
||||||
|
try {
|
||||||
|
await getOrRender(view, i);
|
||||||
|
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[warmup] Periodic precache complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(warmupCache, CONFIG.WARMUP_INTERVAL_MS);
|
||||||
|
|
||||||
function cacheInvalidate(viewId: string): void {
|
function cacheInvalidate(viewId: string): void {
|
||||||
const prefix = `${viewId}:`;
|
const prefix = `${viewId}:`;
|
||||||
for (const key of renderCache.keys()) {
|
for (const key of renderCache.keys()) {
|
||||||
@@ -478,6 +614,8 @@ function cacheInvalidate(viewId: string): void {
|
|||||||
renderQueue.splice(i, 1);
|
renderQueue.splice(i, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Allow re-warming if view is recreated
|
||||||
|
warmedUpViews.delete(viewId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drainRenderQueue(): void {
|
function drainRenderQueue(): void {
|
||||||
@@ -504,7 +642,6 @@ function drainRenderQueue(): void {
|
|||||||
|
|
||||||
const cached = renderCache.get(next.key);
|
const cached = renderCache.get(next.key);
|
||||||
if (cached && Date.now() < cached.expiresAt) {
|
if (cached && Date.now() < cached.expiresAt) {
|
||||||
cached.expiresAt = Date.now() + view.cacheTtlSec * 1000;
|
|
||||||
next.resolve(cached.body);
|
next.resolve(cached.body);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -535,12 +672,19 @@ async function renderViewToCache(view: View, urlIndex: number, key: string): Pro
|
|||||||
let body: string;
|
let body: string;
|
||||||
if (view.method === "mjpeg") {
|
if (view.method === "mjpeg") {
|
||||||
const jpeg = await renderScreenshotFrame(
|
const jpeg = await renderScreenshotFrame(
|
||||||
urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode,
|
urlItem.url,
|
||||||
|
view.viewportWidth,
|
||||||
|
view.viewportHeight,
|
||||||
|
forceDarkMode,
|
||||||
);
|
);
|
||||||
body = jpeg.toString("base64");
|
body = jpeg.toString("base64");
|
||||||
} else {
|
} else {
|
||||||
body = await renderSSR(
|
body = await renderSSR(
|
||||||
urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, bakeStyles,
|
urlItem.url,
|
||||||
|
view.viewportWidth,
|
||||||
|
view.viewportHeight,
|
||||||
|
forceDarkMode,
|
||||||
|
bakeStyles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +708,6 @@ async function getOrRender(view: View, urlIndex: number): Promise<string> {
|
|||||||
|
|
||||||
const cached = renderCache.get(key);
|
const cached = renderCache.get(key);
|
||||||
if (cached && now < cached.expiresAt) {
|
if (cached && now < cached.expiresAt) {
|
||||||
cached.expiresAt = now + view.cacheTtlSec * 1000;
|
|
||||||
return cached.body;
|
return cached.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,7 +834,8 @@ function stripScriptTags(html: string): string {
|
|||||||
// ── Recursive @import resolver (max 3 levels deep) ──
|
// ── Recursive @import resolver (max 3 levels deep) ──
|
||||||
async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): Promise<string> {
|
async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): Promise<string> {
|
||||||
if (depth > 3) return cssText;
|
if (depth > 3) return cssText;
|
||||||
const importRe = /@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi;
|
const importRe =
|
||||||
|
/@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi;
|
||||||
const replacements: Array<{ match: string; resolved: string }> = [];
|
const replacements: Array<{ match: string; resolved: string }> = [];
|
||||||
|
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
@@ -707,7 +851,10 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
|
|||||||
if (!res.ok) throw new Error("failed");
|
if (!res.ok) throw new Error("failed");
|
||||||
let imported = await res.text();
|
let imported = await res.text();
|
||||||
imported = await resolveCSSImports(imported, importUrl, depth + 1);
|
imported = await resolveCSSImports(imported, importUrl, depth + 1);
|
||||||
replacements.push({ match: m[0], resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */" });
|
replacements.push({
|
||||||
|
match: m[0],
|
||||||
|
resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */",
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
replacements.push({ match: m[0], resolved: "/* unresolved import: " + importUrl + " */" });
|
replacements.push({ match: m[0], resolved: "/* unresolved import: " + importUrl + " */" });
|
||||||
}
|
}
|
||||||
@@ -733,40 +880,117 @@ async function bakeComputedStyles(page: Page): Promise<void> {
|
|||||||
await page.evaluate((): void => {
|
await page.evaluate((): void => {
|
||||||
const computedProperties = [
|
const computedProperties = [
|
||||||
// Layout
|
// Layout
|
||||||
"display", "position", "top", "right", "bottom", "left",
|
"display",
|
||||||
"width", "height", "min-width", "min-height", "max-width", "max-height",
|
"position",
|
||||||
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
"top",
|
||||||
"padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
"right",
|
||||||
|
"bottom",
|
||||||
|
"left",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"min-width",
|
||||||
|
"min-height",
|
||||||
|
"max-width",
|
||||||
|
"max-height",
|
||||||
|
"margin",
|
||||||
|
"margin-top",
|
||||||
|
"margin-right",
|
||||||
|
"margin-bottom",
|
||||||
|
"margin-left",
|
||||||
|
"padding",
|
||||||
|
"padding-top",
|
||||||
|
"padding-right",
|
||||||
|
"padding-bottom",
|
||||||
|
"padding-left",
|
||||||
// Flex/Grid
|
// Flex/Grid
|
||||||
"flex", "flex-direction", "flex-wrap", "flex-flow", "flex-grow", "flex-shrink", "flex-basis",
|
"flex",
|
||||||
"justify-content", "align-items", "align-content", "align-self", "gap", "row-gap", "column-gap",
|
"flex-direction",
|
||||||
"grid", "grid-template", "grid-template-columns", "grid-template-rows", "grid-area",
|
"flex-wrap",
|
||||||
|
"flex-flow",
|
||||||
|
"flex-grow",
|
||||||
|
"flex-shrink",
|
||||||
|
"flex-basis",
|
||||||
|
"justify-content",
|
||||||
|
"align-items",
|
||||||
|
"align-content",
|
||||||
|
"align-self",
|
||||||
|
"gap",
|
||||||
|
"row-gap",
|
||||||
|
"column-gap",
|
||||||
|
"grid",
|
||||||
|
"grid-template",
|
||||||
|
"grid-template-columns",
|
||||||
|
"grid-template-rows",
|
||||||
|
"grid-area",
|
||||||
// Box model
|
// Box model
|
||||||
"box-sizing", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
|
"box-sizing",
|
||||||
"border-top-style", "border-right-style", "border-bottom-style", "border-left-style",
|
"border-top-width",
|
||||||
"border-top-color", "border-right-color", "border-bottom-color", "border-left-color",
|
"border-right-width",
|
||||||
"border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius",
|
"border-bottom-width",
|
||||||
|
"border-left-width",
|
||||||
|
"border-top-style",
|
||||||
|
"border-right-style",
|
||||||
|
"border-bottom-style",
|
||||||
|
"border-left-style",
|
||||||
|
"border-top-color",
|
||||||
|
"border-right-color",
|
||||||
|
"border-bottom-color",
|
||||||
|
"border-left-color",
|
||||||
|
"border-top-left-radius",
|
||||||
|
"border-top-right-radius",
|
||||||
|
"border-bottom-right-radius",
|
||||||
|
"border-bottom-left-radius",
|
||||||
// Colors & Background
|
// Colors & Background
|
||||||
"color", "background-color", "background-image", "background-size", "background-position",
|
"color",
|
||||||
"background-repeat", "background-attachment", "background-clip", "background-origin",
|
"background-color",
|
||||||
|
"background-image",
|
||||||
|
"background-size",
|
||||||
|
"background-position",
|
||||||
|
"background-repeat",
|
||||||
|
"background-attachment",
|
||||||
|
"background-clip",
|
||||||
|
"background-origin",
|
||||||
// Typography
|
// Typography
|
||||||
"font-family", "font-size", "font-weight", "font-style", "font-variant",
|
"font-family",
|
||||||
"line-height", "text-align", "text-decoration-line", "text-decoration-color",
|
"font-size",
|
||||||
"text-transform", "letter-spacing", "word-spacing",
|
"font-weight",
|
||||||
"white-space", "overflow-wrap", "word-break",
|
"font-style",
|
||||||
|
"font-variant",
|
||||||
|
"line-height",
|
||||||
|
"text-align",
|
||||||
|
"text-decoration-line",
|
||||||
|
"text-decoration-color",
|
||||||
|
"text-transform",
|
||||||
|
"letter-spacing",
|
||||||
|
"word-spacing",
|
||||||
|
"white-space",
|
||||||
|
"overflow-wrap",
|
||||||
|
"word-break",
|
||||||
// Visual
|
// Visual
|
||||||
"opacity", "visibility", "z-index", "overflow-x", "overflow-y",
|
"opacity",
|
||||||
"box-shadow", "text-shadow", "transform", "transform-origin",
|
"visibility",
|
||||||
|
"z-index",
|
||||||
|
"overflow-x",
|
||||||
|
"overflow-y",
|
||||||
|
"box-shadow",
|
||||||
|
"text-shadow",
|
||||||
|
"transform",
|
||||||
|
"transform-origin",
|
||||||
// List/Table
|
// List/Table
|
||||||
"list-style-type", "list-style-position", "border-collapse", "border-spacing",
|
"list-style-type",
|
||||||
|
"list-style-position",
|
||||||
|
"border-collapse",
|
||||||
|
"border-spacing",
|
||||||
// Cursor/pointer
|
// Cursor/pointer
|
||||||
"cursor", "pointer-events",
|
"cursor",
|
||||||
|
"pointer-events",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Cache of default computed values keyed by tagName
|
// Cache of default computed values keyed by tagName
|
||||||
const defaultCache = new Map<string, Map<string, string>>();
|
const defaultCache = new Map<string, Map<string, string>>();
|
||||||
const hiddenContainer = document.createElement("div");
|
const hiddenContainer = document.createElement("div");
|
||||||
hiddenContainer.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;top:-9999px;";
|
hiddenContainer.style.cssText =
|
||||||
|
"position:absolute;visibility:hidden;pointer-events:none;top:-9999px;";
|
||||||
document.body.appendChild(hiddenContainer);
|
document.body.appendChild(hiddenContainer);
|
||||||
|
|
||||||
function getTagDefaults(tag: string): Map<string, string> {
|
function getTagDefaults(tag: string): Map<string, string> {
|
||||||
@@ -781,7 +1005,9 @@ async function bakeComputedStyles(page: Page): Promise<void> {
|
|||||||
cache.set(prop, cs.getPropertyValue(prop));
|
cache.set(prop, cs.getPropertyValue(prop));
|
||||||
}
|
}
|
||||||
el.remove();
|
el.remove();
|
||||||
} catch { /* unknown tag, cache will be empty */ }
|
} catch {
|
||||||
|
/* unknown tag, cache will be empty */
|
||||||
|
}
|
||||||
defaultCache.set(tag, cache);
|
defaultCache.set(tag, cache);
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
@@ -789,7 +1015,8 @@ async function bakeComputedStyles(page: Page): Promise<void> {
|
|||||||
function shouldBake(value: string, defaultValue: string): boolean {
|
function shouldBake(value: string, defaultValue: string): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
// Skip browser keywords that cannot be serialized as inline style values
|
// Skip browser keywords that cannot be serialized as inline style values
|
||||||
if (value === "initial" || value === "inherit" || value === "unset" || value === "revert") return false;
|
if (value === "initial" || value === "inherit" || value === "unset" || value === "revert")
|
||||||
|
return false;
|
||||||
return value !== defaultValue;
|
return value !== defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,7 +1026,8 @@ async function bakeComputedStyles(page: Page): Promise<void> {
|
|||||||
if (el === hiddenContainer) continue;
|
if (el === hiddenContainer) continue;
|
||||||
// Skip non-visual tags
|
// Skip non-visual tags
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag)) continue;
|
if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag))
|
||||||
|
continue;
|
||||||
|
|
||||||
const computed = window.getComputedStyle(el);
|
const computed = window.getComputedStyle(el);
|
||||||
const defaults = getTagDefaults(tag);
|
const defaults = getTagDefaults(tag);
|
||||||
@@ -949,7 +1177,7 @@ async function inlineAllResources(page: Page, html: string): Promise<string> {
|
|||||||
|
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
linkRegex,
|
linkRegex,
|
||||||
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`
|
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const img of payload.images) {
|
for (const img of payload.images) {
|
||||||
@@ -1163,6 +1391,11 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
return json(getDebugData());
|
return json(getDebugData());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/banned
|
||||||
|
if (path === "/api/banned" && method === "GET") {
|
||||||
|
return json(dbGetBannedIPs());
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/views
|
// POST /api/views
|
||||||
if (path === "/api/views" && method === "POST") {
|
if (path === "/api/views" && method === "POST") {
|
||||||
try {
|
try {
|
||||||
@@ -1262,6 +1495,60 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/disconnect/:ip
|
||||||
|
const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/);
|
||||||
|
if (disconnectMatch && method === "POST") {
|
||||||
|
const ip = decodeURIComponent(disconnectMatch[1]);
|
||||||
|
devices.delete(ip);
|
||||||
|
console.log(`[admin] Disconnected device: ${ip}`);
|
||||||
|
return json({ ok: true, message: `Device ${ip} disconnected` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/ban/:ip
|
||||||
|
const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/);
|
||||||
|
if (banMatch && method === "POST") {
|
||||||
|
const ip = decodeURIComponent(banMatch[1]);
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(await req.text());
|
||||||
|
const reason = body.reason || "Banned by administrator";
|
||||||
|
const bannedDevice = devices.get(ip);
|
||||||
|
dbBanIP(ip, reason);
|
||||||
|
devices.delete(ip);
|
||||||
|
if (bannedDevice) cacheInvalidate(bannedDevice.viewId);
|
||||||
|
console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`);
|
||||||
|
return json({ ok: true, message: `IP ${ip} banned` });
|
||||||
|
} catch {
|
||||||
|
return json({ error: "Invalid JSON body" }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/unban/:ip
|
||||||
|
const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/);
|
||||||
|
if (unbanMatch && method === "POST") {
|
||||||
|
const ip = decodeURIComponent(unbanMatch[1]);
|
||||||
|
const success = dbUnbanIP(ip);
|
||||||
|
if (success) {
|
||||||
|
console.log(`[admin] Unbanned IP: ${ip}`);
|
||||||
|
return json({ ok: true, message: `IP ${ip} unbanned` });
|
||||||
|
} else {
|
||||||
|
return json({ error: "IP not found in ban list" }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/refresh/:ip
|
||||||
|
const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/);
|
||||||
|
if (refreshMatch && method === "POST") {
|
||||||
|
const ip = decodeURIComponent(refreshMatch[1]);
|
||||||
|
const device = devices.get(ip);
|
||||||
|
if (device) {
|
||||||
|
cacheInvalidate(device.viewId);
|
||||||
|
console.log(`[admin] Forced cache refresh for view: ${device.viewId}`);
|
||||||
|
return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` });
|
||||||
|
} else {
|
||||||
|
return json({ error: "Device not found" }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json({ error: "Not found" }, 404);
|
return json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,7 +1594,9 @@ const server = Bun.serve<{ interval: number }>({
|
|||||||
}, interval);
|
}, interval);
|
||||||
debugClients.set(ws, { interval, timer });
|
debugClients.set(ws, { interval, timer });
|
||||||
// Send initial payload immediately
|
// Send initial payload immediately
|
||||||
try { ws.send(JSON.stringify(getDebugData())); } catch {}
|
try {
|
||||||
|
ws.send(JSON.stringify(getDebugData()));
|
||||||
|
} catch {}
|
||||||
},
|
},
|
||||||
message(ws, msg) {
|
message(ws, msg) {
|
||||||
try {
|
try {
|
||||||
@@ -1319,15 +1608,21 @@ const server = Bun.serve<{ interval: number }>({
|
|||||||
clearInterval(client.timer);
|
clearInterval(client.timer);
|
||||||
client.interval = interval;
|
client.interval = interval;
|
||||||
client.timer = setInterval(() => {
|
client.timer = setInterval(() => {
|
||||||
try { ws.send(JSON.stringify(getDebugData())); } catch {
|
try {
|
||||||
|
ws.send(JSON.stringify(getDebugData()));
|
||||||
|
} catch {
|
||||||
clearInterval(client.timer);
|
clearInterval(client.timer);
|
||||||
debugClients.delete(ws);
|
debugClients.delete(ws);
|
||||||
}
|
}
|
||||||
}, interval);
|
}, interval);
|
||||||
// Push immediately after rate change
|
// Push immediately after rate change
|
||||||
try { ws.send(JSON.stringify(getDebugData())); } catch {}
|
try {
|
||||||
|
ws.send(JSON.stringify(getDebugData()));
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
} catch { /* ignore malformed messages */ }
|
} catch {
|
||||||
|
/* ignore malformed messages */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
const client = debugClients.get(ws);
|
const client = debugClients.get(ws);
|
||||||
@@ -1373,12 +1668,18 @@ const server = Bun.serve<{ interval: number }>({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceTrack(
|
const tracked = deviceTrack(
|
||||||
ip,
|
ip,
|
||||||
view.id,
|
view.id,
|
||||||
view.name,
|
view.name,
|
||||||
req.headers.get("User-Agent") ?? "unknown",
|
req.headers.get("User-Agent") ?? "unknown",
|
||||||
);
|
);
|
||||||
|
if (!tracked) {
|
||||||
|
return new Response(errorPage("Access denied"), {
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
return serveView(view);
|
return serveView(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import asyncio
|
||||||
|
from samsung_mdc import MDC
|
||||||
|
|
||||||
|
|
||||||
|
async def main(ip, display_id):
|
||||||
|
async with MDC(ip, verbose=True) as mdc:
|
||||||
|
# First argument of command is always display_id
|
||||||
|
status = await mdc.status(display_id)
|
||||||
|
print(status) # Result is always tuple
|
||||||
|
|
||||||
|
if status[0] != MDC.power.POWER_STATE.ON:
|
||||||
|
# Command arguments are always Sequence (tuple, list)
|
||||||
|
await mdc.power(display_id, [MDC.power.POWER_STATE.ON])
|
||||||
|
await mdc.close() # Force reconnect on next command
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
await mdc.display_id(display_id, [MDC.display_id.DISPLAY_ID_STATE.ON])
|
||||||
|
# You may also use names or values instead of enums
|
||||||
|
await mdc.display_id(display_id, ['ON']) # same
|
||||||
|
await mdc.display_id(display_id, [1]) # same
|
||||||
|
|
||||||
|
if True:
|
||||||
|
await mdc.power(display_id, [MDC.power.POWER_STATE.OFF])
|
||||||
|
await mdc.close() # Force reconnect on next command
|
||||||
|
|
||||||
|
|
||||||
|
# If you see "Connected" and timeout error, try other display_id (0, 1)
|
||||||
|
asyncio.run(main('10.184.27.132', 1))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
python-samsung-mdc
|
||||||
Reference in New Issue
Block a user