diff --git a/CHANGELOG.md b/CHANGELOG.md index 61851a1..147913d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,13 @@ ## Unreleased ### English +- **Screen on the board (kiosk):** new option in Settings → Farm (`KIOSK=1`). On the images (any systemd + apt system) the dashboard installs cage + Chromium the first time, in the background, and opens `?kiosk` full screen on the HDMI screen at every boot, with no desktop. New service `mineracao-kiosk`, created off by `install.sh`. The option is hidden where it can't work (Docker), and the settings show when it is installing, when it failed or when no screen is plugged in. - **New theme 🟡 Black & Yellow:** near-black background with exchange-style yellow, green for gains and red for alerts, in Settings → Farm → Visual theme (`THEME=gold`). Applies to the dashboard, kiosk mode and the admin screen. - **Worker details (last 24 h):** click a worker in the table to open a dialog with its last 24 hours through the proxy: accepted and rejected shares, best share and average hashrate, a chart of shares per hour (rejections in red), a chart of the best difficulty per hour (log scale, so the ESP32 fit too), the 10 best hashes with their time and the latest shares. The proxy keeps this per hour in its stats file (survives restarts) and serves it at `/stats/worker?name=`; the dashboard at `/api/worker?name=`. Not in kiosk mode. - **Two new achievements:** 🌋 **Trillion hash** (a share with difficulty over 1 trillion) and the 🏆 **Golden prize: block found!**, unlocked when a share passes the network difficulty. The golden prize sits on its own full-width row, shows its trophy even while locked and lights up in gold when won. ### Português +- **Tela ligada na placa (quiosque):** opção nova em Configurações → Fazenda (`KIOSK=1`). Nas imagens (qualquer sistema com systemd e apt), o painel instala o cage e o Chromium na primeira vez, em segundo plano, e abre o `?kiosk` em tela cheia na tela do HDMI em todo boot, sem área de trabalho. Serviço novo `mineracao-kiosk`, criado desligado pelo `install.sh`. A opção some onde não funciona (Docker), e as configurações mostram quando está instalando, quando deu erro ou quando não há tela ligada. - **Tema novo 🟡 Amarelo e Preto:** fundo quase preto com o amarelo de exchange, verde para ganhos e vermelho para alertas, em Configurações → Fazenda → Tema visual (`THEME=gold`). Vale no painel, no modo quiosque e na tela de administração. - **Detalhes do worker (últimas 24 h):** clique num worker da tabela para abrir um modal com as últimas 24 horas dele pelo proxy: shares aceitos e recusados, melhor share e hashrate médio, gráfico de shares por hora (recusados em vermelho), gráfico da melhor dificuldade por hora (escala logarítmica, para os ESP32 caberem), os 10 melhores hashes com a hora e os últimos shares. O proxy guarda isso por hora no arquivo de estatísticas (sobrevive ao reinício) e entrega em `/stats/worker?name=`; o painel, em `/api/worker?name=`. Não abre no modo quiosque. - **Duas conquistas novas:** 🌋 **Um trilhão** (um share com dificuldade acima de 1 trilhão) e o 🏆 **Prêmio de ouro: bloco achado!**, que desbloqueia quando um share passa da dificuldade da rede. O prêmio de ouro fica numa linha inteira só para ele, mostra o troféu mesmo trancado e acende em dourado quando sai. diff --git a/README.md b/README.md index fc14667..ea98b1d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Bitaxe / NerdMiner / cpuminer ──► proxy (port 3333, 3334, 3335…) ── - **Almost a block:** your best hash vs. the latest block hash, leading zeros side by side. The 🎟️ button turns today's best hash into a lottery-ticket image ("NOT A WINNER", until the day it is) to share or save. - **Luck:** each worker's best share compared with what its work should statistically produce (1× is normal, 2× is twice as lucky), a histogram of all share difficulties against the theoretical curve, the **Weekly Championship** (ranked by relative luck so an ESP32/devkit can beat an ASIC), and **Farm Achievements**. - For a screen attached to the server, use `?kiosk`: screens rotate on their own and the cursor is hidden. + For a screen attached to the server, use `?kiosk`: screens rotate on their own and the cursor is hidden. On the ready-made images (or any Debian/Armbian install with systemd), ⚙️ Settings → Farm → **Screen on this board (kiosk)** (`KIOSK=1`) does it for you: the first time it installs a minimal full-screen browser (cage + Chromium, about 300 MB, a few minutes online) and from then on opens `?kiosk` on the HDMI screen at every boot, in place of the text login (Ctrl+Alt+F2 still gives you a console). Turning it off gives the screen back. - **Optional extras:** - **Telegram bot:** alerts on your phone for farm records, stopped workers, pool failover and money arriving in the wallet (a found block!), a daily summary, and commands such as `/status` and `/luck`. See [Telegram bot](#telegram-bot-optional). - **Siren:** an Arduino with LEDs and a speaker, see `extras/sirene`. diff --git a/README.pt-BR.md b/README.pt-BR.md index 7739518..fa32534 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -58,7 +58,7 @@ Bitaxe / NerdMiner / cpuminer ──► proxy (porta 3333, 3334, 3335…) ── - **Quase bloco:** nosso melhor hash × hash do último bloco, com os zeros lado a lado. O botão 🎟️ transforma o melhor hash do dia num bilhete de loteria ("NÃO PREMIADO", até o dia em que for) para compartilhar ou salvar. - **Sorte:** o melhor share de cada worker comparado com o que o trabalho dele deveria render pela estatística (1× é o normal, 2× é o dobro de sorte), um histograma das dificuldades de todos os shares contra a curva teórica, o **Campeonato da semana** (ordenado por sorte relativa para o vovô devkit disputar de igual para igual com um ASIC) e as **Conquistas da fazenda**. - Para uma tela ligada ao servidor, use `?kiosk`: as telas alternam sozinhas e o cursor some. + Para uma tela ligada ao servidor, use `?kiosk`: as telas alternam sozinhas e o cursor some. Nas imagens prontas (ou em qualquer Debian/Armbian com systemd), ⚙️ Configurações → Fazenda → **Tela ligada nesta placa (quiosque)** (`KIOSK=1`) faz isso sozinho: na primeira vez instala um navegador mínimo em tela cheia (cage + Chromium, uns 300 MB, alguns minutos com internet) e daí em diante abre o `?kiosk` na tela do HDMI em todo boot, no lugar do login de texto (Ctrl+Alt+F2 ainda dá um terminal). Desligar devolve a tela. - **Extras opcionais:** - **Bot do Telegram:** avisos no celular de recorde da fazenda, worker parado, failover de pool e dinheiro entrando na carteira (um bloco achado!), resumo diário e comandos como `/status` e `/sorte`. Veja [Bot do Telegram](#bot-do-telegram-opcional). - **Sirene:** Arduino com LEDs e alto-falante, ver `extras/sirene`. diff --git a/config/mineracao.env.example b/config/mineracao.env.example index 6c3c6b6..a996788 100644 --- a/config/mineracao.env.example +++ b/config/mineracao.env.example @@ -147,3 +147,9 @@ PORT=3000 # Hide the "Support the project" (donations) item in the dashboard menu #SHOW_DONATE=0 + +# Screen plugged into this board's HDMI: open the dashboard full screen on boot (kiosk, no desktop). +# The first time, the dashboard installs cage + Chromium with apt (about 300 MB). Also in Settings → Farm. +#KIOSK=1 +# Extra Chromium options for the kiosk, e.g. if the screen flickers or stays black: +#KIOSK_FLAGS=--disable-gpu diff --git a/install.sh b/install.sh index b1768bb..b4814eb 100755 --- a/install.sh +++ b/install.sh @@ -22,7 +22,7 @@ NODE_MAJOR=$("$NODE_BIN" -p 'process.versions.node.split(".")[0]') m "==> Copiando os arquivos para $APP_DIR" "==> Copying files to $APP_DIR" mkdir -p "$APP_DIR" -cp "$HERE"/src/painel.js "$HERE"/src/proxy.js "$HERE"/src/stratum.js "$HERE"/src/i18n.js "$HERE"/src/luck.js "$HERE"/src/telegram.js "$HERE"/src/mqtt.js "$HERE"/src/tuya.js "$HERE"/src/sonoff.js "$HERE"/src/settings.js "$HERE"/src/setup.js "$HERE"/src/update.js "$HERE"/src/localminer.js "$HERE"/src/netctl.js "$HERE"/src/clock.js "$HERE"/src/bitaxe.js "$HERE"/src/store.js "$HERE"/src/discover.js "$HERE"/src/hotspot.js "$HERE"/src/cubo.py "$HERE"/src/ticket.py "$APP_DIR/" +cp "$HERE"/src/painel.js "$HERE"/src/proxy.js "$HERE"/src/stratum.js "$HERE"/src/i18n.js "$HERE"/src/luck.js "$HERE"/src/telegram.js "$HERE"/src/mqtt.js "$HERE"/src/tuya.js "$HERE"/src/sonoff.js "$HERE"/src/settings.js "$HERE"/src/setup.js "$HERE"/src/update.js "$HERE"/src/localminer.js "$HERE"/src/netctl.js "$HERE"/src/clock.js "$HERE"/src/bitaxe.js "$HERE"/src/store.js "$HERE"/src/discover.js "$HERE"/src/hotspot.js "$HERE"/src/kiosk.js "$HERE"/src/kiosk.sh "$HERE"/src/cubo.py "$HERE"/src/ticket.py "$APP_DIR/" m "==> Configuração em $ENV_FILE" "==> Configuration in $ENV_FILE" if [ ! -f "$ENV_FILE" ]; then @@ -110,6 +110,38 @@ if command -v nmcli >/dev/null; then services="$services mineracao-hotspot" fi +# Tela ligada na placa (opção KIOSK): serviço criado desligado. Quem liga é o painel, que instala o cage e o +# Chromium pelo apt na primeira vez. Abre o painel em tela cheia na tela 1 (tty1) no lugar do login de texto. +chmod 755 "$APP_DIR/kiosk.sh" +cat > /etc/systemd/system/mineracao-kiosk.service < process.env.KIOSK_UNIT_FILE || `/etc/systemd/system/${UNIT}.service`; +const PACKAGES = ['cage', 'chromium', 'fonts-noto-color-emoji']; +const CAGE = ['/usr/bin/cage', '/usr/local/bin/cage']; +const CHROMIUM = ['/usr/bin/chromium', '/usr/bin/chromium-browser']; +const which = (list) => list.find((b) => fs.existsSync(b)) || null; + +// Dá para ligar por aqui? Precisa do serviço criado pelo install.sh (ou seja, systemd) e do apt para instalar. +function available() { + return fs.existsSync(UNIT_FILE()) && (installed() || fs.existsSync(process.env.KIOSK_APT || '/usr/bin/apt-get')); +} +const installed = () => !!which(CAGE) && !!which(CHROMIUM); + +// Tela ligada? O kernel diz em /sys/class/drm/card*-*/status ("connected"). Sem a pasta, não dá para saber. +function screen(dir = process.env.KIOSK_DRM_DIR || '/sys/class/drm') { + let names; + try { names = fs.readdirSync(dir); } catch (_) { return null; } + const outs = names.filter((n) => /^card\d+-/.test(n)); + if (!outs.length) return null; + return outs.some((n) => { try { return fs.readFileSync(path.join(dir, n, 'status'), 'utf8').trim() === 'connected'; } catch (_) { return false; } }); +} + +function run(cmd, args, timeout = 20000) { + return new Promise((resolve) => { + execFile(cmd, args, { timeout }, (err, out, errOut) => resolve({ ok: !err, out: String(out || '') + String(errOut || '') })); + }); +} +const systemctl = (args) => run(process.env.KIOSK_SYSTEMCTL || 'systemctl', args); + +// Estado da instalação em segundo plano (para a tela de configurações mostrar) +const stateFile = (dataDir) => path.join(dataDir, 'kiosk.json'); +function readState(dataDir) { try { return JSON.parse(fs.readFileSync(stateFile(dataDir), 'utf8')); } catch (_) { return {}; } } +function writeState(dataDir, s) { try { fs.writeFileSync(stateFile(dataDir), JSON.stringify(s)); } catch (_) { /* segue */ } } + +// Instala os pacotes sem travar o painel; quando terminar, liga o serviço. Um log fica em DATA_DIR/kiosk-install.log. +function installThenStart(dataDir) { + const st = readState(dataDir); + if (st.installing && Date.now() - st.installing < 30 * 60e3) return Promise.resolve({ installing: true }); + writeState(dataDir, { installing: Date.now(), error: null }); + const log = fs.openSync(path.join(dataDir, 'kiosk-install.log'), 'w'); + const apt = process.env.KIOSK_APT || 'apt-get'; + const sh = `${apt} update -q && DEBIAN_FRONTEND=noninteractive ${apt} install -y -q --no-install-recommends ${PACKAGES.join(' ')}`; + return new Promise((resolve) => { + const child = spawn('/bin/sh', ['-c', sh], { stdio: ['ignore', log, log] }); + child.on('error', (e) => { writeState(dataDir, { installing: null, error: e.message }); resolve({ ok: false }); }); + child.on('close', async (code) => { + fs.closeSync(log); + if (code !== 0 || !installed()) { writeState(dataDir, { installing: null, error: 'apt ' + code }); return resolve({ ok: false }); } + writeState(dataDir, { installing: null, error: null, installedAt: Date.now() }); + await systemctl(['enable', '--now', UNIT]); + resolve({ ok: true }); + }); + }); +} + +// Aplica a opção ao subir o painel +async function sync({ enabled, dataDir }) { + if (!fs.existsSync(UNIT_FILE())) return { ok: false, reason: 'nounit' }; + if (enabled) { + if (!installed()) { installThenStart(dataDir); return { ok: true, installing: true }; } + await systemctl(['enable', UNIT]); + const active = await systemctl(['is-active', '--quiet', UNIT]); + if (!active.ok) await systemctl(['start', UNIT]); + return { ok: true }; + } + const active = await systemctl(['is-active', '--quiet', UNIT]); + const en = await systemctl(['is-enabled', '--quiet', UNIT]); + if (active.ok || en.ok) await systemctl(['disable', '--now', UNIT]); + return { ok: true, off: true }; +} + +function status(dataDir) { + const st = readState(dataDir); + return { available: available(), installed: installed(), screen: screen(), installing: !!(st.installing && Date.now() - st.installing < 30 * 60e3), error: st.error || null }; +} + +module.exports = { UNIT, PACKAGES, available, installed, screen, sync, status }; diff --git a/src/kiosk.sh b/src/kiosk.sh new file mode 100644 index 0000000..a39dd83 --- /dev/null +++ b/src/kiosk.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Tela ligada na placa (opção KIOSK, serviço mineracao-kiosk): o painel em tela cheia, sem área de trabalho. +# KIOSK_FLAGS (no .env) acrescenta opções ao Chromium, ex.: KIOSK_FLAGS=--disable-gpu se a tela piscar ou ficar preta. +# Espera o painel responder, depois abre o Chromium dentro do cage (quiosque Wayland mínimo). +PORT=${PORT:-3000} +URL="http://127.0.0.1:${PORT}/?kiosk" +i=0 +while [ $i -lt 90 ]; do + if command -v node >/dev/null && node -e "require('http').get('http://127.0.0.1:${PORT}/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))" 2>/dev/null; then break; fi + i=$((i + 1)); sleep 2 +done +CHROME=$(command -v chromium || command -v chromium-browser) +PROFILE=${KIOSK_PROFILE:-/var/lib/mineracao-kiosk} +mkdir -p "$PROFILE" +# perfil limpo a cada boot evita o aviso "o Chromium não foi fechado corretamente" +rm -f "$PROFILE/SingletonLock" "$PROFILE/SingletonSocket" "$PROFILE/SingletonCookie" +sed -i 's/"exited_cleanly":false/"exited_cleanly":true/; s/"exit_type":"Crashed"/"exit_type":"Normal"/' "$PROFILE/Default/Preferences" 2>/dev/null || true +exec cage -s -- "$CHROME" --no-sandbox --kiosk --noerrdialogs --disable-infobars --no-first-run \ + --password-store=basic --disable-features=Translate,MediaRouter --check-for-update-interval=31536000 \ + --autoplay-policy=no-user-gesture-required --disable-pinch --overscroll-history-navigation=0 \ + --ozone-platform=wayland --user-data-dir="$PROFILE" ${KIOSK_FLAGS:-} "$URL" diff --git a/src/painel.js b/src/painel.js index e746ae0..8155ec5 100644 --- a/src/painel.js +++ b/src/painel.js @@ -2875,6 +2875,8 @@ function start(port = PORT, host = '0.0.0.0') { enabled: process.env.LOCAL_MINER === '1', address: ADDRESS, worker: process.env.LOCAL_WORKER, threads: process.env.MINER_THREADS, appDir: process.env.DATA_DIR || __dirname, }).then((w) => { if (w) LOCAL_WORKER = w; }).catch(() => {}); + // tela ligada na placa (opção KIOSK): instala o navegador na primeira vez e liga/desliga o serviço + require('./kiosk').sync({ enabled: process.env.KIOSK === '1', dataDir: process.env.DATA_DIR || __dirname }).catch(() => {}); const tgBot = startTelegram(); const mqttClient = startMqtt(); resolve({ diff --git a/src/settings.js b/src/settings.js index a1bf537..1ef477c 100644 --- a/src/settings.js +++ b/src/settings.js @@ -38,6 +38,7 @@ const FIELDS = [ { key: 'POWER_PRICE_KWH', group: 'farm', type: 'price', alias: 'TARIFA_KWH' }, { key: 'SHOW_WALLET', group: 'farm', type: 'bool', unset: '1' }, { key: 'SHOW_DONATE', group: 'farm', type: 'bool', unset: '1' }, + { key: 'KIOSK', group: 'farm', type: 'bool' }, { key: 'LOCAL_WORKER', group: 'miners', type: 'worker' }, { key: 'LOCAL_MINER', group: 'miners', type: 'bool' }, { key: 'MINER_THREADS', group: 'miners', type: 'threads' }, @@ -558,6 +559,8 @@ const T = { TEMP_UNIT: ['Unidade de temperatura', 'Celsius ou Fahrenheit no painel, no Telegram e nos limites de alerta dos Bitaxe. O Home Assistant converte sozinho.'], POWER_PRICE_KWH: ['Preço do kWh', 'Na moeda escolhida, para a estimativa da conta de luz.'], SHOW_DONATE: ['Mostrar "Apoiar o projeto"', 'O item ❤️ no fim do menu do painel, com o endereço para doar ao HomeMinerProxy. Nunca aparece no modo quiosque.'], + KIOSK: ['Tela ligada nesta placa (quiosque)', 'Abre o painel em tela cheia, sozinho, numa tela ligada no HDMI desta placa (sem área de trabalho). Na primeira vez instala o navegador (uns 300 MB, alguns minutos com internet); depois liga em cada boot. Desligar devolve a tela ao login de texto.'], + kiosk: { installing: '⏳ Instalando o navegador (alguns minutos). A tela liga sozinha quando terminar.', error: '⚠️ Não consegui instalar o navegador; veja kiosk-install.log na pasta de dados', noscreen: 'Nenhuma tela detectada no HDMI agora. Ligue a tela e reinicie a placa.' }, SHOW_WALLET: ['Mostrar a carteira', 'Saldo, valor e movimentação na tela da fazenda. Desligue se a tela fica à vista de visitas ou se o painel é aberto de fora de casa. A cotação continua aparecendo, e o aviso de dinheiro entrando continua chegando no Telegram.'], BITAXES: ['Bitaxe (API do AxeOS)', 'nome=IP de cada Bitaxe, separados por vírgula (ex.: bitaxe1=192.168.0.140, bitaxe2=192.168.0.141). O painel lê hashrate, temperatura, consumo e fan direto do aparelho. Use o mesmo nome do worker (o que vem depois do ponto no usuário da pool) e reserve um IP fixo no roteador.'], BITAXE_TEMP_MAX: ['Bitaxe: limite do chip', 'Alerta no Telegram e no MQTT quando o chip passa disso em 2 leituras seguidas, na unidade escolhida na aba Fazenda. Vazio = {def}.'], @@ -667,6 +670,8 @@ const T = { TEMP_UNIT: ['Temperature unit', 'Celsius or Fahrenheit on the dashboard, on Telegram and in the Bitaxe alert limits. Home Assistant converts on its own.'], POWER_PRICE_KWH: ['Price per kWh', 'In the chosen currency, for the power bill estimate.'], SHOW_DONATE: ['Show "Support the project"', 'The ❤️ item at the end of the dashboard menu, with the address to donate to HomeMinerProxy. Never shown in kiosk mode.'], + KIOSK: ['Screen on this board (kiosk)', 'Opens the dashboard full screen on its own on a screen plugged into this board\'s HDMI (no desktop). The first time it installs the browser (about 300 MB, a few minutes online); after that it starts on every boot. Turning it off gives the screen back to the text login.'], + kiosk: { installing: '⏳ Installing the browser (a few minutes). The screen turns on by itself when it is done.', error: '⚠️ Could not install the browser; see kiosk-install.log in the data folder', noscreen: 'No screen detected on HDMI right now. Plug it in and restart the board.' }, SHOW_WALLET: ['Show the wallet', 'Balance, value and activity on the farm screen. Turn it off if the screen is in view of visitors or the dashboard is opened from outside your home. The BTC price still shows, and Telegram still alerts when money arrives.'], BITAXES: ['Bitaxe (AxeOS API)', 'name=IP of each Bitaxe, comma separated (e.g. bitaxe1=192.168.0.140, bitaxe2=192.168.0.141). The dashboard reads hashrate, temperature, power and fan straight from the device. Use the worker name (what comes after the dot in the pool user) and reserve a fixed IP in your router.'], BITAXE_TEMP_MAX: ['Bitaxe: chip limit', 'Alert on Telegram and MQTT when the chip goes over this in 2 reads in a row, in the unit chosen in the Farm tab. Empty = {def}.'], @@ -773,7 +778,7 @@ function page(lang, hasPin, theme) { : f.key === 'MQTT_URL' ? ` ` : f.key === 'TUYA_DEVICE_ID' ? ` ` : f.key === 'BITAXES' ? `
` : ''; - const half = ['BITAXE_TEMP_MAX', 'BITAXE_VR_MAX', 'BITAXE_HASH_MIN', 'LOCAL_MINER', 'MINER_THREADS', 'LANGUAGE', 'CURRENCY', 'POWER_PRICE_KWH', 'SHOW_WALLET', 'SHOW_DONATE', 'TELEGRAM_DAILY', 'TELEGRAM_PERSONAL', 'TELEGRAM_BACKUP', 'TUYA_REGION', 'TUYA_CLIENT_ID', 'MQTT_USER', 'MQTT_PASS'].includes(f.key) ? ' half' : ''; + const half = ['BITAXE_TEMP_MAX', 'BITAXE_VR_MAX', 'BITAXE_HASH_MIN', 'LOCAL_MINER', 'MINER_THREADS', 'LANGUAGE', 'CURRENCY', 'POWER_PRICE_KWH', 'SHOW_WALLET', 'SHOW_DONATE', 'KIOSK', 'TELEGRAM_DAILY', 'TELEGRAM_PERSONAL', 'TELEGRAM_BACKUP', 'TUYA_REGION', 'TUYA_CLIENT_ID', 'MQTT_USER', 'MQTT_PASS'].includes(f.key) ? ' half' : ''; const hideLegacy = ['TUYA_DEVICE_ID', 'TUYA_WORKER', 'TUYA_WATCHDOG_MIN'].includes(f.key) ? ' hidden' : ''; // .fl = rótulo + ajuda (à esquerda no computador), .fc = o campo (à direita) const tdef = f.temp ? ({ BITAXE_TEMP_MAX: 68, BITAXE_VR_MAX: 95 }[f.key]) : null; @@ -822,6 +827,7 @@ function page(lang, hasPin, theme) { `
…
` + `
`; const msgs = { + ks: S.kiosk, saved: S.saved, back2: S.back2, nothing: S.nothing, cubeOk: S.cubeOk, cubeFail: S.cubeFail, tgOk: S.tgOk, tgFail: S.tgFail('%s'), mqttOk: S.mqttOk, mqttFail: S.mqttFail('%s'), tuyaOk: S.tuyaOk('%s'), tuyaFail: S.tuyaFail('%s'), tokenSet: S.tokenSet('%s'), @@ -1323,6 +1329,13 @@ fetch('/api/config').then((r) => r.json()).then((c) => { const lm = c.localMiner || {}; for (const k of ['LOCAL_MINER', 'MINER_THREADS']) { const f = form.querySelector('.f[data-key="' + k + '"]'); if (f) f.hidden = !lm.available; } const th = el('MINER_THREADS'); if (th && lm.cores) th.placeholder = lm.defaultThreads + ' / ' + lm.cores; + // tela na placa: só onde dá (systemd + apt); diz se está instalando, se deu erro ou se não achou a tela + const ks = c.kiosk || {}, kf = form.querySelector('.f[data-key="KIOSK"]'); + if (kf) { + kf.hidden = !ks.available; + let note = kf.querySelector('.kiosk-note'); if (!note) { note = document.createElement('div'); note.className = 'hint kiosk-note'; kf.appendChild(note); } + note.textContent = ks.installing ? M.ks.installing : ks.error ? M.ks.error + ' (' + ks.error + ')' : ks.screen === false ? M.ks.noscreen : ''; + } updateTabDots(); }); @@ -1905,7 +1918,7 @@ function handle(req, res, ctx) { } if (url === '/api/config' && req.method === 'GET') { const lm = require('./localminer'); - json(200, { values: current(), workers: ctx.workers(), pin: !!pinEnv, localMiner: { available: lm.available(), cores: lm.cores(), defaultThreads: lm.defaultThreads() } }); + json(200, { values: current(), workers: ctx.workers(), pin: !!pinEnv, localMiner: { available: lm.available(), cores: lm.cores(), defaultThreads: lm.defaultThreads() }, kiosk: require('./kiosk').status(process.env.DATA_DIR || __dirname) }); return true; } if (url === '/api/config/bitaxes' && req.method === 'GET') { diff --git a/tests/kiosk.test.js b/tests/kiosk.test.js new file mode 100644 index 0000000..1c4f7d0 --- /dev/null +++ b/tests/kiosk.test.js @@ -0,0 +1,64 @@ +// Tela ligada na placa (KIOSK): com systemctl e apt falsos. +const test = require('node:test'); +const assert = require('node:assert'); +const os = require('os'); +const fs = require('fs'); +const path = require('path'); + +function sandbox(t, { unit = true, apt = 0 } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ks-')); + const log = path.join(dir, 'calls.log'); + const sc = path.join(dir, 'systemctl'), ap = path.join(dir, 'apt-get'); + fs.writeFileSync(sc, `#!/bin/sh\necho "systemctl $*" >> "${log}"\ncase "$*" in "is-active --quiet"*|"is-enabled --quiet"*) [ -f "${dir}/on" ] || exit 3 ;; "enable --now"*|"start"*) touch "${dir}/on" ;; "disable --now"*) rm -f "${dir}/on" ;; esac\n`, { mode: 0o755 }); + fs.writeFileSync(ap, `#!/bin/sh\necho "apt $*" >> "${log}"\nexit ${apt}\n`, { mode: 0o755 }); + const unitFile = path.join(dir, 'mineracao-kiosk.service'); + if (unit) fs.writeFileSync(unitFile, '[Unit]\n'); + const drm = path.join(dir, 'drm'); fs.mkdirSync(path.join(drm, 'card0-HDMI-A-1'), { recursive: true }); + fs.writeFileSync(path.join(drm, 'card0-HDMI-A-1', 'status'), 'connected\n'); + const prev = {}; + const env = { KIOSK_UNIT_FILE: unitFile, KIOSK_SYSTEMCTL: sc, KIOSK_APT: ap, KIOSK_DRM_DIR: drm }; + for (const [k, v] of Object.entries(env)) { prev[k] = process.env[k]; process.env[k] = v; } + delete require.cache[require.resolve('../src/kiosk')]; + t.after(() => { for (const [k, v] of Object.entries(prev)) { if (v == null) delete process.env[k]; else process.env[k] = v; } fs.rmSync(dir, { recursive: true, force: true }); }); + const calls = () => { try { return fs.readFileSync(log, 'utf8').trim().split('\n'); } catch (_) { return []; } }; + return { ks: require('../src/kiosk'), calls, dir, drm }; +} +const wait = async (cond) => { for (let i = 0; i < 100 && !cond(); i++) await new Promise((r) => setTimeout(r, 30)); }; + +test('sem o serviço (Docker): não aparece e não mexe em nada', async (t) => { + const { ks, calls, dir } = sandbox(t, { unit: false }); + assert.strictEqual(ks.available(), false); + assert.strictEqual((await ks.sync({ enabled: true, dataDir: dir })).reason, 'nounit'); + assert.deepStrictEqual(calls(), []); +}); + +test('ligar sem o navegador: instala pelo apt em segundo plano e mostra "instalando"', async (t) => { + const { ks, calls, dir } = sandbox(t); + if (ks.installed()) return t.skip('cage e chromium já instalados nesta máquina'); + assert.strictEqual(ks.available(), true); + const r = await ks.sync({ enabled: true, dataDir: dir }); + assert.strictEqual(r.installing, true); + assert.strictEqual(ks.status(dir).installing, true); + await wait(() => calls().some((c) => c.includes('install'))); + await wait(() => !ks.status(dir).installing); + assert.ok(calls().some((c) => /^apt install -y -q --no-install-recommends cage chromium/.test(c))); + // o apt "terminou" mas o cage não apareceu (apt falso): fica o erro e o serviço não liga + assert.ok(ks.status(dir).error); + assert.ok(!calls().some((c) => c.startsWith('systemctl enable'))); +}); + +test('desligar: desliga o serviço se estava ligado', async (t) => { + const { ks, calls, dir } = sandbox(t); + fs.writeFileSync(path.join(dir, 'on'), ''); + await ks.sync({ enabled: false, dataDir: dir }); + assert.ok(calls().includes('systemctl disable --now mineracao-kiosk')); + assert.ok(!fs.existsSync(path.join(dir, 'on'))); +}); + +test('tela no HDMI: conectada, desconectada ou sem como saber', (t) => { + const { ks, drm } = sandbox(t); + assert.strictEqual(ks.screen(), true); + fs.writeFileSync(path.join(drm, 'card0-HDMI-A-1', 'status'), 'disconnected\n'); + assert.strictEqual(ks.screen(), false); + assert.strictEqual(ks.screen(path.join(drm, 'nada')), null); +});