diff --git a/CHANGELOG.md b/CHANGELOG.md index d39bdcc..e64eec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH** --- +## [1.37.0] — 2026-06-10 + +### Added +- **Gravação do firmware da balança pela web** (Admin → **Estação de pesagem**, + `/admin/scale`, só admin). O usuário conecta o ESP32‑C3 por USB e grava o firmware + direto do navegador via **Web Serial + esptool‑js** — irmão do gravador Niimbot (Web + Bluetooth): Chrome/Edge no computador, HTTPS (ou `localhost`), sem instalar nada. + - O firmware é compilado e gravado em **4 pedaços separados nos seus offsets** + (`bootloader 0x0 · partitions 0x8000 · boot_app0 0xe000 · app 0x10000`) — exatamente + como o `pio upload` — via `deploy/build-firmware-bin.sh`, em `static/firmware/` + + manifesto (`balanca-c3.json`), **versionados no git** e servidos como estáticos (o + deploy por clone público/`git archive` leva tudo, sem build no servidor). Pedaços + separados (não uma imagem merged única em 0x0) + `data` como **`Uint8Array`** são o + que funciona com o esptool-js — validado em hardware (ESP32‑C3 SuperMini). + - O `esptool-js` é **vendorado** (`static/esptool.js`, via `deploy/vendor-esptool.sh`, + sem CDN/runtime); o adaptador `static/esp-flash.js` baixa o `.bin` e grava em `0x0`. + **Nenhuma mudança de CSP** (Web Serial é API JS, scripts são `'self'`). Mensagens + traduzidas no servidor (EN/ES). Ver `docs/balanca-web-flash.md`. + - Escopo desta versão: **só gravação** (binário genérico, sem segredos). Provisionamento + de Wi‑Fi/URL/chave de API (firmware com Wi‑Fi + `POST /api/weigh` + handshake + serial‑JSON + fallback SoftAP) virá numa próxima versão. + ## [1.36.0] — 2026-06-10 Endurecimento de segurança a partir de um teste externo (caixa-preta). Vários diff --git a/VERSION b/VERSION index 39fc130..bf50e91 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.36.0 +1.37.0 diff --git a/deploy/build-firmware-bin.sh b/deploy/build-firmware-bin.sh new file mode 100644 index 0000000..035c229 --- /dev/null +++ b/deploy/build-firmware-bin.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# ── build-firmware-bin.sh — gera os binários da balança p/ flash pela web ───── +# +# O gravador web (Web Serial + esptool-js, em /admin/scale) grava os MESMOS 4 +# pedaços que o `pio run -t upload` (CLI), cada um no seu offset: +# bootloader 0x0 · partitions 0x8000 · boot_app0 0xe000 · app 0x10000 +# +# IMPORTANTE: gravar os pedaços SEPARADOS (e não uma imagem "merged" única em 0x0) +# é o que funciona com o esptool-js — a imagem merged em 0x0 falha no meio +# ("Failed to write compressed data to flash after seq N"), pois o esptool-js +# erra o cálculo de endereço de bloco numa imagem única grande. O CLI grava +# separado, por isso funciona; aqui replicamos isso. +# +# Os binários + o manifesto (offsets) são gravados em static/firmware/ e VÃO +# committados no git: o deploy é por clone público + `git archive`, então o que +# está no git é o que o site serve (sem build no servidor). +# +# Uso: bash deploy/build-firmware-bin.sh +# Requisitos: PlatformIO (pio) no PATH. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FW_DIR="$ROOT/firmware" +ENV="balanca-c3" +CHIP="esp32c3" +BUILD_DIR="$FW_DIR/.pio/build/$ENV" +OUT_DIR="$ROOT/static/firmware" +OUT_MANIFEST="$OUT_DIR/$ENV.json" + +command -v pio >/dev/null 2>&1 || { echo "PlatformIO (pio) não encontrado no PATH." >&2; exit 1; } + +# Python que de fato roda (no Windows o python/python3 do Store é stub). +PY="" +for _c in python3 py python; do + if command -v "$_c" >/dev/null 2>&1 && "$_c" --version >/dev/null 2>&1; then PY="$_c"; break; fi +done +[ -n "$PY" ] || { echo "Python 3 necessário (python3/py/python)." >&2; exit 1; } + +echo "==> Compilando firmware ($ENV)…" +pio run -d "$FW_DIR" -e "$ENV" + +# Offsets padrão do ESP32-C3 Arduino. O boot_app0 (seletor OTA) vem do framework. +BOOTLOADER="$BUILD_DIR/bootloader.bin" +PARTITIONS="$BUILD_DIR/partitions.bin" +APP="$BUILD_DIR/firmware.bin" +for f in "$BOOTLOADER" "$PARTITIONS" "$APP"; do + [ -f "$f" ] || { echo "Faltando $f (rode o build antes)." >&2; exit 1; } +done + +# boot_app0.bin: prefere o diretório do framework SEM sufixo de versão (o ativo). +BOOT_APP0="$(ls -1 \ + "$HOME"/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + "$HOME"/.platformio/packages/framework-arduinoespressif32*/tools/partitions/boot_app0.bin \ + 2>/dev/null | head -1)" +[ -n "$BOOT_APP0" ] && [ -f "$BOOT_APP0" ] || { echo "boot_app0.bin não encontrado no framework." >&2; exit 1; } + +mkdir -p "$OUT_DIR" + +echo "==> Copiando os 4 pedaços para $OUT_DIR…" +cp -f "$BOOTLOADER" "$OUT_DIR/bootloader.bin" +cp -f "$PARTITIONS" "$OUT_DIR/partitions.bin" +cp -f "$BOOT_APP0" "$OUT_DIR/boot_app0.bin" +cp -f "$APP" "$OUT_DIR/app.bin" + +# Remove a imagem merged legada (não é mais usada — o flasher grava os pedaços). +rm -f "$OUT_DIR/$ENV.bin" + +echo "==> Gerando manifesto (offsets + sha)…" +"$PY" - "$OUT_DIR" "$OUT_MANIFEST" "$CHIP" "$ROOT" <<'PYEOF' +import hashlib, json, os, subprocess, sys, datetime +out_dir, manifest, chip, root = sys.argv[1:5] +# offset → arquivo, na MESMA ordem/endereços do `pio run -t upload`. +layout = [ + ("0x0", "bootloader.bin"), + ("0x8000", "partitions.bin"), + ("0xe000", "boot_app0.bin"), + ("0x10000", "app.bin"), +] +def sha(p): + return hashlib.sha256(open(p, "rb").read()).hexdigest() +parts = [] +for off, fn in layout: + p = os.path.join(out_dir, fn) + parts.append({"offset": off, "file": fn, + "size": os.path.getsize(p), "sha256": sha(p)}) +try: + commit = subprocess.check_output( + ["git", "-C", root, "log", "-1", "--format=%h", "--", "firmware"], + text=True).strip() +except Exception: + commit = "" +m = { + "chip": chip, + "parts": parts, + # "versão" exibida = sha do app (firmware.bin); muda quando o firmware muda. + "app_sha256": next(p["sha256"] for p in parts if p["file"] == "app.bin"), + "source_commit": commit, + "built_at": datetime.datetime.now(datetime.timezone.utc) + .strftime("%Y-%m-%dT%H:%M:%SZ"), +} +json.dump(m, open(manifest, "w"), indent=2) +print(json.dumps(m, indent=2)) +PYEOF + +echo "==> OK. Pedaços + manifesto em $OUT_DIR." +echo "Revise o git diff e committe os artefatos." diff --git a/deploy/vendor-esptool.sh b/deploy/vendor-esptool.sh new file mode 100644 index 0000000..996f4e8 --- /dev/null +++ b/deploy/vendor-esptool.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# ── vendor-esptool.sh — re-vendora o esptool-js (gravador Web Serial) ────────── +# +# spool-control é PÚBLICO e o servidor clona anonimamente, então o esptool-js NÃO +# pode ser baixado em deploy/runtime — é VENDORADO no repo (igual ao driver Niimbot). +# O upstream é o pacote npm `esptool-js` (Espressif), que publica um `bundle.js` +# ESM único (mesmo bundle usado pelo ESP Web Tools). +# +# Esta é a única forma deliberada e reprodutível de atualizar a cópia vendorada: +# baixa o bundle de uma VERSÃO FIXA, carimba versão/origem no topo e grava em +# static/esptool.js. Sem download em runtime/deploy e sem CDN — o que está no git é +# o que roda (preserva CSP, deploy à prova de falhas e operação offline). +# +# Uso: +# deploy/vendor-esptool.sh # versão pinada abaixo +# deploy/vendor-esptool.sh 0.6.0 # uma versão específica +# +# Depois: revise `git diff`, bump VERSION + CHANGELOG, committe, deploy. +set -euo pipefail + +ESPTOOL_VERSION="${1:-0.6.0}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEST="$ROOT/static/esptool.js" +URL="https://unpkg.com/esptool-js@${ESPTOOL_VERSION}/bundle.js" +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +echo "==> Baixando esptool-js@${ESPTOOL_VERSION} de unpkg…" +curl -fsSL --max-time 60 "$URL" -o "$TMP" + +# Sanidade: precisa ser o bundle ESM com os exports que o esp-flash.js usa. +grep -q "as ESPLoader" "$TMP" || { echo "Bundle inesperado: não exporta ESPLoader." >&2; exit 1; } +grep -q "as Transport" "$TMP" || { echo "Bundle inesperado: não exporta Transport." >&2; exit 1; } + +{ + echo "// VENDORADO — esptool-js@${ESPTOOL_VERSION} (Apache-2.0), Espressif Systems." + echo "// Fonte: ${URL}" + echo "// NÃO EDITAR À MÃO — atualize via deploy/vendor-esptool.sh. Bundle ESM único" + echo "// (importado por static/esp-flash.js). Carimbado em $(date -u +%Y-%m-%dT%H:%M:%SZ)." + cat "$TMP" +} > "$DEST" + +echo "==> OK: $DEST ($(wc -c < "$DEST") bytes)" +echo "Revise o git diff e committe." diff --git a/docs/balanca-web-flash.md b/docs/balanca-web-flash.md new file mode 100644 index 0000000..ac7506d --- /dev/null +++ b/docs/balanca-web-flash.md @@ -0,0 +1,88 @@ +# Gravar o firmware da balança pela web + +A página **Admin → Estação de pesagem** (`/admin/scale`, só admin) grava o firmware da +balança (ESP32‑C3) direto do navegador, via **Web Serial + esptool‑js** — o irmão do +gravador Niimbot (Web Bluetooth). Sem instalar PlatformIO, sem app. + +> **Escopo atual:** só **gravação** de um binário genérico. A configuração de Wi‑Fi, URL +> e chave de API (provisionamento) virá numa próxima versão (firmware com Wi‑Fi STA + +> `POST /api/weigh` + handshake serial‑JSON; ver roadmap no fim). + +## Requisitos do usuário + +- **Chrome ou Edge no computador** (Web Serial não existe em Safari/Firefox nem em + celular). +- **Secure context:** HTTPS (produção atrás do Traefik) **ou** `http://localhost` no dev. +- A balança ligada por USB. No diálogo do navegador, escolher a porta serial da placa. + +## Como funciona + +- O firmware é compilado e gravado em **4 pedaços separados, cada um no seu offset** — + exatamente como o `pio upload`: `bootloader 0x0 · partitions 0x8000 · boot_app0 0xe000 + · app 0x10000`. Os pedaços (`bootloader.bin`, `partitions.bin`, `boot_app0.bin`, + `app.bin`) + o manifesto `static/firmware/balanca-c3.json` (offsets, sha256) ficam em + `static/firmware/`. +- **Gravar pedaços separados (e não uma imagem "merged" única em 0x0) é o que funciona + com o esptool-js.** Uma imagem única grande falha no meio (o esptool-js erra o cálculo + de endereço de bloco). Além disso, o `data` de cada pedaço é passado como **`Uint8Array`** + — passar uma "binary string" faz o pako expandir os bytes ≥ 0x80 como UTF‑8 e o stub + rejeita o bloco final com `ESP_TOO_MUCH_DATA` (0xC9). É assim que o ESP Web Tools / ESPHome + fazem. Validado em hardware (ESP32‑C3 SuperMini). +- Os artefatos são **versionados no git** e servidos como estáticos. O deploy é por clone + público + `git archive`, então **o que está no git é o que o site serve** — não há build + no servidor (preserva o deploy à prova de falhas). +- O driver `esptool-js` é **vendorado** em `static/esptool.js` (sem CDN, sem download em + runtime); o adaptador `static/esp-flash.js` importa o driver como módulo, lê o manifesto, + baixa os pedaços e grava cada um no seu offset. As mensagens visíveis são traduzidas no + servidor. + +## Atualizar o binário quando o firmware mudar + +```bash +# 1) Compila o firmware (env balanca-c3) e gera os pedaços + manifesto: +bash deploy/build-firmware-bin.sh +# → static/firmware/{bootloader,partitions,boot_app0,app}.bin + balanca-c3.json +# 2) Revise o git diff, committe os artefatos, bump VERSION + CHANGELOG. +``` + +Requer PlatformIO (`pio`) no PATH; o `esptool.py` vem com a plataforma espressif32 +(chamado via `pio pkg exec`). Offsets ESP32‑C3 Arduino: bootloader `0x0`, partições +`0x8000`, boot_app0 `0xe000`, app `0x10000`. + +## Atualizar o esptool‑js vendorado + +```bash +deploy/vendor-esptool.sh # versão pinada (0.6.0) +deploy/vendor-esptool.sh 0.6.1 # uma versão específica +``` + +Baixa o `bundle.js` (ESM único) da versão fixa para `static/esptool.js`, carimbando a +origem. Sem download em runtime/deploy. + +## Testar + +- **Local (hardware real):** rode o app em `http://localhost:5000` (secure context para + Web Serial), plugue o ESP32‑C3, abra `/admin/scale` no Chrome/Edge → **Conectar e + gravar** → confirme o boot no monitor serial (`pio device monitor -e balanca-c3`, 115200). +- **Rede / staging (sem afetar produção):** `git push` da branch e, numa **LXC separada**, + `bash /opt/spool-control/deploy/update-lxc.sh --ref ` (o script clona o repo + público no ref dado e aplica a árvore). Acesse via HTTPS e repita o flash. **Não** mexa + na LXC 117 (produção). +- **Testes automatizados:** `tests/test_scale_flash.py` cobre o gate admin, a entrega do + binário/manifesto e a presença do adaptador no HTML. + +## Notas + +- O ESP32‑C3 SuperMini usa **USB‑Serial/JTAG** nativo; o esptool‑js grava os pedaços nos + seus offsets sem precisar do botão BOOT. +- `platformio.ini` fixa `upload_port=COM23` — irrelevante para o flash web; só afeta o + `pio upload` manual. A porta real aparece no diálogo do navegador. + +## Roadmap (próxima fase — provisionamento) + +1. Firmware: Wi‑Fi STA + cliente HTTP `POST /api/weigh` + leitura de config no NVS + (`Preferences`). +2. Handshake **serial‑JSON pequeno** logo após o flash: o site envia SSID/senha + URL + + chave de API (lida de Admin → Integrações, integração `scale`) pela mesma sessão Web + Serial; o firmware grava no NVS. +3. Fallback **SoftAP captive portal** para configurar Wi‑Fi sem Web Serial. diff --git a/routes/admin.py b/routes/admin.py index b554680..bc9c357 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -212,6 +212,26 @@ def admin_settings(): ) +# ── Estação de pesagem (gravar firmware da balança pela web) ───────────────── + +def _firmware_manifest(): + """Lê static/firmware/balanca-c3.json (gerado por deploy/build-firmware-bin.sh). + Fail-safe: ausente/corrompido → {} (a página mostra o estado sem versão).""" + path = Path(app.static_folder) / "firmware" / "balanca-c3.json" + try: + return json.loads(path.read_text()) + except Exception: + return {} + + +@app.route("/admin/scale") +@admin_required +def admin_scale(): + manifest = _firmware_manifest() + manifest_url = url_for("static", filename="firmware/balanca-c3.json") + return render_template("admin/scale.html", manifest=manifest, manifest_url=manifest_url) + + # ── Atualização do sistema ─────────────────────────────────────────────────── @app.route("/admin/update") diff --git a/static/esp-flash.js b/static/esp-flash.js new file mode 100644 index 0000000..407fa01 --- /dev/null +++ b/static/esp-flash.js @@ -0,0 +1,175 @@ +/* ── esp-flash.js — adaptador do esptool-js para o spool-control ─────────────── + * Cola específica deste app sobre o driver genérico (static/esptool.js, vendorado + * de esptool-js@0.6.0). Grava o firmware da balança (ESP32-C3) direto do navegador + * via Web Serial — irmão do gravador Niimbot por Web Bluetooth. + * + * Requer secure context (HTTPS ou localhost) e Chrome/Edge desktop (navigator.serial). + * O binário é o MERGED em 0x0 (static/firmware/balanca-c3.bin), gerado por + * deploy/build-firmware-bin.sh. As mensagens vêm traduzidas do servidor num + * +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 417f425..331cd92 100644 --- a/templates/base.html +++ b/templates/base.html @@ -99,6 +99,7 @@
  • {{ _('Marcas / Logos') }}
  • {{ _('Configurações') }}
  • {{ _('Integrações') }}
  • +
  • {{ _('Estação de pesagem') }}
  • {{ _('Backup') }} diff --git a/tests/test_scale_flash.py b/tests/test_scale_flash.py new file mode 100644 index 0000000..57d6025 --- /dev/null +++ b/tests/test_scale_flash.py @@ -0,0 +1,56 @@ +"""Testes da página de gravação do firmware da balança (/admin/scale). + +Cobre o gate admin, a entrega dos pedaços do firmware + manifesto (offsets), e a +presença do adaptador esp-flash.js + i18n no HTML. O flash em si é Web Serial no +navegador (não testável em pytest). O firmware é gravado em pedaços separados, cada +um no seu offset — como o `pio upload` — porque a imagem merged única falha no +esptool-js.""" +import json +from pathlib import Path + +FW_DIR = Path(__file__).resolve().parent.parent / "static" / "firmware" + + +def test_scale_page_requires_admin(app_module, viewer_client, auth_client): + # anônimo → login; viewer → 403; admin → 200. (Cliente anônimo PRÓPRIO: o fixture + # `client` é compartilhado com `auth_client`, que o logaria como admin.) + anon = app_module.app.test_client().get("/admin/scale") + assert anon.status_code == 302 and "/login" in anon.headers["Location"] + assert viewer_client.get("/admin/scale").status_code == 403 + assert auth_client.get("/admin/scale").status_code == 200 + + +def test_scale_page_has_flasher_and_i18n(auth_client): + html = auth_client.get("/admin/scale").get_data(as_text=True) + # botão com a URL do manifesto + módulo esp-flash.js + blob de i18n + assert 'id="esp-flash-btn"' in html + assert "firmware/balanca-c3.json" in html + assert "data-manifest-url" in html + assert "esp-flash.js" in html + assert 'id="esp-flash-i18n"' in html + + +def test_firmware_parts_are_served(auth_client): + # Os pedaços são estáticos/públicos (sem segredos) — o gravador baixa cada um. + manifest = json.loads((FW_DIR / "balanca-c3.json").read_text()) + for part in manifest["parts"]: + resp = auth_client.get("/static/firmware/" + part["file"]) + assert resp.status_code == 200, part["file"] + assert int(resp.headers.get("Content-Length", "0")) == part["size"] + + +def test_firmware_manifest_is_valid(): + # Gerado por deploy/build-firmware-bin.sh; o flasher lê os offsets (como o CLI). + m = json.loads((FW_DIR / "balanca-c3.json").read_text()) + assert m["chip"] == "esp32c3" + offsets = [p["offset"] for p in m["parts"]] + assert offsets == ["0x0", "0x8000", "0xe000", "0x10000"] # mesmos do pio upload + assert {p["file"] for p in m["parts"]} == { + "bootloader.bin", "partitions.bin", "boot_app0.bin", "app.bin"} + assert len(m["app_sha256"]) == 64 + + +def test_scale_page_shows_firmware_version(auth_client): + sha8 = json.loads((FW_DIR / "balanca-c3.json").read_text())["app_sha256"][:8] + html = auth_client.get("/admin/scale").get_data(as_text=True) + assert sha8 in html diff --git a/translations.py b/translations.py index 826cae4..ae8e4c3 100644 --- a/translations.py +++ b/translations.py @@ -450,6 +450,32 @@ "Roxo": "Purple", "Rosa": "Pink", "Marrom": "Brown", + # ── Estação de pesagem (gravar firmware da balança) ────────────────────── + "Estação de pesagem": "Weighing station", + "Gravar firmware da balança": "Flash the scale firmware", + "Conecte a balança (ESP32-C3) por USB e grave o firmware direto do navegador — sem instalar nada. Requer Chrome ou Edge no computador.": + "Connect the scale (ESP32-C3) over USB and flash the firmware straight from the browser — no install needed. Requires Chrome or Edge on a computer.", + "Versão do firmware": "Firmware version", + "indisponível": "unavailable", + "Compilado em": "Built at", + "A gravação substitui o firmware atual da placa conectada.": + "Flashing replaces the current firmware on the connected board.", + "Conectar e gravar": "Connect and flash", + "Esta página só grava o firmware. A configuração de Wi-Fi, URL e chave de API virá numa próxima versão.": + "This page only flashes the firmware. Wi-Fi, URL and API key setup will come in a future version.", + "Use o Chrome ou o Edge no computador (Web Serial).": + "Use Chrome or Edge on a computer (Web Serial).", + "Isto vai gravar o firmware na balança conectada. Continuar?": + "This will flash the firmware onto the connected scale. Continue?", + "Conectando…": "Connecting…", + "Conectado: ": "Connected: ", + "Baixando firmware…": "Downloading firmware…", + "Gravando…": "Flashing…", + "Reiniciando a placa…": "Resetting the board…", + "Gravação concluída. A balança vai reiniciar.": "Flash complete. The scale will restart.", + "Cancelado.": "Cancelled.", + "Nenhuma porta selecionada.": "No port selected.", + "Falha: ": "Failed: ", } _ES = { @@ -902,6 +928,32 @@ "Roxo": "Morado", "Rosa": "Rosa", "Marrom": "Marrón", + # ── Estación de pesaje (grabar firmware de la balanza) ─────────────────── + "Estação de pesagem": "Estación de pesaje", + "Gravar firmware da balança": "Grabar el firmware de la balanza", + "Conecte a balança (ESP32-C3) por USB e grave o firmware direto do navegador — sem instalar nada. Requer Chrome ou Edge no computador.": + "Conecte la balanza (ESP32-C3) por USB y grabe el firmware desde el navegador — sin instalar nada. Requiere Chrome o Edge en una computadora.", + "Versão do firmware": "Versión del firmware", + "indisponível": "no disponible", + "Compilado em": "Compilado el", + "A gravação substitui o firmware atual da placa conectada.": + "La grabación reemplaza el firmware actual de la placa conectada.", + "Conectar e gravar": "Conectar y grabar", + "Esta página só grava o firmware. A configuração de Wi-Fi, URL e chave de API virá numa próxima versão.": + "Esta página solo graba el firmware. La configuración de Wi-Fi, URL y clave de API llegará en una próxima versión.", + "Use o Chrome ou o Edge no computador (Web Serial).": + "Use Chrome o Edge en una computadora (Web Serial).", + "Isto vai gravar o firmware na balança conectada. Continuar?": + "Esto grabará el firmware en la balanza conectada. ¿Continuar?", + "Conectando…": "Conectando…", + "Conectado: ": "Conectado: ", + "Baixando firmware…": "Descargando firmware…", + "Gravando…": "Grabando…", + "Reiniciando a placa…": "Reiniciando la placa…", + "Gravação concluída. A balança vai reiniciar.": "Grabación completada. La balanza se reiniciará.", + "Cancelado.": "Cancelado.", + "Nenhuma porta selecionada.": "Ninguna puerta seleccionada.", + "Falha: ": "Error: ", } # PT-BR: só os termos que mudam em relação ao texto-fonte (Spool → Rolo).