From dd40c730be7ea229e53bcf92f05c57dd154bc0ac Mon Sep 17 00:00:00 2001 From: julio Date: Tue, 6 Oct 2026 04:48:11 -0300 Subject: [PATCH] feat(sono): madrugada silenciosa e N sons para acordar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes um único som acima do limiar, um quadro de movimento ou um pulso do PIR acordavam o rosto; deitado na cama, ele acordava toda hora. - Som: precisa de `somAcordar` barulhos (padrão 3) em 1 minuto; o mesmo barulho comprido conta uma vez. - Madrugada silenciosa (padrão 23h às 7h, configurável): movimento, rastreamento, PIR e modo presença não acordam; só barulho ALTO repetido, toque ou conversa. - Vida: de madrugada não acorda sozinho e volta a dormir mesmo com gente se mexendo, a não ser que alguém tenha interagido nos últimos 10 min. - Painel: grupo "Sono e madrugada" nos Ajustes. Co-Authored-By: Claude Opus 5.5 --- android/app/assets/carinha.html | 35 +++++++++++++++++++++-- carinha.html | 35 +++++++++++++++++++++-- controle_pc.py | 15 +++++++++- kiosk-server.py | 11 ++++--- tests/test_vida.py | 20 ++++++++++--- vida.py | 16 +++++++---- web/src/lib/ajustes.svelte.ts | 17 ++++++++++- web/src/lib/componentes/VidaCartao.svelte | 8 +++++- 8 files changed, 135 insertions(+), 22 deletions(-) diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index a94269b..46b7c39 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -741,6 +741,27 @@ const MIC = { cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos }; +/* Acordar com som: precisa de N barulhos em 1 minuto (ajuste "Sons para acordar"). Na madrugada + silenciosa só barulho ALTO conta, e movimento/câmera não acordam (alguém se mexendo na cama). */ +const DESPERTAR = { sons: 3, janela: 60, noite: true, ini: 23, fim: 7, eventos: [], ultimo: -Infinity, estava: false }; +function madrugada() { + if (!DESPERTAR.noite || DESPERTAR.ini === DESPERTAR.fim) return false; + const h = new Date().getHours(); + return DESPERTAR.ini < DESPERTAR.fim ? h >= DESPERTAR.ini && h < DESPERTAR.fim : h >= DESPERTAR.ini || h < DESPERTAR.fim; +} +/* Conta o INÍCIO de cada barulho (o mesmo barulho comprido vale um) e diz se já dá para acordar. */ +function barulhoAcorda(ouviu, agora) { + const D = DESPERTAR; + if (ouviu && !D.estava && agora - D.ultimo >= 1.5) { + D.ultimo = agora; + D.eventos = D.eventos.filter((t) => agora - t < D.janela).concat(agora); + console.log("ouvido: barulho " + D.eventos.length + "/" + D.sons + (madrugada() ? " (madrugada)" : "")); + } + D.estava = ouviu; + if (D.eventos.length >= D.sons && agora - D.eventos[D.eventos.length - 1] < 1) { D.eventos = []; return true; } + return false; +} + function som(nivel) { const n = Math.max(0, Math.min(1, Number(nivel) || 0)); const agora = performance.now() / 1000; @@ -765,12 +786,14 @@ function som(nivel) { console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2)); } - if (r.dormindo && temSom) { // acordou: tinha barulho + if (r.dormindo) { + if (barulhoAcorda(madrugada() ? alto : temSom, agora)) { // acordou: barulho repetido r.dormindo = false; animar("surpreso"); MIC.proxSurpresa = agora + MIC.cooldown; console.log("ouvido: acordou nivel=" + n.toFixed(2)); relatarSom("acordou", n); + } } else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) { MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino animar("surpreso"); @@ -857,7 +880,7 @@ function rastrear(t) { MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65; if(t.atividade===true) { MOV.ultimoMov=agora; - if(r.dormindo) { r.dormindo=false;animar('surpreso'); } + if(r.dormindo && !madrugada()) { r.dormindo=false;animar('surpreso'); } } } function movimento(vel, cx, cy) { @@ -887,12 +910,14 @@ function movimento(vel, cx, cy) { } const forte = v >= MOV.limiarForte; - if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente + if (r.dormindo) { + if (mexeu && !madrugada()) { // acordou: algo se mexeu na frente (de dia) r.dormindo = false; animar("surpreso"); MOV.proxSurpresa = agora + MOV.cooldown; console.log("presenca: acordou nivel=" + v.toFixed(2)); relatarMov("acordou", v); + } } else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) { // susto so no INICIO do movimento forte: se continuar se mexendo, nao repete MOV.proxSurpresa = agora + MOV.cooldown; @@ -1040,6 +1065,10 @@ function configurarApp(c) { EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true; if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca; SONO.apagar = c.apagarTela !== false; + if (typeof c.somAcordar === 'number') DESPERTAR.sons = Math.max(1, Math.round(c.somAcordar)); + DESPERTAR.noite = c.noiteSilenciosa !== false; + if (typeof c.noiteInicio === 'number') DESPERTAR.ini = Math.round(c.noiteInicio); + if (typeof c.noiteFim === 'number') DESPERTAR.fim = Math.round(c.noiteFim); if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin; if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha; AUTO = c.auto; diff --git a/carinha.html b/carinha.html index a94269b..46b7c39 100644 --- a/carinha.html +++ b/carinha.html @@ -741,6 +741,27 @@ const MIC = { cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos }; +/* Acordar com som: precisa de N barulhos em 1 minuto (ajuste "Sons para acordar"). Na madrugada + silenciosa só barulho ALTO conta, e movimento/câmera não acordam (alguém se mexendo na cama). */ +const DESPERTAR = { sons: 3, janela: 60, noite: true, ini: 23, fim: 7, eventos: [], ultimo: -Infinity, estava: false }; +function madrugada() { + if (!DESPERTAR.noite || DESPERTAR.ini === DESPERTAR.fim) return false; + const h = new Date().getHours(); + return DESPERTAR.ini < DESPERTAR.fim ? h >= DESPERTAR.ini && h < DESPERTAR.fim : h >= DESPERTAR.ini || h < DESPERTAR.fim; +} +/* Conta o INÍCIO de cada barulho (o mesmo barulho comprido vale um) e diz se já dá para acordar. */ +function barulhoAcorda(ouviu, agora) { + const D = DESPERTAR; + if (ouviu && !D.estava && agora - D.ultimo >= 1.5) { + D.ultimo = agora; + D.eventos = D.eventos.filter((t) => agora - t < D.janela).concat(agora); + console.log("ouvido: barulho " + D.eventos.length + "/" + D.sons + (madrugada() ? " (madrugada)" : "")); + } + D.estava = ouviu; + if (D.eventos.length >= D.sons && agora - D.eventos[D.eventos.length - 1] < 1) { D.eventos = []; return true; } + return false; +} + function som(nivel) { const n = Math.max(0, Math.min(1, Number(nivel) || 0)); const agora = performance.now() / 1000; @@ -765,12 +786,14 @@ function som(nivel) { console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2)); } - if (r.dormindo && temSom) { // acordou: tinha barulho + if (r.dormindo) { + if (barulhoAcorda(madrugada() ? alto : temSom, agora)) { // acordou: barulho repetido r.dormindo = false; animar("surpreso"); MIC.proxSurpresa = agora + MIC.cooldown; console.log("ouvido: acordou nivel=" + n.toFixed(2)); relatarSom("acordou", n); + } } else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) { MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino animar("surpreso"); @@ -857,7 +880,7 @@ function rastrear(t) { MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65; if(t.atividade===true) { MOV.ultimoMov=agora; - if(r.dormindo) { r.dormindo=false;animar('surpreso'); } + if(r.dormindo && !madrugada()) { r.dormindo=false;animar('surpreso'); } } } function movimento(vel, cx, cy) { @@ -887,12 +910,14 @@ function movimento(vel, cx, cy) { } const forte = v >= MOV.limiarForte; - if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente + if (r.dormindo) { + if (mexeu && !madrugada()) { // acordou: algo se mexeu na frente (de dia) r.dormindo = false; animar("surpreso"); MOV.proxSurpresa = agora + MOV.cooldown; console.log("presenca: acordou nivel=" + v.toFixed(2)); relatarMov("acordou", v); + } } else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) { // susto so no INICIO do movimento forte: se continuar se mexendo, nao repete MOV.proxSurpresa = agora + MOV.cooldown; @@ -1040,6 +1065,10 @@ function configurarApp(c) { EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true; if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca; SONO.apagar = c.apagarTela !== false; + if (typeof c.somAcordar === 'number') DESPERTAR.sons = Math.max(1, Math.round(c.somAcordar)); + DESPERTAR.noite = c.noiteSilenciosa !== false; + if (typeof c.noiteInicio === 'number') DESPERTAR.ini = Math.round(c.noiteInicio); + if (typeof c.noiteFim === 'number') DESPERTAR.fim = Math.round(c.noiteFim); if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin; if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha; AUTO = c.auto; diff --git a/controle_pc.py b/controle_pc.py index df58f5c..1fa63b0 100644 --- a/controle_pc.py +++ b/controle_pc.py @@ -22,6 +22,7 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35, economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30, boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True, apagarTela=True, apagarTelaMin=5, + somAcordar=3, noiteSilenciosa=True, noiteInicio=23, noiteFim=7, sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False, cameraLado='user', videoResolucao='auto', videoFPS=0, rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True) @@ -32,7 +33,8 @@ LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1), somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120), movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100), brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600), - subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120)) + subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120), + somAcordar=(1, 20), noiteInicio=(0, 23), noiteFim=(0, 23)) def validar(dados): @@ -54,6 +56,17 @@ def validar(dados): return dados +def madrugada(config, agora=None): + """Dentro do horário da madrugada silenciosa (ex.: 23h às 7h, atravessando a meia-noite)?""" + if not config.get('noiteSilenciosa', True): + return False + ini, fim = int(config.get('noiteInicio', 23)), int(config.get('noiteFim', 7)) + hora = time.localtime(agora if agora is not None else time.time()).tm_hour + if ini == fim: + return False + return ini <= hora < fim if ini < fim else (hora >= ini or hora < fim) + + def ip_informado(dados): """IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy.""" aparelho = dados.get('aparelho') diff --git a/kiosk-server.py b/kiosk-server.py index f42a086..e4717f3 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -32,7 +32,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import unquote from pathlib import Path from comum import autorizado, carregar_token, gravar_atomico, ler_json -from controle_pc import ControlePC, versao +from controle_pc import ControlePC, madrugada, versao from eventos import Eventos from servos import ControleServos from braco import Braco @@ -505,13 +505,15 @@ def main(): vida.celular_dormindo = celular_dormindo vida.celular_online = lambda: servidor.controle.resumo()['online'] vida.alguem_perto = alguem_perto + vida.madrugada = lambda agora: madrugada(servidor.controle.config, agora) # E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente" def presenca(tipo): vida.estimulo(tipo) - # PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda - if tipo == "pir": + # PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda. + # De madrugada não: alguém se mexendo na cama não é motivo para acordar. + if tipo == "pir" and not madrugada(servidor.controle.config): with servidor.controle.lock: dormindo = bool(servidor.controle.estado.get("dormindo")) if dormindo: @@ -523,7 +525,8 @@ def main(): def alguem_chegou(): camera(True) # modo presença: o PIR liga a câmera do celular - enfileirar("acordar") + if not madrugada(servidor.controle.config): + enfileirar("acordar") def ninguem_por_perto(): camera(False) # sem ninguém: câmera desligada, dorme e fica em silêncio diff --git a/tests/test_vida.py b/tests/test_vida.py index 5264a07..a75517f 100644 --- a/tests/test_vida.py +++ b/tests/test_vida.py @@ -69,12 +69,24 @@ class VidaTest(unittest.TestCase): self.v.necessidades['energia'] = 100 self.assertEqual(self.v.agir(DIA + 120), 'acordar') - def test_noite_dorme_sem_ninguem(self): + def test_madrugada_dorme_mesmo_com_gente_se_mexendo(self): noite = time.mktime((2026, 10, 6, 23, 30, 0, 0, 0, -1)) + self.v.alguem_perto = lambda: True # deitado na cama, o PIR vê self.assertEqual(self.v.agir(noite), 'dormir') - self.v.dormindo_proprio = False - self.v.alguem_perto = lambda: True - self.assertNotEqual(self.v.agir(noite), 'dormir') + self.v.necessidades['energia'] = 100 + self.assertIsNone(self.v.agir(noite + 60)) # descansado, mas é madrugada: não acorda + self.v.estimulo('carinho', noite + 120) # carinho acorda e segura acordado um tempo + self.assertFalse(self.v.dormindo()) + self.assertNotEqual(self.v.agir(noite + 180), 'dormir') + self.assertEqual(self.v.agir(noite + 180 + 11 * 60), 'dormir') + + def test_horario_da_madrugada(self): + from controle_pc import madrugada + h = lambda hora: time.mktime((2026, 10, 6, hora, 10, 0, 0, 0, -1)) + cfg = dict(noiteSilenciosa=True, noiteInicio=23, noiteFim=7) + self.assertEqual([madrugada(cfg, h(x)) for x in (22, 23, 3, 6, 7, 12)], [False, True, True, True, False, False]) + self.assertTrue(madrugada(dict(cfg, noiteInicio=1, noiteFim=5), h(2))) + self.assertFalse(madrugada(dict(cfg, noiteSilenciosa=False), h(3))) def test_humor_e_gestos_de_carente_com_gente_perto(self): self.v.necessidades.update(energia=80, social=10) diff --git a/vida.py b/vida.py index 9d51a31..1f43443 100644 --- a/vida.py +++ b/vida.py @@ -25,7 +25,8 @@ SONO_LENTO = 0.25 RITMOS = {'calmo': (60, 150), 'normal': (25, 75), 'agitado': (12, 35)} # segundos entre atos PADRAO = dict(ligado=True, criar_emojis=True, max_emojis_dia=3, ritmo='normal', noite=True) -NOITE = (23, 7) # dorme das 23h às 7h, se ninguém estiver por perto +NOITE = (23, 7) # horário padrão da madrugada (o servidor usa o dos ajustes do celular) +PEDIU_ATENCAO = 10 * 60 # de madrugada, só fica acordado se alguém interagiu (carinho, conversa) há pouco INTERVALO_EMOJI = 90 * 60 # no mínimo 90 min entre duas figurinhas criadas sozinho # O que cada estímulo faz com as necessidades (e o gesto que sai na hora, se houver). @@ -100,6 +101,8 @@ class Vida: self.alguem_perto = lambda: False # PIR/movimento recente self.bloqueado = lambda: False # E-Stop ou modo presença com ninguém em casa self.pode_gemini = lambda: bool(os.environ.get('GEMINI_API_KEY')) + self.madrugada = self.noite_padrao # o main troca pelo horário dos ajustes + self.interagiu_em = 0.0 agora = time.time() self.config = dict(PADRAO) @@ -251,6 +254,8 @@ class Vida: self.necessidades[k] = _limitar(self.necessidades[k] + v) if tipo in CUIDADOS: self.anotar('cuidado', CUIDADOS[tipo], agora) + if tipo in CUIDADOS or tipo == 'conversa': + self.interagiu_em = agora # alguém quis ele acordado (sensor não conta) if gesto and not self.bloqueado(): if self.dormindo(): self.acordar(agora, None) @@ -271,7 +276,8 @@ class Vida: if motivo: self.anotar('sono', motivo, agora) - def noite(self, agora): + @staticmethod + def noite_padrao(agora): hora = time.localtime(agora).tm_hour ini, fim = NOITE return hora >= ini or hora < fim @@ -340,12 +346,12 @@ class Vida: n = self.necessidades perto = self.alguem_perto() if self.dormindo(): - # acorda descansado de dia, ou sozinho quando a energia enche e tem alguém por perto - if n['energia'] >= 95 and (not self.noite(agora) or perto): + # de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, quando descansou + if n['energia'] >= 95 and not self.madrugada(agora): self.acordar(agora, 'Acordou descansado') return 'acordar' return None - if self.config['noite'] and self.noite(agora) and not perto: + if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO: self.dormir(agora, 'Ficou tarde, foi dormir') return 'dormir' if n['energia'] < 20: diff --git a/web/src/lib/ajustes.svelte.ts b/web/src/lib/ajustes.svelte.ts index a579cec..a1514a7 100644 --- a/web/src/lib/ajustes.svelte.ts +++ b/web/src/lib/ajustes.svelte.ts @@ -55,7 +55,7 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = { chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' }, { chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 }, { chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' }, - { chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som, movimento, toque ou PIR', tipo: 'toggle' }, + { chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som repetido, movimento, toque ou PIR', tipo: 'toggle' }, { chave: 'apagarTelaMin', rotulo: 'Dormindo há quanto tempo para apagar', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' min' }, ], }, @@ -73,6 +73,21 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = { chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' }, ], }, + { + titulo: 'Sono e madrugada', + descricao: 'O que acorda o rosto enquanto ele dorme.', + campos: [ + { chave: 'somAcordar', rotulo: 'Sons para acordar', ajuda: 'Quantos barulhos em 1 minuto para ele acordar', tipo: 'slider', min: 1, max: 10, passo: 1 }, + { + chave: 'noiteSilenciosa', + rotulo: 'Madrugada silenciosa', + ajuda: 'Movimento, câmera e PIR não acordam; só barulho alto repetido, toque ou conversa', + tipo: 'toggle', + }, + { chave: 'noiteInicio', rotulo: 'Começa às', tipo: 'slider', min: 0, max: 23, passo: 1, unidade: 'h' }, + { chave: 'noiteFim', rotulo: 'Termina às', tipo: 'slider', min: 0, max: 23, passo: 1, unidade: 'h' }, + ], + }, { titulo: 'Vídeo e rastreamento', descricao: 'Qualidade da transmissão e rastreamento feito neste PC.', diff --git a/web/src/lib/componentes/VidaCartao.svelte b/web/src/lib/componentes/VidaCartao.svelte index 4740069..b44ff50 100644 --- a/web/src/lib/componentes/VidaCartao.svelte +++ b/web/src/lib/componentes/VidaCartao.svelte @@ -30,6 +30,12 @@ function idade(d: number) { return d === 0 ? 'nasceu hoje' : d === 1 ? '1 dia de vida' : `${d} dias de vida`; } + const horarioNoite = $derived.by(() => { + const c = estado.appConfig; + const ini = typeof c?.noiteInicio === 'number' ? c.noiteInicio : 23; + const fim = typeof c?.noiteFim === 'number' ? c.noiteFim : 7; + return `Das ${ini}h às ${fim}h. Gente se mexendo não acorda; carinho e conversa sim`; + }); const detalheFigurinhas = $derived( v ? [`${v.emojis_hoje} de ${v.config.max_emojis_dia} hoje`, MOTIVOS[v.pode_criar] ?? v.pode_criar].filter(Boolean).join(' · ') : '', ); @@ -104,7 +110,7 @@ {/snippet} - + {#snippet acessorio()} configurarVida({ noite: l })} /> {/snippet}