From 673dea62e83de026abf5162d2a45c03ccc648d7f Mon Sep 17 00:00:00 2001 From: julio Date: Mon, 5 Oct 2026 19:56:48 -0300 Subject: [PATCH] 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 --- braco.py | 12 +- servos.py | 11 +- tests/test_painel_web_browser.py | 28 +- tests/test_servidor.py | 8 + web/src/lib/braco.svelte.ts | 214 +++++++++++++ web/src/lib/componentes/BracoDesenho.svelte | 96 ++++++ web/src/telas/Movimento.svelte | 89 +++++- web/src/telas/movimento/Calibracao.svelte | 195 ++++++++++++ web/src/telas/movimento/Controle.svelte | 325 ++++++++++++++++++++ web/src/telas/movimento/Diagnostico.svelte | 184 +++++++++++ 10 files changed, 1153 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/braco.svelte.ts create mode 100644 web/src/lib/componentes/BracoDesenho.svelte create mode 100644 web/src/telas/movimento/Calibracao.svelte create mode 100644 web/src/telas/movimento/Controle.svelte create mode 100644 web/src/telas/movimento/Diagnostico.svelte diff --git a/braco.py b/braco.py index 6866294..068488c 100644 --- a/braco.py +++ b/braco.py @@ -84,7 +84,7 @@ class Braco: try: for channel,pulse in pulses.items(): self.servos.hw.pulse(channel,pulse) - self.servos.active[channel]=prazo + self.servos.ligar([(channel,prazo) for channel in pulses]) except OSError: self.alvo=None; self.servos.stop() raise @@ -102,7 +102,13 @@ class Braco: self.escrever(pulses,prazo) self.atual=[float(a) for a in pose] return dict(ok=True,pulsos=pulses,movendo=False) - self.alvo=[float(a) for a in pose]; self.velocidade=velocidade; self.lease=prazo + alvo=[float(a) for a in pose] + if all(abs(a-t)<.01 for a,t in zip(self.atual,alvo)): + # Já no alvo: só renova o prazo (e reenergiza canais que tenham desligado). + self.alvo=None + self.escrever(pulses,prazo) + return dict(ok=True,pulsos=pulses,movendo=False) + self.alvo=alvo; self.velocidade=velocidade; self.lease=prazo for channel in list(self.servos.active): self.servos.active[channel]=prazo return dict(ok=True,pulsos=pulses,movendo=True) def cancelar(self): @@ -142,7 +148,7 @@ class Braco: self.servos.reservar('braco'); self.alvo=None; self.atual=None for ch,us in outputs.items(): self.servos.hw.pulse(ch,us) - self.servos.active[ch]=time.monotonic()+2 + self.servos.ligar([(ch,time.monotonic()+2) for ch in outputs]) return dict(ok=True,pulsos=outputs) def rotas(self,h,method): route=h.path.split('?',1)[0] diff --git a/servos.py b/servos.py index 13e885c..7a0d6d8 100644 --- a/servos.py +++ b/servos.py @@ -88,6 +88,15 @@ class ControleServos: with self.lock: return dict(disponivel=self.hw is not None, erro=self.error, ativos=sorted(self.active), dono=self.dono, travado=self.travado) + def ligar(self, canais_prazos): + """Registra canais energizados até o prazo; avisa o painel quando algum canal liga.""" + with self.lock: + novos = False + for canal, prazo in canais_prazos: + novos = novos or canal not in self.active + self.active[canal] = prazo + if novos: + self.publicar('motores', self.motores()) def reservar(self, nome): """Braço e diagnóstico usam o mesmo PCA9685 com limites diferentes: um de cada vez.""" with self.lock: @@ -110,7 +119,7 @@ class ControleServos: if self.hw is None: raise OSError('PCA9685 indisponível neste servidor') self.reservar('diagnostico') self.hw.pulse(row['canal'], pulse) - self.active[row['canal']] = time.monotonic() + 2 + self.ligar([(row['canal'], time.monotonic() + 2)]) return self.status() def expire(self, now): with self.lock: diff --git a/tests/test_painel_web_browser.py b/tests/test_painel_web_browser.py index f7b24df..00d7e25 100644 --- a/tests/test_painel_web_browser.py +++ b/tests/test_painel_web_browser.py @@ -150,6 +150,32 @@ def testar(base, server, capturas): if capturas: page.screenshot(path=str(Path(capturas) / 'reacoes.png'), full_page=True) + # 6d. braço: opt-in, homem-morto e desligamento pelo prazo ao soltar + hw = server.servos.hw + page.get_by_role('link', name='Movimento').first.click() + segurar = page.get_by_role('button', name='Segure para ir ao alvo') + expect(segurar).to_be_disabled() + page.get_by_role('switch', name='Ativar movimento físico').locator('xpath=..').click() + expect(segurar).to_be_enabled() + hw.calls.clear() + segurar.hover() + page.mouse.down() + page.wait_for_timeout(700) + assert server.servos.active, 'motores deveriam estar ligados enquanto segura' + expect(page.get_by_text('Motores ligados')).to_be_visible(timeout=3000) + page.mouse.up() + page.wait_for_timeout(900) + assert not server.servos.active, 'motores deveriam desligar sozinhos após soltar' + assert any(us for _, us in hw.calls), hw.calls + if capturas: + page.screenshot(path=str(Path(capturas) / 'movimento.png'), full_page=True) + # E-Stop desativa o movimento físico na hora + page.get_by_role('button', name='Parar tudo').click() + expect(page.get_by_role('switch', name='Ativar movimento físico')).to_be_disabled(timeout=3000) + expect(segurar).to_be_disabled() + page.get_by_role('button', name='PARADO · Rearmar').click() + expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=3000) + # 7. celular: barra inferior e nada de rolagem horizontal page.set_viewport_size({'width': 400, 'height': 860}) page.get_by_role('link', name='Ao vivo').click() @@ -170,7 +196,7 @@ def testar(base, server, capturas): assert not erros, erros browser.close() - print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, conversa, memória, reações com desfazer, celular 400px, tema') + print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, conversa, memória, reações com desfazer, braço com homem-morto, celular 400px, tema') if __name__ == '__main__': diff --git a/tests/test_servidor.py b/tests/test_servidor.py index 8516da8..a09ef85 100644 --- a/tests/test_servidor.py +++ b/tests/test_servidor.py @@ -215,6 +215,14 @@ class BracoMovimentoTest(unittest.TestCase): self.assertEqual(self.servos.active, {}) self.assertIsNone(self.servos.dono) + def test_renovar_no_alvo_informa_que_chegou(self): + self.arm.move([90] * 7) + self.hw.calls.clear() + r = self.arm.move([90] * 7, velocidade=60, ttl_ms=400) + self.assertFalse(r['movendo']) + self.assertIsNone(self.arm.alvo) + self.assertEqual(len(self.hw.calls), 8) # reenergiza os 8 canais com o novo prazo + def test_parametros_invalidos(self): for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)): with self.assertRaises(ValueError): diff --git a/web/src/lib/braco.svelte.ts b/web/src/lib/braco.svelte.ts new file mode 100644 index 0000000..a711188 --- /dev/null +++ b/web/src/lib/braco.svelte.ts @@ -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(null); + alvo = $state(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('/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('/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; + } + }); +}); diff --git a/web/src/lib/componentes/BracoDesenho.svelte b/web/src/lib/componentes/BracoDesenho.svelte new file mode 100644 index 0000000..01309e1 --- /dev/null +++ b/web/src/lib/componentes/BracoDesenho.svelte @@ -0,0 +1,96 @@ + + +{#snippet braco(p: number[], classe: string)} + + + + + + + + + + + + + + + + + + + + + + + +{/snippet} + + + + + + + {#if fantasma}{@render braco(fantasma, 'fantasma')}{/if} + {@render braco(pose, 'real')} + + + diff --git a/web/src/telas/Movimento.svelte b/web/src/telas/Movimento.svelte index 0c5902e..4903479 100644 --- a/web/src/telas/Movimento.svelte +++ b/web/src/telas/Movimento.svelte @@ -1,7 +1,88 @@ - -

Enquanto isso, use a tela antiga: abrir Movimento (versão anterior).

-
+
+
+
+ {#if !motores?.disponivel} + PCA9685 indisponível neste servidor + {:else if estado.estop.ativo || motores.travado} + Travado pelo E-Stop + {:else if motores.ativos.length} + Motores ligados · {motores.dono === 'braco' ? 'braço' : 'diagnóstico'} · canais {motores.ativos.join(', ')} + {:else} + Motores desligados + {/if} + {#if motores?.erro}{motores.erro}{/if} +
+ +
+ + + + {#if aba === 'controle'} + + {:else if aba === 'calibracao'} + + {:else} + + {/if} +
+ + diff --git a/web/src/telas/movimento/Calibracao.svelte b/web/src/telas/movimento/Calibracao.svelte new file mode 100644 index 0000000..850f4cc --- /dev/null +++ b/web/src/telas/movimento/Calibracao.svelte @@ -0,0 +1,195 @@ + + +
+ + { + habilitado = v; + if (!v) braco.pararMotores(); + }} + /> + +
+ + {pulso} µs + +
+ +
+ + + +
+

Marcar só altera a tabela; nada se move. Canais 5 e 6 andam juntos, espelhados.

+
+ + + {#snippet acoes()} + {#if sujo}Alterações não salvas{/if} + + {/snippet} +
+ + + + + + {#each cal as c, i (i)} + + + {#each ['minimo', 'centro', 'maximo'] as const as campo (campo)} + + {/each} + + {/each} + +
CanalMínimoCentroMáximo
{i} {nomeCanal(i)} + editar(i, campo, Number(e.currentTarget.value))} + /> +
+
+ {#if invalido}

Canal {invalido.canal}: o centro precisa ficar entre mínimo e máximo.

{/if} +
+
+ + diff --git a/web/src/telas/movimento/Controle.svelte b/web/src/telas/movimento/Controle.svelte new file mode 100644 index 0000000..98ea764 --- /dev/null +++ b/web/src/telas/movimento/Controle.svelte @@ -0,0 +1,325 @@ + + + braco.soltar()} /> + +
+ +
+

+ {#if previa}Prévia na tela · sem movimento físico{:else if st?.atual}Contorno claro: última posição enviada aos motores{:else}Alvo na tela{/if} +

+
+ +
+ + (v ? (braco.ativado = true) : braco.desativar())} + /> + +

+ O braço só se move enquanto você segura (botão, slider ou Espaço). Ao soltar, os motores desligam em menos de meio segundo. +

+ (braco.velocidade = v)} /> +
+ + + {#snippet acoes()} + + {/snippet} + {#if st} + +
braco.ativado && (e.target as HTMLElement).tagName === 'INPUT' && braco.segurar()} + onkeydown={teclaSlider} + onkeyup={() => braco.soltar()} + > + {#each st.articulacoes as a, i (a.canal)} + (braco.alvo[i] = v)} + /> + {/each} +
+ {/if} +
+
+ + +
    + {#each salvas ? [...seqs, salvas] : seqs as s (s.nome)} +
  • + {s.nome} + + + + +
  • + {/each} +
+
+ + + {#snippet acoes()} + + + + {/snippet} +
+ + +
+
    + {#each st?.posturas ?? [] as p, i (i)} +
  • + + + {#if excluindo === i} + + + {:else} + + + {/if} + +
  • + {:else} +
  • Nenhuma postura salva. Ajuste as articulações e salve.
  • + {/each} +
+
+
+ + diff --git a/web/src/telas/movimento/Diagnostico.svelte b/web/src/telas/movimento/Diagnostico.svelte new file mode 100644 index 0000000..ddba577 --- /dev/null +++ b/web/src/telas/movimento/Diagnostico.svelte @@ -0,0 +1,184 @@ + + +
+ + {#snippet acoes()} + {#if sujo}Alterações não salvas{/if} + + + {/snippet} + {#if ocupadoPeloBraco} +

O braço está usando os motores. Pare o braço antes de testar aqui.

+ {/if} + (v ? (aoVivo = true) : parar())} + /> +
+ +
+ {#each servos as s, i (i)} + +
+ + + + + +
+ enfileirar(i, v)} /> +
+ + + + +
+
+ {/each} +
+
+ +