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
+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>