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>
This commit is contained in:
julio
2026-10-09 18:18:39 -03:00
co-authored by Claude Opus 5.5
parent b4cf13df11
commit 47a836276a
8 changed files with 547 additions and 42 deletions
+2
View File
@@ -21,6 +21,7 @@
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 DiaADia from './telas/DiaADia.svelte';
import Ligacoes from './telas/Ligacoes.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';
@@ -35,6 +36,7 @@
guia: Guia, guia: Guia,
criar: Criar, criar: Criar,
'dia-a-dia': DiaADia, 'dia-a-dia': DiaADia,
ligacoes: Ligacoes,
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
+16 -2
View File
@@ -74,6 +74,20 @@ export const TOPICOS: Record<string, TopicoGuia> = {
], ],
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.', 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', texto: 'VCC no 5 V (pino 2 ou 4), OUT no pino 11 (PC6) e GND num terra (pino 9, por exemplo). Nunca ligue o VCC no pino 11: os GPIOs são de 3,3 V. A ordem dos terminais muda entre módulos; leia o que está escrito no sensor.' },
{ 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 18, 19, 21, 22, 23 e 24. Não ligue sensores neles.' },
{ 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, costuma ser fio trocado (VCC e OUT invertidos), sensor apontado para uma fonte de calor ou o ajuste de tempo do HC-SR501 no máximo.',
},
cuidados: { cuidados: {
titulo: 'Cuidar do Carinha', titulo: 'Cuidar do Carinha',
emoji: '❤️', emoji: '❤️',
@@ -354,12 +368,12 @@ 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', 'aprendizado', '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', 'dia-a-dia': 'aprendizado', movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado', ligacoes: 'ligacoes',
}; };
+133
View File
@@ -0,0 +1,133 @@
/**
* 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;
}
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: '#34c759',
situacao: 'em uso',
descricao: 'Módulo PIR (tipo HC-SR501 ou AM312). Avisa quando alguém se mexe na frente: acorda o rosto e liga a câmera no modo presença.',
fios: [
{ pino: 2, terminal: 'VCC', cor: '#ff3b30' },
{ pino: saida, terminal: 'OUT', cor: '#ffcc00' },
{ pino: 9, terminal: 'GND', cor: '#8e8e93' },
],
avisos: [
'A ordem dos 3 terminais muda de um módulo para outro: leia o que está escrito na plaquinha do sensor (no HC-SR501, tire a cúpula branca para ver VCC, OUT e GND).',
`Nunca ligue o VCC (5 V) no pino ${saida}: os GPIOs da placa são de 3,3 V e queimam com 5 V.`,
'A saída do HC-SR501 e do AM312 é de 3,3 V, segura para o GPIO. Se o seu sensor soltar 5 V na saída, use um divisor de tensão.',
'O VCC pode ir em qualquer 5 V (pino 2 ou 4) e o GND em qualquer terra (6, 9, 14, 20 ou 25).',
],
},
{
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: '#ff9500' },
{ pino: 3, terminal: 'SDA', cor: '#0a84ff' },
{ pino: 5, terminal: 'SCL', cor: '#5ac8fa' },
{ pino: 6, terminal: 'GND', cor: '#8e8e93' },
],
avisos: [
'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.',
fios: [
{ 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: [],
},
];
}
+1
View File
@@ -37,6 +37,7 @@ 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('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-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]) => ({
+2 -1
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' | 'dia-a-dia'; export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'ligacoes';
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' },
@@ -17,6 +17,7 @@ export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ 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' }, { id: 'dia-a-dia', titulo: 'Dia a dia', volta: 'ao-vivo' },
{ id: 'ligacoes', titulo: 'Ligações da placa', volta: 'ajustes' },
]; ];
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 ?? '';
+2 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { BookOpen, Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte'; import { BookOpen, Cable, Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/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';
@@ -225,6 +225,7 @@
</button> </button>
<a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a> <a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a>
<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>
<a class="btn pequeno" href="#/ligacoes"><Cable size={14} /> Ligações da placa</a>
</div> </div>
</Card> </Card>
</div> </div>
+77 -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';
@@ -125,16 +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`. -->
{#if estado.vida?.config.ligado}<AprendizadoCartao />{/if} <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 })} />
@@ -163,9 +163,9 @@ const pirAtivo = $derived(
</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" />
</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>
@@ -188,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="#/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"
@@ -202,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"
@@ -215,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 {
@@ -380,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;
@@ -429,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>
+314
View File
@@ -0,0 +1,314 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pedir } from '../lib/api';
import Card from '../lib/componentes/Card.svelte';
import { estado } from '../lib/estado.svelte';
import { dispositivos, NOME_TIPO, pinoDaLinha, PINOS, type Dispositivo, type Pino, type TipoPino } from '../lib/ligacoes';
// 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, { d: Dispositivo; terminal: string; cor: string }[]>();
for (const d of lista) for (const f of d.fios) m.set(f.pino, [...(m.get(f.pino) ?? []), { d, terminal: f.terminal, cor: f.cor }]);
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.d.emoji} {f.terminal}</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.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>
<table>
<thead><tr><th>No dispositivo</th><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>
<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;
}
.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>