Compare commits

...
6 Commits
Author SHA1 Message Date
julioandClaude Opus 5.5 91c64e5b18 feat(rede): placa em carinha.local e app que acha a placa sozinho
- sistema/rede: a placa vira "carinha" na rede (avahi), http://carinha.local
  na porta 80 (nftables redireciona para a 8100, sem tocar no 192.168.4.1
  do portal Carinha-Config) e anúncio "_carinha._tcp" na 8100;
- app 0.9.1: quando o endereço salvo não responde, procura o anúncio na
  rede (NsdManager), testa o endereço achado e passa a usar e guardar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:04:25 -03:00
julioandClaude Opus 5.5 ccd89d125e feat(painel): Ajustes com submenu e ligações do PIR pelo esquema real
Ajustes vira uma tela com submenu (#/ajustes/<seção>): cada grupo do
celular é uma seção (Tela, Sensores, Sono, Vídeo, Sensibilidade), mais
Robô (Inteligência artificial, Ligações da placa) e Painel (Segurança,
Sistema). No PC o menu fica numa coluna fixa com descrição; no celular,
numa fileira rolável que traz a seção aberta para a vista. A busca
procura em todos os grupos do celular.

Ligações da placa passa a ser uma seção dos Ajustes (#/ligacoes antigo
redireciona) e segue o esquema que já tinha sido desenhado para o PIR:
- HC-SR505 mini, marcação + · out · −; no cabo, + no fio preto →
  pino 4, out no verde → pino 11, − no branco → pino 14;
- etiquetas do conector e tabela mostram a cor real do fio;
- tela TFT reserva também o pino 7; pinos livres listados;
- avisos do HC-SR505 (sinal de ~8 s, 30–60 s para estabilizar, teste
  com gpiomon).

Rotas ganham nav.sub (seção da tela); atalhos vão direto à seção
(câmera, IA, ligações).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:26:15 -03:00
julioandClaude Opus 5.5 47a836276a feat(painel): Ao vivo em duas colunas e página "Ligações da placa"
Ao vivo no PC: a câmera deixa de ficar presa no topo e ganha cartões
embaixo (Gestos, Celular, Câmera, Acontecimentos, Aparência e
Personalidade); à direita ficam Vida e O que ele aprendeu. No celular
segue uma coluna só, na mesma ordem de antes (display: contents +
order).

Ligações da placa (#/ligacoes): mapa do conector de 26 pinos do
Orange Pi Zero 3 com os dispositivos registrados (lib/ligacoes.ts):
- PIR: VCC no 5 V (2), OUT no pino que a placa informa (CARINHA_PIR,
  padrão gpiochip1:70 = PC6 = pino 11), GND no 9; avisos de 3,3 V nos
  GPIOs e da ordem dos terminais que muda entre módulos;
- PCA9685: 3,3 V (1), SDA (3), SCL (5), GND (6); servos na fonte
  externa com terra comum;
- tela SPI do overlay tft35: pinos 18, 19, 21, 22, 23 e 24 reservados.
Estado ao vivo de cada um (sinal do PIR, PCA9685 no I²C), destaque dos
pinos ao tocar no dispositivo, atalhos em Ajustes, Ao vivo e Ctrl+K e
tópico no guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:18:39 -03:00
julioandClaude Opus 5.5 b4cf13df11 feat(vida): página "Dia a dia" com as decisões e o porquê
Cada decisão tomada com base no que ele aprendeu passa a ser guardada
com o motivo (aprendizado.decidir, até 800, persistidas):
- acordou do cochilo antes da hora: "às 18h de quarta costuma ter
  gente, pelo plano que você contou";
- cochilo com a casa vazia, com a duração e o porquê;
- sensor segurado numa hora em que acordou à toa (no máximo 1 nota a
  cada 10 min);
- gesto escolhido porque costuma agradar (chance ≥ ×1,3);
- fechamento do dia: horas com gente, carinhos e quanto o dia conferiu
  com o plano;
- análise da IA (adotada ou sugestão, com a porcentagem) e rotina
  contada por você.

esperado_e_fonte diz de onde veio a expectativa (plano seu, plano da
IA, o que viu naquele dia da semana ou média). GET /aprendizado/dia
devolve um dia: medido × plano por hora, conferência do dia, resumo e
decisões.

Painel: tela "Dia a dia" (#/dia-a-dia) com navegação entre dias, os
números do dia, as faixas "Viu" × "Esperava" e a linha do tempo das
decisões; atalho no cartão "O que ele aprendeu", no Ctrl+K e no guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:34:30 -03:00
julioandClaude Opus 5.5 935bf8d586 feat(vida): semana da casa com análise por IA e rotina contada
O aprendizado passa a entender a logística da semana, não só a hora:
- presença por dia da semana × hora (7×24, média móvel por dia) e
  histórico dos últimos 42 dias;
- depois da meia-noite, com 7 dias observados, a IA escolhida (Gemini,
  IA local ou Qwen do S10, via Criacao.chamar) cruza os dias e devolve
  padrões em frases e um plano da semana (casa / fora / dormindo);
- o plano da IA só é adotado se conferir com 70% das horas medidas;
  senão fica como sugestão ("Usar este plano");
- "Conte sua rotina": texto livre vira plano na hora e vale sobre o da
  IA; o painel mostra o quanto confere com o que os sensores veem;
- o plano guia o comportamento: acorda do cochilo 15 min antes da
  chegada naquele dia da semana e, com a casa vazia, tira cochilos
  longos para guardar energia;
- lição da rotina só quando muda 3+ horas ou depois de uma semana, e
  some quando já existe plano da semana;
- painel: quadro da semana (o que eu vi / plano), a rotina entendida,
  sugestão, contar rotina, analisar agora e a IA da análise diária.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:36:32 -03:00
julioandClaude Opus 5.5 24625453f7 feat(vida): aprendizado com o dia a dia (gosto, rotina da casa e sono)
O Carinha passa a melhorar as interações com o que vive em casa, sem IA
e sem internet (aprendizado.py):
- gosto: gesto ou atividade feito com alguém por perto é uma tentativa;
  carinho, brincadeira ou conversa até 60 s depois contam como "gostaram"
  (cutucada vale meio). A chance de cada ato vai de 0,5x a 2x, relativa à
  média, e só depois de 3 tentativas;
- rotina: fração de minutos com gente em cada hora (média móvel entre
  dias). Antes de uma hora movimentada, acorda do cochilo 15 min antes;
  sono pesado e madrugada continuam respeitados;
- sono: PIR acordou e ninguém ficou nem interagiu em 3 min = à toa. Se
  repete na mesma hora, ali pede 1 ou 2 disparos a mais para acordar;
- à meia-noite fecha o dia: lições em primeira pessoa no diário e o
  passado pesa 15% menos a cada dia (o gosto muda);
- rotas /aprendizado/status|config|esquecer e SSE 'aprendizado';
- painel: cartão "O que ele aprendeu" no Ao vivo (rotina em 24 barras,
  faz mais / faz menos, lições, esquecer com confirmação) e tópico no
  guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:13:23 -03:00
26 changed files with 3343 additions and 225 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto" package="br.carinha.rosto"
android:versionCode="12" android:versionCode="13"
android:versionName="0.9.0"> android:versionName="0.9.1">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
@@ -0,0 +1,93 @@
package br.carinha.rosto;
import android.content.Context;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
/**
* Acha a placa na rede pelo anúncio mDNS "_carinha._tcp" (avahi na Orange Pi, o mesmo do
* carinha.local). Assim, se o IP da placa mudar, o app se reconecta sem ninguém digitar endereço.
* Uma busca por vez; desiste depois de alguns segundos e responde no fio principal.
*/
final class Descoberta {
interface Resposta { void achou(String endereco); } // null = não achou
private static final String TAG = "CarinhaDescoberta";
private static final String TIPO = "_carinha._tcp.";
private static final long LIMITE = 8000;
private final NsdManager nsd;
private final Handler principal = new Handler(Looper.getMainLooper());
private NsdManager.DiscoveryListener ouvinte;
private Resposta resposta;
Descoberta(Context c) {
nsd = (NsdManager) c.getApplicationContext().getSystemService(Context.NSD_SERVICE);
}
boolean procurando() { return ouvinte != null; }
void procurar(Resposta r) {
if (nsd == null) { r.achou(null); return; }
if (ouvinte != null) return;
resposta = r;
ouvinte = new NsdManager.DiscoveryListener() {
@Override public void onDiscoveryStarted(String tipo) { }
@Override public void onDiscoveryStopped(String tipo) { }
@Override public void onStartDiscoveryFailed(String tipo, int erro) { terminar(null); }
@Override public void onStopDiscoveryFailed(String tipo, int erro) { }
@Override public void onServiceLost(NsdServiceInfo s) { }
@Override public void onServiceFound(NsdServiceInfo s) {
if (!s.getServiceType().startsWith("_carinha._tcp")) return;
resolver(s);
}
};
try {
nsd.discoverServices(TIPO, NsdManager.PROTOCOL_DNS_SD, ouvinte);
} catch (Exception e) {
Log.w(TAG, "busca falhou: " + e);
ouvinte = null;
r.achou(null);
return;
}
principal.postDelayed(new Runnable() {
@Override public void run() { terminar(null); }
}, LIMITE);
}
@SuppressWarnings("deprecation")
private void resolver(NsdServiceInfo s) {
try {
nsd.resolveService(s, new NsdManager.ResolveListener() {
@Override public void onResolveFailed(NsdServiceInfo s, int erro) { Log.w(TAG, "não resolveu: " + erro); }
@Override public void onServiceResolved(NsdServiceInfo s) {
if (s.getHost() == null || s.getHost().isLoopbackAddress()) return;
String ip = s.getHost().getHostAddress();
if (ip == null || ip.contains(":")) return; // só IPv4: o servidor ouve em 0.0.0.0
terminar("http://" + ip + ":" + s.getPort());
}
});
} catch (Exception e) {
Log.w(TAG, "resolver falhou: " + e);
}
}
private void terminar(final String endereco) {
principal.post(new Runnable() {
@Override public void run() {
if (ouvinte == null) return; // já respondeu
try { nsd.stopServiceDiscovery(ouvinte); } catch (Exception e) { /* já parou */ }
ouvinte = null;
Resposta r = resposta;
resposta = null;
Log.i(TAG, endereco == null ? "placa não encontrada na rede" : "placa encontrada: " + endereco);
if (r != null) r.achou(endereco);
}
});
}
void parar() { terminar(null); }
}
@@ -108,6 +108,7 @@ public class MainActivity extends Activity {
private String servidor; private String servidor;
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
private final CerebroLocal cerebroLocal = new CerebroLocal(); private final CerebroLocal cerebroLocal = new CerebroLocal();
private Descoberta descoberta; // acha a placa na rede quando o endereço salvo não responde
private boolean jaCaiuParaLocal = false; private boolean jaCaiuParaLocal = false;
private boolean jaRecarregou = false; private boolean jaRecarregou = false;
private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK
@@ -191,6 +192,7 @@ public class MainActivity extends Activity {
usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar
cerebroLocal.chave = token; cerebroLocal.chave = token;
cerebroLocal.iniciar(this, servidor); cerebroLocal.iniciar(this, servidor);
descoberta = new Descoberta(this);
remoto = new ControleRemoto(new Runnable() { remoto = new ControleRemoto(new Runnable() {
@Override public void run() { @Override public void run() {
runOnUiThread(new Runnable() { runOnUiThread(new Runnable() {
@@ -582,7 +584,7 @@ public class MainActivity extends Activity {
runOnUiThread(new Runnable() { runOnUiThread(new Runnable() {
@Override public void run() { @Override public void run() {
if (!usandoLocal || !alvo.equals(servidor)) return; if (!usandoLocal || !alvo.equals(servidor)) return;
if (!voltou) { procurarServidor(); return; } if (!voltou) { procurarNaRede(); return; }
Log.i(TAG, "servidor voltou: " + servidor); Log.i(TAG, "servidor voltou: " + servidor);
usandoLocal = false; usandoLocal = false;
jaCaiuParaLocal = false; jaCaiuParaLocal = false;
@@ -594,6 +596,36 @@ public class MainActivity extends Activity {
} }
}; };
/**
* O endereço salvo não respondeu: procura a placa pelo anúncio na rede (carinha.local).
* Achou em outro endereço que responde, passa a usar esse e guarda; senão tenta de novo daqui a pouco.
*/
private void procurarNaRede() {
if (descoberta.procurando()) { procurarServidor(); return; }
descoberta.procurar(new Descoberta.Resposta() {
@Override public void achou(final String endereco) {
if (!usandoLocal || endereco == null || endereco.equals(servidor)) { procurarServidor(); return; }
new Thread(new Runnable() {
@Override public void run() {
final boolean responde = servidorResponde(endereco);
runOnUiThread(new Runnable() {
@Override public void run() {
if (!responde || !usandoLocal) { procurarServidor(); return; }
Log.i(TAG, "placa achada na rede: " + servidor + " -> " + endereco);
servidor = endereco;
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE, servidor).apply();
cerebroLocal.iniciar(MainActivity.this, servidor);
usandoLocal = false;
jaCaiuParaLocal = false;
abrirRosto();
}
});
}
}, "testa-placa-achada").start();
}
});
}
/** Qualquer resposta HTTP (até 401 de token) quer dizer que o servidor está no ar. */ /** Qualquer resposta HTTP (até 401 de token) quer dizer que o servidor está no ar. */
private static boolean servidorResponde(String endereco) { private static boolean servidorResponde(String endereco) {
java.net.HttpURLConnection c = null; java.net.HttpURLConnection c = null;
+751
View File
@@ -0,0 +1,751 @@
"""Aprendizado: o Carinha melhora as interações com o que vive no dia a dia.
Sem IA e sem internet: só conta o que acontece em casa e ajusta o modo vida aos poucos.
- Gosto: cada gesto ou atividade feito com alguém por perto vira uma tentativa; um carinho, uma
brincadeira ou uma conversa logo depois conta como "gostaram". Os que agradam saem mais, os
ignorados saem menos (no máximo o dobro ou a metade da chance).
- Rotina: em que horas costuma ter gente por perto. Um pouco antes da hora de sempre, ele acorda
de um cochilo para estar de pé quando a pessoa chegar.
- Sono: quando o sensor acorda o rosto e ninguém fica nem interage, foi à toa. Se isso se repete
na mesma hora, ele passa a pedir mais movimento para acordar nesse horário.
- Semana: a presença também é guardada por dia da semana × hora, com o histórico dos últimos dias.
Uma vez por dia uma IA (Gemini, IA local ou o S10) cruza esse quadro, acha os padrões ("fora de
terça a quinta, em casa na segunda e na sexta") e propõe um plano da semana. O plano só é usado
se bater com o que os sensores mediram; senão fica como sugestão. A pessoa também pode contar a
rotina com as próprias palavras, e os dados vão dizendo o quanto ela confere.
À meia-noite ele fecha o dia: o que aprendeu vira lição no diário e o que ficou para trás pesa
menos (o gosto das pessoas muda). "Esquecer" zera tudo.
"""
import json
import threading
import time
from datetime import date
from pathlib import Path
from comum import gravar_atomico, ler_json
PADRAO = dict(ligado=True, analise_ia=True, ia='gemini')
IAS = ('gemini', 'local', 's10')
JANELA = 60 # s depois de um ato em que a resposta de alguém conta para ele
PESO_RESPOSTA = {'carinho': 1.0, 'brincar': 1.0, 'conversa': 1.0, 'alimentar': 0.6, 'cutucar': 0.5}
ESQUECE = 0.85 # todo dia, o que aprendeu antes pesa um pouco menos
MEMORIA_ROTINA = 0.3 # quanto um dia novo pesa na rotina aprendida
MIN_TENTATIVAS = 3 # com menos que isso, ainda não tem opinião sobre um ato
FAIXA = (0.5, 2.0) # quanto o gosto pode mudar a chance de um ato
FALSO_ALARME = 180 # s que esperar para ver se acordar pelo sensor valeu a pena
PRESENCA_FICOU = 0.3 # fração da espera com alguém por perto para o acordar ter valido
MIN_MINUTOS_HORA = 10 # minutos observados numa hora para ela entrar na rotina
ROTINA_CHEIA, ROTINA_VAZIA = 0.5, 0.3
AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo
MAX_LICOES = 30
MAX_DECISOES = 800 # decisões guardadas (com o motivo), umas duas semanas
AVISO_SONO = 10 * 60 # no máximo uma nota de "não acordou com o sensor" a cada 10 min
DIAS_SEMANA = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo']
CURTO = ['seg', 'ter', 'qua', 'qui', 'sex', 'sáb', 'dom']
MEMORIA_SEMANA = 0.5 # cada dia da semana aparece 1 vez por semana: aprende mais rápido
MAX_HISTORICO = 42 # dias guardados para a IA cruzar
MIN_DIAS_ANALISE = 7 # análise automática só com uma semana inteira
MIN_DIAS_MANUAL = 3
MIN_HORAS_CONFERIR = 24 # horas com dados para dizer se um plano bate
CONCORDA = 0.7 # plano da IA só vale se bater com 70% das horas medidas
TIPOS = ['casa', 'fora', 'dormindo']
SCHEMA_PLANO = dict(type='object', required=['padroes', 'dias'], properties=dict(
padroes=dict(type='array', items=dict(type='string')),
dias=dict(type='array', items=dict(type='object', required=['dia', 'periodos'], properties=dict(
dia=dict(type='integer'),
periodos=dict(type='array', items=dict(type='object', required=['ini', 'fim', 'tipo'], properties=dict(
ini=dict(type='integer'), fim=dict(type='integer'), tipo=dict(type='string', enum=TIPOS)))))))))
NOMES = {
'alegre': 'saltitar', 'musica': 'cantarolar', 'feliz': 'sorrir', 'piscadinha': 'piscadinha',
'coracao': 'coraçãozinho', 'rir': 'dar risada', 'comemorar': 'comemorar', 'olhar_esq': 'olhar em volta',
'olhar_dir': 'olhar em volta', 'piscar2': 'piscar devagar', 'pensar': 'ficar pensativo', 'timido': 'ficar tímido',
'espiar': 'espiar', 'bocejar': 'bocejar', 'confuso': 'ficar confuso', 'triste': 'ficar tristinho',
'olhar_baixo': 'olhar para baixo', 'acenar': 'acenar', 'surpreso': 'fazer cara de "oi!"',
'esconder': 'esconder o rosto', 'bravo': 'ficar emburrado', 'tonto': 'ficar tonto',
'ler': 'ler', 'cafe': 'tomar café', 'meditar': 'meditar', 'telescopio': 'olhar as estrelas', 'pescar': 'pescar',
'computador': 'mexer no computador', 'regar': 'regar a plantinha', 'bola': 'jogar bola', 'malhar': 'malhar',
'corda': 'pular corda', 'skate': 'andar de skate', 'dancar': 'dançar', 'pipa': 'soltar pipa', 'pintar': 'pintar',
'violao': 'tocar violão', 'cantar': 'cantar', 'cozinhar': 'cozinhar', 'bolhas': 'fazer bolhas de sabão',
'videogame': 'jogar videogame', 'dormir': 'cochilar',
}
def _dia(agora):
return time.strftime('%Y-%m-%d', time.localtime(agora))
def _hora(agora):
return time.localtime(agora).tm_hour
def _faixas(horas):
"""[18, 19, 20, 22] -> [(18, 21), (22, 23)]: horas seguidas viram uma faixa (fim aberto)."""
faixas = []
for h in sorted(horas):
if faixas and faixas[-1][1] == h:
faixas[-1] = (faixas[-1][0], h + 1)
else:
faixas.append((h, h + 1))
return faixas
def _texto_faixas(faixas):
partes = ['das %dh às %dh' % (a, b % 24) for a, b in faixas]
return partes[0] if len(partes) == 1 else ', '.join(partes[:-1]) + ' e ' + partes[-1]
class Aprendizado:
def __init__(self, caminho, nomes=None):
self.caminho = Path(caminho)
self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None
self.anotar = lambda tipo, texto, agora=None: None # diário do modo vida
self.nomes = nomes or (lambda ident: None) # nome das atividades criadas por IA
self.config = dict(PADRAO)
self.itens = {} # nome -> {'t': tentativas, 's': agradou}
self.rotina = [None] * 24 # chance de ter gente por perto em cada hora
self.falsos = [0.0] * 24 # acordou à toa nessa hora (média dos dias)
self.dias = 0
self.licoes = []
self.rotina_avisada = []
self.rotina_avisada_dia = 0
self.hoje = self._dia_vazio(_dia(time.time()))
self.pendentes = [] # atos esperando resposta: (nome, horario)
self.acordou_em = None # acordou pelo sensor: esperando para ver se valeu
self.presenca_depois = [0, 0] # (com gente, total) nas observações depois de acordar
self.minuto = None
self.salvo_em = 0.0
self.semana = [[None] * 24 for _ in range(7)] # presença por dia da semana × hora
self.semana_n = [[0] * 24 for _ in range(7)] # quantos dias mediram cada célula
self.historico = [] # [{dia, sem, presenca[24], respostas, atos}]
self.plano = None # {origem, criado, padroes, dias[7][...]}
self.sugestao = None # plano da IA que não bateu com os dados
self.analise = dict(ocupado=False, erro='', em=None, tipo='')
self.ia = None # (provedor, prompt, schema) -> dict (o main liga)
self.pode_ia = lambda provedor: False
self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro
self.sono_avisado = 0.0
try:
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {})))
self.itens = {k: dict(t=float(v['t']), s=float(v['s'])) for k, v in salvo.get('itens', {}).items()}
if len(salvo.get('rotina', [])) == 24:
self.rotina = [None if v is None else float(v) for v in salvo['rotina']]
if len(salvo.get('falsos', [])) == 24:
self.falsos = [float(v) for v in salvo['falsos']]
self.dias = int(salvo.get('dias', 0))
self.licoes = [l for l in salvo.get('licoes', []) if isinstance(l, dict)][:MAX_LICOES]
self.rotina_avisada = list(salvo.get('rotina_avisada', []))
self.rotina_avisada_dia = int(salvo.get('rotina_avisada_dia', 0))
if len(salvo.get('semana', [])) == 7:
self.semana = [[None if v is None else float(v) for v in linha] for linha in salvo['semana']]
self.semana_n = [[int(v) for v in linha] for linha in salvo['semana_n']]
self.historico = [d for d in salvo.get('historico', []) if isinstance(d, dict)][-MAX_HISTORICO:]
self.decisoes = [d for d in salvo.get('decisoes', []) if isinstance(d, dict)][:MAX_DECISOES]
if salvo.get('plano'):
self.plano = self.validar_plano(salvo['plano'], salvo['plano'].get('origem', 'ia'), salvo['plano'].get('criado'))
if salvo.get('sugestao'):
self.sugestao = self.validar_plano(salvo['sugestao'], 'ia', salvo['sugestao'].get('criado'))
if isinstance(salvo.get('analise_em'), (int, float)):
self.analise['em'] = salvo['analise_em']
if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'):
self.hoje.update(salvo['hoje'])
except (OSError, ValueError, TypeError, KeyError, AttributeError):
pass
@staticmethod
def _dia_vazio(dia):
return dict(dia=dia, observado=[0] * 24, presenca=[0] * 24, falsos=[0] * 24, acordou=[0] * 24,
atos=0, respostas={}, contagem={})
@staticmethod
def validar(dados):
if not isinstance(dados, dict):
raise ValueError('Esperava um objeto')
for chave, valor in dados.items():
if chave not in PADRAO:
raise ValueError('Ajuste desconhecido: ' + chave)
if chave == 'ia':
if valor not in IAS:
raise ValueError('IA: gemini, local ou s10')
elif not isinstance(valor, bool):
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
return dict(dados)
def configurar(self, dados):
limpo = self.validar(dados)
with self.lock:
self.config.update(limpo)
self.salvar()
self.avisar()
return self.status()
def esquecer(self):
with self.lock:
self.itens, self.rotina, self.falsos = {}, [None] * 24, [0.0] * 24
self.semana, self.semana_n = [[None] * 24 for _ in range(7)], [[0] * 24 for _ in range(7)]
self.historico, self.plano, self.sugestao, self.decisoes = [], None, None, []
self.dias, self.licoes, self.rotina_avisada, self.rotina_avisada_dia = 0, [], [], 0
self.hoje = self._dia_vazio(_dia(time.time()))
self.pendentes, self.acordou_em = [], None
self.anotar('aprendizado', 'Esqueceu tudo o que tinha aprendido e começou do zero')
self.salvar()
self.avisar()
return self.status()
def salvar(self):
with self.lock:
dados = dict(config=self.config, itens=self.itens, rotina=self.rotina, falsos=self.falsos, dias=self.dias,
licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia,
hoje=self.hoje, semana=self.semana,
semana_n=self.semana_n, historico=self.historico, plano=self.plano, sugestao=self.sugestao,
analise_em=self.analise['em'], decisoes=self.decisoes)
self.salvo_em = time.time()
try:
gravar_atomico(self.caminho, dados)
except OSError:
pass
# ------------------------------------------------------------------ o que acontece no dia
def ligado(self):
return self.config['ligado']
def observar(self, agora, perto):
"""A cada tick do modo vida: conta os minutos com gente por perto e fecha o dia à meia-noite."""
if not self.ligado():
return
self.virar_dia(agora)
with self.lock:
self.pendentes = [(n, t) for n, t in self.pendentes if agora - t <= JANELA]
if self.acordou_em is not None:
self.presenca_depois[0] += 1 if perto else 0
self.presenca_depois[1] += 1
if agora - self.acordou_em >= FALSO_ALARME:
com_gente, total = self.presenca_depois
if total and com_gente / total < PRESENCA_FICOU:
self.hoje['falsos'][_hora(self.acordou_em)] += 1
self.acordou_em = None
minuto = int(agora // 60)
if minuto == self.minuto:
return
self.minuto = minuto
h = _hora(agora)
self.hoje['observado'][h] += 1
if perto:
self.hoje['presenca'][h] += 1
if agora - self.salvo_em > 300:
self.salvar()
def ato(self, nome, agora, perto):
"""Ele fez um gesto ou uma atividade. Só conta com plateia: sozinho ninguém pode gostar."""
if not self.ligado() or not perto or not nome:
return
with self.lock:
self.pendentes.append((nome, agora))
self.hoje['atos'] += 1
item = self.itens.setdefault(nome, dict(t=0.0, s=0.0))
item['t'] += 1
c = self.hoje['contagem'].setdefault(nome, [0, 0.0])
c[0] += 1
def resposta(self, tipo, agora):
"""Carinho, brincadeira, conversa...: o último ato de pouco antes agradou."""
if not self.ligado() or tipo not in PESO_RESPOSTA:
return
with self.lock:
self.hoje['respostas'][tipo] = self.hoje['respostas'].get(tipo, 0) + 1
if self.acordou_em is not None: # alguém quis ele acordado: valeu a pena
self.acordou_em = None
recentes = [(n, t) for n, t in self.pendentes if agora - t <= JANELA]
if not recentes:
return
nome, _ = recentes[-1]
self.pendentes = [p for p in self.pendentes if p[0] != nome] # cada ato agrada uma vez
item = self.itens.setdefault(nome, dict(t=1.0, s=0.0))
item['s'] = min(item['t'], item['s'] + PESO_RESPOSTA[tipo])
c = self.hoje['contagem'].setdefault(nome, [1, 0.0])
c[1] += PESO_RESPOSTA[tipo]
def acordou_pelo_sensor(self, agora):
if not self.ligado():
return
with self.lock:
self.hoje['acordou'][_hora(agora)] += 1
self.acordou_em = agora
self.presenca_depois = [0, 0]
# ------------------------------------------------------------------ o que muda no comportamento
def peso(self, nome):
"""Multiplica a chance de um ato: acima de 1 se costuma agradar, abaixo se é ignorado."""
if not self.ligado():
return 1.0
with self.lock:
item = self.itens.get(nome)
if not item or item['t'] < MIN_TENTATIVAS:
return 1.0
total_t = sum(i['t'] for i in self.itens.values())
total_s = sum(i['s'] for i in self.itens.values())
media = (total_s + 1) / (total_t + 2)
taxa = (item['s'] + 1) / (item['t'] + 2)
return round(max(FAIXA[0], min(FAIXA[1], taxa / media)), 2)
def extra_pir(self, agora):
"""Disparos a mais do PIR para acordar nesta hora (acordou à toa aqui outras vezes)."""
return self._extra(self.falsos[_hora(agora)]) if self.ligado() else 0
@staticmethod
def _extra(falsos):
return 2 if falsos >= 1.5 else 1 if falsos >= 0.5 else 0
def esperado(self, agora):
"""'casa', 'fora', 'dormindo' ou None nesta hora: pelo plano da semana, senão pelo que mediu
naquele dia da semana, senão pela média de todos os dias."""
return self.esperado_e_fonte(agora)[0]
def esperado_e_fonte(self, agora):
t = time.localtime(agora)
if self.plano:
fonte = 'pelo plano que você contou' if self.plano['origem'] == 'voce' else 'pelo plano da IA'
for p in self.plano['dias'][t.tm_wday]:
if p['ini'] <= t.tm_hour < p['fim']:
return p['tipo'], fonte
return None, fonte
if self.semana_n[t.tm_wday][t.tm_hour] >= 2:
v, fonte = self.semana[t.tm_wday][t.tm_hour], 'pelo que vi nas %ss' % DIAS_SEMANA[t.tm_wday].replace('-feira', '')
if t.tm_wday >= 5:
fonte = 'pelo que vi nos %ss' % DIAS_SEMANA[t.tm_wday]
else:
v, fonte = self.rotina[t.tm_hour], 'pela média de todos os dias'
if v is None:
return None, fonte
return ('casa' if v >= ROTINA_CHEIA else 'fora' if v < ROTINA_VAZIA else None), fonte
# ------------------------------------------------------------------ decisões (com o porquê)
def decidir(self, tipo, texto, motivo, agora=None):
"""Guarda uma decisão tomada com base no que aprendeu, para o painel mostrar dia a dia."""
with self.lock:
self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo))
del self.decisoes[MAX_DECISOES:]
def porque_chegada(self, agora):
t = time.localtime(agora + 3600)
_, fonte = self.esperado_e_fonte(agora + 3600)
return 'às %dh de %s costuma ter gente, %s' % (t.tm_hour, DIAS_SEMANA[t.tm_wday], fonte)
def porque_vazia(self, agora):
t = time.localtime(agora)
_, fonte = self.esperado_e_fonte(agora)
return 'às %dh de %s a casa costuma ficar vazia, %s' % (t.tm_hour, DIAS_SEMANA[t.tm_wday], fonte)
def segurou_sono(self, agora, precisa, extra):
"""O PIR viu algo, mas nesta hora ele pede mais movimento: anota (sem repetir toda hora)."""
if agora - self.sono_avisado < AVISO_SONO:
return
self.sono_avisado = agora
self.decidir('sono', 'Não acordou com o sensor',
'por volta das %dh já acordou à toa outras vezes: pede %d %s a mais'
% (_hora(agora), extra, 'movimento' if extra == 1 else 'movimentos'), agora)
def escolheu(self, nome, agora=None):
"""Escolheu um ato que costuma agradar: anota quando o gosto pesou de verdade."""
peso = self.peso(nome)
if peso >= 1.3:
self.decidir('gosto', 'Escolheu ' + self.nome(nome),
'costuma agradar: chance ×%s' % ('%.1f' % peso).replace('.', ','), agora)
def chega_logo(self, agora):
"""Falta pouco para a hora em que costuma ter gente (e agora costuma estar vazio)?"""
if not self.ligado():
return False
t = time.localtime(agora)
falta = 3600 - (t.tm_min * 60 + t.tm_sec)
return falta <= AVISO_CHEGADA and self.esperado(agora) != 'casa' and self.esperado(agora + falta + 1) == 'casa'
def casa_vazia(self, agora):
"""Nesta hora a casa costuma ficar vazia: dá para descansar e guardar energia para quando chegarem."""
return self.ligado() and self.esperado(agora) == 'fora'
# ------------------------------------------------------------------ fechar o dia
def virar_dia(self, agora):
dia = _dia(agora)
if self.hoje['dia'] == dia:
return
with self.lock:
licoes = self.fechar_dia()
self.hoje = self._dia_vazio(dia)
self.pendentes, self.acordou_em = [], None
for texto in licoes:
self.anotar('aprendizado', texto, agora)
self.salvar()
self.avisar()
if self.config['analise_ia'] and len(self.historico) >= MIN_DIAS_ANALISE and self.ia and self.pode_ia(self.config['ia']):
self.rodar('analise', self.analisar)
def nome(self, ident):
if ident.startswith('criado:'):
try:
nome = self.nomes(int(ident[7:]))
except (ValueError, TypeError):
nome = None
return 'fazer ' + nome if nome else 'fazer uma atividade nova'
return NOMES.get(ident, ident)
def fechar_dia(self):
"""Junta o dia na memória e devolve as lições (frases curtas, em primeira pessoa)."""
hoje, licoes = self.hoje, []
# rotina: média móvel da fração de minutos com gente em cada hora
for h in range(24):
if hoje['observado'][h] >= MIN_MINUTOS_HORA:
frac = hoje['presenca'][h] / hoje['observado'][h]
antes = self.rotina[h]
self.rotina[h] = round(frac if antes is None else antes + (frac - antes) * MEMORIA_ROTINA, 3)
self.falsos[h] = round(self.falsos[h] + (hoje['falsos'][h] - self.falsos[h]) * MEMORIA_ROTINA, 3)
observou = sum(hoje['observado']) >= 60
if observou:
self.dias += 1
sem = date.fromisoformat(hoje['dia']).weekday()
fracs = [round(hoje['presenca'][h] / hoje['observado'][h], 2) if hoje['observado'][h] >= MIN_MINUTOS_HORA
else None for h in range(24)]
for h, frac in enumerate(fracs):
if frac is not None:
antes = self.semana[sem][h]
self.semana[sem][h] = round(frac if antes is None else antes + (frac - antes) * MEMORIA_SEMANA, 3)
self.semana_n[sem][h] += 1
self.historico.append(dict(dia=hoje['dia'], sem=sem, presenca=fracs, atos=hoje['atos'],
respostas=sum(hoje['respostas'].values()), a_toa=sum(hoje['falsos'])))
del self.historico[:-MAX_HISTORICO]
horas = sum(1 for v in fracs if v is not None and v >= ROTINA_VAZIA)
nota = self.conferir_dia(sem, fracs)
fim_do_dia = time.mktime(time.strptime(hoje['dia'] + ' 23:59:59', '%Y-%m-%d %H:%M:%S'))
self.decisoes.insert(0, dict(
horario=fim_do_dia, tipo='dia', texto='Fechou o dia: atualizou o quadro de ' + DIAS_SEMANA[sem],
motivo='%d %s com gente, %d %s%s' % (
horas, 'hora' if horas == 1 else 'horas', sum(hoje['respostas'].values()),
'carinho ou conversa' if sum(hoje['respostas'].values()) == 1 else 'carinhos ou conversas',
'' if nota is None else '; o dia conferiu %d%% com o plano' % round(nota * 100))))
# gosto: o que agradou e o que foi ignorado hoje
contagem = hoje['contagem']
gostou = sorted(((s, n) for n, (t, s) in contagem.items() if s >= 1), reverse=True)
if gostou:
nomes = [self.nome(n) for _, n in gostou[:2]]
licoes.append('Aprendi que vocês gostam de me ver ' + ' e '.join(nomes) + '. Vou fazer mais!')
ignorado = sorted(((t, n) for n, (t, s) in contagem.items() if t >= 4 and s == 0), reverse=True)
if ignorado and sum(hoje['respostas'].values()):
licoes.append('Tentei %s %d vezes e ninguém ligou. Vou fazer menos.' % (self.nome(ignorado[0][1]), ignorado[0][0]))
if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()):
licoes.append('Hoje ninguém brincou comigo. Amanhã eu tento coisas diferentes.')
# rotina: quando passa a ter um padrão (ou ele muda), conta
if self.dias >= 2 and not self.plano:
cheias = [h for h in range(24) if self.rotina[h] is not None and self.rotina[h] >= ROTINA_CHEIA]
mudou = not self.rotina_avisada or len(set(cheias) ^ set(self.rotina_avisada)) >= 3 or (
cheias != self.rotina_avisada and self.dias - self.rotina_avisada_dia >= 7)
if cheias and mudou:
licoes.append('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.'
% _texto_faixas(_faixas(cheias)))
self.rotina_avisada, self.rotina_avisada_dia = cheias, self.dias
# sono: acordou à toa várias vezes na mesma hora
piores = sorted(((n, h) for h, n in enumerate(hoje['falsos']) if n >= 2), reverse=True)
if piores:
n, h = piores[0]
licoes.append('Acordei à toa %d vezes por volta das %dh. Nessa hora vou precisar de mais movimento para acordar.'
% (n, h))
# o gosto muda: o passado pesa menos a cada dia
for nome in list(self.itens):
item = self.itens[nome]
item['t'] = round(item['t'] * ESQUECE, 3)
item['s'] = round(item['s'] * ESQUECE, 3)
if item['t'] < 0.5:
del self.itens[nome]
for texto in licoes:
self.licoes.insert(0, dict(dia=hoje['dia'], texto=texto))
del self.licoes[MAX_LICOES:]
return licoes
# ------------------------------------------------------------------ semana: IA e plano
@staticmethod
def validar_plano(bruto, origem, criado=None):
"""Plano vindo da IA (ou salvo): 7 dias de períodos casa/fora/dormindo, horas de 0 a 24."""
if not isinstance(bruto, dict) or not isinstance(bruto.get('dias'), list):
raise ValueError('A IA não devolveu um plano da semana')
dias = [[] for _ in range(7)]
for i, d in enumerate(bruto['dias'][:7]):
if isinstance(d, list): # formato salvo: lista por dia
dia, periodos = i, d
elif isinstance(d, dict):
dia, periodos = d.get('dia'), d.get('periodos')
else:
continue
if isinstance(dia, bool) or not isinstance(dia, int) or not 0 <= dia <= 6 or not isinstance(periodos, list):
continue
for p in periodos[:8]:
if not isinstance(p, dict) or p.get('tipo') not in TIPOS:
continue
try:
ini, fim = max(0, min(23, int(p['ini']))), max(1, min(24, int(p['fim'])))
except (KeyError, TypeError, ValueError):
continue
if ini < fim:
dias[dia].append(dict(ini=ini, fim=fim, tipo=p['tipo']))
for periodos in dias:
periodos.sort(key=lambda p: p['ini'])
for a, b in zip(periodos, periodos[1:]): # sem sobrepor: o anterior termina antes
a['fim'] = min(a['fim'], b['ini'])
periodos[:] = [p for p in periodos if p['ini'] < p['fim']]
if not any(dias):
raise ValueError('O plano veio vazio')
padroes = [str(t).strip()[:160] for t in bruto.get('padroes') or [] if str(t).strip()][:5]
return dict(origem=origem, criado=criado or time.time(), padroes=padroes, dias=dias)
def conferir(self, plano):
"""Quanto o plano bate com o que os sensores mediram (0 a 1), ou None com poucos dados."""
if not plano:
return None
certas = total = 0
for sem in range(7):
for p in plano['dias'][sem]:
for h in range(p['ini'], p['fim']):
v = self.semana[sem][h]
if v is None or not self.semana_n[sem][h]:
continue
total += 1
certas += (v >= ROTINA_VAZIA) if p['tipo'] == 'casa' else (v < ROTINA_CHEIA)
return round(certas / total, 2) if total >= MIN_HORAS_CONFERIR else None
def conferir_dia(self, sem, fracs, plano=None):
"""Quanto um dia medido bateu com o plano (0 a 1), ou None sem plano ou com poucas horas."""
plano = plano or self.plano
if not plano:
return None
certas = total = 0
for p in plano['dias'][sem]:
for h in range(p['ini'], p['fim']):
if fracs[h] is None:
continue
total += 1
certas += (fracs[h] >= ROTINA_VAZIA) if p['tipo'] == 'casa' else (fracs[h] < ROTINA_CHEIA)
return round(certas / total, 2) if total >= 6 else None
def dia_a_dia(self, dia=None):
"""Um dia para o painel: o que viu, o que esperava pelo plano, quanto conferiu e as decisões."""
hoje = self.hoje['dia']
dia = dia or hoje
try:
d = date.fromisoformat(dia)
except (TypeError, ValueError):
raise ValueError('Dia inválido (use AAAA-MM-DD)') from None
sem = d.weekday()
with self.lock:
if dia == hoje:
h = self.hoje
fracs = [round(h['presenca'][i] / h['observado'][i], 2) if h['observado'][i] >= 5 else None for i in range(24)]
resumo = dict(atos=h['atos'], respostas=sum(h['respostas'].values()), a_toa=sum(h['falsos']))
else:
registro = next((x for x in self.historico if x['dia'] == dia), None)
fracs = registro['presenca'] if registro else [None] * 24
resumo = dict(atos=registro['atos'], respostas=registro['respostas'], a_toa=registro['a_toa']) if registro else None
decisoes = [x for x in self.decisoes if _dia(x['horario']) == dia]
dias = sorted({x['dia'] for x in self.historico} | {_dia(x['horario']) for x in self.decisoes} | {hoje})
plano = self.plano
horas = sum(1 for v in fracs if v is not None and v >= ROTINA_VAZIA)
return dict(dia=dia, sem=sem, nome_dia=DIAS_SEMANA[sem], hoje=dia == hoje, medido=fracs,
plano=plano['dias'][sem] if plano else None, origem_plano=plano['origem'] if plano else None,
confere=self.conferir_dia(sem, fracs), horas_com_gente=horas, resumo=resumo,
decisoes=decisoes, dias=dias[-60:])
def tabela_semana(self):
linhas = []
for sem in range(7):
celulas = ['%dh:%s' % (h, '—' if v is None else '%d%%' % round(v * 100)) for h, v in enumerate(self.semana[sem])]
linhas.append('%s (%d dias medidos): %s' % (DIAS_SEMANA[sem], max(self.semana_n[sem]), ' '.join(celulas)))
return '\n'.join(linhas)
def resumo_dias(self):
linhas = []
for d in self.historico[-21:]:
horas = [h for h, v in enumerate(d['presenca']) if v is not None and v >= ROTINA_VAZIA]
casa = _texto_faixas(_faixas(horas)) if horas else 'ninguém por perto'
linhas.append('%s (%s): gente %s; %d carinhos/conversas' % (d['dia'], DIAS_SEMANA[d['sem']], casa, d['respostas']))
return '\n'.join(linhas)
def montar_prompt(self, pedido=None):
base = (
'Você é o Carinha, um robô de mesa que observa a casa por um sensor de presença. Analise a rotina da '
'casa e responda em português do Brasil.\n\n'
'Presença por dia da semana e hora (% do tempo com alguém perto do robô; — = sem dados):\n'
+ self.tabela_semana() + '\n\nÚltimos dias, um por linha:\n' + (self.resumo_dias() or '(nenhum ainda)') + '\n\n')
regras = (
'"dias": para cada dia da semana (dia 0 = segunda ... 6 = domingo) dê períodos que cubram de 0 a 24h sem '
'sobrepor, com tipo "casa" (tem gente acordada por perto), "fora" (casa vazia) ou "dormindo" (noite, '
'gente em casa dormindo). Horas inteiras, ini de 0 a 23, fim de 1 a 24.')
if pedido:
return base + (
'A pessoa contou a rotina dela com as próprias palavras:\n"' + pedido + '"\n\n'
'Transforme isso no plano da semana. Vale o que a pessoa disse; use os dados só para completar o que '
'ela não falou (por exemplo, o horário de dormir). ' + regras + '\n"padroes": até 4 frases curtas, em '
'primeira pessoa, resumindo a rotina que você entendeu (ex.: "Você fica fora de terça a quinta, das 8h às 18h").')
return base + (
'Cruze os dias: procure dias da semana parecidos entre si (dias úteis iguais, fim de semana diferente, um '
'dia que foge da regra), horários de chegada e de saída, e quando a casa fica vazia. Só afirme o que os '
'dados mostram; para dias sem dados, repita o padrão dos dias parecidos.\n'
'"padroes": até 5 frases curtas, em primeira pessoa, sobre o que você percebeu (ex.: "Percebi que de terça a '
'quinta a casa fica vazia das 8h às 18h, mas na segunda e na sexta tem gente o dia todo"). ' + regras)
def rodar(self, tipo, funcao, *args):
"""Uma análise por vez, em segundo plano (a IA local pode levar minutos)."""
with self.lock:
if self.analise['ocupado']:
raise ValueError('Já estou analisando, espere terminar')
self.analise.update(ocupado=True, erro='', tipo=tipo)
self.avisar()
def tarefa():
try:
funcao(*args)
except Exception as e:
with self.lock:
self.analise['erro'] = str(e)[:200]
finally:
with self.lock:
self.analise['ocupado'] = False
self.salvar()
self.avisar()
threading.Thread(target=tarefa, daemon=True).start()
def chamar_ia(self, pedido=None):
provedor = self.config['ia']
if not self.ia or not self.pode_ia(provedor):
raise ValueError('IA indisponível: confira a chave do Gemini ou a IA local nos Ajustes')
return self.ia(provedor, self.montar_prompt(pedido), SCHEMA_PLANO)
def analisar(self, agora=None):
"""A IA cruza a semana e propõe um plano. Só vale se bater com os dados; senão vira sugestão."""
agora = agora or time.time()
plano = self.validar_plano(self.chamar_ia(), 'ia')
nota = self.conferir(plano)
with self.lock:
self.analise['em'] = agora
usar = nota is not None and nota >= CONCORDA and (not self.plano or self.plano['origem'] == 'ia')
if usar:
self.plano, self.sugestao = plano, None
else:
self.sugestao = plano
if usar:
motivo = 'plano adotado: confere %d%% com o que mediu' % round(nota * 100)
elif nota is None:
motivo = 'ficou como sugestão: ainda poucos dados para conferir'
elif self.plano and self.plano['origem'] == 'voce' and nota >= CONCORDA:
motivo = 'ficou como sugestão: a rotina que você contou continua valendo'
else:
motivo = 'ficou como sugestão: confere só %d%% (precisa de %d%%)' % (round(nota * 100), round(CONCORDA * 100))
self.decidir('ia', 'IA analisou %d dias' % len(self.historico), motivo, agora)
if plano['padroes']:
texto = plano['padroes'][0] if usar else 'Acho que ' + plano['padroes'][0][0].lower() + plano['padroes'][0][1:]
self.anotar('aprendizado', texto, agora)
with self.lock:
self.licoes.insert(0, dict(dia=_dia(agora), texto=texto))
del self.licoes[MAX_LICOES:]
return plano
def informar(self, texto, agora=None):
"""A pessoa contou a rotina: vira o plano na hora (os dados conferem depois)."""
texto = str(texto or '').strip()
if not 5 <= len(texto) <= 600:
raise ValueError('Conte a rotina em uma ou duas frases (até 600 letras)')
plano = self.validar_plano(self.chamar_ia(texto), 'voce')
with self.lock:
self.plano, self.sugestao = plano, None
self.analise['em'] = agora or time.time()
self.anotar('aprendizado', 'Você me contou sua rotina. Vou seguir e conferir com o que eu vir.', agora)
self.decidir('voce', 'Passou a seguir a rotina que você contou', '"%s"' % texto[:140], agora)
def chamar_ia_pronta(self):
if not self.ia or not self.pode_ia(self.config['ia']):
raise ValueError('IA indisponível: confira a chave do Gemini ou a IA local nos Ajustes')
def usar_sugestao(self):
with self.lock:
if not self.sugestao:
raise ValueError('Não há sugestão para usar')
self.plano, self.sugestao = dict(self.sugestao, origem='voce'), None
self.salvar()
self.avisar()
return self.status()
def apagar_plano(self):
with self.lock:
self.plano = None
self.salvar()
self.avisar()
return self.status()
# ------------------------------------------------------------------ painel
def status(self):
with self.lock:
gosto = []
for nome, item in self.itens.items():
if item['t'] >= MIN_TENTATIVAS:
gosto.append(dict(id=nome, nome=self.nome(nome), tentativas=round(item['t'], 1),
agradou=round(item['s'], 1), peso=self.peso(nome)))
gosto.sort(key=lambda g: -g['peso'])
hoje = self.hoje
return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina),
sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos],
gosto=gosto, licoes=self.licoes[:12],
semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)]
for s, linha in enumerate(self.semana)],
historico_dias=len(self.historico),
decisoes_hoje=sum(1 for x in self.decisoes if _dia(x['horario']) == self.hoje['dia']),
plano=self.plano and dict(self.plano, confere=self.conferir(self.plano)),
sugestao=self.sugestao and dict(self.sugestao, confere=self.conferir(self.sugestao)),
analise=dict(self.analise, pode=bool(self.ia) and self.pode_ia(self.config['ia']),
minimo=MIN_DIAS_MANUAL),
hoje=dict(dia=hoje['dia'], atos=hoje['atos'], respostas=dict(hoje['respostas']),
acordou=sum(hoje['acordou']), a_toa=sum(hoje['falsos'])))
def avisar(self):
self.publicar('aprendizado', self.status())
def rotas(self, h, metodo):
rota = h.path.split('?', 1)[0]
if not rota.startswith('/aprendizado/'):
return False
try:
if metodo == 'GET' and rota == '/aprendizado/status':
resultado = self.status()
elif metodo == 'GET' and rota == '/aprendizado/dia':
from urllib.parse import parse_qs, urlsplit
resultado = self.dia_a_dia((parse_qs(urlsplit(h.path).query).get('dia') or [None])[0])
elif metodo == 'POST' and rota == '/aprendizado/config':
resultado = self.configurar(ler_json(h, 1024))
elif metodo == 'POST' and rota == '/aprendizado/esquecer':
resultado = self.esquecer()
elif metodo == 'POST' and rota == '/aprendizado/analisar':
ler_json(h, 64)
if len(self.historico) < MIN_DIAS_MANUAL:
raise ValueError('Preciso de pelo menos %d dias observados para analisar' % MIN_DIAS_MANUAL)
self.chamar_ia_pronta()
self.rodar('analise', self.analisar)
resultado = self.status()
elif metodo == 'POST' and rota == '/aprendizado/rotina':
texto = ler_json(h, 4096).get('texto')
if not 5 <= len(str(texto or '').strip()) <= 600:
raise ValueError('Conte a rotina em uma ou duas frases (até 600 letras)')
self.chamar_ia_pronta()
self.rodar('rotina', self.informar, texto)
resultado = self.status()
elif metodo == 'POST' and rota == '/aprendizado/plano':
acao = ler_json(h, 256).get('acao')
if acao == 'usar_sugestao':
resultado = self.usar_sugestao()
elif acao == 'apagar':
resultado = self.apagar_plano()
else:
raise ValueError('Ação: usar_sugestao ou apagar')
else:
raise ValueError('Rota inválida')
h.enviar(json.dumps(resultado, ensure_ascii=False))
except (ValueError, TypeError) as e:
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
return True
+1 -1
View File
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @( $arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'aprendizado.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json', 'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js' 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
) )
+15 -3
View File
@@ -44,6 +44,7 @@ from historico import Historico
from vida import Vida from vida import Vida
from criacao import Criacao from criacao import Criacao
from inspiracao import Inspiracao from inspiracao import Inspiracao
from aprendizado import Aprendizado
TRAVA = threading.Lock() TRAVA = threading.Lock()
@@ -77,7 +78,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. # Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"} PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/criacao/", PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -263,7 +264,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self): def modulos(self):
s = self.server s = self.server
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "inspiracao", "criacao", "treinamento", "conversa", "cerebro", return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)] "braco", "servos", "controle") if hasattr(s, nome)]
def estado(self): def estado(self):
@@ -291,6 +292,8 @@ class Handler(SimpleHTTPRequestHandler):
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca)) dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
if hasattr(s, "vida"): if hasattr(s, "vida"):
dados["vida"] = s.vida.status() dados["vida"] = s.vida.status()
if hasattr(s, "aprendizado"):
dados["aprendizado"] = s.aprendizado.status()
return dados return dados
def do_GET(self): def do_GET(self):
@@ -483,7 +486,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor): def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao"): for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado"):
if hasattr(servidor, nome): if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -553,6 +556,15 @@ def main():
vida.do_dia = lambda: inspiracao.hoje['ids'] vida.do_dia = lambda: inspiracao.hoje['ids']
vida.aprendi_pendente = inspiracao.pendente vida.aprendi_pendente = inspiracao.pendente
vida.contar_aprendi = lambda ident, agora=None: inspiracao.contar(ident, agora) vida.contar_aprendi = lambda ident, agora=None: inspiracao.contar(ident, agora)
# Aprendizado: com o dia a dia (carinhos, conversas, presença, sensor) ele ajusta o que faz e quando
def nome_criada(ident):
return next((a['nome'] for a in servidor.criacao.atividades() if a['id'] == ident), None)
servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada)
aprendizado.anotar = lambda tipo, texto, agora=None: (vida.anotar(tipo, texto, agora), vida.avisar())
vida.aprendizado = aprendizado
# a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10)
aprendizado.ia = servidor.criacao.chamar
aprendizado.pode_ia = lambda provedor: bool(servidor.criacao.provedores().get(provedor, {}).get('pronto'))
# E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada # 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" vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
+12
View File
@@ -0,0 +1,12 @@
# Carinha: http://carinha.local (porta 80) leva ao painel na 8100.
# O 192.168.4.1 fica de fora: é a página de configuração do Wi-Fi no modo ponto de acesso.
table ip carinha {
chain entrada {
type nat hook prerouting priority dstnat; policy accept;
ip daddr != 192.168.4.1 tcp dport 80 redirect to :8100
}
chain local {
type nat hook output priority -100; policy accept;
ip daddr 127.0.0.0/8 tcp dport 80 redirect to :8100
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<!-- Anúncio na rede: http://carinha.local (porta 80) e "_carinha._tcp" (porta 8100), que o app do
celular procura quando o endereço salvo não responde. -->
<service-group>
<name>Carinha</name>
<service><type>_http._tcp</type><port>80</port></service>
<service><type>_carinha._tcp</type><port>8100</port></service>
</service-group>
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Nome na rede: a placa vira "carinha" e responde em http://carinha.local (rodar como root, desta pasta).
set -e
cd "$(dirname "$0")"
apt-get install -y --no-install-recommends avahi-daemon libnss-mdns nftables
hostnamectl set-hostname carinha
sed -i "s/^127\.0\.1\.1.*/127.0.1.1 carinha/" /etc/hosts
grep -q "^127.0.1.1 carinha" /etc/hosts || echo "127.0.1.1 carinha" >> /etc/hosts
install -m 644 carinha.service.avahi /etc/avahi/services/carinha.service
install -m 644 carinha.nft /etc/nftables.d-carinha.nft
grep -q "nftables.d-carinha.nft" /etc/nftables.conf || echo 'include "/etc/nftables.d-carinha.nft"' >> /etc/nftables.conf
nft -c -f /etc/nftables.conf
systemctl enable --now avahi-daemon nftables
systemctl restart avahi-daemon nftables
echo "pronto: http://carinha.local"
+431
View File
@@ -0,0 +1,431 @@
"""Aprendizado com o dia a dia: gosto pelos atos, rotina da casa, sono e o fechamento do dia."""
import http.client
import importlib.util
import json
import random
import sys
import tempfile
import threading
import time
import unittest
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from aprendizado import Aprendizado # noqa: E402
from vida import Vida # noqa: E402
spec = importlib.util.spec_from_file_location('kiosk_aprendizado', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
def em(dia, hora, minuto=0, segundo=0):
return time.mktime((2026, 10, dia, hora, minuto, segundo, 0, 0, -1))
class AprendizadoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.caminho = Path(self.tmp.name) / 'aprendizado.json'
self.a = Aprendizado(self.caminho)
self.a.hoje = self.a._dia_vazio('2026-10-06')
self.diario = []
self.a.anotar = lambda tipo, texto, agora=None: self.diario.append((tipo, texto))
def tearDown(self):
self.tmp.cleanup()
def fazer(self, nome, vezes, resposta=None, inicio=None):
t = inicio or em(6, 15)
for i in range(vezes):
self.a.ato(nome, t + i * 120, perto=True)
if resposta:
self.a.resposta(resposta, t + i * 120 + 20)
def test_gosto_sobe_com_carinho_e_desce_quando_ignorado(self):
self.fazer('rir', 5, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 16))
self.assertGreater(self.a.peso('rir'), 1.0)
self.assertLess(self.a.peso('bocejar'), 1.0)
self.assertLessEqual(self.a.peso('rir'), 2.0)
self.assertGreaterEqual(self.a.peso('bocejar'), 0.5)
def test_sem_opiniao_com_poucas_tentativas_ou_sozinho(self):
self.fazer('rir', 2, 'carinho')
self.assertEqual(self.a.peso('rir'), 1.0)
for i in range(10):
self.a.ato('pensar', em(6, 15) + i, perto=False) # sem plateia ninguém pode gostar
self.assertEqual(self.a.peso('pensar'), 1.0)
self.assertNotIn('pensar', self.a.itens)
def test_resposta_fora_da_janela_nao_conta(self):
for i in range(4):
self.a.ato('acenar', em(6, 15) + i * 300, perto=True)
self.a.resposta('carinho', em(6, 15) + i * 300 + 200) # 200 s depois: já era outra coisa
self.assertEqual(self.a.itens['acenar']['s'], 0)
def test_cada_ato_agrada_uma_vez(self):
self.a.ato('rir', em(6, 15), perto=True)
for _ in range(5):
self.a.resposta('carinho', em(6, 15) + 10)
self.assertEqual(self.a.itens['rir']['s'], 1.0)
def test_fechar_o_dia_gera_licoes_e_esquece_um_pouco(self):
self.fazer('rir', 4, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 17))
t_antes = self.a.itens['rir']['t']
self.a.observar(em(7, 0, 1), perto=False) # meia-noite: fecha o dia 6
textos = [t for _, t in self.diario]
self.assertTrue(any('gostam de me ver dar risada' in t for t in textos), textos)
self.assertTrue(any('Tentei bocejar 5 vezes e ninguém ligou' in t for t in textos), textos)
self.assertAlmostEqual(self.a.itens['rir']['t'], t_antes * 0.85, places=2)
self.assertEqual(self.a.hoje['dia'], '2026-10-07')
self.assertTrue(self.caminho.exists())
def test_rotina_da_casa_e_acordar_antes_de_chegarem(self):
for dia in (6, 7, 8):
for minuto in range(60):
self.a.observar(em(dia, 17, minuto), perto=False) # 17h: casa vazia
self.a.observar(em(dia, 18, minuto), perto=True) # 18h: chegou gente
self.a.observar(em(9, 0, 1), perto=False)
self.assertGreaterEqual(self.a.rotina[18], 0.5)
self.assertLess(self.a.rotina[17], 0.3)
self.assertTrue(any('costumam estar por perto das 18h às 19h' in t for _, t in self.diario), self.diario)
self.assertTrue(self.a.chega_logo(em(9, 17, 50)))
self.assertFalse(self.a.chega_logo(em(9, 17, 10))) # ainda falta muito
self.assertFalse(self.a.chega_logo(em(9, 12, 50)))
def test_acordar_a_toa_pede_mais_movimento_naquela_hora(self):
for dia in (6, 7, 8, 9):
for vez in range(2):
t = em(dia, 22, vez * 20)
self.a.acordou_pelo_sensor(t)
for s in range(0, 200, 5):
self.a.observar(t + s, perto=s < 10) # mexeu e sumiu: foi à toa
self.a.observar(em(dia + 1, 0, 1), perto=False)
self.assertGreaterEqual(self.a.extra_pir(em(10, 22, 30)), 1)
self.assertEqual(self.a.extra_pir(em(10, 15)), 0)
self.assertTrue(any('Acordei à toa 2 vezes por volta das 22h' in t for _, t in self.diario), self.diario)
def test_acordar_com_carinho_valeu_a_pena(self):
t = em(6, 22)
self.a.acordou_pelo_sensor(t)
self.a.observar(t + 5, perto=True)
self.a.resposta('carinho', t + 30)
for s in range(35, 200, 5):
self.a.observar(t + s, perto=False)
self.assertEqual(self.a.hoje['falsos'][22], 0)
def test_desligado_nao_aprende_nem_muda_nada(self):
self.a.configurar(dict(ligado=False))
self.fazer('rir', 6, 'carinho')
self.assertEqual(self.a.peso('rir'), 1.0)
self.assertEqual(self.a.extra_pir(em(6, 22)), 0)
self.assertFalse(self.a.chega_logo(em(6, 17, 50)))
def test_esquecer_e_persistencia(self):
self.fazer('rir', 5, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 17)) # o gosto é relativo: precisa de algo menos querido
self.a.salvar()
outro = Aprendizado(self.caminho)
self.assertGreater(outro.peso('rir'), 1.0)
outro.anotar = lambda *a, **k: None
outro.esquecer()
self.assertEqual(outro.peso('rir'), 1.0)
self.assertEqual(Aprendizado(self.caminho).itens, {})
def test_validar(self):
with self.assertRaises(ValueError):
self.a.configurar(dict(ligado='sim'))
with self.assertRaises(ValueError):
self.a.configurar(dict(outro=True))
class VidaComAprendizadoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'aprendizado.json')
self.a.hoje = self.a._dia_vazio('2026-10-06')
self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(3))
self.v.aprendizado = self.a
self.ordens = []
self.v.enfileirar = self.ordens.append
self.v.ultimo_tick = em(6, 15)
def tearDown(self):
self.tmp.cleanup()
def test_gesto_que_agrada_sai_mais(self):
for i in range(8):
self.a.ato('rir', em(6, 15) + i * 100, perto=True)
self.a.resposta('carinho', em(6, 15) + i * 100 + 5)
self.a.ato('feliz', em(6, 15) + i * 100 + 50, perto=True)
opcoes = [('rir', 1, ''), ('feliz', 1, '')]
saiu = [self.v.escolher(opcoes)[0] for _ in range(400)]
self.assertGreater(saiu.count('rir'), saiu.count('feliz') * 2)
def test_carinho_e_atos_com_plateia_chegam_ao_aprendizado(self):
self.v.alguem_perto = lambda: True
self.v.celular_online = lambda: True
self.v.proximo_ato = 0
self.v.necessidades.update(energia=90, social=90, diversao=90, alegria=90)
ato = None
for i in range(30):
ato = self.v.tick(em(6, 15) + i * 200)
if ato in self.a.itens:
break
self.assertIn(ato, self.a.itens)
self.v.estimulo('carinho', em(6, 15) + i * 200 + 10)
self.assertGreater(self.a.itens[ato]['s'], 0)
def test_pir_pede_mais_quando_acordou_a_toa_naquela_hora(self):
self.a.falsos[22] = 1.0
self.assertFalse(self.v.pir_acorda(em(6, 22)))
self.assertTrue(self.v.pir_acorda(em(6, 22) + 10))
self.assertEqual(self.a.acordou_em, em(6, 22) + 10)
self.assertTrue(self.v.pir_acorda(em(6, 15)))
def test_acorda_do_cochilo_antes_de_chegarem(self):
self.a.rotina[18], self.a.rotina[17] = 0.8, 0.1
self.v.dormindo_proprio = True
self.v.necessidades['energia'] = 70
self.assertEqual(self.v.agir(em(6, 17, 50)), 'acordar')
self.assertIn('acordar', self.ordens)
def test_sono_profundo_nao_e_interrompido_pela_rotina(self):
self.a.rotina[18], self.a.rotina[17] = 0.8, 0.1
self.v.dormindo_proprio = True
self.v.sono_profundo = True
self.v.necessidades['energia'] = 70
self.assertIsNone(self.v.agir(em(6, 17, 50)))
def semana_tipica(a, semanas=2):
"""Segunda, sexta e fim de semana em casa (7h-23h); terça a quinta fora das 8h às 18h. Começa em 05/10 (segunda)."""
inicio = date(2026, 10, 5)
for i in range(semanas * 7):
d = inicio + timedelta(days=i)
a.hoje = a._dia_vazio(d.isoformat())
for h in range(24):
for m in range(0, 60, 2):
fora = d.weekday() in (1, 2, 3) and 8 <= h < 18
perto = 7 <= h < 23 and not fora and m < 40
a.minuto = None
a.observar(time.mktime((d.year, d.month, d.day, h, m, 0, 0, 0, -1)), perto)
fim = d + timedelta(days=1)
a.observar(time.mktime((fim.year, fim.month, fim.day, 0, 0, 30, 0, 0, -1)), False)
def plano_certo():
dias = []
for dia in range(7):
if dia in (1, 2, 3):
periodos = [dict(ini=0, fim=7, tipo='dormindo'), dict(ini=7, fim=8, tipo='casa'),
dict(ini=8, fim=18, tipo='fora'), dict(ini=18, fim=23, tipo='casa'), dict(ini=23, fim=24, tipo='dormindo')]
else:
periodos = [dict(ini=0, fim=7, tipo='dormindo'), dict(ini=7, fim=23, tipo='casa'), dict(ini=23, fim=24, tipo='dormindo')]
dias.append(dict(dia=dia, periodos=periodos))
return dict(padroes=['Percebi que de terça a quinta a casa fica vazia das 8h às 18h'], dias=dias)
class SemanaTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.diario = []
self.a.anotar = lambda tipo, texto, agora=None: self.diario.append(texto)
self.pedidos = []
self.resposta = plano_certo()
def ia(provedor, prompt, schema):
self.pedidos.append((provedor, prompt))
return self.resposta
self.a.ia, self.a.pode_ia = ia, lambda provedor: True
self.a.config['analise_ia'] = False # nos testes a análise é chamada direto
def tearDown(self):
self.tmp.cleanup()
def test_quadro_da_semana_separa_os_dias(self):
semana_tipica(self.a)
self.assertEqual(len(self.a.historico), 14)
self.assertLess(self.a.semana[2][10], 0.3) # quarta 10h: fora
self.assertGreater(self.a.semana[0][10], 0.5) # segunda 10h: em casa
self.assertGreater(self.a.semana[4][10], 0.5) # sexta 10h: em casa
self.assertEqual(self.a.esperado(em(14, 10)), 'fora') # 14/10 é quarta
self.assertEqual(self.a.esperado(em(12, 10)), 'casa') # 12/10 é segunda
self.assertTrue(self.a.chega_logo(em(14, 17, 50))) # quarta: chegam às 18h
self.assertFalse(self.a.chega_logo(em(12, 17, 50))) # segunda: já tem gente em casa
self.assertTrue(self.a.casa_vazia(em(14, 10)))
def test_plano_da_ia_que_bate_com_os_dados_e_usado(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
self.assertIn('quarta', self.pedidos[0][1])
self.assertIn('sexta (2 dias medidos)', self.pedidos[0][1])
self.assertEqual(self.a.plano['origem'], 'ia')
self.assertGreaterEqual(self.a.conferir(self.a.plano), 0.7)
self.assertIsNone(self.a.sugestao)
self.assertEqual(self.a.esperado(em(14, 10)), 'fora')
self.assertEqual(self.a.esperado(em(14, 2)), 'dormindo')
self.assertTrue(any('terça a quinta' in t for t in self.diario))
def test_plano_da_ia_que_nao_bate_vira_so_sugestao(self):
semana_tipica(self.a)
errado = plano_certo()
for d in errado['dias']:
d['periodos'] = [dict(ini=0, fim=24, tipo='fora')] # "a casa vive vazia": falso
self.resposta = errado
self.a.analisar(em(19, 1))
self.assertIsNone(self.a.plano)
self.assertIsNotNone(self.a.sugestao)
self.assertLess(self.a.conferir(self.a.sugestao), 0.7)
self.assertTrue(any(t.startswith('Acho que') for t in self.diario))
self.a.usar_sugestao()
self.assertEqual(self.a.plano['origem'], 'voce')
def test_rotina_contada_vale_na_hora_e_a_ia_nao_atropela(self):
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
self.assertIn('Fico fora de terça a quinta', self.pedidos[0][1])
self.assertEqual(self.a.plano['origem'], 'voce')
self.assertIsNone(self.a.conferir(self.a.plano)) # ainda sem dados para conferir
semana_tipica(self.a)
self.assertGreaterEqual(self.a.conferir(self.a.plano), 0.7)
self.a.analisar(em(19, 1))
self.assertEqual(self.a.plano['origem'], 'voce') # o que a pessoa disse continua valendo
self.assertIsNotNone(self.a.sugestao)
def test_validar_plano_conserta_o_que_a_ia_erra(self):
p = Aprendizado.validar_plano(dict(padroes=['ok'] * 9, dias=[
dict(dia=0, periodos=[dict(ini=8, fim=20, tipo='casa'), dict(ini=18, fim=30, tipo='fora'),
dict(ini=5, fim=5, tipo='casa'), dict(ini=1, fim=3, tipo='festa')]),
dict(dia=9, periodos=[dict(ini=0, fim=24, tipo='casa')]), 'lixo']), 'ia')
self.assertEqual(p['dias'][0], [dict(ini=8, fim=18, tipo='casa'), dict(ini=18, fim=24, tipo='fora')])
self.assertEqual(len(p['padroes']), 5)
with self.assertRaises(ValueError):
Aprendizado.validar_plano(dict(padroes=[], dias=[]), 'ia')
def test_analise_roda_sozinha_a_meia_noite_com_uma_semana(self):
self.a.config['analise_ia'] = True
semana_tipica(self.a, semanas=1)
for _ in range(50):
if not self.a.analise['ocupado'] and (self.a.plano or self.a.sugestao):
break
time.sleep(0.05)
self.assertEqual(len(self.pedidos), 1) # só na 7ª meia-noite
self.assertIsNotNone(self.a.plano or self.a.sugestao)
def test_persistencia_da_semana_e_do_plano(self):
semana_tipica(self.a, semanas=1)
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
self.a.salvar()
outro = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.assertEqual(outro.semana, self.a.semana)
self.assertEqual(outro.plano['dias'], self.a.plano['dias'])
self.assertEqual(len(outro.historico), 7)
def test_casa_vazia_faz_ele_cochilar_para_guardar_pique(self):
v = Vida(Path(self.tmp.name) / 'v.json', iniciar=False, rng=random.Random(5))
v.aprendizado = self.a
v.enfileirar = lambda ordem: None
v.celular_online = lambda: True
v.necessidades.update(energia=70, diversao=80, social=80, alegria=80)
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
v.visto_em = em(14, 9)
atos = [v.agir(em(14, 10) + i * 120) for i in range(10)]
self.assertIn('atividade dormir', atos)
cochilo = next(d for d in self.a.decisoes if d['tipo'] == 'cochilo')
self.assertEqual(cochilo['motivo'], 'às 10h de quarta a casa costuma ficar vazia, pelo plano que você contou')
class DiaADiaTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.a.ia, self.a.pode_ia = (lambda provedor, prompt, schema: plano_certo()), (lambda provedor: True)
self.a.config['analise_ia'] = False
self.v = Vida(Path(self.tmp.name) / 'v.json', iniciar=False, rng=random.Random(2))
self.v.aprendizado = self.a
self.v.enfileirar = lambda ordem: None
def tearDown(self):
self.tmp.cleanup()
def test_cada_decisao_guarda_o_porque(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
self.assertEqual(self.a.decisoes[0]['tipo'], 'ia')
self.assertIn('plano adotado: confere', self.a.decisoes[0]['motivo'])
# acordar antes da chegada (quarta, 17h50)
self.v.dormindo_proprio = True
self.v.necessidades['energia'] = 70
self.assertEqual(self.v.agir(em(14, 17, 50)), 'acordar')
self.assertEqual(self.a.decisoes[0]['motivo'], 'às 18h de quarta costuma ter gente, pelo plano da IA')
# sensor segurado numa hora em que acordou à toa
self.a.falsos[22] = 1.0
self.v.pir_acorda(em(14, 22))
self.v.pir_acorda(em(14, 22) + 30) # a 2ª nota em menos de 10 min não repete
notas = [d for d in self.a.decisoes if d['tipo'] == 'sono']
self.assertEqual(len(notas), 1)
self.assertIn('pede 1 movimento a mais', notas[0]['motivo'])
def test_fechar_o_dia_resume_e_confere_com_o_plano(self):
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
semana_tipica(self.a, semanas=1)
fechou = [d for d in self.a.decisoes if d['tipo'] == 'dia']
self.assertEqual(len(fechou), 7)
quarta = next(d for d in fechou if 'quarta' in d['texto'])
self.assertIn('conferiu 100% com o plano', quarta['motivo'])
def test_dia_a_dia_de_um_dia_passado_e_de_hoje(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
d = self.a.dia_a_dia('2026-10-14')
self.assertEqual(d['nome_dia'], 'quarta')
self.assertFalse(d['hoje'])
self.assertLess(d['medido'][10], 0.3)
self.assertEqual(d['plano'][2], dict(ini=8, fim=18, tipo='fora'))
self.assertEqual(d['confere'], 1.0)
self.assertTrue(any(x['tipo'] == 'dia' for x in d['decisoes']))
self.assertIn('2026-10-05', d['dias'])
hoje = self.a.dia_a_dia()
self.assertTrue(hoje['hoje'])
with self.assertRaises(ValueError):
self.a.dia_a_dia('ontem')
class RotasTest(unittest.TestCase):
def test_status_config_e_esquecer(self):
with tempfile.TemporaryDirectory() as tmp:
kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'a.json')
s = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
s.aprendizado = Aprendizado(Path(tmp) / 'ap.json')
kiosk.ligar_eventos(s)
threading.Thread(target=s.serve_forever, daemon=True).start()
try:
def pedir(metodo, rota, corpo=None):
c = http.client.HTTPConnection('127.0.0.1', s.server_port, timeout=5)
dados = json.dumps(corpo).encode() if corpo is not None else None
c.request(metodo, rota, body=dados, headers={'Content-Type': 'application/json'} if dados else {})
r = c.getresponse()
return r.status, json.loads(r.read())
cod, st = pedir('GET', '/aprendizado/status')
self.assertEqual(cod, 200)
self.assertEqual(len(st['rotina']), 24)
cod, st = pedir('POST', '/aprendizado/config', dict(ligado=False))
self.assertEqual((cod, st['config']['ligado']), (200, False))
cod, st = pedir('POST', '/aprendizado/config', dict(ligado='x'))
self.assertEqual(cod, 400)
cod, st = pedir('POST', '/aprendizado/esquecer', {})
self.assertEqual((cod, st['dias']), (200, 0))
cod, est = pedir('GET', '/estado')
self.assertIn('aprendizado', est)
finally:
s.shutdown()
kiosk.EVENTOS.fechar()
if __name__ == '__main__':
unittest.main()
+48
View File
@@ -112,6 +112,7 @@ PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícar
(0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])] (0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])]
MAX_DIARIO = 60 MAX_DIARIO = 60
GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos}
def _limitar(v): def _limitar(v):
@@ -147,6 +148,7 @@ class Vida:
self.do_dia = lambda: [] # ids das ideias de hoje self.do_dia = lambda: [] # ids das ideias de hoje
self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar
self.contar_aprendi = lambda ident, agora=None: None self.contar_aprendi = lambda ident, agora=None: None
self.aprendizado = None # o que ele aprende com o dia a dia (aprendizado.py)
agora = time.time() agora = time.time()
self.config = dict(PADRAO) self.config = dict(PADRAO)
@@ -319,6 +321,8 @@ class Vida:
self.anotar('cuidado', CUIDADOS[tipo], agora) self.anotar('cuidado', CUIDADOS[tipo], agora)
if tipo in CUIDADOS or tipo == 'conversa': if tipo in CUIDADOS or tipo == 'conversa':
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta) self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
if self.aprendizado:
self.aprendizado.resposta(tipo, agora)
if gesto and not self.bloqueado(): if gesto and not self.bloqueado():
if self.dormindo(): if self.dormindo():
self.acordar(agora, None) self.acordar(agora, None)
@@ -369,7 +373,10 @@ class Vida:
else: else:
lista = ATIVAS + CRIATIVAS * 2 + CALMAS lista = ATIVAS + CRIATIVAS * 2 + CALMAS
opcoes = [a for a in lista if a != self.ultima_atividade] or lista opcoes = [a for a in lista if a != self.ultima_atividade] or lista
pesos = [self.gosto(a) for a in opcoes]
if all(p == 1.0 for p in pesos):
return self.rng.choice(opcoes) return self.rng.choice(opcoes)
return self.rng.choices(opcoes, weights=pesos)[0] # as que agradam saem mais
def comecar_atividade(self, agora, nome, segundos): def comecar_atividade(self, agora, nome, segundos):
if nome.startswith('criado:'): if nome.startswith('criado:'):
@@ -394,8 +401,14 @@ class Vida:
agora = agora or time.time() agora = agora or time.time()
self.pir_eventos = [t for t in self.pir_eventos if agora - t < 120] + [agora] self.pir_eventos = [t for t in self.pir_eventos if agora - t < 120] + [agora]
precisa = PIR_ACORDAR[self.config['ritmo']] if self.config['ligado'] else 1 precisa = PIR_ACORDAR[self.config['ritmo']] if self.config['ligado'] else 1
extra = self.aprendizado.extra_pir(agora) if self.aprendizado and self.config['ligado'] else 0
if extra and precisa <= len(self.pir_eventos) < precisa + extra:
self.aprendizado.segurou_sono(agora, precisa, extra)
precisa += extra # nesta hora já acordou à toa outras vezes
if len(self.pir_eventos) >= precisa: if len(self.pir_eventos) >= precisa:
self.pir_eventos = [] self.pir_eventos = []
if self.aprendizado and self.config['ligado']:
self.aprendizado.acordou_pelo_sensor(agora)
return True return True
return False return False
@@ -406,7 +419,12 @@ class Vida:
return hora >= ini or hora < fim return hora >= ini or hora < fim
# ------------------------------------------------------------------ decidir o que fazer # ------------------------------------------------------------------ decidir o que fazer
def gosto(self, nome):
"""Quanto o aprendizado multiplica a chance de um ato (1 = sem opinião)."""
return self.aprendizado.peso(nome) if self.aprendizado else 1.0
def escolher(self, opcoes): def escolher(self, opcoes):
opcoes = [(g, p * self.gosto(g), t) for g, p, t in opcoes]
total = sum(p for _, p, _ in opcoes) total = sum(p for _, p, _ in opcoes)
x = self.rng.uniform(0, total) x = self.rng.uniform(0, total)
for gesto, peso, texto in opcoes: for gesto, peso, texto in opcoes:
@@ -473,6 +491,12 @@ class Vida:
if n['energia'] >= DESCANSADO and not self.madrugada(agora): if n['energia'] >= DESCANSADO and not self.madrugada(agora):
self.acordar(agora, 'Acordou descansado') self.acordar(agora, 'Acordou descansado')
return 'acordar' return 'acordar'
# aprendeu a rotina: acorda do cochilo um pouco antes da hora em que costuma ter gente
if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50
and self.aprendizado.chega_logo(agora)):
self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém')
self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.aprendizado.porque_chegada(agora), agora)
return 'acordar'
return None return None
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO: if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
self.dormir(agora, 'Ficou tarde, foi dormir') self.dormir(agora, 'Ficou tarde, foi dormir')
@@ -495,6 +519,13 @@ class Vida:
if perto: if perto:
self.visto_em = agora self.visto_em = agora
sozinho = agora - self.visto_em > SOZINHO sozinho = agora - self.visto_em > SOZINHO
# nesta hora a casa costuma ficar vazia: um cochilo longo guarda pique para quando chegarem
if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85
and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3):
segundos = self.rng.uniform(600, 1200)
self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60),
self.aprendizado.porque_vazia(agora), agora)
return self.comecar_atividade(agora, 'dormir', segundos)
# alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!" # alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!"
if dia and perto and humor != 'bravo': if dia and perto and humor != 'bravo':
item = self.aprendi_pendente() item = self.aprendi_pendente()
@@ -522,6 +553,8 @@ class Vida:
if sozinho: # sem plateia: nada de acenar ou ficar tímido if sozinho: # sem plateia: nada de acenar ou ficar tímido
lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3] lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
gesto, texto = self.escolher(lista) gesto, texto = self.escolher(lista)
if self.aprendizado and perto:
self.aprendizado.escolheu(gesto, agora)
self.enfileirar(gesto) self.enfileirar(gesto)
if humor == 'entediado': if humor == 'entediado':
with self.lock: with self.lock:
@@ -529,11 +562,25 @@ class Vida:
self.anotar('acao', texto, agora) self.anotar('acao', texto, agora)
return gesto return gesto
def aprender_ato(self, ato, agora):
"""Gestos e atividades feitos com plateia entram no aprendizado (para ver se agradam)."""
if not self.aprendizado or not ato:
return
if ato.startswith('atividade '):
nome = ato[len('atividade '):]
elif ato in GESTOS_VIDA:
nome = ato
else:
return
self.aprendizado.ato(nome, agora, self.alguem_perto())
def tick(self, agora=None): def tick(self, agora=None):
agora = agora or time.time() agora = agora or time.time()
self.passar_tempo(agora) self.passar_tempo(agora)
if not self.config['ligado']: if not self.config['ligado']:
return None return None
if self.aprendizado:
self.aprendizado.observar(agora, self.alguem_perto())
ato = None ato = None
humor = self.humor() humor = self.humor()
# acordaram ele de propósito (toque, painel) antes de descansar: o sono pesado acaba aqui # acordaram ele de propósito (toque, painel) antes de descansar: o sono pesado acaba aqui
@@ -554,6 +601,7 @@ class Vida:
self.zanga_enviada = zanga self.zanga_enviada = zanga
if agora >= self.proximo_ato: if agora >= self.proximo_ato:
ato = self.agir(agora) ato = self.agir(agora)
self.aprender_ato(ato, agora)
lo, hi = RITMOS[self.config['ritmo']] lo, hi = RITMOS[self.config['ritmo']]
self.proximo_ato = agora + self.rng.uniform(lo, hi) self.proximo_ato = agora + self.rng.uniform(lo, hi)
if ato or agora - self.publicado_em > 30 or humor != self.humor(): if ato or agora - self.publicado_em > 30 or humor != self.humor():
+2
View File
@@ -20,6 +20,7 @@
import Eventos from './telas/Eventos.svelte'; import Eventos from './telas/Eventos.svelte';
import Guia from './telas/Guia.svelte'; import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte'; import Criar from './telas/Criar.svelte';
import DiaADia from './telas/DiaADia.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte'; import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia'; import { TOPICO_DA_TELA } from './lib/guia';
@@ -33,6 +34,7 @@
rosto: Rosto, rosto: Rosto,
guia: Guia, guia: Guia,
criar: Criar, criar: Criar,
'dia-a-dia': DiaADia,
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
+34
View File
@@ -0,0 +1,34 @@
/** Aprendizado com o dia a dia: ajustes, análise da semana com IA e a rotina contada por você.
* O estado chega pelo SSE 'aprendizado'. */
import { postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
import type { Aprendizado } from './tipos';
export async function configurarAprendizado(parcial: Partial<Aprendizado['config']>) {
const antes = estado.aprendizado;
if (antes) estado.aprendizado = { ...antes, config: { ...antes.config, ...parcial } };
try {
estado.aprendizado = await postar<Aprendizado>('/aprendizado/config', parcial);
} catch (e) {
estado.aprendizado = antes;
avisos.erro(e);
}
}
async function pedir(rota: string, corpo: object, sucesso?: string) {
try {
estado.aprendizado = await postar<Aprendizado>(rota, corpo);
if (sucesso) avisos.ok(sucesso);
return true;
} catch (e) {
avisos.erro(e);
return false;
}
}
export const esquecerAprendizado = () => pedir('/aprendizado/esquecer', {}, 'Ele esqueceu o que tinha aprendido e começa do zero');
export const analisarSemana = () => pedir('/aprendizado/analisar', {});
export const contarRotina = (texto: string) => pedir('/aprendizado/rotina', { texto });
export const usarSugestao = () => pedir('/aprendizado/plano', { acao: 'usar_sugestao' }, 'Plano da IA em uso');
export const apagarPlano = () => pedir('/aprendizado/plano', { acao: 'apagar' }, 'Plano apagado: ele volta a seguir só o que mede');
@@ -0,0 +1,543 @@
<script lang="ts">
import { CalendarClock, CalendarDays, GraduationCap } from '@lucide/svelte';
import {
analisarSemana,
apagarPlano,
configurarAprendizado,
contarRotina,
esquecerAprendizado,
usarSugestao,
} from '../aprendizado.svelte';
import { estado } from '../estado.svelte';
import { nav } from '../rotas.svelte';
import type { PlanoSemana, TipoPeriodo } from '../tipos';
import Grupo from './Grupo.svelte';
import Interruptor from './Interruptor.svelte';
import Linha from './Linha.svelte';
// O que ele aprendeu com o dia a dia: a semana da casa (medida e planejada), do que gostam,
// as lições e o sono. A IA cruza a semana; você também pode contar a rotina.
const a = $derived(estado.aprendizado);
let confirmar = $state(false);
let vista = $state<'visto' | 'plano'>('visto');
let rotina = $state('');
const DIAS = ['seg', 'ter', 'qua', 'qui', 'sex', 'sáb', 'dom'];
const NOME_TIPO: Record<TipoPeriodo, string> = { casa: 'em casa', fora: 'casa vazia', dormindo: 'dormindo' };
const IAS = [
{ valor: 'gemini', nome: 'Gemini' },
{ valor: 'local', nome: 'IA local' },
{ valor: 's10', nome: 'Qwen do S10' },
] as const;
const agora = $derived(new Date(estado.agora));
const hojeSemana = $derived((agora.getDay() + 6) % 7); // 0 = segunda, como na placa
const horaAgora = $derived(agora.getHours());
const temDados = $derived(!!a && a.semana.some((l) => l.some((v) => v !== null)));
const plano = $derived(a?.plano ?? null);
const verPlano = $derived(vista === 'plano' && !!plano);
const faz_mais = $derived(a ? a.gosto.filter((g) => g.peso > 1.05).slice(0, 4) : []);
const faz_menos = $derived(a ? [...a.gosto].reverse().filter((g) => g.peso < 0.95).slice(0, 3) : []);
const respostas = $derived(a ? Object.values(a.hoje.respostas).reduce((s, n) => s + n, 0) : 0);
const ocupado = $derived(!!a?.analise.ocupado);
const faltamDias = $derived(a ? Math.max(0, a.analise.minimo - a.historico_dias) : 0);
const detalhe = $derived.by(() => {
if (!a) return '';
if (!a.config.ligado) return 'Desligado: ele faz tudo como veio de fábrica';
const dias = a.dias === 0 ? 'primeiro dia observando' : a.dias === 1 ? '1 dia observado' : `${a.dias} dias observados`;
return `${dias} · hoje: ${a.hoje.atos} ${a.hoje.atos === 1 ? 'gesto' : 'gestos'} com alguém perto, ${respostas} ${respostas === 1 ? 'carinho ou conversa' : 'carinhos ou conversas'}`;
});
function tipoNoPlano(p: PlanoSemana, dia: number, h: number): TipoPeriodo | null {
return p.dias[dia]?.find((x) => x.ini <= h && h < x.fim)?.tipo ?? null;
}
function rotuloCelula(dia: number, h: number) {
if (verPlano && plano) {
const t = tipoNoPlano(plano, dia, h);
return `${DIAS[dia]} ${h}h: ${t ? NOME_TIPO[t] : 'sem plano'}`;
}
const v = a?.semana[dia][h];
return `${DIAS[dia]} ${h}h: ${v == null ? 'sem dados' : Math.round(v * 100) + '% do tempo com gente'}`;
}
function confere(p: PlanoSemana) {
return p.confere === null ? 'ainda conferindo com o que eu vejo' : `confere ${Math.round(p.confere * 100)}% com o que eu vi`;
}
function quando(s: number | null) {
if (!s) return '';
return new Date(s * 1000).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function dia(iso: string) {
const [, m, d] = iso.split('-');
return `${d}/${m}`;
}
function vezes(peso: number) {
return '×' + peso.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
}
async function enviarRotina(e: SubmitEvent) {
e.preventDefault();
if (await contarRotina(rotina.trim())) {
rotina = '';
vista = 'plano';
}
}
</script>
<Grupo
titulo="O que ele aprendeu"
ajuda="aprendizado"
rodape="Ele conta o que acontece em casa: quando tem gente, o que agrada e quando acorda à toa. À meia-noite junta o dia, escreve as lições e a IA cruza a semana."
>
{#if !a}
<p class="vazio">Carregando…</p>
{:else}
<Linha icone={GraduationCap} cor="#30b0c7" rotulo="Aprender com o dia a dia" {detalhe}>
{#snippet acessorio()}
<Interruptor rotulo="Aprender com o dia a dia" checked={a.config.ligado} onchange={(l) => configurarAprendizado({ ligado: l })} />
{/snippet}
</Linha>
{#if a.config.ligado}
<Linha
icone={CalendarDays}
cor="#5e5ce6"
rotulo="Ver dia a dia"
detalhe={a.decisoes_hoje
? `${a.decisoes_hoje} ${a.decisoes_hoje === 1 ? 'decisão' : 'decisões'} hoje, com o porquê`
: 'O que ele viu, o que esperava e as decisões de cada dia'}
onclick={() => nav.ir('dia-a-dia')}
/>
<section class="bloco" aria-labelledby="ap-semana">
<div class="cabeca">
<h3 id="ap-semana">Semana da casa</h3>
{#if plano}
<span class="segmentos" role="radiogroup" aria-label="Mostrar">
<button role="radio" aria-checked={vista === 'visto'} class:ativo={vista === 'visto'} onclick={() => (vista = 'visto')}>O que eu vi</button>
<button role="radio" aria-checked={vista === 'plano'} class:ativo={vista === 'plano'} onclick={() => (vista = 'plano')}>Plano</button>
</span>
{/if}
</div>
<div class="quadro-rolagem">
<div class="quadro" class:modo-plano={verPlano} role="img" aria-label={verPlano ? 'Plano da semana' : 'Presença medida por dia da semana e hora'}>
{#each DIAS as nomeDia, d (d)}
<span class="dia" class:hoje={d === hojeSemana}>{nomeDia}</span>
{#each a.semana[d] as v, h (h)}
{@const t = verPlano && plano ? tipoNoPlano(plano, d, h) : null}
<span
class="celula {t ?? ''}"
class:sem={!verPlano && v === null}
class:agora={d === hojeSemana && h === horaAgora}
style={!verPlano && v !== null ? `--v: ${v}` : ''}
title={rotuloCelula(d, h)}
></span>
{/each}
{/each}
<span></span>
{#each Array(24) as _, h (h)}
<span class="hora mono" aria-hidden="true">{h % 6 === 0 ? h + 'h' : ''}</span>
{/each}
</div>
</div>
{#if verPlano}
<p class="legenda">
<span class="ponto casa"></span> em casa <span class="ponto fora"></span> casa vazia <span class="ponto dormindo"></span> dormindo
</p>
{:else if !temDados}
<p class="nota">Cada dia aparece aqui depois da meia-noite. Com 2 ou 3 semanas ele separa bem um dia do outro.</p>
{:else if a.sono.some((n) => n > 0)}
<p class="nota">☾ Em algumas horas ele acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.</p>
{/if}
</section>
<section class="bloco" aria-labelledby="ap-rotina">
<h3 id="ap-rotina"><CalendarClock size={13} /> A rotina que ele entendeu</h3>
{#if plano}
<div class="plano">
<span class="selo" class:voce={plano.origem === 'voce'}>{plano.origem === 'voce' ? 'Você contou' : 'A IA percebeu'}</span>
<span class="confere">{confere(plano)}</span>
{#if plano.padroes.length}
<ul class="padroes">
{#each plano.padroes as p (p)}<li>{p}</li>{/each}
</ul>
{/if}
<button class="btn fantasma pequeno" onclick={apagarPlano}>Apagar plano</button>
</div>
{:else}
<p class="nota">Sem plano ainda: ele segue só o que mede em cada dia da semana.</p>
{/if}
{#if a.sugestao}
<div class="plano sugestao">
<span class="selo">Sugestão da IA</span>
<span class="confere">{confere(a.sugestao)}</span>
<ul class="padroes">
{#each a.sugestao.padroes as p (p)}<li>{p}</li>{/each}
</ul>
<button class="btn pequeno" onclick={usarSugestao}>Usar este plano</button>
</div>
{/if}
<form class="contar" onsubmit={enviarRotina}>
<label for="ap-contar">Conte sua rotina</label>
<textarea
id="ap-contar"
rows="2"
maxlength="600"
placeholder="Ex.: fico fora de terça a quinta, das 8h às 18h. Segunda, sexta e fim de semana fico em casa."
bind:value={rotina}
></textarea>
<div class="acoes">
<button class="btn pequeno" type="submit" disabled={ocupado || rotina.trim().length < 5 || !a.analise.pode}>Ensinar</button>
<button
class="btn fantasma pequeno"
type="button"
onclick={analisarSemana}
disabled={ocupado || faltamDias > 0 || !a.analise.pode}
title={faltamDias > 0 ? `Faltam ${faltamDias} dias observados` : ''}
>
{ocupado && a.analise.tipo === 'analise' ? 'Analisando…' : 'Analisar a semana agora'}
</button>
{#if ocupado && a.analise.tipo === 'rotina'}<span class="nota">Entendendo sua rotina…</span>{/if}
</div>
{#if a.analise.erro}
<p class="erro" role="alert">{a.analise.erro}</p>
{:else if !a.analise.pode}
<p class="nota">A IA escolhida não está pronta. Confira a chave do Gemini ou a IA local nos Ajustes.</p>
{:else if faltamDias > 0}
<p class="nota">A análise da IA libera com {a.analise.minimo} dias observados (faltam {faltamDias}). Contar a rotina já funciona.</p>
{:else if a.analise.em}
<p class="nota">Última análise: {quando(a.analise.em)}</p>
{/if}
</form>
<div class="ajustes-ia">
<Interruptor rotulo="Analisar todo dia com IA" checked={a.config.analise_ia} onchange={(l) => configurarAprendizado({ analise_ia: l })} />
<span>Analisar todo dia com</span>
<select aria-label="IA da análise" value={a.config.ia} onchange={(e) => configurarAprendizado({ ia: e.currentTarget.value as any })}>
{#each IAS as ia (ia.valor)}<option value={ia.valor}>{ia.nome}</option>{/each}
</select>
</div>
</section>
{#if faz_mais.length || faz_menos.length}
<section class="bloco" aria-labelledby="ap-gosto">
<h3 id="ap-gosto">Do que vocês gostam</h3>
<div class="colunas">
<ul>
<li class="titulo">Faz mais</li>
{#each faz_mais as g (g.id)}
<li class="mais"><span>{g.nome}</span><b class="mono">{vezes(g.peso)}</b></li>
{:else}
<li class="nada">Nada se destacou ainda</li>
{/each}
</ul>
<ul>
<li class="titulo">Faz menos</li>
{#each faz_menos as g (g.id)}
<li class="menos"><span>{g.nome}</span><b class="mono">{vezes(g.peso)}</b></li>
{:else}
<li class="nada">Nada foi ignorado</li>
{/each}
</ul>
</div>
</section>
{/if}
<section class="bloco" aria-labelledby="ap-licoes">
<h3 id="ap-licoes">Lições</h3>
{#if a.licoes.length}
<ol class="licoes">
{#each a.licoes.slice(0, 5) as l (l.dia + l.texto)}
<li><time class="mono">{dia(l.dia)}</time> <span>{l.texto}</span></li>
{/each}
</ol>
{:else}
<p class="nota">A primeira lição chega à meia-noite. Faça carinho ou converse logo depois de um gesto que você gostou.</p>
{/if}
</section>
{/if}
<div class="esquecer">
{#if confirmar}
<span>Apagar semana, plano, gostos e lições?</span>
<button class="btn pequeno perigo" onclick={() => { confirmar = false; esquecerAprendizado(); }}>Esquecer tudo</button>
<button class="btn fantasma pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
{:else}
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!a.dias && !a.gosto.length && !a.licoes.length && !plano}>
Esquecer o que aprendeu
</button>
{/if}
</div>
{/if}
</Grupo>
<style>
.vazio,
.nota {
margin: 0;
color: var(--texto-3);
font-size: var(--t-xs);
}
.vazio {
padding: var(--e-3) var(--e-4);
}
.erro {
margin: 0;
color: var(--perigo);
font-size: var(--t-xs);
}
.bloco {
display: grid;
gap: var(--e-2);
padding: var(--e-3) var(--e-4);
border-top: 1px solid var(--borda);
}
.cabeca {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--e-2);
}
h3 {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--texto-3);
}
.segmentos {
display: inline-flex;
padding: 2px;
border-radius: var(--raio-sm);
background: var(--superficie-3);
}
.segmentos button {
min-height: 26px;
padding: 0 var(--e-2);
border: 0;
border-radius: calc(var(--raio-sm) - 2px);
background: transparent;
color: var(--texto-2);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.segmentos button.ativo {
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra);
}
.quadro-rolagem {
overflow-x: auto;
}
.quadro {
display: grid;
grid-template-columns: 2.2em repeat(24, minmax(9px, 1fr));
gap: 2px;
min-width: 280px;
align-items: center;
}
.dia {
font-size: 10px;
color: var(--texto-3);
}
.dia.hoje {
color: var(--texto);
font-weight: 600;
}
.celula {
height: 14px;
border-radius: 2px;
background: color-mix(in srgb, #30b0c7 calc(var(--v, 0) * 100%), var(--superficie-2));
}
.celula.sem {
background: var(--superficie-2);
opacity: 0.5;
}
.modo-plano .celula {
background: var(--superficie-2);
}
.modo-plano .celula.casa,
.ponto.casa {
background: #30b0c7;
}
.modo-plano .celula.fora,
.ponto.fora {
background: color-mix(in srgb, var(--texto-3) 35%, var(--superficie-2));
}
.modo-plano .celula.dormindo,
.ponto.dormindo {
background: #5e5ce6;
}
.celula.agora {
outline: 1.5px solid var(--texto);
outline-offset: 1px;
}
.hora {
font-size: 9px;
color: var(--texto-3);
white-space: nowrap;
}
.legenda {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--e-2);
margin: 0;
font-size: var(--t-xs);
color: var(--texto-3);
}
.ponto {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.plano {
display: grid;
gap: 6px;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--superficie-2);
justify-items: start;
}
.plano.sugestao {
background: transparent;
border: 1px dashed var(--borda);
}
.selo {
font-size: var(--t-xs);
font-weight: 600;
color: #30b0c7;
}
.selo.voce {
color: var(--texto);
}
.confere {
font-size: var(--t-xs);
color: var(--texto-3);
}
.padroes {
display: grid;
gap: 4px;
margin: 0;
padding-left: 1.1em;
font-size: var(--t-sm);
color: var(--texto);
}
.contar {
display: grid;
gap: 6px;
}
.contar label {
font-size: var(--t-xs);
color: var(--texto-2);
}
.contar textarea {
width: 100%;
box-sizing: border-box;
padding: var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
background: var(--superficie);
color: var(--texto);
font: var(--t-sm) var(--fonte);
resize: vertical;
}
.acoes,
.ajustes-ia {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--e-2);
}
.ajustes-ia {
font-size: var(--t-sm);
color: var(--texto-2);
}
.ajustes-ia select {
width: auto;
flex: none;
min-height: 28px;
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
background: var(--superficie);
color: var(--texto);
font: var(--t-sm) var(--fonte);
}
.colunas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: var(--e-2) var(--e-4);
}
.colunas ul {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
font-size: var(--t-sm);
}
.colunas li {
display: flex;
justify-content: space-between;
gap: var(--e-2);
color: var(--texto-2);
}
.colunas .titulo {
color: var(--texto-3);
font-size: var(--t-xs);
}
.colunas b {
font-weight: 600;
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
}
.mais b {
color: var(--ok);
}
.menos b {
color: var(--texto-3);
}
.nada {
color: var(--texto-3);
font-size: var(--t-xs);
}
.licoes {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
font-size: var(--t-sm);
}
.licoes li {
display: flex;
gap: var(--e-2);
color: var(--texto);
}
.licoes time {
flex: none;
padding-top: 2px;
color: var(--texto-3);
font-size: var(--t-xs);
}
.esquecer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3) var(--e-3);
border-top: 1px solid var(--borda);
font-size: var(--t-sm);
color: var(--texto-2);
}
.esquecer span {
padding-left: var(--e-1);
}
</style>
@@ -0,0 +1,335 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pedir } from '../api';
import { estado } from '../estado.svelte';
import { dispositivos, NOME_TIPO, pinoDaLinha, PINOS, type Dispositivo, type Fio, type Pino, type TipoPino } from '../ligacoes';
import Card from './Card.svelte';
// Conector de 26 pinos do Orange Pi Zero 3 com o que está ligado em cada pino. O pino do PIR
// vem da placa (CARINHA_PIR); o estado ao vivo do PIR e da PCA9685 vem do servidor.
let pir = $state<{ linha: string; disponivel: boolean; erro: string; ativo?: boolean } | null>(null);
let foco = $state<string | null>(null);
onMount(async () => {
try {
pir = (await pedir<{ pir: typeof pir }>('/historico/status')).pir;
} catch {
pir = null;
}
});
const linhaPir = $derived(pir ? Number(pir.linha.split(':')[1]) : 70);
const pinoPir = $derived(pinoDaLinha(linhaPir));
const lista = $derived(dispositivos(pinoPir));
const pirAtivo = $derived(estado.pir?.ativo ?? pir?.ativo ?? false);
// pino -> fios ligados nele (um pino pode ter mais de um dispositivo só no caso dos GND/5 V)
const fiosPorPino = $derived.by(() => {
const m = new Map<number, (Fio & { d: Dispositivo })[]>();
for (const d of lista) for (const f of d.fios) m.set(f.pino, [...(m.get(f.pino) ?? []), { ...f, d }]);
return m;
});
const linhas = $derived(Array.from({ length: 13 }, (_, i) => [PINOS[i * 2], PINOS[i * 2 + 1]] as [Pino, Pino]));
function apagado(p: Pino) {
if (!foco) return false;
return !(fiosPorPino.get(p.num) ?? []).some((f) => f.d.id === foco);
}
function estadoDe(d: Dispositivo): { texto: string; tom: 'ok' | 'aviso' | 'neutro' } {
if (d.id === 'pir') {
if (!pir) return { texto: 'sem resposta da placa', tom: 'neutro' };
if (!pir.disponivel) return { texto: pir.erro || 'sensor indisponível', tom: 'aviso' };
return pirAtivo ? { texto: 'sinal alto agora (vendo movimento)', tom: 'ok' } : { texto: 'ligado, sem movimento agora', tom: 'ok' };
}
if (d.id === 'pca9685') {
const m = estado.motores;
if (!m) return { texto: 'sem resposta da placa', tom: 'neutro' };
return m.disponivel ? { texto: 'respondendo no I²C', tom: 'ok' } : { texto: m.erro || 'não encontrada no I²C', tom: 'aviso' };
}
return { texto: 'pinos reservados', tom: 'neutro' };
}
const CORES: Record<TipoPino, string> = {
'5v': '#ff3b30', '3v3': '#ff9500', gnd: '#636366', gpio: '#34c759', i2c: '#0a84ff', spi: '#af52de', uart: '#30b0c7',
};
</script>
<div class="pagina">
<Card titulo="Conector da placa" ajuda="ligacoes" descricao="Orange Pi Zero 3, conector de 26 pinos em 2 fileiras: ímpares de um lado, pares do outro. Para achar o pino 1, procure o número 1 impresso na placa ou a ilha de solda quadrada no verso. Toque num dispositivo para destacar os pinos dele.">
<div class="rolagem">
<div class="conector" role="img" aria-label="Mapa do conector de 26 pinos e o que está ligado em cada pino">
{#each linhas as [impar, par] (impar.num)}
{@const fe = fiosPorPino.get(impar.num) ?? []}
{@const fd = fiosPorPino.get(par.num) ?? []}
<div class="fios esq" class:apagado={apagado(impar)}>
{#each fe as f (f.d.id)}
<span class="fio" style="--c: {f.cor}" title={f.d.nome + (f.cabo ? ' · fio ' + f.cabo.nome : '')}>
{f.d.emoji} {f.terminal}{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}" aria-label={'fio ' + f.cabo.nome}></i>{/if}
</span>
{/each}
</div>
<div class="pino esq" class:apagado={apagado(impar)} class:usado={fe.length} title={impar.funcao ?? NOME_TIPO[impar.tipo]}>
<span class="nome">{impar.nome}</span>
<span class="bola" style="--c: {CORES[impar.tipo]}" class:quadrada={impar.num === 1}>{impar.num}</span>
</div>
<div class="pino dir" class:apagado={apagado(par)} class:usado={fd.length} title={par.funcao ?? NOME_TIPO[par.tipo]}>
<span class="bola" style="--c: {CORES[par.tipo]}">{par.num}</span>
<span class="nome">{par.nome}</span>
</div>
<div class="fios dir" class:apagado={apagado(par)}>
{#each fd as f (f.d.id)}
<span class="fio" style="--c: {f.cor}" title={f.d.nome + (f.cabo ? ' · fio ' + f.cabo.nome : '')}>
{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}" aria-label={'fio ' + f.cabo.nome}></i>{/if}{f.d.emoji} {f.terminal}
</span>
{/each}
</div>
{/each}
</div>
</div>
<p class="legenda">
{#each Object.entries(NOME_TIPO) as [tipo, nome] (tipo)}
<span><i style="--c: {CORES[tipo as TipoPino]}"></i>{nome}</span>
{/each}
</p>
</Card>
<div class="lista">
{#each lista as d (d.id)}
{@const st = estadoDe(d)}
<Card>
<button
class="cabeca"
class:ativo={foco === d.id}
onclick={() => (foco = foco === d.id ? null : d.id)}
aria-pressed={foco === d.id}
title="Destacar os pinos deste dispositivo"
>
<span class="emoji" aria-hidden="true">{d.emoji}</span>
<span class="txt">
<strong>{d.nome}</strong>
<span class="st {st.tom}">{d.situacao === 'reservado' ? 'reservado' : 'em uso'} · {st.texto}</span>
</span>
</button>
<p class="desc">{d.descricao}</p>
{@const temCabo = d.fios.some((f) => f.cabo)}
<table>
<thead><tr><th>No dispositivo</th>{#if temCabo}<th>Fio do cabo</th>{/if}<th>Pino da placa</th><th>O que é</th></tr></thead>
<tbody>
{#each d.fios as f (f.pino + f.terminal)}
{@const p = PINOS[f.pino - 1]}
<tr>
<td><span class="ponto" style="--c: {f.cor}"></span>{f.terminal}</td>
{#if temCabo}
<td class="sem-quebra">{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}"></i> {f.cabo.nome}{/if}</td>
{/if}
<td class="mono">{f.pino}</td>
<td>{p.funcao ?? NOME_TIPO[p.tipo]}{p.tipo === 'gpio' ? ` (${p.nome})` : ''}</td>
</tr>
{/each}
</tbody>
</table>
{#if d.avisos.length}
<ul class="avisos">
{#each d.avisos as a (a)}<li>{a}</li>{/each}
</ul>
{/if}
{#if d.id === 'pir' && pir}
<p class="nota mono">Configuração da placa: CARINHA_PIR={pir.linha} → pino {pinoPir ?? '?'}</p>
{/if}
</Card>
{/each}
</div>
</div>
<style>
.pagina {
display: grid;
gap: var(--e-4);
align-items: start;
}
@media (min-width: 1024px) {
.pagina {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
.lista {
display: grid;
gap: var(--e-4);
}
.rolagem {
overflow-x: auto;
}
.conector {
display: grid;
grid-template-columns: minmax(6.5em, 1fr) auto auto minmax(6.5em, 1fr);
gap: 3px 6px;
align-items: center;
width: max-content;
min-width: 100%;
margin-top: var(--e-3);
}
.pino {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--t-xs);
color: var(--texto-2);
transition: opacity var(--transicao);
}
.pino.esq {
justify-content: flex-end;
}
.pino.usado .nome {
color: var(--texto);
font-weight: 600;
}
.bola {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: color-mix(in srgb, var(--c) 22%, var(--superficie-2));
border: 2px solid var(--c);
color: var(--texto);
font: 600 10px var(--fonte-mono, monospace);
font-variant-numeric: tabular-nums;
}
.bola.quadrada {
border-radius: 4px;
}
.fios {
display: flex;
flex-wrap: wrap;
gap: 4px;
transition: opacity var(--transicao);
}
.fios.esq {
justify-content: flex-end;
}
.fio {
padding: 1px 7px;
border-radius: var(--raio-pill);
background: color-mix(in srgb, var(--c) 18%, transparent);
border: 1px solid var(--c);
color: var(--texto);
font-size: 11px;
white-space: nowrap;
}
.sem-quebra {
white-space: nowrap;
}
.cabo {
display: inline-block;
width: 10px;
height: 10px;
margin: 0 4px;
border-radius: 50%;
background: var(--k);
border: 1.5px solid var(--texto-3);
vertical-align: -1px;
}
.apagado {
opacity: 0.25;
}
.legenda {
display: flex;
flex-wrap: wrap;
gap: 4px var(--e-3);
margin: var(--e-3) 0 0;
font-size: var(--t-xs);
color: var(--texto-3);
}
.legenda span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legenda i {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--c);
}
.cabeca {
display: flex;
align-items: center;
gap: var(--e-3);
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.cabeca .emoji {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: 50%;
background: var(--superficie-2);
font-size: 22px;
}
.cabeca.ativo .emoji {
outline: 2px solid var(--destaque);
}
.txt {
display: grid;
gap: 2px;
}
.st {
font-size: var(--t-xs);
color: var(--texto-3);
}
.st.ok {
color: var(--ok);
}
.st.aviso {
color: var(--aviso, #ff9500);
}
.desc {
margin: var(--e-3) 0 var(--e-2);
font-size: var(--t-sm);
color: var(--texto-2);
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--t-sm);
}
th {
text-align: left;
font-weight: 500;
font-size: var(--t-xs);
color: var(--texto-3);
padding: 4px 6px;
border-bottom: 1px solid var(--borda);
}
td {
padding: 5px 6px;
border-bottom: 1px solid var(--borda);
}
td .ponto {
display: inline-block;
width: 9px;
height: 9px;
margin-right: 6px;
border-radius: 50%;
background: var(--c);
}
.avisos {
display: grid;
gap: 6px;
margin: var(--e-3) 0 0;
padding-left: 1.1em;
font-size: var(--t-sm);
color: var(--texto-2);
}
.nota {
margin: var(--e-2) 0 0;
font-size: var(--t-xs);
color: var(--texto-3);
}
</style>
@@ -296,6 +296,9 @@
.diario li.emoji { .diario li.emoji {
color: #af52de; color: #af52de;
} }
.diario li.aprendizado {
color: #30b0c7;
}
.diario li.cuidado { .diario li.cuidado {
color: var(--texto); color: var(--texto);
} }
+5 -2
View File
@@ -4,7 +4,7 @@
*/ */
import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import { avisos } from './avisos.svelte'; import { avisos } from './avisos.svelte';
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos'; import type { Aprendizado, AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -34,6 +34,7 @@ class Estado {
treinamento = $state({ ocupado: false, erro: '' }); treinamento = $state({ ocupado: false, erro: '' });
api = $state<EstadoServidor['api'] | null>(null); api = $state<EstadoServidor['api'] | null>(null);
vida = $state<Vida | null>(null); vida = $state<Vida | null>(null);
aprendizado = $state<Aprendizado | null>(null);
feed = $state<ItemFeed[]>([]); feed = $state<ItemFeed[]>([]);
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */ /** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null); pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
@@ -110,6 +111,7 @@ function aplicarFoto(f: EstadoServidor) {
if (f.treinamento) estado.treinamento = f.treinamento; if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api; if (f.api) estado.api = f.api;
if (f.vida) estado.vida = f.vida; if (f.vida) estado.vida = f.vida;
if (f.aprendizado) estado.aprendizado = f.aprendizado;
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca }; if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
} }
@@ -158,6 +160,7 @@ const tratadores: Record<string, (d: any) => void> = {
if (novo && novo.horario !== estado.vida?.diario[0]?.horario && novo.tipo !== 'acao') noFeed('vida', d.emoji + ' ' + novo.texto); if (novo && novo.horario !== estado.vida?.diario[0]?.horario && novo.tipo !== 'acao') noFeed('vida', d.emoji + ' ' + novo.texto);
estado.vida = d; estado.vida = d;
}, },
aprendizado: (d: Aprendizado) => (estado.aprendizado = d),
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }), pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
historico: (d) => { historico: (d) => {
const n = d?.novo; const n = d?.novo;
@@ -185,7 +188,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu'; if (estado.conexao !== 'token') estado.conexao = 'caiu';
}; };
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao', 'inspiracao']) { 'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao']) {
fonte.addEventListener(tipo, (e) => { fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data); const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados); tratadores[tipo]?.(dados);
+36 -3
View File
@@ -55,6 +55,39 @@ export const TOPICOS: Record<string, TopicoGuia> = {
], ],
dica: 'Desligar o modo vida volta o celular aos gestos soltos de sempre e à sensibilidade dos Ajustes.', dica: 'Desligar o modo vida volta o celular aos gestos soltos de sempre e à sensibilidade dos Ajustes.',
}, },
aprendizado: {
titulo: 'O que ele aprendeu',
emoji: '🎓',
onde: 'Ao vivo › O que ele aprendeu (com o modo vida ligado)',
resumo:
'Ele observa o próprio dia e melhora aos poucos: conta o que acontece em casa, ajusta o modo vida e, uma vez por dia, uma IA cruza a semana para entender a rotina. À meia-noite fecha o dia, escreve as lições no diário e o que ficou para trás passa a pesar menos, porque o gosto das pessoas muda.',
itens: [
{ emoji: '❤️', nome: 'Do que vocês gostam', texto: 'Cada gesto ou atividade que ele faz com alguém por perto é uma tentativa. Carinho, brincadeira ou conversa até 1 minuto depois contam como "gostaram" (cutucada conta meio). Os que agradam passam a sair até 2 vezes mais; os ignorados, até a metade. Precisa de pelo menos 3 tentativas para ele formar opinião.' },
{ emoji: '🗓️', nome: 'Semana da casa', texto: 'Quanto do tempo tem gente por perto (pelo PIR e pela câmera) em cada dia da semana e hora: quanto mais forte a cor, mais gente. Cada dia da semana aparece uma vez por semana, então com 2 ou 3 semanas ele separa bem uma segunda de uma terça.' },
{ emoji: '🧠', nome: 'Análise com IA', texto: 'Depois da meia-noite, com uma semana observada, a IA escolhida (Gemini, IA local ou o Qwen do S10) cruza os dias: quais são parecidos, quando a casa fica vazia, que dia foge da regra. Ela escreve o que percebeu e propõe um plano da semana. O plano só é usado se conferir com pelo menos 70% do que os sensores mediram; senão fica como sugestão, e você decide.' },
{ emoji: '✍️', nome: 'Conte sua rotina', texto: 'Escreva do seu jeito ("fico fora de terça a quinta, das 8h às 18h") e a IA transforma em plano na hora, sem esperar semanas. O que você contou vale sobre o que a IA acha, e o cartão mostra o quanto confere com o que ele vê.' },
{ emoji: '🏠', nome: 'O que muda', texto: 'Quando a próxima hora é de gente em casa e a atual não, ele acorda do cochilo 15 minutos antes, para estar de pé quando você chegar. Com a casa vazia, aproveita para tirar cochilos longos e guardar energia. Sono pesado e madrugada continuam respeitados.' },
{ emoji: '☾', nome: 'Acordar à toa', texto: 'Se o PIR acorda o rosto e ninguém fica nem interage em 3 minutos, foi à toa. Quando isso se repete na mesma hora em dias diferentes, ali ele passa a precisar de 1 ou 2 disparos a mais para acordar.' },
{ emoji: '📖', nome: 'Lições', texto: 'O que ele concluiu em cada dia, em primeira pessoa. Também aparecem no diário do cartão Vida.' },
{ emoji: '📅', nome: 'Dia a dia', texto: 'Página com um dia por vez: o que ele viu em cada hora, o que esperava pelo plano, quanto o dia conferiu, e cada decisão tomada com base no que aprendeu, com o porquê ("acordou às 17h45: às 18h de quarta costuma ter gente"). Abre pelo botão "Ver dia a dia" no cartão ou pelo Ctrl+K.' },
{ emoji: '🧽', nome: 'Esquecer', texto: 'Apaga a semana, o plano, os gostos e as lições, e ele começa do zero. Desligar o aprendizado só para de usar o que aprendeu, sem apagar.' },
],
dica: 'Para ensinar rápido: quando ele fizer algo que você gostou, toque na tela do celular (vale como carinho) ou fale com ele logo em seguida.',
},
ligacoes: {
titulo: 'Ligações da placa',
emoji: '🔌',
onde: 'Ajustes › Ligações da placa (também em Ao vivo › Acontecimentos)',
resumo:
'Mapa do conector de 26 pinos do Orange Pi Zero 3 com o que está ligado em cada pino: o sensor de presença (PIR), a placa dos servos (PCA9685) e os pinos reservados para a tela SPI. O pino do PIR vem da própria placa, então o desenho acompanha a configuração.',
itens: [
{ emoji: '🚶', nome: 'PIR (HC-SR505)', texto: 'Plaquinha marcada + · out · −. No seu cabo: + no fio PRETO → pino 4 (5 V); out no fio VERDE → pino 11 (PC6); − no fio BRANCO → pino 14 (terra; o 9 também serve). Siga a marcação, não a cor. Nunca ligue o + no pino 11: os GPIOs são de 3,3 V.' },
{ emoji: '🦾', nome: 'PCA9685', texto: 'VCC no 3,3 V (pino 1), SDA no 3, SCL no 5 e GND no 6. Os servos usam a fonte externa no borne V+, com terra comum com a placa.' },
{ emoji: '🖥️', nome: 'Tela SPI (tft35)', texto: 'O overlay tft35 reserva os pinos 7, 18, 19, 21, 22, 23 e 24. Não ligue sensores neles. Livres para sensores novos: 12, 13, 15, 16 e 26.' },
{ emoji: '🟢', nome: 'Estado ao vivo', texto: 'Cada dispositivo mostra se a placa está lendo: o PIR diz se o sinal está alto agora, e a PCA9685 se responde no I²C.' },
],
dica: 'Sinal do PIR alto o tempo todo, por muito tempo, não é normal no HC-SR505 (ele segura só uns 8 s): confira se o + e o out não estão trocados, ou se ele aponta para uma fonte de calor.',
},
cuidados: { cuidados: {
titulo: 'Cuidar do Carinha', titulo: 'Cuidar do Carinha',
emoji: '❤️', emoji: '❤️',
@@ -334,13 +367,13 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */ /** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [ export const ORDEM_GUIA = [
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia', 'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia',
'personalidade', 'acontecimentos', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade', 'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos', 'reacoes', 'conversa', 'movimento', 'atalhos',
]; ];
/** Tópico de cada tela, para o ⓘ do topo. */ /** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = { export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes', 'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
}; };
+137
View File
@@ -0,0 +1,137 @@
/**
* Ligações da placa: o conector de 26 pinos do Orange Pi Zero 3 e o que está ligado em cada pino.
* O pino do PIR vem ao vivo da placa (CARINHA_PIR, padrão gpiochip1:70 = PC6 = pino 11).
*/
export type TipoPino = '5v' | '3v3' | 'gnd' | 'gpio' | 'i2c' | 'spi' | 'uart';
export interface Pino {
num: number;
nome: string;
tipo: TipoPino;
/** Linha no gpiochip1 (libgpiod), quando é um GPIO. */
linha?: number;
funcao?: string;
}
// Conector de 26 pinos (2 fileiras): ímpares de um lado, pares do outro.
export const PINOS: Pino[] = [
{ num: 1, nome: '3,3 V', tipo: '3v3' },
{ num: 2, nome: '5 V', tipo: '5v' },
{ num: 3, nome: 'PH5', tipo: 'i2c', linha: 229, funcao: 'I²C3 SDA' },
{ num: 4, nome: '5 V', tipo: '5v' },
{ num: 5, nome: 'PH4', tipo: 'i2c', linha: 228, funcao: 'I²C3 SCL' },
{ num: 6, nome: 'GND', tipo: 'gnd' },
{ num: 7, nome: 'PC9', tipo: 'gpio', linha: 73 },
{ num: 8, nome: 'PH2', tipo: 'uart', linha: 226, funcao: 'UART5 TX' },
{ num: 9, nome: 'GND', tipo: 'gnd' },
{ num: 10, nome: 'PH3', tipo: 'uart', linha: 227, funcao: 'UART5 RX' },
{ num: 11, nome: 'PC6', tipo: 'gpio', linha: 70 },
{ num: 12, nome: 'PC11', tipo: 'gpio', linha: 75 },
{ num: 13, nome: 'PC5', tipo: 'gpio', linha: 69 },
{ num: 14, nome: 'GND', tipo: 'gnd' },
{ num: 15, nome: 'PC8', tipo: 'gpio', linha: 72 },
{ num: 16, nome: 'PC15', tipo: 'gpio', linha: 79 },
{ num: 17, nome: '3,3 V', tipo: '3v3' },
{ num: 18, nome: 'PC14', tipo: 'gpio', linha: 78 },
{ num: 19, nome: 'PH7', tipo: 'spi', linha: 231, funcao: 'SPI1 MOSI' },
{ num: 20, nome: 'GND', tipo: 'gnd' },
{ num: 21, nome: 'PH8', tipo: 'spi', linha: 232, funcao: 'SPI1 MISO' },
{ num: 22, nome: 'PC7', tipo: 'gpio', linha: 71 },
{ num: 23, nome: 'PH6', tipo: 'spi', linha: 230, funcao: 'SPI1 CLK' },
{ num: 24, nome: 'PH9', tipo: 'spi', linha: 233, funcao: 'SPI1 CS0' },
{ num: 25, nome: 'GND', tipo: 'gnd' },
{ num: 26, nome: 'PC10', tipo: 'gpio', linha: 74 },
];
export const NOME_TIPO: Record<TipoPino, string> = {
'5v': '5 V', '3v3': '3,3 V', gnd: 'Terra (GND)', gpio: 'GPIO (3,3 V)', i2c: 'I²C', spi: 'SPI', uart: 'Serial',
};
export interface Fio {
pino: number;
/** Nome do terminal no dispositivo (VCC, OUT, GND, SDA...). */
terminal: string;
cor: string;
/** Cor real do fio no cabo, quando a gente sabe (ex.: o PIR tem o fio preto no +). */
cabo?: { nome: string; cor: string };
}
export interface Dispositivo {
id: string;
nome: string;
emoji: string;
cor: string;
descricao: string;
fios: Fio[];
avisos: string[];
/** 'em uso' = o servidor usa; 'reservado' = a placa reserva os pinos (overlay), mas o Carinha não usa. */
situacao: 'em uso' | 'reservado';
}
export function pinoDaLinha(linha: number) {
return PINOS.find((p) => p.linha === linha)?.num ?? null;
}
/** Dispositivos registrados. O pino do PIR é o que a placa informa (CARINHA_PIR). */
export function dispositivos(pinoPir: number | null): Dispositivo[] {
const saida = pinoPir ?? 11;
return [
{
id: 'pir',
nome: 'Sensor de presença (PIR)',
emoji: '🚶',
cor: '#1c9b4f',
situacao: 'em uso',
descricao: 'HC-SR505 mini. Avisa quando alguém se mexe na frente (até uns 3 m, cone de 100°): acorda o rosto e liga a câmera no modo presença.',
fios: [
{ pino: 4, terminal: '+', cor: '#1c9b4f', cabo: { nome: 'preto', cor: '#111111' } },
{ pino: saida, terminal: 'out', cor: '#1c9b4f', cabo: { nome: 'verde', cor: '#1c9b4f' } },
{ pino: 14, terminal: '−', cor: '#1c9b4f', cabo: { nome: 'branco', cor: '#f4f4f4' } },
],
avisos: [
'No seu cabo o fio PRETO está no +. Siga a marcação da plaquinha (+ · out · −, da esquerda para a direita), não a cor do fio. Inverter + e − pode queimar o sensor.',
`Nunca ligue o + (5 V) no pino ${saida}: os GPIOs da placa são de 3,3 V e queimam com 5 V. O out do HC-SR505 já sai em 3,3 V, seguro para o pino ${saida}.`,
'O HC-SR505 não tem ajuste e segura o sinal ligado por uns 8 s depois de cada movimento. Sinal ligado direto por minutos seguidos não é normal: confira os fios.',
'Depois de ligado, ele leva de 30 a 60 s para estabilizar; disparos falsos nesse tempo são normais. Aponte para onde as pessoas passam, longe de sol e de saída de ar quente.',
'O − também pode ir no pino 9 (é terra). Teste na placa: gpiomon -c gpiochip1 -e rising ' + (saida === 11 ? '70' : 'LINHA') + ' imprime uma linha a cada movimento.',
],
},
{
id: 'pca9685',
nome: 'Placa dos servos (PCA9685)',
emoji: '🦾',
cor: '#0a84ff',
situacao: 'em uso',
descricao: 'Controla os servos do braço por I²C (barramento /dev/i2c-2, endereço 0x40).',
fios: [
{ pino: 1, terminal: 'VCC', cor: '#0a84ff' },
{ pino: 3, terminal: 'SDA', cor: '#0a84ff' },
{ pino: 5, terminal: 'SCL', cor: '#0a84ff' },
{ pino: 6, terminal: 'GND', cor: '#0a84ff' },
],
avisos: [
'Já ligada e funcionando. O VCC da PCA9685 vai no 3,3 V (pino 1): é só a lógica.',
'Os servos NÃO são alimentados pela placa: o borne V+ da PCA9685 recebe a fonte externa (6 V), com o GND da fonte ligado ao GND da placa (terra comum).',
],
},
{
id: 'tela',
nome: 'Tela SPI 3,5" (overlay tft35)',
emoji: '🖥️',
cor: '#af52de',
situacao: 'reservado',
descricao: 'O overlay tft35 está ativo no boot e reserva estes pinos para uma tela SPI. O Carinha não usa essa tela: não ligue outros sensores neles. Livres para sensores novos: 12, 13, 15, 16 e 26.',
fios: [
{ pino: 7, terminal: 'TFT', cor: '#af52de' },
{ pino: 18, terminal: 'DC', cor: '#af52de' },
{ pino: 19, terminal: 'MOSI', cor: '#af52de' },
{ pino: 21, terminal: 'MISO', cor: '#af52de' },
{ pino: 22, terminal: 'RESET', cor: '#af52de' },
{ pino: 23, terminal: 'CLK', cor: '#af52de' },
{ pino: 24, terminal: 'CS', cor: '#af52de' },
],
avisos: [],
},
];
}
+2
View File
@@ -37,6 +37,8 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [ registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), ...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') }, { id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
{ id: 'ir-ligacoes', grupo: 'Ir para', titulo: 'Ligações da placa (pinos do PIR, servos e tela)', fazer: () => nav.ir('ajustes', 'ligacoes') },
{ id: 'ir-dia-a-dia', grupo: 'Ir para', titulo: 'Dia a dia (rotina e decisões do Carinha)', fazer: () => nav.ir('dia-a-dia') },
{ id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') }, { id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({ ...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id), id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id),
+19 -6
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ /** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar'; export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -16,22 +16,35 @@ export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }, { id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' }, { id: 'guia', titulo: 'Guia', volta: 'ajustes' },
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' }, { id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
{ id: 'dia-a-dia', titulo: 'Dia a dia', volta: 'ao-vivo' },
]; ];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? ''; export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
/** #/ajustes/ia -> ['ajustes', 'ia']: a segunda parte é a seção (submenu) da tela. */
function partes(): [string, string] {
const [id, sub = ''] = location.hash.replace(/^#\/?/, '').split('?')[0].split('/');
if (id === 'historico') return ['eventos', sub]; // endereço antigo
if (id === 'ligacoes') return ['ajustes', 'ligacoes']; // virou seção dos Ajustes
return [id, sub];
}
function lerHash(): Rota { function lerHash(): Rota {
let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota; const id = partes()[0] as Rota;
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo'; return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
} }
class Navegacao { class Navegacao {
atual = $state<Rota>(lerHash()); atual = $state<Rota>(lerHash());
ir(rota: Rota) { /** Seção dentro da tela (ex.: a parte dos Ajustes aberta). */
location.hash = '/' + rota; sub = $state(partes()[1]);
ir(rota: Rota, sub = '') {
location.hash = '/' + rota + (sub ? '/' + sub : '');
} }
} }
export const nav = new Navegacao(); export const nav = new Navegacao();
addEventListener('hashchange', () => (nav.atual = lerHash())); addEventListener('hashchange', () => {
nav.atual = lerHash();
nav.sub = partes()[1];
});
+54 -1
View File
@@ -83,6 +83,7 @@ export interface EstadoServidor {
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown }; api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null }; pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
vida?: Vida; vida?: Vida;
aprendizado?: Aprendizado;
} }
/** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */ /** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */
@@ -103,5 +104,57 @@ export interface Vida {
sono_profundo?: boolean; sono_profundo?: boolean;
/** '' quando pode inventar figurinha agora; senão, o motivo. */ /** '' quando pode inventar figurinha agora; senão, o motivo. */
pode_criar: string; pode_criar: string;
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade'; texto: string }[]; diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi'; texto: string }[];
}
/** O que ele aprendeu com o dia a dia (aprendizado.py). */
export type TipoPeriodo = 'casa' | 'fora' | 'dormindo';
/** Plano da semana: 7 dias (0 = segunda) com períodos. confere = quanto bate com o medido (null = poucos dados). */
export interface PlanoSemana {
origem: 'ia' | 'voce';
criado: number;
padroes: string[];
dias: { ini: number; fim: number; tipo: TipoPeriodo }[][];
confere: number | null;
}
export interface Aprendizado {
config: { ligado: boolean; analise_ia: boolean; ia: 'gemini' | 'local' | 's10' };
/** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */
semana: (number | null)[][];
historico_dias: number;
decisoes_hoje: number;
plano: PlanoSemana | null;
/** Plano da IA que não bateu com os dados (ou que não substitui o que você contou). */
sugestao: PlanoSemana | null;
analise: { ocupado: boolean; erro: string; em: number | null; tipo: '' | 'analise' | 'rotina'; pode: boolean; minimo: number };
/** Dias inteiros observados. */
dias: number;
/** 24 horas: chance de ter gente por perto (null = ainda sem dados nessa hora). */
rotina: (number | null)[];
/** 24 horas: disparos a mais do PIR para acordar (0 a 2), por ter acordado à toa ali. */
sono: number[];
/** Atos com opinião formada, do mais querido ao mais ignorado. peso multiplica a chance (0,5 a 2). */
gosto: { id: string; nome: string; tentativas: number; agradou: number; peso: number }[];
licoes: { dia: string; texto: string }[];
hoje: { dia: string; atos: number; respostas: Record<string, number>; acordou: number; a_toa: number };
}
/** Um dia visto pelo aprendizado: o que mediu, o que esperava pelo plano e as decisões com o porquê. */
export interface DiaADia {
dia: string;
sem: number;
nome_dia: string;
hoje: boolean;
/** 24 horas: fração do tempo com gente (null = sem dados). */
medido: (number | null)[];
/** Períodos do plano para esse dia da semana (null = sem plano). */
plano: { ini: number; fim: number; tipo: TipoPeriodo }[] | null;
origem_plano: 'ia' | 'voce' | null;
confere: number | null;
horas_com_gente: number;
resumo: { atos: number; respostas: number; a_toa: number } | null;
decisoes: { horario: number; tipo: 'acordar' | 'cochilo' | 'sono' | 'gosto' | 'dia' | 'ia' | 'voce'; texto: string; motivo: string }[];
dias: string[];
} }
+242 -55
View File
@@ -1,29 +1,74 @@
<script lang="ts"> <script lang="ts">
import { BookOpen, Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte'; import {
Activity, BookOpen, Brain, Cable, Download, Info, KeyRound, Moon, RefreshCw, RotateCcw, Search, Shield, ShieldCheck, ShieldOff,
SlidersHorizontal, Smartphone, Video,
} from '@lucide/svelte';
import type { Component } from 'svelte';
import { gravarToken, lerToken, postar } from '../lib/api'; import { gravarToken, lerToken, postar } from '../lib/api';
import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte'; import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte';
import { avisos, tentar } from '../lib/avisos.svelte'; import { avisos, tentar } from '../lib/avisos.svelte';
import { recarregarCelular } from '../lib/comandos.svelte'; import { recarregarCelular } from '../lib/comandos.svelte';
import Ajuda from '../lib/componentes/Ajuda.svelte'; import Ajuda from '../lib/componentes/Ajuda.svelte';
import Card from '../lib/componentes/Card.svelte'; import Card from '../lib/componentes/Card.svelte';
import LigacoesPlaca from '../lib/componentes/LigacoesPlaca.svelte';
import Selo from '../lib/componentes/Selo.svelte'; import Selo from '../lib/componentes/Selo.svelte';
import Slider from '../lib/componentes/Slider.svelte'; import Slider from '../lib/componentes/Slider.svelte';
import Toggle from '../lib/componentes/Toggle.svelte'; import Toggle from '../lib/componentes/Toggle.svelte';
import { conectar, estado } from '../lib/estado.svelte'; import { conectar, estado } from '../lib/estado.svelte';
import { nav } from '../lib/rotas.svelte';
// ---------- busca nos ajustes do celular // Ajustes divididos em seções (submenu): os grupos do celular, o robô (IA e ligações da placa)
// e o painel (segurança e sistema). A seção fica no endereço: #/ajustes/ia, #/ajustes/ligacoes...
type Item = { id: string; titulo: string; detalhe: string; icone: Component<any>; cor: string };
const ICONE_GRUPO: Record<string, [Component<any>, string]> = {
tela: [Smartphone, '#0a84ff'], sensores: [Activity, '#34c759'], sono: [Moon, '#5e5ce6'], camera: [Video, '#ff9500'],
sensibilidade: [SlidersHorizontal, '#8e8e93'],
};
const SECOES: { grupo: string; itens: Item[] }[] = [
{
grupo: 'Celular',
itens: GRUPOS.map((g) => ({ id: g.ajuda, titulo: g.titulo, detalhe: g.descricao, icone: ICONE_GRUPO[g.ajuda]?.[0] ?? Smartphone, cor: ICONE_GRUPO[g.ajuda]?.[1] ?? '#8e8e93' })),
},
{
grupo: 'Robô',
itens: [
{ id: 'ia', titulo: 'Inteligência artificial', detalhe: 'Motor da conversa e chave do Gemini', icone: Brain, cor: '#af52de' },
{ id: 'ligacoes', titulo: 'Ligações da placa', detalhe: 'Pinos do PIR, dos servos e da tela', icone: Cable, cor: '#1c9b4f' },
],
},
{
grupo: 'Painel',
itens: [
{ id: 'seguranca', titulo: 'Segurança', detalhe: 'Token de acesso', icone: Shield, cor: '#ff3b30' },
{ id: 'sistema', titulo: 'Sistema', detalhe: 'Versão, app do celular e guia', icone: Info, cor: '#8e8e93' },
],
},
];
const TODOS = SECOES.flatMap((s) => s.itens);
const secao = $derived(TODOS.some((i) => i.id === nav.sub) ? nav.sub : TODOS[0].id);
const atual = $derived(TODOS.find((i) => i.id === secao)!);
const grupoAtual = $derived(GRUPOS.find((g) => g.ajuda === secao) ?? null);
// ---------- busca: procura em todos os ajustes do celular, de qualquer seção
let busca = $state(''); let busca = $state('');
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const grupos = $derived( const achados = $derived(
GRUPOS.map((g) => ({ GRUPOS.map((g) => ({
...g, ...g,
campos: g.campos.filter((c) => !busca || normal(c.rotulo + ' ' + (c.ajuda ?? '')).includes(normal(busca))), campos: g.campos.filter((c) => normal(c.rotulo + ' ' + (c.ajuda ?? '')).includes(normal(busca))),
})).filter((g) => g.campos.length), })).filter((g) => g.campos.length),
); );
const valor = (c: Campo) => estado.appConfig?.[c.chave]; const valor = (c: Campo) => estado.appConfig?.[c.chave];
let confirmarPadrao = $state(false); let confirmarPadrao = $state(false);
// no celular o submenu é uma fileira que rola: traz a seção aberta para a vista
let menu = $state<HTMLElement>();
$effect(() => {
secao;
menu?.querySelector<HTMLElement>('.item.ativo')?.scrollIntoView({ block: 'nearest', inline: 'center' });
});
// ---------- IA // ---------- IA
let modelo = $state(''); let modelo = $state('');
let chave = $state(''); let chave = $state('');
@@ -67,41 +112,8 @@
} }
</script> </script>
<div class="ajustes"> {#snippet campos(lista: Campo[])}
<Card titulo="Celular" descricao="Mudanças valem na hora no aparelho. Nada trava enquanto salva."> {#each lista as c (c.chave)}
{#snippet acoes()}
<label class="busca">
<Search size={16} />
<input type="search" bind:value={busca} placeholder="Buscar ajuste" aria-label="Buscar ajuste" />
</label>
{#if confirmarPadrao}
<span class="pequeno-texto">Restaurar todos?</span>
<button
class="btn pequeno perigo"
onclick={async () => {
confirmarPadrao = false;
await tentar(restaurarPadrao);
}}>Sim, restaurar</button
>
<button class="btn pequeno" onclick={() => (confirmarPadrao = false)}>Cancelar</button>
{:else}
<button class="btn pequeno" onclick={() => (confirmarPadrao = true)} disabled={!estado.appConfig}>
<RotateCcw size={14} /> Restaurar padrão
</button>
{/if}
{/snippet}
{#if !estado.appConfig}
<p class="fraco">Carregando ajustes…</p>
{:else}
<div class="grupos">
{#each grupos as g (g.titulo)}
<fieldset>
<legend>
<strong class="titulo-grupo">{g.titulo} <Ajuda topico={g.ajuda} /></strong>
<span class="fraco pequeno-texto">{g.descricao}</span>
</legend>
{#each g.campos as c (c.chave)}
{#if c.tipo === 'toggle'} {#if c.tipo === 'toggle'}
<Toggle rotulo={c.rotulo} ajuda={c.ajuda} checked={!!valor(c)} onchange={(v) => salvarApp({ [c.chave]: v })} /> <Toggle rotulo={c.rotulo} ajuda={c.ajuda} checked={!!valor(c)} onchange={(v) => salvarApp({ [c.chave]: v })} />
{:else if c.tipo === 'slider'} {:else if c.tipo === 'slider'}
@@ -129,15 +141,77 @@
</label> </label>
{/if} {/if}
{/each} {/each}
{/snippet}
{#snippet restaurar()}
{#if confirmarPadrao}
<span class="pequeno-texto">Restaurar todos os ajustes do celular?</span>
<button
class="btn pequeno perigo"
onclick={async () => {
confirmarPadrao = false;
await tentar(restaurarPadrao);
}}>Sim, restaurar</button
>
<button class="btn pequeno" onclick={() => (confirmarPadrao = false)}>Cancelar</button>
{:else}
<button class="btn pequeno" onclick={() => (confirmarPadrao = true)} disabled={!estado.appConfig}>
<RotateCcw size={14} /> Restaurar padrão
</button>
{/if}
{/snippet}
<div class="ajustes">
<nav class="menu" aria-label="Seções dos ajustes" bind:this={menu}>
<label class="busca">
<Search size={16} />
<input type="search" bind:value={busca} placeholder="Buscar ajuste do celular" aria-label="Buscar ajuste do celular" />
</label>
{#each SECOES as g (g.grupo)}
<div class="grupo-menu">
<span class="rotulo-grupo">{g.grupo}</span>
{#each g.itens as it (it.id)}
<a
href={'#/ajustes/' + it.id}
class="item"
class:ativo={!busca && secao === it.id}
aria-current={!busca && secao === it.id ? 'page' : undefined}
onclick={() => (busca = '')}
>
<span class="icone" style="--c: {it.cor}"><it.icone size={15} /></span>
<span class="txt"><span>{it.titulo}</span><small>{it.detalhe}</small></span>
</a>
{/each}
</div>
{/each}
</nav>
<div class="conteudo">
{#if busca}
<Card titulo={`Ajustes com “${busca}”`} descricao="Resultado em todos os grupos do celular.">
{#if !estado.appConfig}
<p class="fraco">Carregando ajustes…</p>
{:else}
{#each achados as g (g.titulo)}
<fieldset>
<legend><strong class="titulo-grupo">{g.titulo} <Ajuda topico={g.ajuda} /></strong></legend>
{@render campos(g.campos)}
</fieldset> </fieldset>
{:else} {:else}
<p class="fraco">Nenhum ajuste com “{busca}”.</p> <p class="fraco">Nenhum ajuste com “{busca}”.</p>
{/each} {/each}
</div>
{/if} {/if}
</Card> </Card>
{:else if grupoAtual}
<div class="coluna"> <Card titulo={grupoAtual.titulo} ajuda={grupoAtual.ajuda} descricao={grupoAtual.descricao + ' Mudanças valem na hora no aparelho.'}>
{#snippet acoes()}{@render restaurar()}{/snippet}
{#if !estado.appConfig}
<p class="fraco">Carregando ajustes…</p>
{:else}
<div class="campos">{@render campos(grupoAtual.campos)}</div>
{/if}
</Card>
{:else if secao === 'ia'}
<Card titulo="Inteligência artificial" ajuda="conversa"> <Card titulo="Inteligência artificial" ajuda="conversa">
<div class="campo"> <div class="campo">
<span>Motor da conversa</span> <span>Motor da conversa</span>
@@ -191,7 +265,9 @@
{/if} {/if}
</form> </form>
</Card> </Card>
{:else if secao === 'ligacoes'}
<LigacoesPlaca />
{:else if secao === 'seguranca'}
<Card titulo="Segurança"> <Card titulo="Segurança">
<div class="linha"> <div class="linha">
{#if estado.autenticacao} {#if estado.autenticacao}
@@ -212,7 +288,7 @@
<button class="btn pequeno" onclick={esquecerToken}>Esquecer token neste navegador</button> <button class="btn pequeno" onclick={esquecerToken}>Esquecer token neste navegador</button>
{/if} {/if}
</Card> </Card>
{:else}
<Card titulo="Sistema"> <Card titulo="Sistema">
<dl class="info"> <dl class="info">
<div><dt>Versão do servidor</dt><dd class="mono">{estado.versao || '—'}</dd></div> <div><dt>Versão do servidor</dt><dd class="mono">{estado.versao || '—'}</dd></div>
@@ -227,6 +303,8 @@
<a class="btn pequeno" href="#/guia"><BookOpen size={14} /> Guia do painel</a> <a class="btn pequeno" href="#/guia"><BookOpen size={14} /> Guia do painel</a>
</div> </div>
</Card> </Card>
{/if}
<span class="sr" aria-live="polite">{busca ? '' : atual.titulo}</span>
</div> </div>
</div> </div>
@@ -236,14 +314,63 @@
gap: var(--e-4); gap: var(--e-4);
align-items: start; align-items: start;
} }
.coluna { .conteudo {
min-width: 0;
display: grid; display: grid;
gap: var(--e-4); gap: var(--e-4);
} }
/* ---------- submenu: fileira rolável no celular, coluna fixa no PC */
.menu {
display: flex;
gap: var(--e-2);
align-items: center;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: var(--e-1);
}
.grupo-menu {
display: contents;
}
.rotulo-grupo {
display: none;
}
.item {
display: inline-flex;
align-items: center;
gap: var(--e-2);
flex: none;
padding: 6px 12px 6px 6px;
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto-2);
font-size: var(--t-sm);
text-decoration: none;
white-space: nowrap;
}
.item.ativo {
border-color: var(--destaque);
background: var(--destaque-suave);
color: var(--texto);
}
.item small {
display: none;
}
.icone {
display: grid;
place-items: center;
width: 26px;
height: 26px;
flex: none;
border-radius: 7px;
background: var(--c);
color: #fff;
}
.busca { .busca {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--e-2); gap: var(--e-2);
flex: none;
padding-left: var(--e-3); padding-left: var(--e-3);
border: 1px solid var(--borda-forte); border: 1px solid var(--borda-forte);
border-radius: var(--raio-sm); border-radius: var(--raio-sm);
@@ -253,17 +380,73 @@
.busca input { .busca input {
border: 0; border: 0;
background: transparent; background: transparent;
min-height: 32px; min-height: 34px;
width: 160px; width: 150px;
} }
.grupos { @media (min-width: 900px) {
.ajustes {
grid-template-columns: 250px minmax(0, 1fr);
}
.menu {
position: sticky;
top: calc(var(--topo-altura) + var(--e-4));
flex-direction: column;
align-items: stretch;
gap: var(--e-4);
overflow: visible;
}
.busca input {
width: 100%;
}
.grupo-menu {
display: grid; display: grid;
gap: var(--e-5); gap: 2px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); }
.rotulo-grupo {
display: block;
padding: 0 var(--e-2) var(--e-1);
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--texto-3);
}
.item {
padding: var(--e-2);
border: 0;
border-radius: var(--raio-sm);
background: transparent;
white-space: normal;
}
.item:hover {
background: var(--superficie-2);
}
.item.ativo {
background: var(--superficie);
box-shadow: var(--sombra);
}
.txt {
display: grid;
gap: 1px;
min-width: 0;
}
.item small {
display: block;
color: var(--texto-3);
font-size: var(--t-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.campos {
display: grid;
gap: var(--e-2);
max-width: 640px;
} }
fieldset { fieldset {
border: 0; border: 0;
margin: 0; margin: 0 0 var(--e-4);
padding: 0; padding: 0;
display: grid; display: grid;
gap: var(--e-2); gap: var(--e-2);
@@ -316,6 +499,7 @@
gap: var(--e-3); gap: var(--e-3);
padding-top: var(--e-3); padding-top: var(--e-3);
border-top: 1px solid var(--borda); border-top: 1px solid var(--borda);
max-width: 560px;
} }
.ok { .ok {
color: var(--ok); color: var(--ok);
@@ -327,6 +511,7 @@
display: grid; display: grid;
gap: var(--e-2); gap: var(--e-2);
margin: 0; margin: 0;
max-width: 480px;
} }
.info div { .info div {
display: flex; display: flex;
@@ -340,9 +525,11 @@
dd { dd {
margin: 0; margin: 0;
} }
@media (min-width: 1200px) { .sr {
.ajustes { position: absolute;
grid-template-columns: 2fr 1fr; width: 1px;
} height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
} }
</style> </style>
+79 -38
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import {
Brain, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Wand2, Brain, Cable, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Wand2,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api'; import { postar } from '../lib/api';
@@ -15,6 +15,7 @@
import PresencaPir from '../lib/componentes/PresencaPir.svelte'; import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import VidaCartao from '../lib/componentes/VidaCartao.svelte'; import VidaCartao from '../lib/componentes/VidaCartao.svelte';
import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte';
import { estado } from '../lib/estado.svelte'; import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte'; import { VideoAoVivo } from '../lib/video.svelte';
@@ -124,15 +125,16 @@ const pirAtivo = $derived(
</div> </div>
</section> </section>
<div class="lista"> <!-- No PC: à direita Vida e o que ele aprendeu; embaixo da câmera, o resto.
<VidaCartao /> No celular as duas colunas se desfazem (display: contents) e a ordem vem do `order`. -->
<div class="coluna direita">
<div class="c-vida"><VidaCartao /></div>
{#if estado.vida?.config.ligado}<div class="c-aprendizado"><AprendizadoCartao /></div>{/if}
<Grupo titulo="Celular" ajuda="celular"> </div>
<Celular />
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
</Grupo>
<Grupo titulo="Câmera" ajuda="camera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}> <div class="coluna esquerda">
<div class="c-camera"><Grupo titulo="Câmera" ajuda="camera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
<Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}> <Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
{#snippet acessorio()} {#snippet acessorio()}
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} /> <Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
@@ -160,10 +162,10 @@ const pirAtivo = $derived(
{/snippet} {/snippet}
</Linha> </Linha>
{/if} {/if}
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" /> <Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" />
</Grupo> </Grupo></div>
<Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto."> <div class="c-gestos"><Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos"> <Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos /> <Gestos />
</Linha> </Linha>
@@ -186,9 +188,31 @@ const pirAtivo = $derived(
</Linha> </Linha>
<Linha icone={Wand2} cor="#af52de" rotulo="Criar com IA" detalhe="Expressões, atividades e figurinhas novas" href="#/criar" /> <Linha icone={Wand2} cor="#af52de" rotulo="Criar com IA" detalhe="Expressões, atividades e figurinhas novas" href="#/criar" />
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" /> <Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo> </Grupo></div>
<Grupo titulo="Aparência" ajuda="aparencia"> <div class="c-acontecimentos"><Grupo titulo="Acontecimentos" ajuda="acontecimentos">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
{#snippet acessorio()}<PresencaPir />{/snippet}
</Linha>
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
<Linha icone={Cable} cor="#8e8e93" rotulo="Ligações da placa" detalhe="Em que pino vai cada fio do PIR e dos servos" href="#/ajustes/ligacoes" />
{#if estado.feed.length}
<ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)}
<li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
{/each}
</ol>
{:else}
<p class="vazio-feed">Gestos, respostas e reações aparecem aqui na hora.</p>
{/if}
</Grupo></div>
<div class="c-celular"><Grupo titulo="Celular" ajuda="celular">
<Celular />
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
</Grupo></div>
<div class="c-aparencia"><Grupo titulo="Aparência" ajuda="aparencia">
<Linha <Linha
icone={Palette} icone={Palette}
cor="#ff9500" cor="#ff9500"
@@ -200,9 +224,9 @@ const pirAtivo = $derived(
<span class="amostra-cor" style="--c: {hex(estado.aparencia.cor)}" aria-hidden="true"></span> <span class="amostra-cor" style="--c: {hex(estado.aparencia.cor)}" aria-hidden="true"></span>
{/snippet} {/snippet}
</Linha> </Linha>
</Grupo> </Grupo></div>
<Grupo titulo="Personalidade" ajuda="personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}> <div class="c-personalidade"><Grupo titulo="Personalidade" ajuda="personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
<Linha <Linha
icone={Brain} icone={Brain}
cor="#5e5ce6" cor="#5e5ce6"
@@ -213,30 +237,14 @@ const pirAtivo = $derived(
<Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} /> <Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} />
{/snippet} {/snippet}
</Linha> </Linha>
</Grupo> </Grupo></div>
<Grupo titulo="Acontecimentos" ajuda="acontecimentos">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
{#snippet acessorio()}<PresencaPir />{/snippet}
</Linha>
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
{#if estado.feed.length}
<ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)}
<li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
{/each}
</ol>
{:else}
<p class="vazio-feed">Gestos, respostas e reações aparecem aqui na hora.</p>
{/if}
</Grupo>
</div> </div>
</div> </div>
<style> <style>
.ao-vivo { .ao-vivo {
display: grid; display: grid;
gap: var(--e-4); gap: var(--e-5);
align-items: start; align-items: start;
} }
.atividades { .atividades {
@@ -378,12 +386,22 @@ const pirAtivo = $derived(
0%, 100% { box-shadow: 0 0 0 0 rgb(52 199 89 / 0.7); } 0%, 100% { box-shadow: 0 0 0 0 rgb(52 199 89 / 0.7); }
50% { box-shadow: 0 0 0 7px rgb(52 199 89 / 0); } 50% { box-shadow: 0 0 0 7px rgb(52 199 89 / 0); }
} }
/* ---------- lista */ /* ---------- cartões: uma coluna no celular (na ordem abaixo), duas no PC */
.lista { .coluna {
display: grid; display: contents;
gap: var(--e-5); }
.coluna > div {
min-width: 0; min-width: 0;
} }
.palco { order: 0; }
.c-vida { order: 1; }
.c-aprendizado { order: 2; }
.c-celular { order: 3; }
.c-camera { order: 4; }
.c-gestos { order: 5; }
.c-aparencia { order: 6; }
.c-personalidade { order: 7; }
.c-acontecimentos { order: 8; }
.amostra-cor { .amostra-cor {
width: 22px; width: 22px;
height: 22px; height: 22px;
@@ -427,10 +445,33 @@ const pirAtivo = $derived(
@media (min-width: 1024px) { @media (min-width: 1024px) {
.ao-vivo { .ao-vivo {
grid-template-columns: minmax(0, 1.5fr) minmax(340px, 1fr); grid-template-columns: minmax(0, 1.5fr) minmax(340px, 1fr);
grid-template-areas: 'palco direita' 'esquerda direita';
grid-template-rows: auto 1fr;
gap: var(--e-5); gap: var(--e-5);
} }
/* com cartões embaixo, a câmera deixa de ficar presa no topo (rolaria por cima deles) */
.palco { .palco {
top: calc(var(--topo-altura) + var(--e-5)); grid-area: palco;
position: relative;
top: auto;
} }
.coluna {
display: grid;
align-content: start;
gap: var(--e-5);
min-width: 0;
}
.esquerda {
grid-area: esquerda;
}
.direita {
grid-area: direita;
}
.esquerda > .c-gestos { order: 1; }
.esquerda > .c-celular { order: 2; }
.esquerda > .c-camera { order: 3; }
.esquerda > .c-acontecimentos { order: 4; }
.esquerda > .c-aparencia { order: 5; }
.esquerda > .c-personalidade { order: 6; }
} }
</style> </style>
+1 -1
View File
@@ -128,7 +128,7 @@
{#snippet acoes()} {#snippet acoes()}
{#if conversa} {#if conversa}
<Selo tom="destaque">{conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'} · {conversa.modelo}</Selo> <Selo tom="destaque">{conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'} · {conversa.modelo}</Selo>
<a class="pequeno-texto" href="#/ajustes">trocar</a> <a class="pequeno-texto" href="#/ajustes/ia">trocar</a>
{/if} {/if}
{/snippet} {/snippet}
+329
View File
@@ -0,0 +1,329 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { onMount } from 'svelte';
import { pedir } from '../lib/api';
import { avisos } from '../lib/avisos.svelte';
import Card from '../lib/componentes/Card.svelte';
import { estado, ouvir } from '../lib/estado.svelte';
import type { DiaADia, TipoPeriodo } from '../lib/tipos';
// Um dia por vez: o que ele viu em cada hora, o que esperava pelo plano, quanto conferiu
// e cada decisão tomada com base no que aprendeu, com o porquê.
let dia = $state<string | null>(null);
let dados = $state<DiaADia | null>(null);
let carregando = $state(false);
const ICONE: Record<DiaADia['decisoes'][number]['tipo'], string> = {
acordar: '☀️', cochilo: '💤', sono: '☾', gosto: '❤️', dia: '📅', ia: '🧠', voce: '✍️',
};
const NOME_TIPO: Record<TipoPeriodo, string> = { casa: 'em casa', fora: 'casa vazia', dormindo: 'dormindo' };
async function carregar(alvo: string | null = dia) {
carregando = true;
try {
dados = await pedir<DiaADia>('/aprendizado/dia' + (alvo ? '?dia=' + alvo : ''));
dia = dados.dia;
} catch (e) {
avisos.erro(e);
} finally {
carregando = false;
}
}
onMount(() => {
carregar(null);
// o dia de hoje vai mudando: recarrega quando o aprendizado avisa
return ouvir('aprendizado', () => {
if (dados?.hoje) carregar(dados.dia);
});
});
const indice = $derived(dados ? dados.dias.indexOf(dados.dia) : -1);
const anterior = $derived(dados && indice > 0 ? dados.dias[indice - 1] : null);
const proximo = $derived(dados && indice >= 0 && indice < dados.dias.length - 1 ? dados.dias[indice + 1] : null);
const decisoes = $derived(dados ? [...dados.decisoes].sort((a, b) => a.horario - b.horario) : []);
const horaAgora = $derived(new Date(estado.agora).getHours());
function titulo(d: DiaADia) {
const [, m, dd] = d.dia.split('-');
return `${d.nome_dia[0].toUpperCase() + d.nome_dia.slice(1)}, ${dd}/${m}`;
}
function hora(s: number) {
return new Date(s * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function tipoNaHora(h: number): TipoPeriodo | null {
return dados?.plano?.find((p) => p.ini <= h && h < p.fim)?.tipo ?? null;
}
function plural(n: number, um: string, varios: string) {
return `${n} ${n === 1 ? um : varios}`;
}
</script>
<div class="pagina">
<Card>
<nav class="dias" aria-label="Escolher o dia">
<button class="btn fantasma icone" onclick={() => carregar(anterior)} disabled={!anterior || carregando} aria-label="Dia anterior">
<ChevronLeft size={18} />
</button>
<div class="qual">
{#if dados}
<strong>{titulo(dados)}</strong>
<span>{dados.hoje ? 'hoje, até agora' : 'dia fechado'}</span>
{:else}
<strong>Carregando…</strong>
{/if}
</div>
<button class="btn fantasma icone" onclick={() => carregar(proximo)} disabled={!proximo || carregando} aria-label="Próximo dia">
<ChevronRight size={18} />
</button>
</nav>
{#if dados}
<dl class="numeros">
<div>
<dt>Com gente por perto</dt>
<dd class="mono">{plural(dados.horas_com_gente, 'hora', 'horas')}</dd>
</div>
<div>
<dt>Carinhos e conversas</dt>
<dd class="mono">{dados.resumo ? dados.resumo.respostas : '—'}</dd>
</div>
<div>
<dt>Conferiu com o plano</dt>
<dd class="mono" class:bom={dados.confere !== null && dados.confere >= 0.7} class:ruim={dados.confere !== null && dados.confere < 0.5}>
{dados.confere === null ? '—' : Math.round(dados.confere * 100) + '%'}
</dd>
</div>
</dl>
<section class="faixas" aria-label="O que ele viu e o que esperava, hora a hora">
<span class="rotulo">Viu</span>
<div class="faixa">
{#each dados.medido as v, h (h)}
<span
class="celula"
class:sem={v === null}
class:agora={dados.hoje && h === horaAgora}
style={v === null ? '' : `--v: ${v}`}
title={`${h}h: ${v === null ? 'sem dados' : Math.round(v * 100) + '% do tempo com gente'}`}
></span>
{/each}
</div>
<span class="rotulo">Esperava</span>
<div class="faixa">
{#each Array(24) as _, h (h)}
{@const t = tipoNaHora(h)}
<span class="celula plano {t ?? 'nada'}" title={`${h}h: ${t ? NOME_TIPO[t] : 'sem plano'}`}></span>
{/each}
</div>
<span></span>
<div class="eixo mono" aria-hidden="true">
{#each Array(24) as _, h (h)}<span>{h % 6 === 0 ? h + 'h' : ''}</span>{/each}
</div>
</section>
<p class="legenda">
<span class="ponto visto"></span> gente por perto
<span class="ponto casa"></span> em casa
<span class="ponto fora"></span> casa vazia
<span class="ponto dormindo"></span> dormindo
{#if !dados.plano}<em>· sem plano ainda: conte sua rotina no cartão "O que ele aprendeu"</em>{/if}
</p>
{/if}
</Card>
<Card titulo="Decisões" ajuda="aprendizado" descricao="O que ele fez por causa do que aprendeu, e por quê.">
{#if decisoes.length}
<ol class="decisoes">
{#each decisoes as d (d.horario + d.texto)}
<li class={d.tipo}>
<time class="mono">{hora(d.horario)}</time>
<span class="icone" aria-hidden="true">{ICONE[d.tipo] ?? '•'}</span>
<div>
<strong>{d.texto}</strong>
<span>{d.motivo}</span>
</div>
</li>
{/each}
</ol>
{:else}
<p class="vazio">
Nenhuma decisão neste dia. Elas aparecem quando ele usa o que aprendeu: acordar antes de você chegar, cochilar
com a casa vazia, segurar o sensor numa hora em que acorda à toa, escolher um gesto que vocês gostam. À
meia-noite ele fecha o dia aqui.
</p>
{/if}
</Card>
</div>
<style>
.pagina {
display: grid;
gap: var(--e-4);
max-width: 760px;
}
.dias {
display: flex;
align-items: center;
gap: var(--e-2);
}
.qual {
display: grid;
flex: 1;
text-align: center;
}
.qual strong {
font-size: var(--t-md);
}
.qual span {
color: var(--texto-3);
font-size: var(--t-xs);
}
.numeros {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--e-2);
margin: var(--e-3) 0 0;
}
.numeros div {
display: grid;
gap: 2px;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--superficie-2);
}
.numeros dt {
color: var(--texto-3);
font-size: var(--t-xs);
}
.numeros dd {
margin: 0;
font-size: var(--t-md);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.numeros dd.bom {
color: var(--ok);
}
.numeros dd.ruim {
color: var(--perigo);
}
.faixas {
display: grid;
grid-template-columns: 4.6em 1fr;
align-items: center;
gap: 4px var(--e-2);
margin-top: var(--e-3);
}
.rotulo {
color: var(--texto-3);
font-size: var(--t-xs);
}
.faixa,
.eixo {
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 2px;
}
.celula {
height: 18px;
border-radius: 2px;
background: color-mix(in srgb, #30b0c7 calc(var(--v, 0) * 100%), var(--superficie-2));
}
.celula.sem {
background: var(--superficie-2);
opacity: 0.5;
}
.celula.agora {
outline: 1.5px solid var(--texto);
outline-offset: 1px;
}
.celula.plano.casa,
.ponto.casa {
background: #30b0c7;
}
.celula.plano.fora,
.ponto.fora {
background: color-mix(in srgb, var(--texto-3) 35%, var(--superficie-2));
}
.celula.plano.dormindo,
.ponto.dormindo {
background: #5e5ce6;
}
.celula.plano.nada {
background: var(--superficie-2);
opacity: 0.5;
}
.ponto.visto {
background: color-mix(in srgb, #30b0c7 60%, var(--superficie-2));
}
.eixo span {
font-size: 9px;
color: var(--texto-3);
white-space: nowrap;
}
.legenda {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--e-2);
margin: var(--e-2) 0 0;
color: var(--texto-3);
font-size: var(--t-xs);
}
.legenda em {
font-style: normal;
}
.ponto {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.decisoes {
display: grid;
gap: var(--e-3);
margin: 0;
padding: 0;
list-style: none;
}
.decisoes li {
display: grid;
grid-template-columns: 3.2em 1.4em 1fr;
align-items: start;
gap: var(--e-2);
}
.decisoes time {
padding-top: 2px;
color: var(--texto-3);
font-size: var(--t-xs);
}
.decisoes .icone {
text-align: center;
}
.decisoes div {
display: grid;
gap: 2px;
min-width: 0;
}
.decisoes strong {
font-size: var(--t-sm);
font-weight: 600;
}
.decisoes div span {
color: var(--texto-2);
font-size: var(--t-sm);
}
.decisoes li.dia strong,
.decisoes li.ia strong {
color: #30b0c7;
}
.vazio {
margin: 0;
color: var(--texto-3);
font-size: var(--t-sm);
}
@media (max-width: 480px) {
.numeros dd {
font-size: var(--t-sm);
}
}
</style>