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:
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Braço (/braco/*) e diagnóstico (/servos/*). Segurança:
|
||||
* - homem-morto: só há movimento enquanto o botão/slider está pressionado; cada pedido leva
|
||||
* um prazo curto (ttl_ms) e o servidor desliga os motores se ele vencer sem renovação;
|
||||
* - a interpolação e o limite de velocidade são do servidor, não deste navegador.
|
||||
*/
|
||||
import { pedir, postar } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import { estado } from './estado.svelte';
|
||||
|
||||
export interface Calibracao {
|
||||
canal: number;
|
||||
minimo: number;
|
||||
maximo: number;
|
||||
centro: number;
|
||||
}
|
||||
export interface Postura {
|
||||
nome: string;
|
||||
angulos: number[];
|
||||
}
|
||||
export interface StatusBraco {
|
||||
calibracao: Calibracao[];
|
||||
posturas: Postura[];
|
||||
disponivel: boolean;
|
||||
erro: string;
|
||||
dono: string | null;
|
||||
travado: boolean;
|
||||
atual: number[] | null;
|
||||
movendo: boolean;
|
||||
articulacoes: { nome: string; canal: number }[];
|
||||
}
|
||||
|
||||
export const PRAZO_MS = 450;
|
||||
const RENOVAR_MS = 150;
|
||||
|
||||
function anguloDoPulso(p: number, c: Calibracao) {
|
||||
const centro = c.centro ?? 1500;
|
||||
return p <= centro ? (90 * (p - 500)) / (centro - 500) : 90 + (90 * (p - centro)) / (2500 - centro);
|
||||
}
|
||||
|
||||
/** Faixa segura (graus) de cada articulação a partir da calibração; o canal 5 respeita o par 6 invertido. */
|
||||
export function limites(st: StatusBraco): [number, number][] {
|
||||
return st.articulacoes.map(({ canal }) => {
|
||||
const c = st.calibracao[canal];
|
||||
let lo = anguloDoPulso(c.minimo, c);
|
||||
let hi = anguloDoPulso(c.maximo, c);
|
||||
if (canal === 5) {
|
||||
const par = st.calibracao[6];
|
||||
lo = Math.max(lo, 180 - anguloDoPulso(par.maximo, par));
|
||||
hi = Math.min(hi, 180 - anguloDoPulso(par.minimo, par));
|
||||
}
|
||||
return [Math.ceil(lo), Math.floor(hi)];
|
||||
});
|
||||
}
|
||||
|
||||
/** As 5 sequências prontas, escaladas para a faixa calibrada (porta de movimentos-core.js). */
|
||||
export function sequencias(faixas: [number, number][]): { nome: string; passos: number[][] }[] {
|
||||
const neutro = [0, 0, 0, 0, 0, 0, 0];
|
||||
const base: [string, number[][]][] = [
|
||||
['Acenar', [[0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], [0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], neutro]],
|
||||
['Olhar para os lados', [[0, 0.2, -0.4, 0, 0, 0, -0.7], [0, -0.2, 0.4, 0, 0, 0, 0.7], neutro]],
|
||||
['Alcançar e recolher', [[0, 0, 0.2, 0.6, -0.5, 0.4, 0], [0, 0, -0.2, -0.4, 0.5, -0.4, 0], neutro]],
|
||||
['Garra e punho', [[-0.7, 0.3, 0, 0.2, 0, 0.1, 0], [0.7, -0.3, 0, 0.2, 0, 0.1, 0], [-0.7, 0.3, 0, 0, 0, 0, 0], neutro]],
|
||||
['Apresentação', [[0.3, 0.3, -0.3, 0.4, -0.3, 0.3, -0.6], [-0.3, -0.3, 0.3, -0.3, 0.4, -0.3, 0.6], [0.5, 0.4, 0.2, 0.3, -0.3, 0.2, 0], neutro]],
|
||||
];
|
||||
return base.map(([nome, passos]) => ({
|
||||
nome,
|
||||
passos: passos.map((s) =>
|
||||
s.map((v, i) => {
|
||||
const [lo, hi] = faixas[i];
|
||||
// Centro 90°, mas sempre dentro da faixa calibrada.
|
||||
const meio = Math.max(lo, Math.min(hi, 90));
|
||||
return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio));
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
class Braco {
|
||||
status = $state<StatusBraco | null>(null);
|
||||
alvo = $state<number[]>(Array(7).fill(90));
|
||||
velocidade = $state(60);
|
||||
/** Opt-in explícito antes de qualquer movimento físico. */
|
||||
ativado = $state(false);
|
||||
segurando = $state(false);
|
||||
passoSequencia = $state<{ nome: string; indice: number; total: number } | null>(null);
|
||||
|
||||
private relogio = 0;
|
||||
private emVoo = false;
|
||||
private sequencia: number[][] | null = null;
|
||||
private pausaAte = 0;
|
||||
|
||||
faixas = $derived(this.status ? limites(this.status) : []);
|
||||
|
||||
async carregar() {
|
||||
try {
|
||||
this.status = await pedir<StatusBraco>('/braco/status');
|
||||
this.limitarAlvo();
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
limitarAlvo() {
|
||||
if (!this.faixas.length) return;
|
||||
this.alvo = this.alvo.map((v, i) => Math.max(this.faixas[i][0], Math.min(this.faixas[i][1], v)));
|
||||
}
|
||||
|
||||
/** Começa a segurar: renova o movimento a cada 150 ms até soltar. */
|
||||
segurar(sequencia?: { nome: string; passos: number[][] }) {
|
||||
if (!this.ativado || this.segurando) return;
|
||||
if (sequencia) {
|
||||
this.sequencia = sequencia.passos;
|
||||
this.passoSequencia = { nome: sequencia.nome, indice: 0, total: sequencia.passos.length };
|
||||
this.alvo = [...sequencia.passos[0]];
|
||||
this.pausaAte = 0;
|
||||
}
|
||||
this.segurando = true;
|
||||
this.renovar();
|
||||
this.relogio = window.setInterval(() => this.renovar(), RENOVAR_MS);
|
||||
}
|
||||
|
||||
/** Soltou: para de renovar. O servidor desliga os motores quando o prazo vence (≤ 450 ms). */
|
||||
soltar() {
|
||||
if (!this.segurando) return;
|
||||
this.segurando = false;
|
||||
clearInterval(this.relogio);
|
||||
if (this.passoSequencia) avisos.mostrar(`${this.passoSequencia.nome} interrompida ao soltar.`);
|
||||
this.sequencia = null;
|
||||
this.passoSequencia = null;
|
||||
}
|
||||
|
||||
private async renovar() {
|
||||
if (this.emVoo || !this.segurando) return;
|
||||
this.emVoo = true;
|
||||
try {
|
||||
const r = await postar<{ movendo: boolean }>('/braco/mover', {
|
||||
angulos: $state.snapshot(this.alvo),
|
||||
velocidade: this.velocidade,
|
||||
ttl_ms: PRAZO_MS,
|
||||
});
|
||||
if (!r.movendo && this.sequencia && this.passoSequencia) this.avancarSequencia();
|
||||
} catch (e) {
|
||||
this.segurando = false;
|
||||
clearInterval(this.relogio);
|
||||
this.sequencia = null;
|
||||
this.passoSequencia = null;
|
||||
avisos.erro(e);
|
||||
} finally {
|
||||
this.emVoo = false;
|
||||
}
|
||||
}
|
||||
|
||||
private avancarSequencia() {
|
||||
if (!this.pausaAte) {
|
||||
this.pausaAte = performance.now() + 700;
|
||||
return;
|
||||
}
|
||||
if (performance.now() < this.pausaAte) return;
|
||||
this.pausaAte = 0;
|
||||
const p = this.passoSequencia!;
|
||||
if (p.indice + 1 >= p.total) {
|
||||
avisos.ok(`${p.nome} concluída.`);
|
||||
this.sequencia = null;
|
||||
this.passoSequencia = null;
|
||||
this.segurando = false;
|
||||
clearInterval(this.relogio);
|
||||
return;
|
||||
}
|
||||
this.passoSequencia = { ...p, indice: p.indice + 1 };
|
||||
this.alvo = [...this.sequencia![p.indice + 1]];
|
||||
}
|
||||
|
||||
async pararMotores() {
|
||||
this.soltar();
|
||||
try {
|
||||
await postar('/braco/parar');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async desativar() {
|
||||
this.ativado = false;
|
||||
await this.pararMotores();
|
||||
}
|
||||
|
||||
async salvar(dados: { calibracao: Calibracao[]; posturas: Postura[] }, sucesso: string) {
|
||||
this.soltar();
|
||||
try {
|
||||
await postar('/braco/parar');
|
||||
this.status = await postar<StatusBraco>('/braco/config', dados);
|
||||
this.limitarAlvo();
|
||||
avisos.ok(sucesso);
|
||||
return true;
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const braco = new Braco();
|
||||
|
||||
// Segurança: aba escondida ou E-Stop param de segurar na hora.
|
||||
document.addEventListener('visibilitychange', () => document.hidden && braco.soltar());
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
if (estado.estop.ativo) {
|
||||
braco.soltar();
|
||||
braco.ativado = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
// Vista lateral esquemática: cada articulação é um <g> aninhado que gira no próprio eixo.
|
||||
// Ordem dos ângulos: 0 garra, 1 punho 1, 2 punho 2, 3 braço superior, 4 braço inferior, 5 antebraço, 6 base.
|
||||
let { pose, fantasma = null }: { pose: number[]; fantasma?: number[] | null } = $props();
|
||||
const r = (v: number) => v - 90;
|
||||
</script>
|
||||
|
||||
{#snippet braco(p: number[], classe: string)}
|
||||
<g class={classe}>
|
||||
<g transform="translate(120 236) rotate({r(p[4])})">
|
||||
<line x1="0" y1="0" x2="0" y2="-78" class="seg" />
|
||||
<g transform="translate(0 -78) rotate({r(p[3])})">
|
||||
<line x1="0" y1="0" x2="0" y2="-66" class="seg" />
|
||||
<g transform="translate(0 -66) rotate({r(p[5])})">
|
||||
<line x1="0" y1="0" x2="0" y2="-40" class="seg fino" />
|
||||
<g transform="translate(0 -40) rotate({r(p[1])})">
|
||||
<line x1="0" y1="0" x2="0" y2="-18" class="seg fino" />
|
||||
<ellipse cx="0" cy="-18" rx={7 * Math.abs(Math.cos(((p[2] - 90) * Math.PI) / 180)) + 2} ry="4" class="punho" />
|
||||
<g transform="translate(0 -20)">
|
||||
<path d="M0 0 L-4 -16 L-1 -24" class="dedo" transform="rotate({-8 - (p[0] - 90) * 0.25})" />
|
||||
<path d="M0 0 L4 -16 L1 -24" class="dedo" transform="rotate({8 + (p[0] - 90) * 0.25})" />
|
||||
</g>
|
||||
<circle r="4" class="junta" />
|
||||
</g>
|
||||
<circle r="5" class="junta" />
|
||||
</g>
|
||||
<circle r="6" class="junta" />
|
||||
</g>
|
||||
<circle r="7" class="junta" />
|
||||
</g>
|
||||
</g>
|
||||
{/snippet}
|
||||
|
||||
<svg viewBox="0 0 240 290" role="img" aria-label="Postura do braço">
|
||||
<!-- base: o arco mostra a rotação (vista de cima) -->
|
||||
<rect x="80" y="240" width="80" height="16" rx="5" class="base" />
|
||||
<path d="M85 274 A40 14 0 0 0 155 274" class="trilho" />
|
||||
<circle
|
||||
cx={120 + 35 * Math.cos(Math.PI - (pose[6] * Math.PI) / 180)}
|
||||
cy={274 + 12 * Math.sin((pose[6] * Math.PI) / 180) * 0.4}
|
||||
r="4"
|
||||
class="marcador"
|
||||
/>
|
||||
{#if fantasma}{@render braco(fantasma, 'fantasma')}{/if}
|
||||
{@render braco(pose, 'real')}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.seg {
|
||||
stroke: var(--destaque);
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.fino {
|
||||
stroke-width: 8;
|
||||
}
|
||||
.junta {
|
||||
fill: var(--superficie);
|
||||
stroke: var(--texto-2);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.punho {
|
||||
fill: var(--texto-2);
|
||||
}
|
||||
.dedo {
|
||||
fill: none;
|
||||
stroke: var(--texto);
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.base {
|
||||
fill: var(--superficie-3);
|
||||
stroke: var(--borda-forte);
|
||||
}
|
||||
.trilho {
|
||||
fill: none;
|
||||
stroke: var(--borda-forte);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 3 4;
|
||||
}
|
||||
.marcador {
|
||||
fill: var(--aviso);
|
||||
}
|
||||
.fantasma {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.fantasma .seg {
|
||||
stroke: var(--texto-3);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user