import fs from 'fs'; import http from 'http'; const STATUS_PORT = 8082; export function startStatusServer({ getState, WATER_BUTTON_MAC, REMOTE_SWITCH_MAC, setLight, persistedState, saveState }) { async function getButtonOnline() { try { const ctxGetState = global.__waterBotGetState; if (!ctxGetState) return null; const online = await ctxGetState(WATER_BUTTON_MAC, 'system', 'online'); // DB stores booleans as strings 'true'/'false' if (online === true || online === 'true') return true; if (online === false || online === 'false') return false; return null; } catch { return null; } } async function getValveOnline() { try { const ctxGetState = global.__waterBotGetState; if (!ctxGetState) return null; const online = await ctxGetState(REMOTE_SWITCH_MAC, 'system', 'online'); if (online === true || online === 'true') return true; if (online === false || online === 'false') return false; return null; } catch { return null; } } function getStatusJSON() { const state = getState(WATER_BUTTON_MAC); return { valveOpen: state.timer !== null || (!state.countMode && state.timer === null), timerActive: state.timer !== null, countMode: state.countMode, countStart: state.countStart || null, storedDuration: state.storedDuration, timerStartedAt: state.timerStartedAt, timerEndsAt: state.timerEndsAt, now: Date.now() }; } const statusPageHTML = ` Water Timer

💧 Water Timer

Button
Button Checking… Short press: run timer · Long press: start learn, short press to stop
Valve
Valve Checking… ⚠️ Connect according to flow direction mark
IDLE
Loading…
sec
`; // Prevent duplicate listeners across hot reloads if (global.__waterStatusServer) { try { global.__waterStatusServer.close(); } catch (e) { } } const srv = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); const jsonHeaders = { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*' }; if (url.pathname === '/style.css') { res.writeHead(200, { 'Content-Type': 'text/css' }); res.end(fs.readFileSync(new URL('./public/waterButtonStatus/style.css', import.meta.url))); return; } if (url.pathname === '/script.js') { res.writeHead(200, { 'Content-Type': 'application/javascript' }); res.end(fs.readFileSync(new URL('./public/waterButtonStatus/script.js', import.meta.url))); return; } if (url.pathname === '/taster.png') { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' }); res.end(fs.readFileSync(new URL('./public/waterButtonStatus/taster.png', import.meta.url))); return; } if (url.pathname === '/api/button-online') { const online = await getButtonOnline(); res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ online })); return; } if (url.pathname === '/valve.png') { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' }); res.end(fs.readFileSync(new URL('./public/waterButtonStatus/valve.png', import.meta.url))); return; } if (url.pathname === '/api/valve-online') { const online = await getValveOnline(); res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ online })); return; } if (url.pathname === '/api/status') { res.writeHead(200, jsonHeaders); res.end(JSON.stringify(getStatusJSON())); return; } // ── POST command endpoints ── if (req.method === 'POST' && url.pathname.startsWith('/api/')) { let body = ''; req.on('data', c => body += c); req.on('end', async () => { const sendRPC = global.__waterBotRPC; const state = getState(WATER_BUTTON_MAC); // ── Learn / Stop-learn: work regardless of device connection ── if (url.pathname === '/api/learn') { if (state.timer) { clearTimeout(state.timer); state.timer = null; } state.timerStartedAt = null; state.timerEndsAt = null; state.countMode = true; state.countStart = Date.now(); if (sendRPC) { try { await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); } catch (e) { console.warn('[WaterStatus] learn: setLight failed:', e.message); } } res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ ok: true, action: 'learn' })); return; } if (url.pathname === '/api/stop-learn') { if (!state.countMode) { res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ ok: true, action: 'stop-learn', skipped: true })); return; } const elapsed = Date.now() - (state.countStart || Date.now()); state.countMode = false; state.countStart = 0; state.storedDuration = elapsed; persistedState[WATER_BUTTON_MAC] = { storedDuration: elapsed }; saveState(persistedState); if (sendRPC) { try { await setLight(sendRPC, WATER_BUTTON_MAC, true, 95); } catch (e) { console.warn('[WaterStatus] stop-learn: setLight failed:', e.message); } } res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ ok: true, action: 'stop-learn', storedDuration: elapsed })); return; } // ── All other commands require a live device connection ── if (!sendRPC) { res.writeHead(503, jsonHeaders); res.end(JSON.stringify({ ok: false, error: 'Not ready — no device connection' })); return; } try { if (url.pathname === '/api/run') { if (state.timer) { clearTimeout(state.timer); state.timer = null; } state.countMode = false; await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); const duration = state.storedDuration; state.timerStartedAt = Date.now(); state.timerEndsAt = Date.now() + duration; state.timer = setTimeout(async () => { try { const rpc = global.__waterBotRPC; if (rpc) await setLight(rpc, WATER_BUTTON_MAC, true, 95); state.timer = null; state.timerStartedAt = null; state.timerEndsAt = null; } catch (err) { console.error('[WaterStatus] Timer callback error:', err); } }, duration); res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ ok: true, action: 'run', duration })); } else if (url.pathname === '/api/open') { if (state.timer) { clearTimeout(state.timer); state.timer = null; } state.countMode = false; state.timerStartedAt = null; state.timerEndsAt = null; await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ ok: true, action: 'open' })); } else if (url.pathname === '/api/close') { if (state.timer) { clearTimeout(state.timer); state.timer = null; } state.countMode = false; state.timerStartedAt = null; state.timerEndsAt = null; await setLight(sendRPC, WATER_BUTTON_MAC, true, 95); res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ ok: true, action: 'close' })); } else if (url.pathname === '/api/duration') { const data = JSON.parse(body || '{}'); const ms = Math.round((data.seconds || 0) * 1000); if (ms <= 0 || ms > 300000) { res.writeHead(400, jsonHeaders); res.end(JSON.stringify({ ok: false, error: 'Duration must be 1-300s' })); return; } state.storedDuration = ms; persistedState[WATER_BUTTON_MAC] = { storedDuration: ms }; saveState(persistedState); res.writeHead(200, jsonHeaders); res.end(JSON.stringify({ ok: true, action: 'duration', ms })); } else { res.writeHead(404, jsonHeaders); res.end(JSON.stringify({ ok: false, error: 'Unknown endpoint' })); } } catch (err) { console.error('[WaterStatus] API error:', err); res.writeHead(500, jsonHeaders); res.end(JSON.stringify({ ok: false, error: err.message })); } }); return; } // Serve HTML status page res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' }); res.end(statusPageHTML); }); srv.listen(STATUS_PORT, () => { console.log(`[WaterStatus] Status page at http://localhost:${STATUS_PORT}`); }); global.__waterStatusServer = srv; }