From f45e05ab8158885c2155eb9d63cd405f17f45b2f Mon Sep 17 00:00:00 2001 From: julio Date: Tue, 6 Oct 2026 02:24:33 -0300 Subject: [PATCH] feat(ao-vivo): avisa bateria baixa e mostra o ultimo sinal do celular - aviso no painel quando a bateria do celular chega a 15% e a 5% sem carregador (uma vez cada; volta a valer depois de carregar) - selo do celular fica vermelho com bateria baixa - celular offline mostra ha quanto tempo foi o ultimo sinal e com quanta bateria; abaixo de 10% sugere que a bateria acabou Co-Authored-By: Claude Opus 5.5 --- tests/test_painel_web_browser.py | 7 ++++++ web/src/App.svelte | 8 ++---- web/src/lib/componentes/Celular.svelte | 35 ++++++++++++++++++++------ web/src/lib/estado.svelte.ts | 34 ++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/tests/test_painel_web_browser.py b/tests/test_painel_web_browser.py index 7b52275..6b05d78 100644 --- a/tests/test_painel_web_browser.py +++ b/tests/test_painel_web_browser.py @@ -82,6 +82,13 @@ def testar(base, server, capturas): expect(page.get_by_text('samsung SM-G973F')).to_be_visible() expect(page.get_by_text('192.168.1.16')).to_be_visible() + # 2b. bateria baixa sem carregador vira aviso na hora + page.request.post(base + '/app/status', headers=auth, data={'bateria': {'nivel': 12, 'carregando': False}}) + expect(page.get_by_text('Bateria do celular em 12%. Ligue o carregador')).to_be_visible(timeout=5000) + page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8, + 'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12', + 'app': '0.8.1', 'wifi': 3, 'rssi': -58}}) + # 3. gesto pelo dock kiosk.FILA.limpar() page.get_by_role('button', name='Feliz').first.click() diff --git a/web/src/App.svelte b/web/src/App.svelte index 761e0cd..bb120f0 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -104,11 +104,7 @@ const conexaoTexto = $derived( { conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao], ); - const bateria = $derived.by(() => { - const b = estado.app?.estado?.bateria; - const n = typeof b === 'number' ? b : b?.nivel; - return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null; - }); + const bateria = $derived(estado.bateria.nivel); @@ -147,7 +143,7 @@ {conexaoTexto} - + Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''} {#if estado.conversa} diff --git a/web/src/lib/componentes/Celular.svelte b/web/src/lib/componentes/Celular.svelte index 43cd9f4..30054d3 100644 --- a/web/src/lib/componentes/Celular.svelte +++ b/web/src/lib/componentes/Celular.svelte @@ -5,15 +5,19 @@ // Cartão do aparelho com o que o próprio app informa (IP, modelo, Android, versão, Wi-Fi, bateria). const s = $derived(estado.app?.estado ?? {}); const ap = $derived(s.aparelho ?? null); - const bateria = $derived.by(() => { - const b = s.bateria; - const n = typeof b === 'number' ? b : b?.nivel; - return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null; + const bateria = $derived(estado.bateria.nivel); + const carregando = $derived(estado.bateria.carregando); + // Tempo desde o último sinal, em texto curto ("há 3 min"). + const semSinal = $derived.by(() => { + if (!estado.ultimoSinalEm) return ''; + const seg = Math.max(0, Math.round((estado.agora - estado.ultimoSinalEm) / 1000)); + if (seg < 60) return `há ${seg} s`; + if (seg < 3600) return `há ${Math.round(seg / 60)} min`; + if (seg < 86400) return `há ${Math.round(seg / 3600)} h`; + return `há ${Math.round(seg / 86400)} dia(s)`; }); - const carregando = $derived(typeof s.bateria === 'object' && !!s.bateria?.carregando); const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull); const ip = $derived(ap?.ip || estado.app?.celular_ip || '—'); - const visto = $derived(estado.app?.idade ?? null);
@@ -25,7 +29,7 @@ {#if estado.celularOnline} {[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'} {:else} - offline{visto !== null ? ` · visto há ${Math.round(visto)} s` : ''} + offline{semSinal ? ' · último sinal ' + semSinal : ''} {/if}
@@ -70,6 +74,12 @@ {:else} + {#if bateria !== null && estado.ultimoSinalEm} +

+ Último sinal com {bateria}%{carregando ? ', carregando' : ''}. + {#if bateria <= 10 && !carregando}Provavelmente a bateria acabou: ligue o carregador e abra o app.{/if} +

+ {/if}

Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).

{/if} @@ -170,6 +180,17 @@ rect.cheia { fill: var(--texto); } + .ultimo { + display: flex; + align-items: center; + gap: var(--e-2); + flex-wrap: wrap; + font-size: var(--t-sm); + font-weight: 500; + } + .ultimo.baixa { + color: var(--perigo); + } .ajuda { font-size: var(--t-sm); color: var(--texto-3); diff --git a/web/src/lib/estado.svelte.ts b/web/src/lib/estado.svelte.ts index 8903046..e1ab0cf 100644 --- a/web/src/lib/estado.svelte.ts +++ b/web/src/lib/estado.svelte.ts @@ -3,6 +3,7 @@ * Nenhuma tela faz polling; todas leem daqui. */ import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; +import { avisos } from './avisos.svelte'; import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; @@ -37,6 +38,18 @@ class Estado { agora = $state(Date.now()); celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000); + /** Quando o celular mandou o último sinal (0 = nunca, nesta sessão nem no servidor). */ + ultimoSinalEm = $state(0); + /** Bateria do último sinal recebido, mesmo com o celular offline. */ + bateria = $derived.by(() => { + const b = this.app?.estado?.bateria; + const n = typeof b === 'number' ? b : b?.nivel; + return { + nivel: typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null, + carregando: typeof b === 'object' && !!b?.carregando, + }; + }); + bateriaBaixa = $derived(this.bateria.nivel !== null && this.bateria.nivel <= 15 && !this.bateria.carregando); } export const estado = new Estado(); @@ -51,6 +64,23 @@ export function ouvir(tipo: string, fn: Ouvinte): () => void { return () => ouvintes.get(tipo)!.delete(fn); } +// Avisa uma vez em 15% e outra em 5% (sem carregador); zera quando volta a carregar. +let avisouEm: number | null = null; +function avisarBateria() { + const { nivel, carregando } = estado.bateria; + if (nivel === null) return; + if (carregando || nivel > 20) { + avisouEm = null; + return; + } + const limite = nivel <= 5 ? 5 : nivel <= 15 ? 15 : null; + if (limite !== null && avisouEm !== limite) { + avisouEm = limite; + avisos.mostrar(`Bateria do celular em ${nivel}%. Ligue o carregador ou o Carinha vai desligar.`, 'erro'); + noFeed('estop', `Bateria do celular em ${nivel}%`); + } +} + let seqFeed = 0; function noFeed(tipo: string, texto: string) { estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40); @@ -67,6 +97,7 @@ function aplicarFoto(f: EstadoServidor) { if (f.app) { estado.app = f.app; estado.appRecebidoEm = Date.now(); + estado.ultimoSinalEm = typeof f.app.idade === 'number' ? Date.now() - f.app.idade * 1000 : 0; } if (f.app_config) estado.appConfig = f.app_config; if (f.motores) estado.motores = f.motores; @@ -83,7 +114,8 @@ const tratadores: Record void> = { }, telemetria: (d: Telemetria) => { estado.app = d; - estado.appRecebidoEm = Date.now(); + estado.appRecebidoEm = estado.ultimoSinalEm = Date.now(); + avisarBateria(); }, app_config: (d: AppConfig) => (estado.appConfig = d), aparencia: (d) => (estado.aparencia = d),