feat(web): migra Movimento com homem-morto e trava de hardware

- Controle: opt-in de movimento fisico, botao "segure para mover"
  (mouse, toque e teclado), sliders das articulacoes tambem so movem
  enquanto pressionados; velocidade e interpolacao ficam no servidor
- sequencias prontas e posturas salvas: previa so na tela ou execucao
  segurando o botao ate o fim; soltar interrompe na hora
- posturas com nome inline (sem prompt), exclusao confirmada inline,
  exportar/importar; posturas aparecem na paleta (Ctrl+K)
- Calibracao: testes de fim de curso por canal, marcar limites e tabela
  editavel com validacao antes de salvar
- Diagnostico (antigo Servos): ao vivo limitado a 10/s, bloqueado quando
  o braco esta com os motores
- desenho esquematico do braco em SVG com previa animada
- servidor: evento 'motores' tambem ao ligar canais; renovar no alvo
  devolve movendo=false para a sequencia avancar
- aba escondida ou E-Stop soltam o braco e desativam o movimento

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-05 19:56:48 -03:00
co-authored by Claude Opus 5.5
parent 947c0a3127
commit 673dea62e8
10 changed files with 1153 additions and 9 deletions
+184
View File
@@ -0,0 +1,184 @@
<script lang="ts">
import { Plus, Save, Trash2, Zap } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { pedir, postar } from '../../lib/api';
import { avisos, tentar } from '../../lib/avisos.svelte';
import Card from '../../lib/componentes/Card.svelte';
import Selo from '../../lib/componentes/Selo.svelte';
import Slider from '../../lib/componentes/Slider.svelte';
import Toggle from '../../lib/componentes/Toggle.svelte';
import { estado } from '../../lib/estado.svelte';
interface Servo {
nome: string;
canal: number;
minimo: number;
maximo: number;
centro: number;
}
let servos = $state<Servo[]>([]);
let pulsos = $state<number[]>([]);
let disponivel = $state(false);
let sujo = $state(false);
let aoVivo = $state(false);
async function carregar() {
const st = await tentar(() => pedir<{ servos: Servo[]; disponivel: boolean }>('/servos/status'));
if (!st) return;
servos = st.servos;
pulsos = st.servos.map((s) => s.centro);
disponivel = st.disponivel;
sujo = false;
}
onMount(carregar);
// Ao vivo: no máximo 10 comandos/s, sempre o último valor.
let pendente: { indice: number; pulso: number } | null = null;
let emVoo = false;
let ultimo = 0;
let espera = 0;
function enfileirar(indice: number, pulso: number) {
pulsos[indice] = pulso;
if (!aoVivo || sujo) return;
pendente = { indice, pulso };
bombear();
}
async function bombear() {
if (!pendente || emVoo || espera) return;
const falta = 100 - (performance.now() - ultimo);
if (falta > 0) {
espera = window.setTimeout(() => {
espera = 0;
bombear();
}, falta);
return;
}
const cmd = pendente;
pendente = null;
ultimo = performance.now();
emVoo = true;
try {
await postar('/servos/mover', cmd);
} catch (e) {
aoVivo = false;
avisos.erro(e);
} finally {
emVoo = false;
bombear();
}
}
async function parar() {
aoVivo = false;
pendente = null;
clearTimeout(espera);
espera = 0;
await tentar(() => postar('/servos/parar'));
}
onDestroy(() => {
if (aoVivo) parar();
});
const testar = (i: number, pulso: number) =>
tentar(() => postar('/servos/mover', { indice: i, pulso }), `${servos[i].nome}: ${pulso} µs por 2 s.`);
function editar(i: number, campo: keyof Servo, v: string) {
servos[i] = { ...servos[i], [campo]: campo === 'nome' ? v : Number(v) };
sujo = true;
aoVivo = false;
}
function adicionar() {
const canal = Array.from({ length: 16 }, (_, i) => i).find((c) => !servos.some((s) => s.canal === c));
if (canal === undefined) return;
servos = [...servos, { nome: `Servo ${servos.length + 1}`, canal, minimo: 1400, maximo: 1600, centro: 1500 }];
pulsos = [...pulsos, 1500];
sujo = true;
}
function remover(i: number) {
servos = servos.filter((_, k) => k !== i);
pulsos = pulsos.filter((_, k) => k !== i);
sujo = true;
}
async function salvar() {
await parar();
const r = await tentar(() => postar<{ servos: Servo[] }>('/servos/config', { servos: $state.snapshot(servos) }), 'Servos salvos. Nenhum movimento enviado.');
if (r) {
servos = r.servos;
pulsos = r.servos.map((s) => s.centro);
sujo = false;
}
}
const ocupadoPeloBraco = $derived(estado.motores?.dono === 'braco' && !!estado.motores.ativos.length);
</script>
<div class="diagnostico">
<Card titulo="Diagnóstico do PCA9685" descricao="Teste canal a canal, com limites conservadores (1000–2000 µs). Use com o braço parado.">
{#snippet acoes()}
{#if sujo}<Selo tom="aviso">Alterações não salvas</Selo>{/if}
<button class="btn pequeno" onclick={adicionar} disabled={servos.length >= 16}><Plus size={14} /> Servo</button>
<button class="btn pequeno primario" onclick={salvar} disabled={!sujo}><Save size={14} /> Salvar</button>
{/snippet}
{#if ocupadoPeloBraco}
<p class="aviso pequeno-texto">O braço está usando os motores. Pare o braço antes de testar aqui.</p>
{/if}
<Toggle
rotulo="Controle ao vivo"
ajuda="Arrastar o pulso envia ao motor (até 10 vezes por segundo)"
checked={aoVivo}
disabled={!disponivel || sujo || estado.estop.ativo || ocupadoPeloBraco}
onchange={(v) => (v ? (aoVivo = true) : parar())}
/>
</Card>
<div class="servos">
{#each servos as s, i (i)}
<Card>
<div class="campos">
<label class="campo nome"><span>Nome</span><input type="text" maxlength="40" value={s.nome} onchange={(e) => editar(i, 'nome', e.currentTarget.value)} /></label>
<label class="campo"><span>Canal</span><input type="number" min="0" max="15" value={s.canal} onchange={(e) => editar(i, 'canal', e.currentTarget.value)} /></label>
<label class="campo"><span>Mín. µs</span><input type="number" min="1000" max="2000" value={s.minimo} onchange={(e) => editar(i, 'minimo', e.currentTarget.value)} /></label>
<label class="campo"><span>Centro</span><input type="number" min="1000" max="2000" value={s.centro} onchange={(e) => editar(i, 'centro', e.currentTarget.value)} /></label>
<label class="campo"><span>Máx. µs</span><input type="number" min="1000" max="2000" value={s.maximo} onchange={(e) => editar(i, 'maximo', e.currentTarget.value)} /></label>
</div>
<Slider rotulo="Pulso" valor={pulsos[i] ?? s.centro} min={s.minimo} max={s.maximo} passo={5} unidade=" µs" oninput={(v) => enfileirar(i, v)} />
<div class="linha">
<button class="btn pequeno" disabled={!disponivel || sujo} onclick={() => testar(i, pulsos[i])}><Zap size={14} /> Testar 2 s</button>
<button class="btn pequeno" disabled={!disponivel || sujo} onclick={() => testar(i, s.centro)}>Centro</button>
<span class="espaco"></span>
<button class="btn pequeno icone fantasma" aria-label="Remover {s.nome}" onclick={() => remover(i)}><Trash2 size={14} /></button>
</div>
</Card>
{/each}
</div>
</div>
<style>
.diagnostico {
display: grid;
gap: var(--e-4);
}
.servos {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
}
.campos {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--e-2);
}
.campos .nome {
grid-column: 1 / -1;
}
.campos input {
min-height: 34px;
padding: var(--e-1) var(--e-2);
}
.espaco {
flex: 1;
}
.aviso {
color: var(--aviso);
}
</style>