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:
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
@@ -1,7 +1,88 @@
|
||||
<script lang="ts">
|
||||
import Card from '../lib/componentes/Card.svelte';
|
||||
import { Power } from '@lucide/svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { braco } from '../lib/braco.svelte';
|
||||
import Abas from '../lib/componentes/Abas.svelte';
|
||||
import Selo from '../lib/componentes/Selo.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { registrarNaPaleta } from '../lib/paleta.svelte';
|
||||
import Calibracao from './movimento/Calibracao.svelte';
|
||||
import Controle from './movimento/Controle.svelte';
|
||||
import Diagnostico from './movimento/Diagnostico.svelte';
|
||||
|
||||
type Aba = 'controle' | 'calibracao' | 'diagnostico';
|
||||
let aba = $state<Aba>('controle');
|
||||
const motores = $derived(estado.motores);
|
||||
|
||||
onMount(() => {
|
||||
braco.carregar();
|
||||
});
|
||||
// Posturas na paleta: carregar como alvo (mover exige segurar o botão).
|
||||
const sair = registrarNaPaleta(() =>
|
||||
(braco.status?.posturas ?? []).map((p, i) => ({
|
||||
id: 'postura-' + i,
|
||||
grupo: 'Posturas do braço',
|
||||
titulo: 'Carregar ' + p.nome,
|
||||
fazer: () => (braco.alvo = [...p.angulos]),
|
||||
})),
|
||||
);
|
||||
onDestroy(() => {
|
||||
sair();
|
||||
braco.soltar();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card titulo="Movimento" descricao="Braço, calibração e diagnóstico migram na fase 4.">
|
||||
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/braco">abrir Movimento (versão anterior)</a>.</p>
|
||||
</Card>
|
||||
<div class="movimento">
|
||||
<div class="estado">
|
||||
<div class="linha">
|
||||
{#if !motores?.disponivel}
|
||||
<Selo tom="aviso">PCA9685 indisponível neste servidor</Selo>
|
||||
{:else if estado.estop.ativo || motores.travado}
|
||||
<Selo tom="perigo">Travado pelo E-Stop</Selo>
|
||||
{:else if motores.ativos.length}
|
||||
<Selo tom="ok">Motores ligados · {motores.dono === 'braco' ? 'braço' : 'diagnóstico'} · canais {motores.ativos.join(', ')}</Selo>
|
||||
{:else}
|
||||
<Selo>Motores desligados</Selo>
|
||||
{/if}
|
||||
{#if motores?.erro}<span class="erro pequeno-texto">{motores.erro}</span>{/if}
|
||||
</div>
|
||||
<button class="btn" onclick={() => braco.pararMotores()} disabled={!motores?.ativos.length}>
|
||||
<Power size={16} /> Desligar motores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Abas
|
||||
rotulo="Movimento"
|
||||
bind:ativa={aba}
|
||||
abas={[
|
||||
{ id: 'controle', titulo: 'Controle' },
|
||||
{ id: 'calibracao', titulo: 'Calibração' },
|
||||
{ id: 'diagnostico', titulo: 'Diagnóstico' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if aba === 'controle'}
|
||||
<Controle />
|
||||
{:else if aba === 'calibracao'}
|
||||
<Calibracao />
|
||||
{:else}
|
||||
<Diagnostico />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.movimento {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
}
|
||||
.estado {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.erro {
|
||||
color: var(--perigo);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { Minus, Plus, Save, Target } from '@lucide/svelte';
|
||||
import { postar } from '../../lib/api';
|
||||
import { avisos } from '../../lib/avisos.svelte';
|
||||
import { braco, type Calibracao } from '../../lib/braco.svelte';
|
||||
import Card from '../../lib/componentes/Card.svelte';
|
||||
import Selo from '../../lib/componentes/Selo.svelte';
|
||||
import Toggle from '../../lib/componentes/Toggle.svelte';
|
||||
import { estado } from '../../lib/estado.svelte';
|
||||
|
||||
const st = $derived(braco.status);
|
||||
// Cópia local editável; só vai ao servidor em "Salvar calibração".
|
||||
let cal = $state<Calibracao[]>([]);
|
||||
let sujo = $state(false);
|
||||
$effect(() => {
|
||||
if (st && !sujo) cal = st.calibracao.map((c) => ({ ...c, centro: c.centro ?? 1500 }));
|
||||
});
|
||||
|
||||
let habilitado = $state(false);
|
||||
let canal = $state(0);
|
||||
let pulso = $state(1500);
|
||||
let ocupado = $state(false);
|
||||
const nomeCanal = (i: number) => st?.articulacoes.find((a) => a.canal === i)?.nome ?? (i === 6 ? 'Antebraço (par invertido)' : `Canal ${i}`);
|
||||
|
||||
function trocarCanal(i: number) {
|
||||
canal = i;
|
||||
pulso = cal[i]?.centro ?? 1500;
|
||||
habilitado = false;
|
||||
braco.pararMotores();
|
||||
}
|
||||
|
||||
async function testar(delta: number) {
|
||||
if (!habilitado || ocupado) return;
|
||||
const prox = pulso + delta;
|
||||
if (prox < 500 || prox > 2500) return avisos.mostrar('Faixa absoluta: 500–2500 µs', 'erro');
|
||||
ocupado = true;
|
||||
try {
|
||||
braco.soltar();
|
||||
const r = await postar<{ pulsos: Record<string, number> }>('/braco/calibrar', { canal, pulso: prox });
|
||||
pulso = prox;
|
||||
avisos.mostrar(`Teste: ${Object.entries(r.pulsos).map(([c, us]) => `canal ${c} = ${us} µs`).join(', ')}. Desliga em 2 s.`);
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
} finally {
|
||||
ocupado = false;
|
||||
}
|
||||
}
|
||||
|
||||
function marcar(campo: 'minimo' | 'centro' | 'maximo') {
|
||||
cal[canal] = { ...cal[canal], [campo]: pulso };
|
||||
sujo = true;
|
||||
}
|
||||
function editar(i: number, campo: 'minimo' | 'centro' | 'maximo', v: number) {
|
||||
cal[i] = { ...cal[i], [campo]: v };
|
||||
sujo = true;
|
||||
}
|
||||
const invalido = $derived(cal.find((c) => !(c.minimo < c.centro && c.centro < c.maximo)));
|
||||
|
||||
async function salvar() {
|
||||
if (!st) return;
|
||||
habilitado = false;
|
||||
if (await braco.salvar({ calibracao: $state.snapshot(cal), posturas: st.posturas }, 'Calibração salva. Movimento desativado.')) {
|
||||
sujo = false;
|
||||
braco.ativado = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="calibracao">
|
||||
<Card titulo="Testar fim de curso" descricao="Passos de 100 µs. Observe o motor e recue ao atingir o limite: não há detecção automática.">
|
||||
<Toggle
|
||||
rotulo="Habilitar testes de calibração"
|
||||
ajuda="Cada teste mantém o sinal por até 2 s"
|
||||
checked={habilitado}
|
||||
disabled={!st?.disponivel || estado.estop.ativo}
|
||||
onchange={(v) => {
|
||||
habilitado = v;
|
||||
if (!v) braco.pararMotores();
|
||||
}}
|
||||
/>
|
||||
<label class="campo">
|
||||
<span>Canal</span>
|
||||
<select value={canal} onchange={(e) => trocarCanal(Number(e.currentTarget.value))}>
|
||||
{#each Array.from({ length: 8 }, (_, i) => i) as i (i)}
|
||||
<option value={i}>Canal {i} · {nomeCanal(i)}{i === 5 || i === 6 ? ' (par 5/6)' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="pulso">
|
||||
<button class="btn icone" onclick={() => testar(-100)} disabled={!habilitado || ocupado} aria-label="Menos 100 µs"><Minus size={18} /></button>
|
||||
<output class="mono" aria-live="polite">{pulso} µs</output>
|
||||
<button class="btn icone" onclick={() => testar(100)} disabled={!habilitado || ocupado} aria-label="Mais 100 µs"><Plus size={18} /></button>
|
||||
</div>
|
||||
<button class="btn" onclick={() => testar(0)} disabled={!habilitado || ocupado}><Target size={16} /> Testar posição atual</button>
|
||||
<div class="linha">
|
||||
<button class="btn pequeno" onclick={() => marcar('minimo')}>Marcar mínimo</button>
|
||||
<button class="btn pequeno" onclick={() => marcar('centro')}>Marcar centro</button>
|
||||
<button class="btn pequeno" onclick={() => marcar('maximo')}>Marcar máximo</button>
|
||||
</div>
|
||||
<p class="fraco pequeno-texto">Marcar só altera a tabela; nada se move. Canais 5 e 6 andam juntos, espelhados.</p>
|
||||
</Card>
|
||||
|
||||
<Card titulo="Limites por canal">
|
||||
{#snippet acoes()}
|
||||
{#if sujo}<Selo tom="aviso">Alterações não salvas</Selo>{/if}
|
||||
<button class="btn pequeno primario" onclick={salvar} disabled={!sujo || !!invalido}><Save size={14} /> Salvar calibração</button>
|
||||
{/snippet}
|
||||
<div class="tabela">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Canal</th><th>Mínimo</th><th>Centro</th><th>Máximo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each cal as c, i (i)}
|
||||
<tr class:atual={i === canal} class:erro={!(c.minimo < c.centro && c.centro < c.maximo)}>
|
||||
<th scope="row">{i} <small class="fraco">{nomeCanal(i)}</small></th>
|
||||
{#each ['minimo', 'centro', 'maximo'] as const as campo (campo)}
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="500"
|
||||
max="2500"
|
||||
step="10"
|
||||
value={c[campo]}
|
||||
aria-label="{campo} do canal {i}"
|
||||
onchange={(e) => editar(i, campo, Number(e.currentTarget.value))}
|
||||
/>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if invalido}<p class="erro pequeno-texto">Canal {invalido.canal}: o centro precisa ficar entre mínimo e máximo.</p>{/if}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.calibracao {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
.pulso {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--e-4);
|
||||
}
|
||||
output {
|
||||
font-size: var(--t-xl);
|
||||
min-width: 7ch;
|
||||
text-align: center;
|
||||
}
|
||||
.tabela {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--e-1) var(--e-2);
|
||||
text-align: left;
|
||||
}
|
||||
thead th {
|
||||
color: var(--texto-3);
|
||||
font-weight: 500;
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
tbody th {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody th small {
|
||||
display: block;
|
||||
}
|
||||
td input {
|
||||
min-width: 84px;
|
||||
min-height: 34px;
|
||||
}
|
||||
tr.atual {
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
tr.erro input {
|
||||
border-color: var(--perigo);
|
||||
}
|
||||
.erro {
|
||||
color: var(--perigo);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script lang="ts">
|
||||
import { Crosshair, Download, Eye, Hand, Play, Save, Trash2, Upload } from '@lucide/svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { avisos } from '../../lib/avisos.svelte';
|
||||
import { braco, sequencias } from '../../lib/braco.svelte';
|
||||
import BracoDesenho from '../../lib/componentes/BracoDesenho.svelte';
|
||||
import Card from '../../lib/componentes/Card.svelte';
|
||||
import Slider from '../../lib/componentes/Slider.svelte';
|
||||
import Toggle from '../../lib/componentes/Toggle.svelte';
|
||||
import { estado } from '../../lib/estado.svelte';
|
||||
|
||||
const st = $derived(braco.status);
|
||||
const podeMover = $derived(!!st?.disponivel && !estado.estop.ativo);
|
||||
const seqs = $derived(st ? sequencias(braco.faixas) : []);
|
||||
|
||||
// ---------- botão de homem-morto (mouse, toque e teclado)
|
||||
function segurarBotao(e: PointerEvent, seq?: { nome: string; passos: number[][] }) {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
braco.segurar(seq);
|
||||
}
|
||||
function teclaSegurar(e: KeyboardEvent, seq?: { nome: string; passos: number[][] }) {
|
||||
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
|
||||
e.preventDefault();
|
||||
braco.segurar(seq);
|
||||
}
|
||||
}
|
||||
function teclaSoltar(e: KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') braco.soltar();
|
||||
}
|
||||
// Arrastar um slider com o movimento ativado também é "segurar".
|
||||
function teclaSlider(e: KeyboardEvent) {
|
||||
if (braco.ativado && /^Arrow|Page|Home|End/.test(e.key)) braco.segurar();
|
||||
}
|
||||
|
||||
// ---------- prévia animada (só na tela)
|
||||
let previa = $state<number[] | null>(null);
|
||||
let quadro = 0;
|
||||
function animar(passos: number[][]) {
|
||||
cancelAnimationFrame(quadro);
|
||||
let pose = [...braco.alvo];
|
||||
let i = 0;
|
||||
let pausa = 0;
|
||||
let antes = performance.now();
|
||||
const passo = (agora: number) => {
|
||||
const dt = Math.min(0.1, (agora - antes) / 1000);
|
||||
antes = agora;
|
||||
const meta = passos[i];
|
||||
const dist = Math.max(...pose.map((v, k) => Math.abs(meta[k] - v)));
|
||||
const frac = dist ? Math.min(1, (braco.velocidade * dt) / dist) : 1;
|
||||
pose = pose.map((v, k) => v + (meta[k] - v) * frac);
|
||||
previa = pose;
|
||||
if (dist < 0.01) {
|
||||
if (!pausa) pausa = agora + 700;
|
||||
if (agora >= pausa) {
|
||||
pausa = 0;
|
||||
if (++i >= passos.length) {
|
||||
previa = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
quadro = requestAnimationFrame(passo);
|
||||
};
|
||||
quadro = requestAnimationFrame(passo);
|
||||
}
|
||||
onDestroy(() => {
|
||||
cancelAnimationFrame(quadro);
|
||||
braco.soltar();
|
||||
});
|
||||
|
||||
// ---------- posturas
|
||||
let nomePostura = $state('');
|
||||
let excluindo = $state<number | null>(null);
|
||||
async function salvarPostura(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!st) return;
|
||||
const nome = nomePostura.trim() || `Postura ${st.posturas.length + 1}`;
|
||||
const posturas = [...st.posturas, { nome, angulos: $state.snapshot(braco.alvo) }];
|
||||
if (await braco.salvar({ calibracao: st.calibracao, posturas }, `“${nome}” salva, sem movimento.`)) nomePostura = '';
|
||||
}
|
||||
async function excluirPostura(i: number) {
|
||||
if (!st) return;
|
||||
excluindo = null;
|
||||
await braco.salvar({ calibracao: st.calibracao, posturas: st.posturas.filter((_, k) => k !== i) }, 'Postura excluída.');
|
||||
}
|
||||
const salvas = $derived(st?.posturas.length ? { nome: 'Posturas salvas em ordem', passos: st.posturas.map((p) => p.angulos) } : null);
|
||||
|
||||
function exportar() {
|
||||
if (!st) return;
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify({ formato: 'carinha-braco-v1', calibracao: st.calibracao, posturas: st.posturas }, null, 2)], {
|
||||
type: 'application/json',
|
||||
}),
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'braco-posturas.json';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
let arquivo: HTMLInputElement;
|
||||
async function importar() {
|
||||
const f = arquivo.files?.[0];
|
||||
arquivo.value = '';
|
||||
if (!f) return;
|
||||
try {
|
||||
if (f.size > 65536) throw new Error('Arquivo maior que 64 KB');
|
||||
const dados = JSON.parse(await f.text());
|
||||
if (dados.formato !== 'carinha-braco-v1') throw new Error('Use um arquivo exportado por este painel');
|
||||
await braco.salvar({ calibracao: dados.calibracao, posturas: dados.posturas }, 'Calibração e posturas importadas, sem movimento.');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpointerup={() => braco.soltar()} />
|
||||
|
||||
<div class="controle">
|
||||
<Card class="desenho">
|
||||
<div class="tela"><BracoDesenho pose={previa ?? braco.alvo} fantasma={previa ? null : st?.atual} /></div>
|
||||
<p class="fraco pequeno-texto">
|
||||
{#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}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<div class="coluna">
|
||||
<Card titulo="Mover">
|
||||
<Toggle
|
||||
rotulo="Ativar movimento físico"
|
||||
ajuda={!st?.disponivel ? 'PCA9685 indisponível neste servidor' : estado.estop.ativo ? 'Rearme o E-Stop primeiro' : 'Com isso desligado, tudo acontece só na tela'}
|
||||
checked={braco.ativado}
|
||||
disabled={!podeMover}
|
||||
onchange={(v) => (v ? (braco.ativado = true) : braco.desativar())}
|
||||
/>
|
||||
<button
|
||||
class="segurar"
|
||||
class:ativo={braco.segurando}
|
||||
disabled={!braco.ativado}
|
||||
onpointerdown={(e) => segurarBotao(e)}
|
||||
onpointerup={() => braco.soltar()}
|
||||
onpointercancel={() => braco.soltar()}
|
||||
onlostpointercapture={() => braco.soltar()}
|
||||
onkeydown={(e) => teclaSegurar(e)}
|
||||
onkeyup={teclaSoltar}
|
||||
onblur={() => braco.soltar()}
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<Hand size={22} />
|
||||
{#if braco.passoSequencia}
|
||||
{braco.passoSequencia.nome} · passo {braco.passoSequencia.indice + 1}/{braco.passoSequencia.total}
|
||||
{:else if braco.segurando}Movendo… solte para parar{:else}Segure para ir ao alvo{/if}
|
||||
</button>
|
||||
<p class="fraco pequeno-texto">
|
||||
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.
|
||||
</p>
|
||||
<Slider rotulo="Velocidade" valor={braco.velocidade} min={5} max={180} passo={5} unidade=" °/s" oninput={(v) => (braco.velocidade = v)} />
|
||||
</Card>
|
||||
|
||||
<Card titulo="Articulações">
|
||||
{#snippet acoes()}
|
||||
<button class="btn pequeno" onclick={() => ((braco.alvo = Array(7).fill(90)), braco.limitarAlvo())}>
|
||||
<Crosshair size={14} /> Centralizar
|
||||
</button>
|
||||
{/snippet}
|
||||
{#if st}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="articulacoes"
|
||||
onpointerdown={(e) => braco.ativado && (e.target as HTMLElement).tagName === 'INPUT' && braco.segurar()}
|
||||
onkeydown={teclaSlider}
|
||||
onkeyup={() => braco.soltar()}
|
||||
>
|
||||
{#each st.articulacoes as a, i (a.canal)}
|
||||
<Slider
|
||||
rotulo={`${a.nome} · canal ${a.canal}${a.canal === 5 ? ' + 6' : ''}`}
|
||||
valor={braco.alvo[i]}
|
||||
min={braco.faixas[i][0]}
|
||||
max={braco.faixas[i][1]}
|
||||
unidade="°"
|
||||
oninput={(v) => (braco.alvo[i] = v)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card titulo="Sequências" descricao="Prévia anima só a tela. Para executar, segure o botão até o fim.">
|
||||
<ul class="lista">
|
||||
{#each salvas ? [...seqs, salvas] : seqs as s (s.nome)}
|
||||
<li>
|
||||
<span>{s.nome}</span>
|
||||
<span class="linha">
|
||||
<button class="btn pequeno" onclick={() => animar(s.passos)}><Eye size={14} /> Prévia</button>
|
||||
<button
|
||||
class="btn pequeno primario"
|
||||
disabled={!braco.ativado || (braco.segurando && braco.passoSequencia?.nome !== s.nome)}
|
||||
onpointerdown={(e) => segurarBotao(e, s)}
|
||||
onpointerup={() => braco.soltar()}
|
||||
onpointercancel={() => braco.soltar()}
|
||||
onkeydown={(e) => teclaSegurar(e, s)}
|
||||
onkeyup={teclaSoltar}
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<Play size={14} /> Segure
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card titulo="Posturas">
|
||||
{#snippet acoes()}
|
||||
<button class="btn pequeno fantasma" onclick={exportar} disabled={!st}><Download size={14} /> Exportar</button>
|
||||
<button class="btn pequeno fantasma" onclick={() => arquivo.click()}><Upload size={14} /> Importar</button>
|
||||
<input bind:this={arquivo} type="file" accept="application/json" hidden onchange={importar} />
|
||||
{/snippet}
|
||||
<form class="linha" onsubmit={salvarPostura}>
|
||||
<input type="text" bind:value={nomePostura} maxlength="40" placeholder="Nome da postura atual" aria-label="Nome da postura" />
|
||||
<button class="btn" type="submit" disabled={!st}><Save size={16} /> Salvar atual</button>
|
||||
</form>
|
||||
<ul class="lista">
|
||||
{#each st?.posturas ?? [] as p, i (i)}
|
||||
<li>
|
||||
<button class="nome" onclick={() => (braco.alvo = [...p.angulos])} title="Carregar como alvo (sem mover)">{p.nome}</button>
|
||||
<span class="linha">
|
||||
{#if excluindo === i}
|
||||
<button class="btn pequeno perigo" onclick={() => excluirPostura(i)}>Excluir</button>
|
||||
<button class="btn pequeno" onclick={() => (excluindo = null)}>Cancelar</button>
|
||||
{:else}
|
||||
<button class="btn pequeno" onclick={() => (braco.alvo = [...p.angulos])}>Carregar</button>
|
||||
<button class="btn pequeno icone fantasma" aria-label="Excluir {p.nome}" onclick={() => (excluindo = i)}><Trash2 size={14} /></button>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="fraco pequeno-texto">Nenhuma postura salva. Ajuste as articulações e salve.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controle {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
.coluna {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
}
|
||||
.tela {
|
||||
aspect-ratio: 4 / 5;
|
||||
max-height: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
background: var(--fundo-2);
|
||||
border-radius: var(--raio);
|
||||
}
|
||||
.segurar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--e-3);
|
||||
min-height: 64px;
|
||||
padding: var(--e-3);
|
||||
border: 2px dashed var(--destaque);
|
||||
border-radius: var(--raio);
|
||||
background: var(--destaque-suave);
|
||||
color: var(--destaque);
|
||||
font: 600 var(--t-md) var(--fonte);
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.segurar.ativo {
|
||||
border-style: solid;
|
||||
background: var(--destaque);
|
||||
color: var(--destaque-texto);
|
||||
}
|
||||
.segurar:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.articulacoes {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.lista {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.lista li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-2) var(--e-3);
|
||||
border-radius: var(--raio-sm);
|
||||
background: var(--superficie-2);
|
||||
font-size: var(--t-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nome {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
form input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user