# CLAUDE.md — Guia do sistema (Nexus — Casa Inteligente)

Este arquivo é a memória do projeto. Leia antes de mexer em qualquer coisa. Ele
existe para **manter a consistência**, **evitar erros** e **preservar decisões**
que se perdem com o tempo. Se você mudar uma convenção, atualize este arquivo.

> Resumo do que é: sistema web (PHP) para portaria/condomínio — pessoas, veículos,
> placas (ALPR), visitantes, pré-cadastro, controle de acesso via **InControl**
> (Intelbras/Control iD), eventos/relatórios, e um **módulo de Automação**
> (luzes, água/cisterna, Sonoff/Tuya/HTTP, cenas, agendas).

---

## 1. Regras invioláveis (quebrar = quebra produção)

1. **PHP 8.3** (WampServer 3.4; servidor novo). O código legado foi escrito em
   estilo 7.3 e **roda 100% no 8.3** (auditado: sem funções removidas, PDO em modo
   exceção, helpers null-safe). Recursos modernos (`match`, arrow fn, `?->`, `??=`,
   `str_contains`, `enum`, `#[...]`, promoção de construtor, tipos união, `readonly`)
   **são permitidos** em código novo — mas **mantenha a consistência** com o arquivo
   que estiver editando e **não reescreva o legado** sem motivo. Cuidado com
   depreciações do 8.x (null p/ parâmetro string de função interna): mantenha os
   casts `(string)`/`(int)` como o código já faz. (Servidor antigo era PHP 7.3 +
   MariaDB 5.5 — ver histórico nos guias `deploy/`.)
2. **PDO com `EMULATE_PREPARES=false`:** um placeholder nomeado **não pode se
   repetir** no mesmo statement (erro `HY093`). Use nomes distintos (`:v`, `:v2`).
   Em `INSERT ... ON DUPLICATE KEY UPDATE`, prefira `VALUES(col)` para não repetir.
3. **`api/database/schema.sql` é CANÔNICO, IDEMPOTENTE e NÃO-DESTRUTIVO.** Um arquivo
   só serve para **criar banco novo** E para **atualizar produção com dados** — mesmo
   comando. **NUNCA pode conter `DROP` / `TRUNCATE` / `DELETE` / `MODIFY` / `CHANGE`.**
   (Já continha: 18 `DROP TABLE` herdados do `mysqldump --add-drop-table` — rodar o
   schema em produção APAGARIA `pessoa`, `usuario`, `historico_passagem` e mais 15
   tabelas. O `db_estrutura.bat` foi corrigido p/ `--skip-add-drop-table`; o
   `static_check.php` **check 12** agora falha se voltar.)
   **Estrutura em 4 seções, nessa ordem:**
   1. **Tabelas** — `CREATE TABLE IF NOT EXISTS`.
   2. **Colunas** — `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` (707). **Essencial:** o
      `CREATE TABLE IF NOT EXISTS` **pula a tabela inteira** se ela já existe — logo
      **não adiciona coluna nova** numa base antiga. Sem esta seção o sistema quebraria
      com *Unknown column*. Colunas `AUTO_INCREMENT` ficam de fora (sempre existem).
      A seção fixa `sql_mode='NO_AUTO_VALUE_ON_ZERO'` (sem STRICT/NO_ZERO_DATE) para que
      as 134 colunas `NOT NULL` sem default possam ser backfilladas sem erro.
   3. **Índices** — `ADD INDEX / ADD UNIQUE INDEX IF NOT EXISTS`.
   4. **Seeds** — `INSERT IGNORE` / `ON DUPLICATE KEY UPDATE` / `WHERE NOT EXISTS`.
      Vêm por ÚLTIMO de propósito: gravam em colunas que a seção 2 pode ter criado.
   **FKs ficam SÓ dentro do `CREATE TABLE`** (nenhuma tabela ganhou FK depois de criada;
   assim não há `ADD CONSTRAINT` que possa falhar sobre dado legado).
   Ao criar tabela/coluna numa `migration_*.sql`, **também incorpore no `schema.sql`**
   (nas seções 1 **e** 2/3). As `migration_*.sql` viram histórico — o `schema.sql`
   sozinho já atualiza qualquer base. Guia leigo: `deploy/ATUALIZAR-BANCO.md`.
4. **Autoloader duplo / camadas:**
   - `App\` → `api/app/` — camada de domínio/API. **NUNCA** referencia `Web\`.
   - `Web\` → `app/` — camada web (controllers, views, repos de leitura).
   - Web PODE chamar App por nome totalmente qualificado (`\App\Repositories\...`,
     `\App\Services\...`) — é o padrão. O contrário é proibido.
5. **Roteador:** primeira rota que casar (regex + método) vence; parâmetros são
   de **um segmento** (`[^/]+`). Declare rotas literais (`/x/new`, `/x/save`)
   **antes** das com parâmetro (`/x/{id}/edit`). Nunca crie um `GET /x/{id}`
   "pelado" que engula as literais.
   **⚠ O Router passa SÓ `Request` e `Context`** (`call_user_func($handler, $request,
   $context)`). Os parâmetros da rota (`{id}`, `{token}`…) **NÃO viram argumentos** —
   leia com **`$request->param('id')`** dentro do método. Declarar um 3º argumento
   obrigatório estoura **`ArgumentCountError` (erro fatal 500)** no PHP 8. Já quebrou:
   o `RulesController` nasceu com `edit(Request, Context, $id)` e as 5 rotas de `{id}`
   davam 500 — o check 2 (rota→método existe) não pega isso. O `static_check.php`
   (**check 10**) agora falha se algum método de rota declarar argumento extra.
6. **Config:** valores vêm de `Config::env('CHAVE', default)` (lê o `.env` de um
   array em memória — `getenv()` é instável no Apache mpm_winnt do Windows).
   Arquivos em `api/config/*.php` retornam arrays; acesso por `Config::get('arquivo.chave')`.
7. **Toda saída dinâmica em view passa por `H::e()`** (XSS). Todo POST tem
   `Csrf::field()`. Toda rota/menu com `ability` exige essa ability no
   `PermissionService`.

---

## 2. Estrutura

```
app/                     # camada Web (namespace Web\)
  Controllers/           # extends Web\Controllers\Controller
  Repositories/          # leitura p/ telas (usam App\Support\Database)
  Services/              # serviços web (ex.: IncontrolPersonSync)
  Views/<modulo>/*.php   # templates (H::e, Csrf::field, H::old)
  Support/               # Helpers, Csrf, Session, Flash, Request, Router...
  routes_modules.php     # TODAS as rotas dos módulos
  Services/PermissionService.php  # mapa ability -> papéis
api/                     # camada App (namespace App\) + infra
  app/Controllers|Services|Repositories|Support/
  app/Automation/        # drivers de automação (+ Cloud/)
  config/*.php           # app, database, incontrol, automation
  database/schema.sql    # CANÔNICO  (+ migration_*.sql)
  scripts/*.php + *.bat  # workers e ferramentas (Task Scheduler do Windows)
public/                  # front controller, assets, receptores (itsapi, etc.)
deploy/*.md              # guias de instalação/integração
```

---

## 3. Convenções de código

- **Controller:** `$this->authorize($context, 'ability')` (403 se não pode);
  `$this->view('modulo/arquivo', [...], 'Título')`; `$this->redirect('/x')`
  (lança `Redirect` — encerra o método); `$this->json($arr)` p/ APIs/AJAX;
  `Flash::success|error(...)`; `$this->auditWeb($context, acao, entidade, id, resultado, meta)`.
- **Repopular formulário após erro:** `$this->keepInput()` antes do redirect e
  `H::old('campo', $default)` na view. (`handleDomainError` já faz isso.)
- **Permissões:** ao criar rota protegida, adicione a ability em
  `app/Services/PermissionService.php` (`$map`). Papéis (login `usuario.tipo_usuario`,
  Fase 2 — apto): **`dono`** (admin total — absorveu administracao/admin_sistema/portaria/
  funcionario_autorizado), **`morador`** (família) e **`convidado`** (hóspede, mínimo:
  `resident.home`+`automation.view_own`). NÃO confundir com `pessoa.tipo` (morador/
  funcionario/visitante/prestador_servico/terceirizado — classificação da PESSOA, não é
  papel de login). Base existente: `migration_papeis.sql`.
- **Views/JS:** o layout carrega `public/assets/js/app.js`, que trata:
  `data-confirm="msg"` (modal de confirmação), `data-once` (previne duplo-submit,
  **desabilita o botão** — NÃO use em form que você intercepta via AJAX, conflita),
  `data-plate` (uppercase placa). Paginação: incluir `components/pagination.php`
  com `$page,$perPage,$total,$base,$query`. **Menu: ver §3.3.**
- **Busca global (topo):** `components/global_search.php` (input sempre visível +
  dropdown ao vivo + atalho Ctrl/Cmd+K; no mobile abre por um botão de lupa).
  Consome `GET /search/global?q=` (`SearchController::globalSearch`, gate só
  `$auth`) → JSON agrupado (telas, pessoas, veículos, placas, câmeras ALPR/vídeo,
  dispositivos, cenas, chaves). Cada grupo é **filtrado pela permissão** do usuário
  (`$this->can`); "telas" reusa `nav_items.php`. Dados em `GlobalSearchRepository`
  (best-effort: erro/tabela ausente → lista vazia). NÃO confundir com o `/search`
  antigo (página cheia, `SearchRepository`, só pessoas/placas/histórico).
- **Ordenação por clique no cabeçalho:** universal e **client-side** (`app.js`) em
  toda `table.data` — clicar na coluna ordena as linhas da página (detecta
  número/data/texto; vazios por último). Opt-out: `class="data no-sort"` ou
  `<th data-nosort>`; colunas sem título (foto/ações/checkbox) são ignoradas.
- **Captura de foto (câmera):** `components/photo_capture_modal.php` (câmera +
  guia de alinhamento face-api, mesmo padrão do pré-cadastro) grava o data URI no
  input escondido `foto_captura`. Na edição da pessoa, o `handleFotoUpload` aceita
  `foto_captura` (base64) além do arquivo e roda a MESMA QA/espelho no InControl.
- **Configurações/Parâmetros:** hub em `/settings` (`SettingsController`, ability
  `settings.manage`) por seções + atalhos. Os **canais de alerta** ficam no KV
  `configuracao` (`alert_pushover_*`, `alert_resend_*`, `alert_zapi_*` — todas as
  chaves de API no banco, editáveis na tela). O envio é centralizado em
  `App\Services\AlertService::send()` (Pushover push, Resend e-mail, Z-API
  WhatsApp — best-effort por canal); o worker `auto_alert` e o botão "enviar
  teste" (`POST /settings/alertas/teste`) usam o mesmo serviço. Z-API:
  `POST /instances/{id}/token/{tk}/send-text` + header `Client-Token`. Resend:
  `POST api.resend.com/emails` com `Authorization: Bearer` (from de domínio
  verificado).
- **Motor de alertas (regras configuráveis):** `/alerts` (`AlertRulesController`,
  ability `alerts.manage`) — CRUD de `alert_rule` (schema; base existente:
  `migration_alert_rules.sql`). Tipos: `device_offline` (escalonamento por tempo),
  `water_level` (limiares por nível), `passage` (pessoa/placa/câmera/acesso, once/always),
  `device_online`, `denials_anomaly` (N negados em M min), `weather` (previsão do
  tempo: chuva/vento/temperatura, OR entre gatilhos), `mqtt_topic` (mensagem MQTT
  casando filtro de tópico + payload). Cada regra tem
  `condicoes`/`escalonamento`/`destinos` (JSON) + `canais` (CSV) + `modo` + `cooldown`.
  **Destinatários por regra** com fallback ao global: `AlertService::dispatch(canais,
  titulo,msg,prio,destinos)` (sobrepõe user/to/phone por canal; `send()` antigo segue
  para o global). Motor em `App\Services\AlertEngine`: **`evaluatePassage()`** roda em
  TEMPO REAL no `AlprService::flush()` (best-effort, pós-portão) para regras `passage`;
  **`runPeriodic()`** roda no worker `auto_alert` (~5 min) para offline/água/online/anomalia
  + varredura de segurança das passagens. Dedup/episódio/cooldown em `alert_rule_state`
  (watermark do maior `historico_passagem.id` processado — tempo real e worker não
  duplicam; `passos` = passos de escalonamento já disparados; re-arma ao recuperar).
  Histórico do que foi enviado em `alert_log` (tela `/alerts/history`). As regras fixas
  antigas (água baixa/offline) viraram **regras-semente** (seeds idempotentes no schema).
  **Kill-switch global**: `configuracao.alerts_enabled` (padrão `1`) — botão "Pausar"
  em `/alerts` (`POST /alerts/global-toggle`); o motor checa em `evaluatePassage`/
  `runPeriodic` e não dispara nada quando pausado. **Passagens InControl em TEMPO
  REAL**: `monitor_bridge` chama `evaluatePassage($ev,'incontrol')` após gravar o
  evento (casa pessoa por NOME + ponto; watermark próprio `evt_ic`). `evaluatePassage`
  recebe `$source` (`alpr`|`incontrol`) → escolhe o watermark. **`once` unificado**
  entre ALPR e InControl (estado `once`). **Prévia**: `POST /alerts/preview` (dry-run,
  só-leitura) conta quantas passagens recentes casariam (botão no form). **Lógica pura
  em `App\Support\AlertMatch`** (passageMatches/waterTarget/offlineFires/normName/…),
  testada por `api/scripts/tests_alerts.php` (roda sem banco: `php ... tests_alerts.php`).
- **Tempo/desastres MULTI-FONTE (regra `weather`):** `App\Services\WeatherService` —
  **prioridade BRASILEIRA → consenso → fallback**. Fontes (ordem em `weather.priority`):
  **`dcsc`** (Defesa Civil SC — rede pública de estações via GraphQL, sem chave: **chuva
  MEDIDA + NÍVEL DE RIO**; a rede é de pluviômetros/rio, então **NÃO fornece temperatura/
  vento** — usar a DC-SC p/ temp dava número que não batia com serviço nenhum),
  **`s2id`** (Defesa Civil Nacional —
  reconhecimentos federais SE/ECP por município, só AVISOS), **`inmet`** (oficial: previsão
  por geocode IBGE + avisos por UF), **`cptec`** (INPE, XML diário), **`google`** (Google
  Weather API, chave), **`open-meteo`** (grátis), **`openweather`** (chave). Config
  `api/config/weather.php`/`.env` (`WEATHER_*`; inerte sem `WEATHER_ENABLED` e sem nenhuma
  fonte). **Combinação:** TEMPERATURA/VENTO ATUAIS vêm da 1ª fonte que MEDE o ponto
  (Google/Open-Meteo/OpenWeather na coordenada exata — **não** da DC-SC); temp máx/mín do
  INMET/CPTEC; PERIGOS (chuva/vento) = pior caso entre as fontes; AVISOS do INMET/S2ID
  entram na frente. Snapshot
  mantém os campos planos de sempre + novos: `temp_max`/`temp_min`, `obs_rain_mm` (chuva
  medida), `river_level_m`/`river_trend`, `sources`/`consenso`. O worker `auto_weather`
  (~15 min) publica `nexus/weather` (retido) e chama `AlertEngine::evalWeather`. Lógica pura
  em `AlertMatch::weatherMatches` (OR entre chuva prevista **OU medida**, vento, temp máx/mín,
  **nível de rio**, **rio subindo**, aviso oficial), testada no `tests_alerts.php`. UI: tipo
  `weather` no form de `/alerts` (campos de rio inclusos). **App puro** (nunca referencia
  `Web\`; cada fonte é best-effort e falha isolada). Guia leigo + engenharia reversa dos
  endpoints públicos: `deploy/INTEGRACAO-DEFESA-CIVIL-SC.md`.
- **Gatilho por tópico MQTT (regra `mqtt_topic`):** dispara em TEMPO REAL quando chega
  uma mensagem MQTT que casa o filtro de tópico (curingas `+`/`#`) + condição de payload
  (`contains`, ou campo JSON pontilhado com `op`/`value`). Lógica pura em
  `AlertMatch::topicMatches`/`mqttMatches` (testada). O `mqtt_bridge` assina os tópicos das
  regras (`AlertEngine::mqttRuleTopics`; reassina regras novas a cada ciclo) e roteia as
  mensagens não-comando para `AlertEngine::evaluateMqtt` (cooldown padrão 5 min). Fonte
  natural de Frigate/medidores/gateways. UI: tipo `mqtt_topic` no form de `/alerts`.
- **Câmera do /monitor e do Cerberus:** ambos usam
  `VideoCameraRepository::monitorEmbedUrl()` (go2rtc, `app.go2rtc_base`). A câmera
  externa (`app.monitor_camera_url`) foi **descontinuada** (fica vazia).
- **go2rtc.yaml automático (deploy):** em vez de exportar pela tela e colar na
  pasta, `api/scripts/go2rtc_config.php` (CLI) gera o yaml do banco via
  `VideoCameraRepository::go2rtcYaml()` e grava atômico (tmp+rename) no destino
  (`$argv[1]` ou `GO2RTC_YAML` no `.env`, padrão `C:\wamp64\go2rtc\go2rtc.yaml`).
  `api/scripts/deploy_go2rtc.bat` chama esse gerador e **reinicia o go2rtc**
  (`taskkill` → o `go2rtc.bat` em loop relança). Rodar ao subir arquivos ou mexer
  nas câmeras de vídeo. O `go2rtc.bat` (contínuo) entra no `install_workers.bat` como tarefa ONSTART (junto de `monitor_bridge`/`mqtt_bridge`).
- **⚠ SINCRONIZAÇÃO AUTOMÁTICA do yaml — worker `auto_go2rtc` (2026-07):** o `deploy_go2rtc.bat`
  é MANUAL (rodar a cada mudança). O worker **`auto_go2rtc`** (`~2 min`; no `install/uninstall_workers.bat`
  e no `WorkerHealthRepository`) faz isso sozinho: gera o yaml do banco (`go2rtcYaml()`), COMPARA com o
  arquivo atual e **só grava + reinicia o go2rtc (`taskkill go2rtc.exe`) SE MUDOU** — sem derrubar os
  streams à toa. Sem câmeras cadastradas, NÃO toca no arquivo (não apaga config boa); sem a pasta do
  go2rtc, sai `ok` (não-configurado ≠ falha). Roda como SYSTEM (tem permissão de escrita + `taskkill`).
  Destino: `GO2RTC_YAML` no `.env`. Assim NÃO é mais preciso exportar/colar o yaml na mão.
- **Monitoramento das câmeras ALPR:** `camera_config.monitor_*`
  (host/porta/path/código/corpo esperados) + estado `mon_*`; o worker
  `camera_monitor.php` checa e atualiza (resiliente via
  `CameraAdminRepository::hasMonitorColumns()`; base existente: `migration_camera_monitor.sql`).
- **AJAX no mesmo endpoint:** responda JSON quando `X-Requested-With:
  XMLHttpRequest` e mantenha o `redirect`/Flash para quem não tem JS
  (ex.: `AutomationController::action/refresh`). Sempre há fallback sem JS.
- **`per_page`:** use `Web\Support\Helpers::perPageLimit()` / `PER_PAGE_ALL`; o
  controller passa `$request->query('per_page')`. Seletor 10/25/50/100/Tudo já
  vem no `components/pagination.php`.
- **DUAS paginações, para dois casos DIFERENTES — não confundir:**
  1. **SERVIDOR** (`components/pagination.php` + `perPageLimit()`): quando a lista vem de
     uma **query SQL paginável** e pode ser enorme (`/people`, `/access-logs`, `/audit`,
     `/events`, `/vehicles`, `/visitors`…). Aqui paginar é obrigação: sem `LIMIT/OFFSET` o
     servidor renderizaria 10 mil linhas. Clicar no cabeçalho ordena **só a página atual**
     (limitação conhecida do modelo).
  2. **CLIENTE** (atributo **`data-page-size="N"`** em qualquer `table.data`, ou em
     container cujos itens sejam `.list-card`/`[data-page-item]`): quando a lista **já está
     em memória** — snapshot ao vivo da rede Omada, painel da CELESC, retorno de repositório
     que já vem com um `LIMIT` de segurança. Fatiar isso no servidor exigiria um round-trip
     a cada clique para reordenar dados que o navegador **já tem**. 36 tabelas usam isto
     (rede, energia, OS, pendências, ocorrências, encomendas, materiais, regras/logs…).
     **Vantagem real:** compõe com a ordenação universal — ordena o conjunto **INTEIRO** e
     depois refatia (volta à página 1). **Sem JS nada quebra:** o servidor já mandou todas
     as linhas; elas simplesmente aparecem todas, como antes.
- **⚠ O paginador do cliente ESCONDE (`display:none`), NUNCA REMOVE do DOM.** Dois motivos,
  ambos já valeriam um bug caro:
  - **Não usar o atributo `hidden`:** em `.cards-mobile` a linha vira `display:block` por
    CSS, e uma regra de CLASSE vence o `[hidden]` do navegador — a linha "escondida"
    continuaria aparecendo no celular.
  - **Não tirar a linha do DOM:** há tabela paginada DENTRO de formulário (`/energia/usina`,
    `/vehicles/stale`). Um `input` com `display:none` **continua sendo enviado** — é
    justamente isso que preserva as marcações das outras páginas ao salvar. Removendo a
    linha, o formulário perderia essas escolhas **em silêncio**.
- **⚠ "SELECIONAR TODOS" numa lista paginada marca só os VISÍVEIS.** Em `/vehicles/stale` o
  botão ao lado é **"Excluir DEFINITIVAMENTE"**: marcar 200 veículos enquanto só 25 estão na
  tela é a receita de apagar coisa sem querer. `staleSelAll()` só mexe nas linhas da página
  atual; a confirmação, essa, conta **todos** os marcados (inclusive de outras páginas, que
  serão enviados) — o número tem que ser honesto. **Ao paginar qualquer lista com ação em
  massa, refaça o "selecionar todos".**
- **A barra de paginação vai DEPOIS do `.table-wrap`, não dentro.** O wrap tem
  `overflow-x:auto`: a barra ali dentro rolaria junto com a tabela larga e os botões de
  página sumiriam da vista em tela estreita.
- **Idioma:** tabelas, colunas, comentários e textos de tela em **português**.
- **Imagens:** `Helpers::imageBytes()` decodifica data URI/base64/binário;
  `ImageTools::crop3x4()` para foto facial antes de enviar ao InControl.
- **Placas:** `Helpers::plate($placa, $small=false)` renderiza a placa com visual
  de **placa Mercosul** (faixa azul "BRASIL" + número) — use SEMPRE que uma placa
  for **exibida** (listas, detalhes, monitor); `$small` p/ tabelas densas. CSS em
  `.plate-tag` (app.css). No `/monitor` o hero já usa o mesmo visual; o feed usa a
  versão JS `plateTag()`. Não use em inputs (lá a placa é editável).
- **Gráficos:** Chart.js via CDN cdnjs (mesmo padrão em relatórios e água);
  sempre com fallback (se a CDN cair, o resto da tela funciona).
- **⚠ NUNCA CONFIE NA ORDEM QUE UMA API EXTERNA DEVOLVE (regra; já quebrou o gráfico da
  usina).** O SolarMan devolve os dias do mês em ordem de **TEXTO** (`1, 10, 11, 12, 2, 3…`)
  e o gráfico plotava exatamente nessa ordem — o mês inteiro embaralhado, sem erro nenhum
  no console. **ORDENE NA FONTE** (no cliente da API, não na view): assim TODO consumidor
  (tela, mural, worker) herda a ordem certa. Feito em `SolarmanClient` (`monthDays` por dia,
  `yearMonths` por mês, `dayCurve` por timestamp) e em `CelescClient::bills` (mais recente
  primeiro — o painel depende de `bills[0]` = "conta atual" e de `array_slice(0,12)` =
  "últimos 12 meses"; antes isso era só uma *esperança* sobre a ordem da API). Séries vindas
  de **SQL** já vêm ordenadas por `ORDER BY` — o risco é só nas APIs externas. Ao ler
  `records[]`/`bills[]` de qualquer integração nova, **ordene explicitamente**.

### 3.1 UI / UX / Acessibilidade — REGRAS (revisão 2026-07)

Auditoria completa de UI/UX/a11y feita em 2026-07; o que ficou valendo:

- **Linguagem visual "enterprise" (restyle 2026-07, inspirado no Omada):** geometria
  contida e sombra mínima — a hierarquia vem da **borda 1px + espaçamento**, não de sombra
  colorida. Tokens: `--radius` 10 / `--radius-sm` 6 / `--radius-lg` 14 / `--radius-xs` 4;
  sombras sutis (`--shadow-xs` quase imperceptível é a do card). Fundo do body **liso**
  (`--bg`, sem gradiente). Base 14px, tabela densa (9×14px). Botões de **cor cheia** sem
  gradiente/glow; **sem salto** (`translateY`) em hover — o feedback é brilho/sombra. Ao
  criar tela nova, use os tokens (não hardcode raio/sombra). Isso mexeu SÓ no `app.css`
  (as ~165 telas usam as classes) — ver `deploy/INSPIRACAO-OMADA-2026-07.md`.
- **Imagens de dispositivo:** `H::deviceThumb($tipoOuCategoria)` (ícone do tipo num chip
  claro `.dev-chip`, com **fallback vetorial**) e `H::deviceProduct($modelo,$tipoRede)`
  (foto do produto). Acervo em `public/assets/img/omada/{tipos,devices}` (arte TP-Link, uso
  interno; o fallback torna a troca indolor). Aplicado em Automação e na tela Rede.
- **⚠ FOTO ERRADA É PIOR QUE NENHUMA FOTO (regra; já mostrou o parque errado).** O
  `deviceImgSrc` tinha um 3º passo de "prefixo frouxo": não achando o modelo, ele pegava o
  **parente mais curto da mesma família**. O `EAP650-Outdoor` do condomínio aparecia com a arte
  do `EAP650_D30-Outdoor` (outro aparelho), e `EAP615-Wall` com a do `EAP615GP-Wall`. O operador
  confere o parque PELA IMAGEM — arte errada mente. O passo foi **REMOVIDO**. Ficaram: casamento
  **EXATO** e **prefixo de VERSÃO** (o resto tem de casar `/^[-_]V?\d/` — `ER605`→`ER605-V2` ✔,
  mas `EAP655`→`EAP655-WE` ✘, que é outra variante). Sem arte ⇒ ícone genérico do TIPO.
- **A arte que falta se BAIXA DO PRÓPRIO CONTROLADOR:** `php api/scripts/omada_icons.php` (one-shot,
  só-leitura). O acervo que veio com o sistema é PARCIAL — não cobria 7 dos 10 modelos deste
  condomínio (EAP225-Outdoor/Wall, EAP245, EAP620 HD, EAP650-Outdoor, EAP670, EAP773, OC300). O
  Omada serve a arte certa em `{base}/theme/img/deviceIcon/<nome>.png`. **⚠ A nomenclatura dele é
  IRREGULAR** (confirmado por HAR): `EAP650-Outdoor`+V1 → `EAP650-Outdoor-V1.png`; `EAP655-Wall` →
  `eap655-wall.png` (minúsculas); **`EAP620 HD` → `hd620.png`** (não deriva do nome!). Por isso
  `App\Support\DeviceModel` (PURO) tem `iconCandidates()` (tenta variantes) + **`$aliases`** para os
  que não derivam. O CLI salva com `DeviceModel::key()` — a MESMA normalização que o
  `deviceImgSrc` usa para procurar, então o arquivo baixado é achado na hora. Modelo que falhar sai
  listado no fim, com o passo a passo para descobrir o nome e cadastrar o apelido.

- **Cor de ACENTO ≠ cor de TEXTO.** `--success`/`--warning`/`--danger`/`--primary` servem para
  **barras, pontos, ícones e bordas**. Como **texto pequeno elas REPROVAM em AA** (o `--warning`
  dava 2.6:1). Para texto use SEMPRE os tokens `--success-text` / `--warning-text` / `--danger-text` /
  `--info-text` / `--neutral-text` / `--accent-text` (links e realces). No tema escuro esses tokens
  apontam de volta para a cor viva (lá o fundo é escuro e ela já passa).
- **Botão sólido:** `background: var(--btn-primary-bg|--btn-success-bg|--btn-danger-bg)` +
  `color: var(--on-accent)`. **NÃO use gradiente em botão com texto** — era o que derrubava o
  contraste para 2.1–3.5:1 nos dois temas. No claro, `--on-accent` é branco sobre fundo escurecido;
  no escuro é tinta ESCURA sobre a cor viva (padrão de dark UI).
- **`data-confirm` funciona em `<form>`, `<a>` E `<button>`.** ATENÇÃO ao histórico: o `app.js`
  interceptava só form/link — 11 confirmações em `<button>` (excluir documento, remover item,
  cancelar OS, ligar bomba…) **nunca apareciam e a ação disparava direto**. Corrigido. Use
  `data-confirm-kind="normal"` em ação de ROTINA (botão de confirmar fica azul, não vermelho);
  o vermelho fica reservado ao que é destrutivo. O modal tem foco preso, foco inicial no botão
  SEGURO (Cancelar), ESC, e devolve o foco a quem abriu.
- **`data-once`** tem watchdog de 20s: se a requisição travar, o botão destrava (o usuário não fica
  preso num "Processando…" eterno).
- **Formulário:** todo campo tem `<label for="x">` + `id="x"` (associação programática — não basta o
  label estar do lado) e a legenda `.form-required-note` ("* campos obrigatórios") quando houver `*`.
- **ERRO POR CAMPO (não só o Flash no topo):** o domínio JÁ devolve o mapa `campo => mensagem`
  (`Validator` → `ApiException::getFields()`); o `Controller::handleDomainError` guarda esse mapa na
  sessão (`_errors`, mesma vida útil do `_old_input` — o layout limpa após 1 render). Na view:
  `<input id="cpf" name="cpf" … <?= H::invalid('cpf') ?>><?= H::fieldError('cpf') ?>` — `invalid()`
  põe `aria-invalid`/`aria-describedby`, `fieldError()` põe o `<p class="field-error">`. O `app.js`
  rola e foca o primeiro campo inválido. Ao lançar `ApiException` no domínio, **sempre passe o mapa
  de campos** (as chaves devem casar com o `name` do input). Form com upload: avise
  (`H::hasErrors()`) que o arquivo precisa ser reselecionado — o navegador nunca restaura `type=file`.
- **Tabela:** todo `<th>` de cabeçalho leva `scope="col"`. A ordenação por clique (`app.js`) é
  **operável por teclado** (Enter/Espaço) e anuncia `aria-sort`. Tela NOVA usa `table.data.cards-mobile`
  + `data-label` (o markup duplicado `mobile-only`/`desktop-only` de 7 telas antigas é legado — não replicar).
- **Título da tela é SEMPRE `<h1>`** no `.page-head` (56 telas usavam `<h2>` e ficavam sem h1 nenhum,
  quebrando a navegação por headings). O tamanho é normalizado no CSS — não force `font-size`.
- **Badge/status: use `H::badge($txt, $variant)`** (`success|warning|danger|info|neutral`), NUNCA hex
  cru como cor de texto ou fundo de chip — o hex não segue o tema escuro e reprovava em AA (branco
  sobre laranja dá 2.6:1). Para barra/linha com texto (calendário, Gantt) use `.status-bar` + `.sb-*`.
  Hex cru só é aceitável em uso **decorativo** (ponto de coluna, borda de acento).
- **Listagem com filtro:** ofereça "Limpar" (volta à URL base) quando houver filtro ativo.
- **Item de menu ativo:** `aria-current="page"` (não basta a classe `.active`, que é só cor).
- **Flash:** `role="status"`+`aria-live="polite"` (sucesso) / `role="alert"`+`aria-live="assertive"`
  (erro) — sem isso o leitor de tela nunca anuncia "salvo"/"erro".
- **Tela AO VIVO (portaria/TV) NUNCA pode mostrar dado velho como se fosse atual.** Toda tela com
  poll (monitor, central, água, mural) conta falhas consecutivas e, na 2ª, troca o selo "Ao vivo"
  por "SEM CONEXÃO — última atualização HH:MM". Vocabulário pronto no `app.css`: `.live-flag` +
  `.live-flag.stale` (ponto + **rótulo textual**; cor sozinha não comunica). "Carregando" também
  **não pode** ter a cara de "tudo ok" (o mural verde de "✅ Carregando…" ficava verde para sempre
  se a 1ª chamada falhasse).
- **Nunca comunique status só por COR.** Sempre cor + **texto** (ou ícone). Idem: nada de informação
  só em `title=` (tooltip não existe em toque).
- **Estado vazio** usa `class="card empty"` com ícone + frase + **CTA** ("Novo X"). `.empty-state`
  existe como alias tolerante.
- **Alvo de toque** ≥ 44×44 em `pointer: coarse` (`.btn`, paginação, bottom-nav, fechar-flash).
  Utilitário `.sr-only` para rótulo só de leitor de tela.
- **Degradação:** todo recurso que depende de CDN (QR, Chart.js) ou de hardware (câmera) precisa de
  **saída alternativa** — ex.: `/minha-foto` aceita **upload de arquivo** quando a câmera é negada
  (antes era beco sem saída), e o QR mostra texto alternativo se a CDN cair.
- **PWA:** ícones em `public/icon-192.png`, `icon-512.png`, `icon-maskable-512.png`,
  `apple-touch-icon.png` + `favicon.ico`, declarados no `manifest.webmanifest` (sem eles o app não
  era instalável). O botão "Ativar notificações" (`push.js`) fica **acima** da bottom-nav
  (`--bottom-nav-h`) — antes cobria o último item do menu.

---

## 4. InControl = ESPELHO (conhecimento institucional)

O **nosso sistema é a fonte da verdade** de pessoas/grupos; o InControl só
reflete. Não reintroduza edição direta de usuário no InControl.

> **MIGRAÇÃO "gestão no Nexus, InControl é PONTE" — ESSENCIALMENTE COMPLETA (2026-07).**
> Feito: usuários (via Pessoas) + grupos de acesso (`/incontrol/groups`, com pontos+zona)
> + credenciais (§4.1) + dispositivos/leitores (§4.2) + departamentos e zonas de tempo
> (§4.3) + áreas e pontos de acesso (§4.4). Auditado: as ~50 rotas `/incontrol/*` sem
> colisão (literais antes de `/incontrol/{id}`), sem método/view/ability órfã.
> **O que resta é read-mostly ou precisa de HAR novo:** ações de evento
> (`/v1/acoes_eventos`, só GET no HAR); área **editar/excluir** e ponto de acesso
> **escrever** (não capturados); biometria/digital (§4.1, diálogo ao vivo com o leitor).
> **Elo pendente identificado:** vincular pessoa ↔ **departamento** — o corpo do
> `PUT /v1/usuario` TEM a chave `departamento`, mas veio **null** no HAR (shape não
> confirmado) E mexeria no fluxo CORE de Pessoas; fazer só com um HAR de usuário COM
> departamento (não inventar). Padrão de todo módulo: espelho (fonte local + push
> best-effort). Manuais de eng. reversa: `uploads/manual-api-engenharia-reversa-v5.md`.

- Propagação automática no ciclo de vida da pessoa (`app/Controllers/PeopleController.php`
  + `app/Services/IncontrolPersonSync.php`):
  criar/editar → `resyncPerson`/`pushNewPerson`; inativar/ativar → `mirrorEstado`;
  **excluir localmente → exclui no InControl** (`deleteInIncontrol`). Best-effort:
  falha no InControl não derruba a operação local.
- Campos espelhados: nome, CPF, telefone, grupo (do perfil), **estado**
  (ativo/inativo), **validade** (validade_inicio/fim) e **tipo de usuário**
  (mapeado de `pessoa.tipo` casando pela descrição de `listUserTypes`; no create
  usa Morador como padrão, no resync só troca se casar — senão preserva).
### 4.1 Credenciais da pessoa (cartão/senha/controle/placa) — espelho (2026-07)

- **FASE 1 da migração "gestão no Nexus, InControl é ponte".** A credencial é
  cadastrada na ficha da pessoa (`/people/{id}/credentials`, ability nova
  **`credential.manage`** = portaria/administração/admin) e vive em `pessoa_credencial`
  (schema + `migration_pessoa_credencial.sql`; FK CASCADE p/ `pessoa`). O Nexus é a
  FONTE DA VERDADE; o InControl é espelho best-effort — mesma filosofia de pessoas/grupos.
- **Fluxo:** `CredentialsController::store` grava local → `Web\Services\CredentialSync::push`
  monta o corpo e chama `App\Services\InControlService::createCredential`; o id devolvido
  vai em `pessoa_credencial.incontrol_credencial_id` (badge "Espelhada"/"Só local"). Remover:
  `CredentialSync::remove` → `deleteCredential` (best-effort) e depois apaga local. Falha no
  InControl NÃO derruba a operação local (a tela avisa; o reconcile/resync corrige o drift).
- **Corpo do `POST /v1/credencial/` confirmado por HAR** (cartão): `nivel{id}` + `tipo{id}`
  (CT=cartão, SE=senha, CRN=controle remoto) + bloco do tipo + `pessoa{id}`. O `buildBody`
  reproduz exatamente isso (testado por espelho contra o HAR). Tipos/tamanhos/níveis vêm de
  `GET /v1/credencial/{tipo,tamanho,nivel}`. **Placa NÃO é `tipo`** — é o bloco `placa` no POST.
- **⚠ Guarda o CÓDIGO cru** (cartão hex/decimal, PIN) porque, sendo fonte da verdade, precisa
  RE-EMPURRAR ao InControl (troca de leitor). É PII sensível como CPF/foto; a tela SEMPRE
  exibe mascarado (••••1234). Só `credential.manage` vê.
- **⚠ DOIS pontos INFERIDOS (o HAR não capturou):** a EXCLUSÃO de credencial
  (`deleteCredential` tenta `POST /v1/credencial/batch_delete` e cai em `DELETE /v1/credencial/{id}`)
  e o campo do PIN no bloco `senha`. Best-effort e marcados no código — **validar com um HAR
  real e fixar** (disciplina do Omada/CELESC: não inventar endpoint, confirmar). Guia:
  `deploy/INTEGRACAO-INCONTROL-CREDENCIAIS.md`.
- **Biometria/digital = FASE 2** (não feita): exige diálogo ao vivo com o leitor
  (`GET /v1/dispositivo/{id}/iniciar_registro_fingerprint` + `PUT .../terminar_registro_fingerprint`).
- **Resiliente:** sem a tabela, `CredentialRepository::tableExists()` false, a tela avisa a
  migração e o botão "Credenciais" ainda aparece (só a tela avisa dentro).

### 4.2 Dispositivos (leitores) do InControl — espelho + gestão (FASE 2, 2026-07)

- **2º módulo da migração "gestão no Nexus, InControl é ponte".** Os leitores (facial/
  biometria/cartão) viram um ESPELHO local (`incontrol_dispositivo`; schema +
  `migration_incontrol_dispositivo.sql`), gerido em **`/incontrol/devices`**
  (`IncontrolDevicesController`, ability **`incontrol.manage`** reusada; menu "Leitores
  InControl" em Configurações). Diferente de pessoas/credenciais, o leitor é **DESCOBERTO
  pelo InControl na rede** — ele é a verdade do hardware. Então o fluxo é PULL + gestão:
  **importar** (reconciliação por `incontrol_id`) e, ao editar, **empurrar PUT**.
- **⚠ NÃO há CRIAÇÃO de dispositivo** — o HAR não capturou POST de criação; o leitor nasce
  na descoberta do InControl e aparece aqui ao **Importar**. (Mesma disciplina de sempre:
  não inventar endpoint — confirmar por HAR. A criação, se um dia formos fazer, precisa de
  um HAR real.)
- **Caminhos confirmados por HAR** (`uploads/incontrol.har`): `GET /v1/dispositivo` (lista),
  `GET/PUT /v1/dispositivo/{id}` (o PUT leva o **objeto COMPLETO** de 47 campos —
  `nome`, `modelo_dispositivo{}`, `ip`/`gateway`/`mascara`/`porta`, `senha`, flags
  `habilitado`/`lotado`/`sincronizando`/`sincronizado`/`status`, `versao_firmware`,
  `pontos_acesso[]`…), `GET /v1/dispositivo/testar_conexao?ip&porta&modelo&senha&login`,
  `GET /v1/dispositivo/sincronismo_parcial` (**GLOBAL, sem id** — empurra pendências a
  TODOS os leitores), `PUT /v1/dispositivo/{id}/reiniciar` (corpo = objeto completo) e
  `GET /v1/dispositivo/{id}/cancelar_alarme`. Métodos em `InControlService` (list/get/update/
  testDeviceConnection/partialSync/rebootDevice/cancelDeviceAlarm/deviceModels).
- **Camada Web** (resiliente; sem a tabela, tudo vazio + aviso; HY093-safe via `VALUES()` no
  upsert): `Web\Repositories\IncontrolDeviceRepository` (CRUD local + `upsert` por
  `incontrol_id` **preservando o `ativo` local**) e `Web\Services\IncontrolDeviceSync`
  (`importAll` = pull→upsert; `pushEdit` = **GET fresco → sobrepõe os campos editáveis →
  PUT** — só grava local se o InControl aceitar; `reboot`; `parse()` puro InControl→colunas).
  Editáveis: nome/ip/gateway/mascara/porta/login/senha/habilitado. Testar conexão é AJAX
  (`/incontrol/devices/{id}/test`) com os valores do form.
- **`raw_json` é ENXUTO:** o `slimRaw()` tira `modelo_dispositivo.foto` e
  `modelo_template_digital` (base64 grandes de catálogo) antes de gravar — o push usa um
  GET fresco de qualquer forma, então o raw local é só referência/fallback. Coluna
  `mediumtext` (mesma lição do KV de energia).
- **`senha` do aparelho** é PII sensível (como as credenciais §4.1): guardada para
  re-empurrar/testar, SEMPRE exibida mascarada, só `incontrol.manage` vê. No form, o campo
  fica vazio e **vazio = manter** (só troca se preencher).
- **ROTAS**: TODAS as literais `/incontrol/devices*` **antes** de `/incontrol/{id}` (o
  `{id}` é `[^/]+` e engoliria "devices"). Guia leigo:
  `deploy/INTEGRACAO-INCONTROL-DISPOSITIVOS.md`.
### 4.3 Departamentos + Zonas de tempo do InControl — CRUD (FASE 3, 2026-07)

- **3º passo da migração.** Diferente de leitores/pessoas/credenciais (espelho local),
  departamento e zona de tempo são **entidades de CONFIGURAÇÃO** do InControl — então,
  como o espelho de usuários, são lidos **AO VIVO** e a gravação é **passada adiante**
  (best-effort, sem tabela local). Ability `incontrol.manage` reusada; menus
  "Departamentos IC" e "Zonas de tempo IC" em Configurações.
- **CRUD completo confirmado por HAR** (`uploads/incontrol.har`): departamento
  (`GET /v1/departamento`, `GET/PUT /v1/departamento/{id}`, `POST /v1/departamento/`=`{nome}`,
  `POST /v1/departamento/batch_delete`=`{ids}`; edição `{id,nome,numero,departamento_principal,
  observacao}`) e zona de tempo (`GET /v1/zona_tempo`, `GET/PUT /v1/zona_tempo/{id}`,
  `POST /v1/zona_tempo/`, `batch_delete`; corpo `{nome_zona_tempo, dias:[{descricao,sequencia,
  ranges:[{data_inicio,data_fim}]}]}`). Métodos em `InControlService` (list/get/create/update/
  deleteDepartments + get/create/update/deleteTimeZones; `listTimeZones` já existia). Controllers
  `IncontrolDepartmentsController` e `IncontrolTimezonesController`.
- **⚠ ZONA DE TEMPO — encoding INFERIDO de UM range de teste no HAR** (Segunda, uma faixa
  09:41–09:45). `App\Support\ZoneTime` (PURO, testado por `api/scripts/tests_zonatempo.php` +
  espelho Python) faz `HH:MM ↔ ms`: **só o HORÁRIO conta** (o dia vem da `sequencia`; a DATA do
  timestamp é ignorada — validado decodificando o vetor do HAR p/ 09:41 local). Mapa de dias:
  `sequencia` **1=Dom … 7=Sáb** (Segunda=2 CONFIRMADO no HAR; o resto é a convenção padrão) +
  chaves i18n `fields.configSunday…`. A tela **DECODIFICA as zonas existentes** (resumo/editor)
  para o operador CONFERIR contra o InControl antes de confiar, e o form tem **aviso** para
  validar a 1ª zona. **Fixar com um HAR de zona semanal real** (mesma disciplina Omada/CELESC).
- **Editor semanal** (`timezones/form`): 7 dias × faixas `type=time`; JS só adiciona/remove
  faixas (sem JS, cada dia mostra as existentes + 1 linha em branco — degrada). O controller
  parseia `dia[SEQ][inicio][]`/`[fim][]` → modelo → `ZoneTime::toIncontrol` (valida: descarta
  faixa com fim≤início). ⚠ No UPDATE mandamos as `ranges` SEM `id` (recriação da coleção — o
  HAR do UPDATE trazia id, mas replace-all é o comportamento esperado; validar).
- **ROTAS**: literais `/incontrol/departments*` e `/incontrol/timezones*` **antes** de
  `/incontrol/{id}` (senão o `{id}` `[^/]+` engole "departments"/"timezones"). Guia leigo:
  `deploy/INTEGRACAO-INCONTROL-DEP-ZONAS.md`.
### 4.4 Áreas + Pontos de acesso do InControl (FASE 4, 2026-07)

- **4º passo — o "ONDE" do acesso.** Como dep/zonas, lidos AO VIVO (entidades de config).
  Ability `incontrol.manage`; menus "Áreas IC" e "Pontos de acesso IC" em Configurações.
  Controllers `IncontrolAreasController` e `IncontrolAccessPointsController`. Métodos novos no
  `InControlService`: `listAreas`/`createArea`/`areaOccupancy`/`areaPeople`/`getAccessPoint`
  (`listAccessPoints` já existia).
- **⚠ ESCOPO LIMITADO PELO HAR — não inventamos escrita:**
  - **Área**: confirmados `GET /v1/area`, `POST /v1/area/` (`{nome,pontos,pontos_area,capacidade}`),
    `GET /v1/area/{id}/quantidade_pessoas` (ocupação AO VIVO) e `.../local_pessoa` (quem está).
    **NÃO há PUT/DELETE nem GET de detalhe** → a tela faz **lista + ocupação + criar**; editar/
    excluir ficam para fase futura (aviso na tela). O `show` acha a área na própria lista (sem GET
    de detalhe). Criar manda `pontos`/`pontos_area` **VAZIOS** (o vínculo com pontos não foi
    capturado — associa-se no InControl).
  - **Ponto de acesso**: só `GET /v1/ponto_acesso` (lista) e `GET /v1/ponto_acesso/{id}` (detalhe)
    → tela **SÓ LEITURA**. As respostas não foram capturadas no HAR, então as views renderizam os
    campos de forma **DEFENSIVA** (nome/leitor/tipo/modo com fallback de chaves; detalhe = tabela
    campo→valor pulando foto/template).
- **Destaque de produto:** ocupação AO VIVO por área (headcount vs capacidade, com barra
  verde/amarelo/vermelho) + "quem está na área agora" — o valor real que o HAR permite. A
  ocupação no index é best-effort por área (N+1 GETs; poucas áreas num condomínio).
- **ROTAS**: literais `/incontrol/areas*` e `/incontrol/access-points*` **antes** de
  `/incontrol/{id}`. Guia: `deploy/INTEGRACAO-INCONTROL-AREAS-PONTOS.md`.
- **Ainda faltam** (no HAR só com leitura ou CRUD parcial): grupos de pontos de acesso
  (`/v1/grupo_pontos_acesso`, CRUD completo — mas já há `/incontrol/groups` no sistema) e
  ações de evento (`/v1/acoes_eventos`, só GET no HAR). **A migração de ESCRITA está
  essencialmente completa** (users, credenciais, dispositivos, departamentos, zonas de tempo,
  áreas-criar, grupos); o que resta é read-mostly ou precisa de HAR novo.

- As telas `/incontrol` de usuário são **somente leitura** (`mirrorOnlyGuard`);
  gerência é em Pessoas. **Grupos/perfis: fonte LOCAL** — `/incontrol/groups` lista
  a partir do banco (`ProfileAdminRepository::incontrolGroups()`) e funciona mesmo
  com o InControl fora do ar; os dados do InControl são só sobrepostos quando há
  (badges "Sincronizado" / "Só local" / "Só no InControl"). `groupSave` mantém a
  cópia local mesmo se o push ao InControl falhar (numa **edição**) — o runtime do
  ALPR já decide pela cópia local dos pontos (`incontrol_pontos`). Criar grupo novo
  ainda precisa do InControl (para o `incontrol_group_id`); "Importar do InControl"
  (`syncGroups`) segue como reconciliação.
- API InControl: `api/app/Services/InControlService.php` (REST Intelbras/Control iD
  em `https://host:4441/v1`, JWT em cache, cURL, config em `api/config/incontrol.php`).
  `faceQa` valida foto (rosto/olhos); `evaluateFaceQa` normaliza a decisão.
- Eventos: o **monitor bridge** (`api/scripts/monitor_bridge.php`) é worker WSS
  contínuo; `monitor_evento` é o **arquivo durável** (retenção configurável) e o
  `import_events.php` traz histórico. Busca/relatórios em `/events`.
- **Decisão ALPR — direção negada:** quando o ALPR nega por sentido/direção
  (`DIRECTION_DENIED` → `historico_passagem.acesso='DIRECAO_NEGADA'`), ele **ainda
  identifica o veículo** (resolve `id_veiculo` pela placa em `AlprService::decideInner`),
  para o histórico/relatórios mostrarem quem passou. O **`/monitor` NÃO exibe**
  `DIRECAO_NEGADA` (só permitido/negado): o filtro está em
  `AccessLogViewRepository::monitorFeed` (`h.acesso <> 'DIRECAO_NEGADA'`).

---

## 5. Módulo Automação (conhecimento institucional)

- **Abstração de drivers** em `api/app/Automation/`: interface `DeviceDriver`
  (status/apply/actions), `HttpCustomDriver` (padrão antigo:
  `GET http://{address}/{identifier}/{acao}`), `HttpGenericDriver` (URLs on/off/status),
  `EwelinkDriver`/`TuyaDriver` (nuvem). `DriverManager::get($driver, $account)`.
- **Canais (contas)** `auto_account`: MÚLTIPLAS contas por provedor (vários
  eWeLink/Tuya). O dispositivo aponta para um canal (`account_id`). Clientes de
  nuvem em `api/app/Automation/Cloud/` (`TuyaClient` assina HMAC + token;
  `EwelinkClient` **OAuth 2.0** — ver §5.50). **Sem credenciais → modo mock** (não quebra a tela).
  **Descoberta de dispositivos — DOIS caminhos, mesma fonte** (`listDevices()`):
  (1) **VISÍVEL** — botão "🔍 Dispositivos" no canal (`/automation/accounts`) abre
  `GET /automation/accounts/{id}/discover` (`accountDiscover`), que LISTA os aparelhos da
  nuvem e, em cada um, "+ Adicionar" abre o `device_form` **pré-preenchido** por query
  (`driver`/`account_id`/`device_id`/`nome`/`outlet|code`); marca os já importados via
  `AutomationRepository::cloudDeviceIds`. ⚠ eWeLink single-channel NÃO recebe `outlet`
  (0 quebraria o status; multi-canal gera um device por outlet). (2) o botão "Buscar do
  canal ▾" DENTRO do `device_form` (`GET /automation/accounts/{id}/devices` JSON) — o
  antigo, que ninguém achava. A rota literal `/discover` fica ANTES de qualquer `{id}`
  solto (é `accounts/{id}/discover`, sem colisão).
  **Templates de config** pré-carregados por driver/provedor em
  `app/Views/automation/_config_templates.php` (usados por device_form e account_form).
- **API de automação (Homebridge/integrações):** `App\Services\AutomationApiService`
  (list/state/set/water, arrays puros — estado do banco/auto_poll; só `set` aciona
  via `applyDevice`, origem 'homebridge'). Servida por DOIS caminhos com o MESMO
  serviço: REST `App\Controllers\AutomationApiController` (`/api/v1/automation/*`,
  `[apiKey, scope]`; escopos `automation:read`/`automation:control` em
  `Permissions::$scopes`) e o receptor `public/homebridge.php` no host da web (rota
  `.htaccess` `^hb/(.*)$ → homebridge.php?__path=/$1`, auth X-API-Key via
  `ApiClientRepository`). Plugin Node em `deploy/homebridge-nexus/` (auto-descoberta:
  switch/outlet/lightbulb + caixa d'água como HumiditySensor+LeakSensor; poll + set).
  **Portões/chaves** são opcionais e sensíveis: escopo próprio `doors:open` +
  `exposeDoors` no plugin → cada porta vira um Lock momentâneo (pulso `POST
  /automation/doors/{id}/open`, rate-limit 10/min + auditoria `portao_aberto_api`
  via `TriggerService`). Sem `doors:open` os portões nem aparecem (403).
  Guia: `deploy/INTEGRACAO-HOMEBRIDGE.md`.
- **Motor (workers, Task Scheduler do Windows — .bat em `api/scripts/`):**
  `auto_poll` (estado/online), `auto_water` (cisterna AUTO liga/desliga bomba),
  `auto_schedule` (agendas por hora ou SUNRISE/SUNSET), `auto_sun` (nascer/pôr do
  sol), `auto_alert` (Pushover: caixa baixa/offline). Importador do sistema antigo:
  `import_automacao.php` (`asgard` → `auto_*`).
- **Água:** `auto_water.data` é PK `timestamp` (padrão consistente da base; no
  11.4 `datetime` também serviria) — `waterInsert` usa `ON DUPLICATE KEY UPDATE` (não estourar PK
  no mesmo segundo). Limiares de alerta derivam do `min_level` configurado.
  **Busca por período** no gráfico: `/automation/water?range=6h|24h|7d|30d|custom`
  (custom usa `from`/`to`); o controller resolve as datas e chama
  `AutomationRepository::waterHistoryRange($from,$to,$maxPontos)` (faz downsample
  preservando os pontos de liga/desliga da bomba). Só janelas curtas (6h/24h) "colam"
  o ponto novo ao vivo (`liveAppend`); períodos longos são estáticos.
- **Habilitar/Desabilitar (kill-switch)** em `auto_setting` (KV): `automation_enabled`
  (geral), `water_enabled` (hidráulica), `schedules_enabled` (elétrica). Efetivo =
  geral E subsistema (`AutomationRepository::automationFlags()` → `water_eff`,
  `schedules_eff`). **Só pausa o motor automático (workers)** — `auto_water` não
  comanda a bomba mas ainda lê o nível (`WaterService::tick($autoActuate)`),
  `auto_schedule` não dispara, `auto_alert` silencia; `auto_poll`/`auto_sun` seguem
  (leitura). **Ações manuais na tela continuam funcionando** (opção do produto), com
  **banner** de aviso no painel, em `/automation/water` e no `/casa`. Toggle em
  `POST /automation/toggle` (`automation.manage`), card "Estado da automação" no
  `/automation`. Uso: migração em paralelo (checar sem acionar) e manutenções.
  `setting()` é resiliente: sem a tabela, tudo volta habilitado.
- Painéis são **ao vivo**: `/automation` faz poll de `/automation/states` e
  aciona liga/desliga via AJAX; `/automation/water` tem tanque animado + gráfico
  + `GET water/latest` (poll) e `POST water/read` (ler agora). O gráfico marca os
  pontos onde a **bomba ligou/desligou** (verde/vermelho, do campo `auto_water.acao`).
- **Dispositivos só de monitoramento:** categoria `sensor`/`other` (ex.: healthchecks
  `http_generic`) **não** têm liga/desliga — a UI mostra "Somente monitoramento" e o
  server bloqueia `action`/`mineAction` (`AutomationController::monitorOnly()`).
- **Central de operação** (`/central`, ability `central.view` — portaria/adm): visão
  que mostra "Tudo normal" por padrão e destaca só problemas (dispositivos offline,
  água baixa/zerada, acessos negados, pré-cadastros). Agrega `StatsRepository` +
  `AutomationRepository`; ao vivo via `GET /central/data` (poll 15s).
- **Controle por morador (Minha Casa):** o morador vê/aciona **apenas** os
  dispositivos atribuídos a ele. Vínculo em `auto_device_pessoa`
  (`device_id`+`pessoa_id`, PK composta, FK CASCADE). Ability `automation.view_own`
  (só `morador`); painel `/casa` (+ `/casa/states` poll, `/casa/devices/{id}/action`),
  que **escopa por `Context::personId()`** e o action **confere `pessoaCanControl`**
  antes de acionar. A atribuição é da administração em `/automation/access`
  (ability `automation.manage`, botão "🏠 Acessos" no painel). A camada de gestão
  (`automation.view`) continua vendo tudo em `/automation`.
- **Histórico de acionamentos por morador:** vem de `auto_event` (o `applyDevice`
  registra `usuario_id`). `eventsForUser($usuarioId)` (morador vê o próprio, card
  "Minhas últimas ações" em `/casa`) e `eventsForPessoa($pessoaId)` (admin vê por
  morador, via `usuario.pessoa_id`, card em `/automation/access`).

### 5.1 Autoatendimento do morador (Minha Foto)
- `SelfServiceController` + `/minha-foto` (ability `person.photo_self`, só `morador`):
  o morador atualiza a **própria** foto facial, escopado por `Context::personId()`.
  Reusa o mesmo QA do pré-cadastro: view `self/photo.php` (instruções + câmera +
  cronômetro 3s + EAR + flash), valida ao vivo em `POST /minha-foto/qa` e grava em
  `POST /minha-foto` — a **QA é revalidada no servidor** (rosto/olhos reprovados NÃO
  salvam) e, se a pessoa for sincronizada e vinculada, a foto é **espelhada no
  InControl** (`linkExistingUserPhoto`, best-effort). `GET /minha-foto/atual` serve
  a foto atual (a rota `/people/{id}/photo` é `person.view`, que o morador não tem).

### 5.2 Barramento MQTT (event bus interno + driver nativo)
- **Barramento de eventos** opcional e **inerte por padrão** (`mqtt.enabled`, do
  `.env` `MQTT_ENABLED`; config em `api/config/mqtt.php`). É o "sistema nervoso":
  o núcleo publica o que já produz e novas fontes (Frigate, medidores, gateways)
  assinam sem tocar no núcleo. **Nada quebra sem MQTT** (fonte da verdade continua
  no banco). Guia: `deploy/INTEGRACAO-MQTT.md`.
- **Cliente autossuficiente** `App\Mqtt\MqttClient` (MQTT 3.1.1 sobre TCP/TLS, sem
  dependências — mesmo espírito do WSS do `monitor_bridge`): connect/publish
  (QoS 0/1)/subscribe/loop (PINGREQ)/`readRetained`/disconnect.
- **Publicar:** `App\Services\EventBus::publish($topic,$payload,$retain=false)` —
  **best-effort, NUNCA lança, no-op se desabilitado**. Prefixa o `base_topic`
  (`nexus`). Já plugado: estado de dispositivo (`AutomationService::applyDevice` →
  `nexus/device/{id}/state`, retido), passagem (`AlprService::flush` +
  `monitor_bridge` → `nexus/passage`) e água (`WaterService::tick` →
  `nexus/water/level`, retido).
- **Caminho B — driver `mqtt`** (`App\Automation\MqttDriver`, registrado no
  `DriverManager`): controla dispositivo NATIVO (Tasmota/ESPHome/OpenBeken). `apply`
  publica no `cmd_topic`; `status` lê o `state_topic` retido; tópicos são **literais
  do aparelho** (NÃO usam o prefixo `nexus/`). Config em `config_json`
  (`cmd_topic`/`payload_on`/`payload_off`/`state_topic`/`state_on`).
- **Worker** `api/scripts/mqtt_bridge.php` (+ `.bat`): assinante CONTÍNUO (como o
  `monitor_bridge`; entra no `install_workers.bat` como ONSTART). Publica presença
  (`nexus/status` retido + Last-Will), assina `nexus/cmd/device/+/set` →
  `AutomationApiService::setDevice($id,$on,'mqtt')`, e bate `WorkerHeartbeat`
  (`mqtt_bridge`, registrado em `WorkerHealthRepository`, opcional). Tópicos:
  ver `deploy/INTEGRACAO-MQTT.md`. Lógica de pacote validada por teste (Node) de encoding.

### 5.3 Web Push (notificações do navegador / PWA)
- **Canal `webpush`** do `AlertService` + PWA instalável. Inerte por padrão
  (`push.enabled`, do `.env` `PUSH_ENABLED` + chaves VAPID; config `api/config/push.php`).
  Guia: `deploy/INTEGRACAO-WEBPUSH.md`.
- **Cripto self-contained** `App\Support\WebPushCrypto` (RFC 8291/8292: ECDH P-256,
  HKDF, AES-128-GCM/aes128gcm, JWT ES256) — só `openssl` + `hash_hkdf`, **sem
  Composer**. Validada contra o **vetor oficial do RFC 8291** (todos os valores
  intermediários batem; teste em Node com o vetor do Apêndice A).
- **Envio** `App\Services\WebPushService` (`sendToAll/Pessoa/Usuario`): assinatura
  expirada (404/410) é **podada**. Assinaturas em `push_subscription` (schema; base
  existente: `migration_push_subscription.sql`) — índice único por `sha256(endpoint)`.
- **Front:** `public/sw.js` (service worker: push + notificationclick),
  `public/manifest.webmanifest`, `public/assets/js/push.js` (registra SW, assina,
  botão "Ativar notificações"), carregados no layout autenticado. CSRF via a meta
  `csrf` já existente; POST em `FormData` (`_csrf`), padrão do projeto.
- **Endpoints** (Web, gate só `$auth`): `GET /push/key`, `POST /push/subscribe`,
  `/push/unsubscribe`, `/push/test` (`PushController`). Chave VAPID gerada por
  `api/scripts/push_generate_keys.php`. **HTTPS obrigatório** em produção.

### 5.4 Convite de visita (QR/WhatsApp) + Pânico/SOS
- **Convite de visita** (`visita_convite`; schema + `migration_visita_convite.sql`):
  o morador cria um convite e gera um **passe público** com token e QR em
  `/convite/{token}` (rota gate `[]`, sem login — mesmo padrão do `door_share`/`/k`).
  Repo `Web\Repositories\VisitConviteRepository` (token `bin2hex(random_bytes(16))`,
  `create/find/findByToken/listForOwner/listAll/validity/cancel/markUsed`; datas do
  `datetime-local` têm o `T`→espaço). Controller `Web\Controllers\VisitConviteController`
  (`/convites` CRUD + `show` com QR + `whatsapp` + `confirm`), ability `visit.invite`
  (morador/portaria/adm). **Escopo:** morador vê os próprios (por pessoa/usuário);
  `visitor.view_all` (portaria/adm) vê todos e **confirma a chegada** (`markUsed` +
  auditoria). QR client-side via `qrcodejs` (cdnjs). Envio do link por **WhatsApp**:
  `AlertService::whatsapp($phone,$msg)` (Z-API, número avulso — não usa o destino
  padrão de alertas; `whatsappReady()` não exige o telefone-destino global).
- **Botão de pânico/SOS** (`PanicController::trigger`, `POST /panico`, ability
  `panic.trigger` = `morador`): dispara `AlertService::send(...,priority 2)` por
  TODOS os canais + publica `nexus/panic` no barramento + auditoria (`panico_acionado`).
  Botão vermelho no header do layout (condicional a `panic.trigger`, padrão do botão
  de abrir chave), confirma antes e faz POST via `FormData`/`_csrf`.

### 5.5 Reserva de áreas comuns
- **Sem hardware**: agenda de espaços reserváveis (salão, churrasqueira, quadra…).
  Tabelas `area_comum` (espaço + regras) e `area_reserva` (reserva com status)
  no schema + `migration_area_reserva.sql`. Repo `Web\Repositories\ReservationRepository`
  (usa `App\Support\Database`; áreas CRUD, `hasConflict` com sobreposição
  `inicio < :fim AND fim > :inicio` para status `pendente`/`confirmada`, listagens
  por dono/gestão, `cancel`/`decide`; resiliente sem as tabelas).
- **Dois papéis:** `reservation.book` (morador/portaria/adm) **reserva** em `/reservas`
  (`ReservationsController`) — só vê/cancela as próprias (escopo por `personId()`/
  `userId()`); `reservation.manage` (adm) **cadastra as áreas** em `/areas`
  (`AreasController`, menu Configurações) e **vê/aprova todas** as reservas
  (filtros Próximas/Pendentes/Todas + Aprovar/Recusar).
- **Validações no `store`** (todas server-side): área ativa, fim>início, não no passado,
  antecedência máx. (`antecedencia_max_dias`, padrão 60), horário de funcionamento
  (`abre`/`fecha`), duração máx. (`duracao_max_min`) e **sem conflito**. Área com
  `requer_aprovacao` entra como `pendente` (senão `confirmada`); ao aprovar, o
  `decide` **reconfere o conflito** (outra pode ter confirmado no meio-tempo) e
  **avisa o morador por Web Push** (`WebPushService::sendToPessoa`, best-effort).
  Datas do `datetime-local` normalizam o `T`→espaço no controller.

### 5.6 Encomendas (portaria)
- **Sem hardware**: registro de recebimento e retirada de pacotes. Tabela `encomenda`
  (schema + `migration_encomenda.sql`; `local` = armário/prateleira em texto, cresce p/
  locker). Repo `Web\Repositories\ParcelRepository` (create, `listAll`/`listForPessoa`,
  `markPicked`/`markReturned`, `countWaiting`, `people`; resiliente sem a tabela).
- **Dois papéis:** `parcel.view` (morador/portaria/adm) — o morador vê **só as próprias**
  (escopo por `personId()`); `parcel.manage` (portaria/adm) **registra** e **dá baixa na
  retirada** (com nome de quem retirou). Controller `ParcelsController` (`/encomendas`),
  menu **Encomendas** em Acesso. Ao registrar com destinatário, **avisa o morador por Web
  Push** (`WebPushService::sendToPessoa`, best-effort). Status: `aguardando`/`retirada`/
  `devolvida`.

### 5.7 Sub-medição de utilidades (água/gás/energia)
- **Modelo genérico**: `medidor` (tipo `agua`/`gas`/`energia`, unidade, `pessoa_id`
  opcional, `local`, `mqtt_topic` opcional, `fator`) + `medidor_leitura` (`valor`
  acumulado, `consumo` = delta, `origem` `manual`/`mqtt`, `lido_em`). Schema +
  `migration_medidor.sql`.
- **Cálculo de consumo é fonte única na camada App**: `App\Services\MeterService`
  (`addReading` calcula o delta desde a leitura anterior; reset/1ª leitura → consumo
  null). Reusado pela UI e pelo `mqtt_bridge`. `mqttTopics()` lista os tópicos dos
  medidores ativos; `ingestMqtt($topic,$payload)` acha o medidor por tópico, extrai o
  número (número puro, campo JSON `value|valor|state|…`, ou 1º número do texto), aplica o
  `fator` e grava — **best-effort, nunca lança**.
- **UI** `Web\Repositories\MeterRepository` (CRUD de medidor + `latest`/`historyAsc` +
  `anomaly()` pura) e `MeteringController`: painel `/medidores` (`metering.view`) com
  card por medidor (último valor + consumo + **sparkline SVG** + badge de **anomalia** =
  último consumo > 2× média), detalhe `/medidores/{id}` (gráfico Chart.js valor+consumo,
  eixo duplo; padrão água) e lançamento de leitura manual; CRUD do medidor em
  `metering.manage`. Menu **Consumo** em Automação.
- **Ingestão MQTT**: o `mqtt_bridge` assina os tópicos dos medidores (`MeterService::
  mqttTopics`, reassina novos a cada ciclo, como as regras) e roteia toda mensagem
  não-comando para `ingestMqtt` (além de `AlertEngine::evaluateMqtt`). Fonte natural:
  medidores/gateways que publicam leitura acumulada. Guia p/ câmeras inteligentes:
  `deploy/INTEGRACAO-FRIGATE-MQTT.md` (Frigate publica detecção no broker → regra
  `mqtt_topic` de alerta; sem código novo).
- **⚠ VÍNCULO com um DISPOSITIVO da nuvem (2026-07):** um medidor pode ler direto de um
  `auto_device` importado (Tuya/eWeLink) — ex.: medidor Tuya **SPM02** (`dlq`) expõe a DP
  `forward_energy_total` (kW.h, **scale 2** → `medidor.fator` = **0.01**). Colunas
  `medidor.device_id`/`device_code` (schema seções 1 **e** 2 + `migration_medidor_device.sql`;
  `MeterRepository::hasDeviceLink()` torna resiliente a base sem a migração). Serviço App
  **`MeterCloudSync`**: para cada medidor vinculado, lê a DP do dispositivo (Tuya:
  `TuyaClient::deviceStatus` → casa `code`==`device_code` → `value`), aplica o `fator` e
  chama `MeterService::addReading(...,'nuvem')` — o **delta (consumo)** sai sozinho. Worker
  **`auto_medidor`** (~15 min; no `install/uninstall_workers.bat` + `WorkerHealthRepository`,
  opcional). Botão **"Ler agora"** na `/medidores/{id}` (`readNow` → `MeterCloudSync::readMeter`)
  para validar na hora. Form do medidor: select do dispositivo (`deviceOptions` = auto_device
  tuya/ewelink ativos) + campo da DP (datalist `forward_energy_total`…). **eWeLink = TODO**
  (a chave de energia varia por produto — ligar o log detalhado §5.51 p/ mapear). Uma vez
  gravadas as leituras, o medidor **já aparece** nos painéis `/medidores`, no dashboard e no
  mural (que já leem `MeterRepository`) — sem código novo. DeviceType mapeia `dlq`/`zndb`
  (medidor) → categoria `sensor` (monitor-only, não "liga/desliga" à toa).

### 5.8 Ocorrências / chamados (morador ↔ síndico)
- **Sem hardware**: chamado com conversa em thread. Tabelas `ocorrencia` +
  `ocorrencia_mensagem` (schema + `migration_ocorrencia.sql`). Repo
  `Web\Repositories\TicketRepository` (criar, `listForOwner`/`listAll` por filtro,
  `addMessage`/`messages`, `setStatus`/`assign`, contadores; resiliente).
- **Sigilo por unidade**: `ticket.view` (morador/adm) — o morador só enxerga as
  próprias (escopo por `personId()`/`userId()`); `ticket.manage` (síndico=administração)
  vê todas, responde, muda status (aberta/em_andamento/resolvida/fechada) e **assume**
  (`atribuido_a`). Controller `TicketsController` (`/ocorrencias`), menu **Ocorrências**
  em Acesso. Resposta da gestão avisa o morador por Web Push (best-effort). Categorias:
  geral/manutenção/barulho/segurança/financeiro/sugestão.

### 5.9 Portal de prestadores
- **Sem hardware novo** (o tipo `prestador_servico` já existe em `pessoa`): agenda +
  janela de acesso. Tabela `prestador_agenda` (schema + `migration_prestador_agenda.sql`).
  Repo `Web\Repositories\ProviderRepository` (create, `listForOwner`/`listAll` por
  filtro hoje/próximos/pendentes/todos, `authorizeVisit`, `setStatus`, resiliente).
- **Dois papéis:** `provider.request` (morador/portaria/adm) **agenda** em `/prestadores`
  (morador vê/cancela os próprios); `provider.manage` (portaria/adm) **autoriza a
  entrada** e acompanha (agendado→autorizado→em_andamento→concluído). Controller
  `ProvidersController` (método público **`authorizeVisit`**, não `authorize` — este é
  do base Controller); menu **Prestadores** em Acesso. Autorização avisa o morador por
  Web Push. Datas do `datetime-local` normalizam `T`→espaço.

### 5.10 Assembleias digitais + votação (Lei 14.309/2022)
- **Voto SECRETO por desenho** (schema + `migration_assembleia.sql`): `assembleia`
  (rascunho→aberta→encerrada) + `assembleia_pauta` (pergunta + `opcoes` CSV) +
  **`assembleia_votante`** (registra QUEM votou, ÚNICO `pauta_id+pessoa_id` → 1
  voto/unidade + auditoria de participação, SEM a escolha) + **`assembleia_voto`** (a
  ESCOLHA, ANÔNIMA — sem FK de pessoa — e só INSERT → imutável). O resultado é a
  contagem por opção (auditável). `AssemblyRepository::vote()` grava votante+voto **em
  transação**; violação do único → `ja_votou` (rollback).
- **Papéis:** `assembly.manage` (administração) cria/edita, adiciona pautas (só em
  rascunho), **abre** (trava as pautas) e **encerra**, e vê o parcial ao vivo;
  `assembly.vote` (morador) vota 1× por pauta; `assembly.view` (morador/adm) vê a lista
  e o **resultado após encerrar** (a gestão vê antes). Controller `AssembliesController`
  (`/assembleias`; rotas de pauta em `/assembleias/pauta/{id}/vote|delete` — literal
  `pauta` antes de `{id}`). A auditoria do voto registra só a **participação** (nunca a
  opção). Resultado em barras CSS (sem dependência). Menu **Assembleias** em Acesso.

### 5.11 Hub do morador (PWA) — `/inicio`
- **Tela inicial mobile-first** do morador (ability `resident.home`, só `morador`):
  grid de tiles para Minha Casa, Reservas, Encomendas, Ocorrências, Assembleias,
  Convites, Prestadores e Minha Foto, com **badge de status ao vivo** por tile
  (reservas próximas, encomendas aguardando, ocorrências abertas, votações abertas,
  convites ativos, prestadores agendados). `ResidentHomeController` **reusa os repos
  existentes** (todos resilientes) e filtra os tiles por permissão (`$this->can`). É o
  primeiro item do grupo **Início** no menu (aparece acima do Dashboard para o morador).

### 5.12 IA de operação (anomalia + manutenção preditiva)
- **Estatística pura, sem ML/Composer**: `App\Support\Anomaly` (média/desvio, MAD,
  z-score robusto, EWMA, regressão linear, ETA `stepsToTarget`, baseline sazonal por
  hora) — testável por `api/scripts/tests_anomaly.php` (roda sem banco). Espelho de
  validação em Node/Python (9/9).
- **Motor** `App\Services\OperationsAI` (camada App, lê o banco direto): varre
  medidores (`medidor_leitura`) e água (`auto_water`) → descobertas de **anomalia**
  (consumo/nível fora do padrão) e **preditivas** (tendência de alta; ETA da caixa ao
  `min_level`). `record()` grava em `ai_finding` (schema + `migration_ai_finding.sql`)
  com dedup por `chave` + cooldown. Worker `auto_ai` (~20 min, no `install_workers.bat`;
  saúde `auto_ai` opcional) publica `nexus/ai/finding` e alerta (`AlertService::send`)
  para severidade alerta/crítico. Painel `/operacao` (`ops.view`,
  `Web\Repositories\OpsFindingRepository`) — "Tudo sob controle" por padrão, lista por
  severidade; menu **Operação (IA)** em Início.

### 5.13 Assistente do condomínio (intents + Ollama opcional)
- **Duas camadas**: (1) intents determinísticos `App\Support\AssistantIntents::detect`
  (puro/testável — `tests_assistant.php`, casamento por `\b`/stems, cuidado: "aguardando"
  ≠ água) → `App\Services\Assistant` responde lendo o estado (água, dispositivos,
  passagens, ocorrências, encomendas, tempo via `WeatherService`); (2) **Ollama local
  OPCIONAL** (`api/config/assistant.php`, `.env` `ASSISTANT_OLLAMA_*`, inerte por
  padrão) para perguntas livres, alimentado com um contexto do estado — best-effort,
  degrada para os intents se cair. Tela `/assistente` (`assistant.use`): chat via AJAX
  (`POST /assistente/ask` → JSON) com fallback sem JS (GET `?q=`). Menu **Assistente** em Início.

### 5.14 Backup/restore de configuração
- `App\Services\ConfigBackup` (camada App): exporta um **allowlist** de tabelas de
  CONFIG (`configuracao`, `auto_setting`, `regra`, `camera_config`, `video_camera`,
  `auto_device`, `auto_room`, `auto_scene`, `area_comum`, `medidor`) em JSON versionado
  (`regra` substituiu o `alert_rule` removido — restaurar config leva as regras;
  o CASCADE limpa `regra_estado`/`fila`/`log`, que se regeneram);
  `import()` restaura DELETE+INSERT por tabela **em transação** (dry-run p/ prévia).
  **Nunca** toca em PII/operacional (pessoa, histórico, auditoria, votos) nem em
  credenciais de nuvem (`auto_account` fora). Controller `/backup` (`backup.manage` =
  só `admin_sistema`): exportar / prever / restaurar (com confirmação). Menu **Backup config** em Sistema.

### 5.15 Servidor MCP
- `deploy/nexus-mcp/` (Node, SDK oficial `@modelcontextprotocol/sdk`): servidor MCP
  stdio que expõe `list_devices`/`set_device`/`get_water`/`list_doors`/`open_door`
  chamando a **API REST do Nexus** (os mesmos endpoints do plugin Homebridge:
  `GET /devices`, `POST /devices/{id}/set`, `GET /water`, `GET /doors`,
  `POST /doors/{id}/open`) com header `X-API-Key`. O escopo é do TOKEN
  (`automation:read`/`control`, `doors:open`) — o MCP só reflete. Config por env
  `NEXUS_URL`/`NEXUS_API_KEY`. Guia leigo: `deploy/INTEGRACAO-MCP.md`. Sem PHP novo.

### 5.16 Interoperabilidade do prédio (Modbus nativo + gateway→MQTT)
- **Modbus TCP nativo**: `App\Modbus\ModbusClient` (self-contained, FC 1/3/4/5/6, MBAP
  big-endian — frames validados 6/6 contra a spec via Node) + `App\Automation\ModbusDriver`
  (registrado no `DriverManager` como `modbus`): `status` lê coil/registrador, `apply`
  liga/desliga por FC5/FC6. Config em `config_json` (`host`/`port`/`unit_id`/`mode`
  coil|register/`address`/`read_address`/`on_value`/`off_value`/`state_on`). Inerte sem host.
- **Matter/BACnet/KNX**: **não** têm stack nativo (inviável/insensato em PHP) — o caminho
  recomendado é **gateway do protocolo → MQTT → Nexus**, reusando regras `mqtt_topic`,
  medidores (`medidor.mqtt_topic`) e o driver `mqtt`. Guia leigo:
  `deploy/INTEGRACAO-INTEROP-PREDIO.md` (inclui nota sobre **voz** Whisper/Piper como
  camada futura de front-end do assistente).

### 5.17 Kill-switch de módulos (o núcleo nunca desliga)
- `App\Support\Modules` (registro dos módulos ESPECIAIS: mqtt, sip, hue, homebridge,
  mcp, webpush, weather, ai, ia_local, assistant). `enabled($key)` lê `configuracao`
  KV `module_<key>` (padrão "1") com cache curto (TTL 30s, para os workers contínuos
  pegarem o toggle). **O NÚCLEO — ALPR e gestão de pessoas/acesso — NÃO está no
  registro e não é desligável.** UI `/modulos` (`modules.manage` = só `admin_sistema`),
  menu em Configurações. Onde o módulo também tem config/.env (mqtt.enabled,
  push.enabled…), o efetivo é `Modules::enabled() E a config`. Gates fiados: worker
  `auto_ai` ('ai'), `mqtt_bridge` ('mqtt'), `auto_weather` ('weather'),
  `AssistantController`/`Assistant::ollama` ('assistant'/'ia_local'), receptor
  `homebridge.php` ('homebridge'), `WebPushService::enabled` ('webpush'),
  `HueDriver`/`IntercomService` ('hue'/'sip'). MCP é externo (kill-switch = parar o
  servidor Node / revogar a chave).

### 5.18 Philips Hue (Bridge local)
- Canal de conta como eWeLink/Tuya: `App\Automation\Cloud\HueClient` (Bridge LOCAL,
  API v1 REST — pair/lights/setLight/sensors/listDevices; cURL, SSL verify off p/ https
  self-signed) + `App\Automation\HueDriver` (registrado no `DriverManager` como `hue`,
  `requiresAccount`): **lâmpada** (liga/desliga/status) e **sensor/interruptor** Hue
  (somente monitoramento — presença/temperatura/nível/botão). Conta `auto_account`
  `{host, app_key}`; dispositivo `config_json` `{kind: light|sensor, light_id, sensor_id}`.
  Templates em `_config_templates.php`. Respeita o módulo `hue`. Guia leigo:
  `deploy/INTEGRACAO-HUE.md` (pareamento por curl + botão da Bridge).
- **Hue é PROVEDOR de primeira classe no `account_form` (2026-07):** dropdown de Provedor
  tem `hue`; a conta guarda só `{host}` (o IP da Bridge — o template de conta perdeu o
  `app_key`). O **pareamento é pela TELA**: botão **Parear** → `POST /automation/accounts/
  {id}/hue/pair` (`AutomationController::huePair`) chama `HueClient::pair()` (aperte o botão
  da Bridge antes; a Bridge devolve o `username`) e grava via `mergeAccountConfig($id,
  ['app_key'=>...])`. A `app_key` é secreta: entra no `$ocultarCfg` do form (não aparece no
  textarea) e o `accountSave` a **preserva** na edição (mesma disciplina dos tokens eWeLink,
  §5.50). O botão fica num `<form id="huePairForm">` IRMÃO (fora do form principal — forms
  aninhados são inválidos) e o botão o referencia por `form="huePairForm"`. `accountDiscover`/
  `accountDevices` têm o ramo `hue` (→ **Buscar dispositivos**: lista lâmpadas+sensores p/
  adicionar 1 a 1). **Descoberta = só listar** (o operador escolhe; sem importação em massa).
- **⚠ CACHE das listas na Bridge — SENÃO A AUTOMAÇÃO FALHA (bug de julho/2026):** o
  `HueDriver::status` roda POR dispositivo (`auto_poll` + `/automation/states`). Sem cache,
  cada lâmpada virava um `GET /lights/{id}` e cada sensor um `GET /sensors` (a lista INTEIRA)
  — num setup real de **61 aparelhos** deu **~60 chamadas/ciclo**. A **Bridge Hue LIMITA a
  ~10 req/s** (429/503); um `PUT` (liga/desliga) disparado no meio do storm é **RECUSADO** →
  "a automação não funciona". Corrigido em `HueClient` com **cache estático das listas**
  (`lights()`/`sensors()`, TTL 15 s): `getLight($id)` agora LÊ da lista em cache (não faz um
  GET por lâmpada), então um ciclo = **1 `GET /lights` + 1 `GET /sensors`**. `setLight()`
  **invalida** o cache no sucesso (o próximo status vem fresco). Cada worker/request é um
  processo PHP novo → o cache estático morre no fim; o TTL só cobre UM ciclo (mesma ideia do
  `OmadaDriver` §5.24, cache 45 s). Diagnóstico: o **trace** (§5.51) mostra o storm — dezenas
  de `GET /sensors` seguidos no mesmo segundo.

### 5.19 Aviso de foto antiga / sem foto
- Coluna `pessoa.foto_atualizada_em` (schema + `migration_pessoa_foto_data.sql`),
  setada em TODA gravação de foto pelo ponto único `IncontrolSyncRepository::setFoto`
  (cobre edição, autoatendimento, aprovação de pré-cadastro e o link público).
  Parâmetro `configuracao.foto_validade_dias` (padrão 365, editável em `/settings` →
  card "Parâmetros gerais"; 0 desliga só o aviso de idade). Helper
  `Helpers::fotoBadge($hasFoto,$fotoAtualizadaEm,$criadoEm,$dias)` → badge "sem foto"
  (vermelho) / "foto antiga" (laranja) / '' . Exibido na lista `/people` (mobile + tabela;
  a query traz `foto_atualizada_em`/`criado_em`).

### 5.20 Link público de atualização de cadastro
- Pessoa JÁ cadastrada atualiza **foto + nome + CPF + telefone + placas** sem login,
  por token em `/atualizar/{token}` (gate `[]`, mesmo padrão de door_share/convite).
  Tabela `pessoa_atualizacao` (schema + `migration_pessoa_atualizacao.sql`; token de
  uso único + validade 7 dias). Repo `Web\Repositories\PersonUpdateRepository`
  (create/activeForPessoa/findByToken/validity/markUsed + `updateCadastro` + `addPlate`).
  Controller `PersonUpdateController`: a gestão gera/envia o link (`person.update_link`,
  botão na ficha da pessoa + WhatsApp via `AlertService::whatsapp` + QR); a página pública
  captura a foto (câmera/arquivo) e o `publicSave` **revalida a QA no servidor** (mesmo
  `InControlService::qaPhoto` do pré-cadastro/autoatendimento), grava a foto
  (`setFoto` → espelha no InControl best-effort), atualiza os dados, adiciona placas e
  marca o token como usado. View pública auto-contida em `app/Views/public/atualizar.php`.

### 5.21 Vídeo-porteiro PVIP 2216 FACE (SIP)
- **A chamada de voz/vídeo é do app SVIP** (SIP proprietário Intelbras, porta 5060) — não
  roteada pelo Nexus. O que o Nexus faz: **vídeo** (RTSP `rtsp://user:pass@IP:554/cam/
  realmonitor?...` no módulo de vídeo/go2rtc), **abrir porta** (URL configurável),
  **eventos** e **espelho de usuários/face** (best-effort, "se possível"). Config no KV
  `configuracao` (`intercom_*`), editável em `/settings` → card "Vídeo-porteiro (PVIP
  2216)". Serviço `App\Services\IntercomService` (`openDoor` via HTTP configurável,
  `pushUser`/`removeUser` inertes sem endpoint, `logEvent`), gated pelo módulo `sip`.
  Receptor `public/pvip.php` (rota `.htaccess` `^pvip(/.*)?$`): recebe evento
  (chamada/porta/face — extração heurística de campos), grava em `intercom_event` (schema
  + `migration_intercom_event.sql`) e dispara alerta best-effort. Guia leigo:
  `deploy/INTEGRACAO-PVIP-2216.md`.

### 5.22 Autoteste do servidor (Diagnóstico → "Autoteste completo")
- `Web\Services\SelfTest::run()` — autoteste de PRODUÇÃO que ENVOLVE o
  `DiagnosticsService` (estrutural: rotas↔controllers↔views↔abilities↔placeholders)
  e ACRESCENTA operação: **Ambiente** (PHP, extensões, escrita nos logs, limites,
  disco, OPcache), **Banco & migrações** (versão + tabelas/colunas esperadas → aponta
  a `migration_*.sql` que falta rodar), **Workers** (`WorkerHealthRepository::all`),
  **Integrações** (go2rtc/MQTT/InControl via ping best-effort com timeout, respeitando
  os módulos), **Módulos** (estados). Captura o **tail dos logs** (`app.log_path` dir +
  `php error_log`) e conta erros, terminando com um **VEREDITO** (ok/atenção/problema).
  Cada checagem é isolada em try/catch (nunca derruba). Item no formato
  `['label','status'=>ok|warn|fail|info,'detail']` (compatível com o painel).
  Controller `DiagnosticsController::selfTest` (`GET /diagnostics/selftest`,
  `diagnostics.view`); botão "🩺 Autoteste completo" no `/diagnostics`; view
  `diagnostics/selftest`. Complementa o CLI `static_check.php` (que faz `php -l`).

### 5.23 Mural (video wall)
- **4 telas kiosk** para monitor(es): reusa o **`/monitor`** (via iframe) + 3 telas
  próprias — **clima** (`mural/screen_clima`, lê o snapshot cacheado do `WeatherService`),
  **sistemas/dispositivos** (`mural/screen_sistemas`: workers + automação + água +
  acessos) e **câmeras** (`mural/screen_cameras`: grade go2rtc auto-ajustável). Todas
  são páginas **kiosk auto-contidas** (`View::fragment`, dark, auto-poll) — usáveis
  sozinhas num monitor dedicado (`/mural/tela/{screen}`) ou no container.
- **Tela `rede` (2026-07):** `mural/screen_rede` — KPIs (aparelhos on/off, clientes,
  Wi-Fi×cabo), lista de **aparelhos de rede** (offline PRIMEIRO — é o que importa numa TV de
  operação) com ícone/modelo/nº de clientes, e lista de **clientes conectados** com ícone por
  tipo, IP, SSID e força de sinal. **NÃO é pública** (`publica => false`): a lista de clientes
  traz nome de aparelho pessoal ("iPhone do João") — é PII e não pode ir num link aberto.
- **Container** `/mural` (`mural/wall`, ability `wall.view` = portaria/adm): **gira** as
  telas via iframes (intervalo configurável no localStorage: 5/10/15/30/60/120s), com
  barra flutuante (play/pause, ‹ ›, "só esta" = abre a tela atual em nova aba p/ outro
  monitor, tela cheia, atalhos ← → espaço F). Iframes com **lazy-load** (só carregam ao
  ficar ativos). Menu: **Mural** em Início.
- **Dados** por `GET /mural/dados/{screen}` (JSON) no `MuralRepository` (Web, best-effort).
  **REGRA DE OURO: a TV NUNCA faz chamada externa no request** — quem fala com API é o
  WORKER, que persiste uma KV em `configuracao`; a tela só lê a KV (+ `idade_seg`, para
  mostrar "dado antigo" em vez de mentir).
  - **`weather()` (rev. 2026-07 — RICO):** lê a KV **`weather_panel`** (o painel COMPLETO
    que o `auto_weather` já gravava e ninguém usava: 48h horárias, 7 dias, nascer/pôr do
    sol, vento+direção+rajada, umidade, pressão, nuvens, UV) e usa **`mural_weather`** como
    FALLBACK + fonte da **chuva medida** e do **nível de rio** (Defesa Civil SC). Resolve
    **no servidor** o texto/emoji do código WMO (`WeatherService::wmo`) e a rosa dos ventos
    (`windDir`) — o JS da TV só pinta.
  - **`network()` (tela `rede`):** DUAS fontes de propósito — **aparelhos** (APs/switches/
    gateway) direto do BANCO (`auto_device` driver `omada`), então o online/offline é sempre
    fresco (quem atualiza é o `auto_poll`, 1 min); **clientes** da KV **`mural_network`**,
    que o worker **`auto_omada`** (~10 min) persiste chamando `NetworkService::clients()`.
  - `systems()` agrega `WorkerHealthRepository` + `AutomationRepository` + `StatsRepository`
    + `OpsFindingRepository` e, desde 2026-07, também **rede** (resumo Omada), **energia**
    (KV do `energy_sync`) e **medidores** (`MeterRepository`) — 6 tiles na tela.
  - `cameras()` monta as URLs go2rtc de `VideoCameraRepository::activeOrdered`.
- **⚠ Ícone de tipo no kiosk: resolva no SERVIDOR.** O acervo `public/assets/img/omada/tipos/`
  é **inconsistente** — vários tipos só existem com sufixo `-dark` (`laptop`, `mobile`,
  `computer`, `television`, `tablet`, `smart-watch`, `wearable`). O helper
  `Helpers::typeImgSrc()` tenta `{t}.png` e depois `{t}-dark.png`; **JS montando
  `"{tipo}.png"` na mão dá imagem quebrada**. Por isso o `network()` já devolve o campo
  **`icone`** (caminho resolvido) por aparelho/cliente, e o JS só usa ele (+ `onerror`).
- **Link público por token** (`mural_share`; schema + `migration_mural_share.sql`; ability
  `wall.share`): admin gera links em `/mural/shares`; público abre `/mural/p/{token}`
  (gate `[]`, mesmo padrão de `/k`/`/convite`) → só as telas **SEM PII**
  (clima/energia/sistemas/câmeras/painéis); o **`/monitor` (pessoas/placas) NUNCA vai pro
  link público**. Telas/dados públicos validam o token e a tela liberada (`publicWall`/
  `publicTela`/`publicDados`). 404 kiosk auto-contido para token inválido/desativado.

#### 5.23.1 Telas CONFIGURÁVEIS + painéis de câmera (2026-07)
- **A rotação do mural NÃO é mais fixa no código.** O que gira, em que ordem e com que
  intervalo vem da KV `configuracao` (`mural_screens` = CSV ordenada de chaves;
  `mural_intervalo` = segundos). Sem configuração salva → todas as telas fixas (comportamento
  antigo). `MuralController::screensLogged()` monta a lista a partir do repo.
- **Catálogo de telas** (`MuralRepository::catalog()`): as **fixas** (`MuralRepository::$builtin`
  = monitor/clima/energia/sistemas/cameras, cada uma com `publica` bool) **+** os **painéis de
  câmera** criados pelo usuário, cuja chave é **`cam-{id}`**. O antigo `$publicScreens` estático
  **saiu** — use `publicKeys()` / `isPublicScreen()` / `validKeys()` / `screenTitle()`.
  O `monitor` é o único **não-público** e não tem tela kiosk própria (usa `/monitor`).
- **Painéis de câmera** (tabela `mural_tela`; schema + `migration_mural_tela.sql`): `nome`,
  `cameras` (CSV de `video_camera.id`, define a ORDEM), `colunas` (0 = automático), `ordem`,
  `ativo`. Permitem várias telas de câmeras por área ("Garagem", "Portaria"). **Reusam a MESMA
  view** `mural/screen_cameras` — o que muda é o JSON: `MuralRepository::cameras($key)` filtra
  pelas câmeras do painel e devolve `cols` (a view honra colunas fixas; 0 = raiz quadrada).
  Câmera inativa/removida some do painel sozinha (o filtro cruza com `activeOrdered`).
- **UI** (ability nova **`wall.manage`** = administracao/admin_sistema): `/mural/telas`
  (`mural/telas.php`) tem (1) **quais telas giram** — checkbox + número de ordem + intervalo,
  salvo por `POST /mural/telas/salvar` → `saveMural()`; e (2) **CRUD dos painéis** (`/mural/telas/novo`,
  `/mural/telas/{id}/editar`, `POST /mural/telas/painel`, `POST /mural/telas/{id}/excluir`).
  Form `mural/painel_form.php` (nome + câmeras + colunas + ativo). Excluir um painel também o
  **tira da rotação** salva. Atalho ⚙ na barra do mural; `/mural/shares` lista os painéis como
  telas liberáveis no link público.
- **ROTAS**: literais (`/mural/telas`, `/telas/salvar`, `/telas/novo`, `/telas/painel`) **antes**
  das com `{id}`. Cuidado: `/mural/telas` (plural, config) ≠ `/mural/tela/{screen}` (singular, kiosk).
- **Resiliente**: sem a tabela `mural_tela`, `panels()` volta vazio e a tela avisa para rodar a
  migração — o mural continua funcionando com as telas fixas.

### 5.24 Controlador TP-Link Omada (Open API) — MONITORAMENTO ATIVO (increm. 1–2)
- **Rede do condomínio no controlador Omada.** Integração via **Omada Open API**
  (OAuth2 **Client Credentials**): App em Settings > Platform Integration > Open
  API → `Client ID`/`Client Secret` + `Omada ID` + `Interface Access Address`
  (endereço base). Auth header é `Authorization: AccessToken={token}` (NÃO é
  Bearer); base das chamadas `{base}/openapi/v1/{omadacId}/...`. Guia leigo:
  `deploy/INTEGRACAO-OMADA.md`.
- **Cliente self-contained** `App\Omada\OmadaClient` (cURL puro, token cacheado em
  arquivo — mesmo espírito do `TuyaClient`): `token()`, `sites()`, `devices($site)`,
  `clients($site)` (leitura, confiáveis) + `setPoe()`/`blockClient()`/`reboot()`/
  `setSsid()`/`createVoucher()` (escrita — **paths centralizados nos métodos, VALIDAR
  contra o controlador antes de confiar**). Config `api/config/omada.php`/`.env`
  (`OMADA_*`; inerte sem `OMADA_ENABLED` e credenciais). **App puro** (nunca `Web\`).
- **Módulo** `omada` no kill-switch (`App\Support\Modules`, grupo Integrações): o
  efetivo é `Modules::enabled('omada')` E `omada.enabled`.
- **CLI de validação** `api/scripts/omada_test.php` (roda mesmo com `OMADA_ENABLED=false`):
  autentica, lista sites/dispositivos/clientes e tem modo exploração
  (`php omada_test.php GET /sites/{id}/devices`) para descobrir/confirmar caminhos
  e campos por versão do controlador.
- **Monitoramento (incremento 2, FEITO):** driver `omada` no `DriverManager`
  (`App\Automation\OmadaDriver`, **monitor-only**). Usa o `.env` via
  `OmadaClient::fromConfig()` (um controlador só — `requiresAccount('omada')` é
  **false**, sem `auto_account`); `status()` lê online + nº de clientes com **cache
  estático curto (45s)** → 1 chamada de API por ciclo de `auto_poll`. Cada
  AP/switch/gateway é um `auto_device` `driver='omada'` `categoria='sensor'`
  `config_json {kind:'device',mac,site_id,type}`. **Roster por SINCRONIZAÇÃO
  CONTÍNUA** (a lista muda muito — o operador adiciona/remove aparelhos): worker
  **`auto_omada`** (~10 min, `App\Services\OmadaSync::reconcile`) CASA por **MAC**
  (imune a troca de IP dos aparelhos; só o IP do CONTROLADOR importa) — **cria** os
  novos, **DESATIVA** (flag `config.auto_removed`) os que sumiram do controlador
  (sem "offline"/alerta falso) e **reativa** os que voltam; **respeita** o que você
  desativar na mão. `omada_import.php` é a MESMA reconciliação sob demanda. O
  **estado online** é do `auto_poll` (via `OmadaDriver`, cache 45s → 1 chamada/ciclo);
  os aparelhos aparecem em `/automation`, na **Central**, no **Mural "Sistemas"** e
  na regra **`device_offline`** SEM código novo. **Importante:** módulo/`OMADA_ENABLED`
  desligado → `OmadaDriver::status()` devolve `online=true` (NÃO gera offline falso
  quando a Omada está pausada) e o `auto_omada` não reconcilia. Endpoint `/devices`
  do Open API **exige `page`+`pageSize`** (o `OmadaClient::devices()` pagina).
  `auto_omada` está no `install_workers.bat`/`uninstall_workers.bat` e no
  `WorkerHealthRepository` (opcional). Template em `_config_templates.php` (`omada`).
- **Incremento 3 — DECISÃO (2026-07): Omada é VIEW-ONLY. O Nexus NUNCA escreve no
  controlador.** Controle de dispositivos, PoE/SSID, bloqueio de cliente e voucher de Wi-Fi ficam
  NO CONTROLADOR Omada. Dois motivos: (1) escolha de produto; (2) VALIDADO no `omada_diag` que a
  Open API deste controlador nem expõe port/WLAN — `switchPorts`/`wlans` (paths `/switches/{mac}/ports`
  e `/setting/wlans`) devolvem **"Unsupported request path"** (são da API INTERNA `/api/v2/…`, com
  login+CSRF, frágil e não sancionada pela TP-Link). O **voucher** de Wi-Fi (que ERA plugado no fluxo
  de Convites) foi **REMOVIDO** (nem era usado): saíram o checkbox/geração no `VisitConviteController`,
  o `setVoucher` do `VisitConviteRepository` e o card em `convites/show`/`convites/public`. No
  `OmadaClient` os métodos de ESCRITA/voucher foram **removidos** — ele só tem LEITURA
  (`token`/`sites`/`devices`/`clients`) + `debug`. Os scripts `omada_diag`/`omada_test` foram aparados
  para só-leitura. A coluna `visita_convite.wifi_voucher` e `omada.voucher_minutes` seguem no
  schema/config como **dormentes** (inertes; sem migração destrutiva). Resumo: o módulo Omada =
  **monitoramento (§incremento 2)**, e nada de escrita.
- **Tela `/rede` (ability `network.view`) — APARELHOS + CLIENTES + TRÁFEGO + DPI (rev. 2026-07):**
  `NetworkService::snapshot()` (App puro, best-effort, respeita módulo+`OMADA_ENABLED`) traz TUDO
  numa chamada: **aparelhos** (`OmadaClient::devices` — AP/switch/gateway, online/offline, modelo,
  IP, CPU, memória, uptime, nº de clientes, tráfego), **clientes** (`clients` — nome/MAC/IP/vendor/
  tipo/SSID/sinal/uptime/cabo×wifi + tráfego), **KPIs**, **top consumo** (clientes por tráfego) e
  **DPI** (top aplicações). Cada parte é isolada (DPI cair não derruba o resto). Web:
  `NetworkController` (`/rede` + `/rede/dados` JSON), view `rede/index.php` (aparelhos com foto do
  produto via `H::deviceProduct`, offline PRIMEIRO; clientes com `H::deviceThumb`; barras de
  proporção no top consumo). Menu **Rede** em Monitoramento. É sinal de PRESENÇA (base p/ automação).
  - **⚠ DPI = TABELA COMPLETA E FILTRÁVEL (rev. 2026-07).** O `OmadaClient::dpi()` já devolve TODAS as
    aplicações (ordenadas por tráfego); o "top 12" era só limite da VIEW. Agora o card DPI é **largura
    total** (saiu da grade de 2 colunas, que apertava a tabela) com **busca** (app/categoria) + **select
    de categoria** + contador "N de M apps" (filtro client-side, `display:none`, instantâneo) numa
    `table.data.cards-mobile` rolável (`max-height`). Colunas App/Categoria/Clientes/Tráfego/%. ⚠ A
    célula de Tráfego leva **`data-sort="<bytes crus>"`** (e a de % o número) — sem isso a **ordenação
    universal** (§3.1, `cellText` lê `data-sort||textContent`) ordenaria "1,2 GB" como TEXTO (errado).
    Sem `data-page-size` (o paginador embutido não coopera com filtro client-side de `display:none` —
    ele fatia TODAS as linhas; por isso a rolagem + filtro próprio). Card vazio ⇒ DPI desligado no Omada.
  - **⚠ `clients()` continua LEVE de propósito** (só clientes): é o que o worker `auto_omada` chama
    a cada ~10 min p/ gravar a KV `mural_network`. Fazê-lo delegar ao `snapshot()` faria o worker
    puxar aparelhos + sondar DPI a cada ciclo, sem ninguém pedir. `/rede` (sob demanda) usa o
    `snapshot()`; o MURAL segue lendo do BANCO/KV (TV não faz chamada externa no request).
  - **⚠ NOME DO CAMPO DE TRÁFEGO MUDA POR VERSÃO** (`download`/`trafficDown`/`rxBytes`…). Um
    `?? 0` num nome só devolveria 0 calado e o gráfico ficaria zerado SEM ERRO — por isso
    `OmadaClient::num($row, [candidatos])` procura o 1º campo numérico existente.
  - **DPI + DASHBOARD — 4 caminhos CONFIRMADOS POR HAR (2026-07).** A primeira versão "autodescobria"
    o DPI testando 5 caminhos **inventados** — nenhum existia. Um HAR da interface do controlador
    resolveu: os endpoints existem e vivem em **`/openapi/v1`** (a superfície SANCIONADA), não na API
    interna `/api/v2`. Constantes em `OmadaClient`:
    - **`PATH_DPI`** = `/sites/{site}/dashboard/mostActiveAppTraffic?start=<epoch_s>&end=<epoch_s>`
      → `result[]`: `applicationName`, `applicationId`, `applicationDescription`, `familyId`,
      **`familyName`** (a "categoria": Streaming, Social, Tunnel…), `traffic` (bytes),
      `trafficPercent`, `clientsCount`. **⚠ `start`/`end` em SEGUNDOS** aqui — o `/health/timeline`
      usa MILISSEGUNDOS. Sem eles o endpoint não devolve lista.
    - **`PATH_OVERVIEW`** = `/sites/{site}/dashboard/card/overview` → um item por `type`:
      `gateway` (+`list[0]` com `cpuUtil`/`memoryUtil`/`temperature`), `ap`, `switch`
      (+`totalPorts`/`availablePorts`/**`powerConsumption`** = PoE em watts), `clients`, `guests`.
      É a fonte MAIS confiável para "clientes conectados AGORA" (a lista de `clients` traz histórico).
    - **`PATH_TOP_CLI`** = `/sites/{site}/dashboard/active-clients` → top consumo JÁ ordenado e com
      `trafficPercent`, `upload`/`download`, `rssi`, `healthScore`, `apName`.
    - **`PATH_REPORT`** = `/sites/{site}/report/cards` (**POST**), corpo
      `{"start":s,"end":s,"cards":[{"type":"internet"},{"type":"wirelessTrafficSingle"}]}` →
      `totalTraffic`/`rxTraffic`/`txTraffic` + **`trafficTrend[]`** (buckets de 5 min com
      `wirelessCount`/`wiredCount`) — é o gráfico de tráfego da tela.
    Card vazio → o **Application Analytics/DPI está DESLIGADO** no Omada (exige gateway Omada no
    site), não é falta de endpoint. `OMADA_DPI_PATH` (com `{site}`) sobrepõe se a versão diferir;
    `OMADA_JANELA_HORAS` (padrão 24) define a janela. `php api/scripts/omada_diag.php dpi` sonda os 4.
    **Regra que isto confirma: não inventar endpoint — capturar um HAR e LER** (mesmo erro do
    `getReadingDates` da CELESC, §5.38).
  - **Próximo passo anotado:** mapear MAC→pessoa (tabela `pessoa_dispositivo`) para virar
    "Fulano está em casa" — ver `deploy/INSPIRACAO-OMADA-2026-07.md`.

### 5.25 Patrimônio — Imóveis, Ativos e Documentos/Manuais (FASE 1, FEITO)
- **Módulo NOVO, sem hardware**, separado das Ocorrências (podem se cruzar depois). Registro do
  patrimônio físico do condomínio para dar base ao futuro OS/Tarefas (Fase 2). **Multi-imóvel
  DENTRO do condomínio** (o condomínio + as casas). 4 tabelas (schema + `migration_patrimonio.sql`):
  `pat_imovel` (condomínio/casa/área), `pat_local` (locais HIERÁRQUICOS — self-FK `local_pai_id`
  SET NULL; `qr_token` único), `pat_ativo` (equipamento: marca/modelo/nº série/capacidade,
  NF via `valor`/`fornecedor`, garantia, **empréstimo** `emprestado_para`/`emprestado_em`/
  `devolucao_prevista`, `situacao`, `foto` mediumblob, `qr_token` único, `campos_dinamicos`
  longtext JSON) e `pat_documento` (metadados; **arquivo em DISCO**, não blob).
- **Vínculo SOFT do ativo a um dispositivo MONITORADO** do Nexus: `pat_ativo.dispositivo_tipo`
  (`auto_device`) + `dispositivo_id` (aponta para `auto_device.id`). É polimórfico e opcional —
  liga a ficha do ativo ao que a automação já monitora (mostra "monitorado" no `ativo_show`).
- **Permissões:** `patrimonio.view` (portaria/adm/func/admin_sistema) e `patrimonio.manage`
  (administração/admin_sistema). NÃO há ability nova de documento — documentos herdam
  view/manage do patrimônio.
- **Camada Web** (tudo resiliente: sem as tabelas, listas voltam vazias; HY093-safe):
  repos `Web\Repositories\PatrimonioRepository` (imóveis/locais/ativos + `ativoByToken` +
  `foto`/`setFoto` PARAM_LOB + `deviceOptions` de `auto_device`) e
  `Web\Repositories\DocumentoRepository` (`forEntity`/`all`/`find`/`create`/`delete`);
  controllers `ImoveisController` (`/imoveis*` + `/imoveis/{id}/locais`), `AtivosController`
  (`/ativos*`, foto em `/ativos/{id}/foto`) e `DocumentosController` (`/documentos*`). Rotas
  literais antes de `{id}` (`/imoveis/new` antes de `/imoveis/{id}`, idem ativos). Menu: seção
  **Patrimônio** (Ativos, Imóveis, Documentos) em `nav_items.php`, entre Cadastros e Automação.
- **Etiqueta/QR PÚBLICA (sem login):** `/pat/{token}` (gate `[]`, padrão convite/door_share)
  abre a ficha READ-ONLY do ativo; a foto sai por `/pat/{token}/foto` (por token — o
  `/ativos/{id}/foto` é `patrimonio.view` e o público não tem). O QR é gerado client-side no
  `ativo_show` (qrcodejs) apontando para `{baseUrl}/pat/{qr_token}`.
- **Documentos/Manuais EM DISCO:** o arquivo vai para `<instalação>/storage/patrimonio/AAAA/MM/`
  (IRMÃO de `public/` → **fora do docroot**, servido só por `/documentos/{id}/download` com
  permissão + **guarda de path traversal** via `realpath` dentro da raiz). Config
  `api/config/patrimonio.php` (`PATRIMONIO_STORAGE_PATH`, padrão `<root>/storage/patrimonio`;
  `PATRIMONIO_MAX_UPLOAD_MB`, padrão 25). **Allowlist** de extensões (pdf/imagens/office/txt/csv/
  zip; SVG/HTML/JS/PHP ficam DE FORA); nome de arquivo aleatório (`random_bytes`); `inline` p/
  pdf/imagem, senão `attachment`; `X-Content-Type-Options: nosniff`. **Metadados no banco**
  (`pat_documento`), evitando `max_allowed_packet` de PDF grande. Vínculo POLIMÓRFICO e OPCIONAL
  (`entidade_tipo` = ativo|imovel|local + `entidade_id`): documento pode ser AVULSO
  (biblioteca `/documentos`) ou preso a um ativo/imóvel. Card reutilizável
  `app/Views/patrimonio/_docs_card.php` (lista + upload) incluído em `ativo_show` e `imovel_show`;
  o `store`/`delete` voltam para a página de origem (campo `redirect`, validado como caminho LOCAL).
- **Fase 2 — OS/Tarefas: a BASE (2.1) está FEITA — ver §5.26.** Módulo novo e separado das
  Ocorrências, referenciando `pat_ativo` (e cruzando com `ocorrencia`). Base do PRD
  `PRD-Sistema-Gestao-Obra-Manutencao`; referência de UX (NÃO copiar) no "Construction Companion".
  Manutenção preditiva pode reusar a IA de operação (§5.12) sobre os ativos com dispositivo
  monitorado vinculado.

### 5.26 OS / Tarefas — Ordens de serviço / manutenção (FASE 2.1, FEITO)
- **Entidade CENTRAL do módulo de manutenção** (PRD §12.4/§14), MÓDULO NOVO e SEPARADO das
  Ocorrências de morador (§5.8) — podem cruzar (`os_tarefa.ocorrencia_id`, SOFT). 2 tabelas
  (schema + `migration_os.sql`): `os_tarefa` (código legível `OS-AAAA-NNNN`, vínculos SOFT a
  imóvel/local/ativo/ocorrência, `tipo_servico`/`categoria`/`prioridade`, `status`, `bloqueado`/
  `bloqueio_motivo`, responsável/supervisor SOFT→`pessoa`, prazos, `inicio_real`/`entregue_em`/
  `validado_em`, custos, `origem`, tags) e `os_tarefa_evento` (thread: comentario|status|sistema;
  **FK CASCADE → os_tarefa**). Vínculos a entidade = SOFT int (baixo acoplamento; só evento→tarefa é FK).
- **Ciclo de vida com "entrega ≠ conclusão"** (PRD §15): `rascunho → aberta → em_andamento →
  aguardando_validacao` ("Entregar" carimba `entregue_em`) `→ concluida` ("Aprovar" carimba
  `validado_em`); + `pausada`, `cancelada`, "Reabrir"; **bloqueio** (com motivo) é paralelo ao status.
  Transições e carimbos de data em `OsController::status` (POST `/os/{id}/status`; `OsRepository::
  changeStatus` com allowlist de colunas de data/bloqueio). Cada transição/comentário vira evento
  (histórico + autor via `usuario.nome`).
- **Permissões:** `os.view` (portaria/administracao/funcionario_autorizado/admin_sistema — vê e
  comenta em `/os/{id}/comment`) e `os.manage` (administracao/admin_sistema — cria, atribui, muda
  status, valida, cancela, bloqueia). Nota: `os.manage` ⊆ `patrimonio.manage` (mesmos papéis) → o
  upload de anexo da OS (que passa pelo GED, `patrimonio.manage`) sempre funciona p/ quem gerencia OS.
- **Camada Web** (resiliente; sem `os_tarefa`, tudo vazio; HY093-safe): `Web\Repositories\OsRepository`
  (`all` com filtros + `summary` de contadores p/ os chips, `find`/`createOs`/`updateOs`/`changeStatus`,
  `addEvent`/`events`, `pessoaOptions` = funcionario/prestador_servico/terceirizado, `forAtivo`,
  `nextCodigo`) e `OsController` (`/os*`). Rotas literais antes de `{id}` (`/os/new` antes de `/os/{id}`).
  Menu: seção **Manutenção** (Ordens de serviço) entre Patrimônio e Automação. Ícone `tool` (novo `lock`
  no `Helpers::icon` p/ o bloqueio).
- **Anexos reusam o GED** (§5.25): o card `patrimonio/_docs_card` entra no `os_show` com
  `entidade_tipo='os'` (allowlist do `DocumentosController::store` + `backTo` já incluem `os`;
  `documentos_index` linka `os #id`). Sem tabela/coluna nova de anexo.
- **Integração com Patrimônio:** a ficha do ativo (`ativo_show`) ganha **"Abrir OS"**
  (`/os/new?ativo_id=&imovel_id=`, `os.manage`) + **lista das OS do ativo** (`OsRepository::forAtivo`,
  gated `os.view`). O form da OS filtra local/ativo por imóvel no JS (mesmo padrão do `ativo_form`).
- **Fase 2.2 — Pendências + loop de reprovação + ocorrência→OS: FEITO — ver §5.27.**
- **ROADMAP — Fase 2 COMPLETA.** Feito: preventivas §5.28; Kanban/Calendário/Gantt §5.29; IA preditiva →
  OS §5.30; Materiais/Estoque/Compras §5.31; Equipes §5.32; QR + impressão §5.33; Checklist de aceite +
  templates §5.34; Ocorrência de obra §5.35; status operacional × gerencial §5.36. Evoluções FUTURAS
  (fora do PRD base): notificações push por evento de OS, relatórios gerenciais avançados, app nativo.

### 5.27 Pendências + loop de reprovação (FASE 2.2, FEITO)
- **Pendência** (PRD §12.5/§16) = problema / item reprovado / ponto incompleto. 2 tabelas (schema +
  `migration_pendencia.sql`): `os_pendencia` (código `PEND-AAAA-NNNN`, vínculos SOFT a
  imóvel/local/ativo/**OS de origem** `os_id`/**ocorrência** `ocorrencia_id`, `categoria`, `severidade`,
  responsável/supervisor SOFT→pessoa, `prazo`, `status`, `resolvido_em`) e `os_pendencia_evento`
  (thread comentario|status|sistema; **FK CASCADE**). REUSA as abilities `os.view`/`os.manage` (sem
  ability nova).
- **Ciclo:** `aberta → em_correcao → aguardando_validacao → resolvida`; `reaberta` (limpa
  `resolvido_em`), `cancelada`. `PendenciasController::status` carimba `resolvido_em` ao resolver.
  Lista CENTRAL em `/pendencias` (contadores + filtros status/severidade/categoria; PRD §16.2). Repo
  `Web\Repositories\PendenciaRepository` (resiliente; HY093-safe). Menu: **Pendências** em Manutenção.
- **Loop de reprovação (PRD §14.5):** na `os_show`, com a OS em `aguardando_validacao`, o botão
  **"Reprovar → pendência"** (`POST /os/{id}/reject`, `OsController::reject`) CRIA uma pendência ligada
  à OS (`os_id`, herda imóvel/local/ativo/responsável, severidade alta, motivo→descrição) e devolve a
  OS para `em_andamento`. Cross-links: card **Pendências** na `os_show` (`PendenciaRepository::forOs` +
  "Nova pendência"); a `pendencia_show` mostra a **OS de origem** e a **ocorrência de origem**.
- **Ocorrência de morador → OS (§12.6):** botão **"Abrir OS"** na `ocorrencias/show` (gate `os.manage`)
  → `/os/new?ocorrencia_id=X`; `OsController::create` lê a ocorrência (`TicketRepository::find`) e
  pré-preenche título (=`assunto`)/descrição, grava `os_tarefa.ocorrencia_id` e marca `origem='ocorrencia'`.
- **Anexos/evidências reusam o GED**: `patrimonio/_docs_card` com `entidade_tipo='pendencia'` (allowlist
  do `DocumentosController::store` + `backTo` + `documentos_index` já incluem `pendencia`).

### 5.28 Manutenção preventiva (planos recorrentes) (FASE 2.3, FEITO)
- **Plano recorrente que GERA OS automaticamente** (PRD §4.1 item 39). Tabela `os_plano` (schema +
  `migration_plano.sql`): alvo SOFT (imóvel/local/ativo), `tipo_servico`/`categoria`/`prioridade`,
  `periodicidade_dias`, `antecedencia_dias` (gera N dias antes do vencimento), `prazo_dias` (SLA da OS),
  responsável/supervisor, `proxima_data` (vencimento), `ultima_geracao`/`ultima_os_id`, `ativo`
  (kill-switch por plano). A OS gerada aponta de volta por **`os_tarefa.plano_id`** (soft; a migração
  adiciona a coluna `plano_id` + índice). Reusa `os.view`/`os.manage` (sem ability nova).
- **Geração na camada App** (o worker usa App, NUNCA Web): `App\Services\PreventiveMaintenance` —
  `runDue()` varre planos ativos com `proxima_data <= hoje + antecedencia` (e ainda não gerados hoje),
  emite UMA OS por plano (origem `preventiva`, código `OS-AAAA-NNNN`, evento de sistema) e **avança
  `proxima_data` pela periodicidade PULANDO ciclos perdidos** (não cria backlog). `generateForPlano($id)`
  é o "gerar agora" manual (reagenda a partir de hoje). Best-effort/resiliente (sem as tabelas, no-op).
- **Worker `auto_preventiva`** (`api/scripts/auto_preventiva.php` + `.bat`, 1x/dia às 02:00) chama
  `runDue()` com heartbeat begin/ok/fail. Está no `install_workers.bat` **e** no `uninstall_workers.bat`
  e registrado em `WorkerHealthRepository` (opcional, cadência diária). NÃO é módulo do kill-switch
  (interno); pausa-se POR PLANO (`ativo=0`).
- **Web** `Web\Repositories\PlanoRepository` (CRUD + `all`/`forAtivo`/`generatedOs` + `setAtivo`;
  resiliente, HY093-safe) e `PlanosController` (`/planos*`): lista (filtros estado/busca, destaque de
  vencidos), form (periodicidade por presets semanal→anual + antecedência/SLA), show (detalhes +
  **Gerar OS agora** + **Pausar/Ativar** + lista das OS geradas). Rotas literais antes de `{id}`. Menu:
  **Preventivas** em Manutenção.
- **Integração Patrimônio/OS:** card **Manutenção preventiva** na `ativo_show` (lista + "Novo plano"
  `/planos/new?ativo_id=&imovel_id=`, gated `os.view`/`os.manage`); a `os_show` mostra "Origem → plano
  preventivo #id" quando a OS veio de um plano.

### 5.29 Visões Kanban / Calendário / Gantt das OS (FASE 2.4, FEITO)
- **Visões READ-ONLY sobre as OS que já existem** (PRD §4.1 itens 30-31), SEM tabelas novas. Alternador
  **Lista · Quadro · Calendário** no topo das três telas (`/os`, `/os/kanban`, `/os/calendar`). Rotas
  literais `/os/kanban` e `/os/calendar` declaradas ANTES de `/os/{id}` (senão o `{id}` engole). Reusa
  `os.view` (ver) e `os.manage` (arrastar).
- **Kanban** (`OsController::kanban` + `os/kanban`): colunas por status (aberta, em andamento, pausada,
  aguardando validação, concluída — rascunho/cancelada ficam DE FORA do quadro). Dados de
  `OsRepository::board($f)` (não-canceladas; concluídas só dos últimos 30 dias; filtros imóvel/busca;
  LIMIT 400). **Arrastar-e-soltar muda o status** — só quando `os.manage` (cards `draggable`), via
  `fetch` POST em `/os/{id}/status` com `_csrf` (meta `csrf`) + `status` da coluna, e recarrega. Sem JS
  ou sem permissão, os cards são apenas links para a OS. NÃO usa o `reject`: arrastar de "aguardando
  validação" p/ "em andamento" só volta o status (a reprovação COM pendência é o botão dedicado da
  `os_show`).
- **Calendário** (`OsController::calendar` + `os/calendar`): grade mensal (CSS grid 7 colunas, sem
  dependência), navegação `?ym=AAAA-MM` (‹ › / Hoje). Cada dia mostra as **OS por `prazo_conclusao`**
  (`OsRepository::dueBetween`) como barras coloridas por status + as **preventivas previstas** por
  `proxima_data` (`PlanoRepository::dueBetween`) como marcador 🔁 tracejado; corte de 3 OS/dia com "+N".
- **Gantt** (`OsController::gantt` + `os/gantt`): linha do tempo em janela de 42 dias (navegação
  `?start=AAAA-MM-DD` + "Hoje"). Cada OS é uma barra posicionada por `prazo_inicio→prazo_conclusao`
  (`OsRepository::ganttItems` = OS cujo intervalo CRUZA a janela), cor por status, linha vermelha de hoje,
  contorno vermelho p/ atrasada; **CSS puro** (sem dependência), rolagem horizontal. O alternador virou
  **Lista · Quadro · Calendário · Gantt** nas quatro telas.
- **Validação**: JS do arrastar testado por `node --check`; HY093 nos SELECTs novos; ordem de rota
  conferida no `static_check` (literais antes de `{id}`).

### 5.30 IA preditiva → OS por condição (FASE 2.5, FEITO)
- **Ponte entre a IA de operação (§5.12) e o módulo de manutenção**: descobertas SEVERAS
  (`ai_finding` severidade alerta/crítico) viram uma **OS CORRETIVA** (origem `ia`), vinculada ao
  **ativo** quando a fonte (dispositivo monitorado, `ai_finding.dominio='dispositivo'` + `alvo_id`) casa
  com `pat_ativo.dispositivo_id`. Serviço `App\Services\PredictiveMaintenance::fromFindings($findings)`
  (camada App, best-effort, NUNCA lança). **Dedup** por `os_tarefa.origem_chave` (= `ai_finding.chave`):
  enquanto houver uma OS ABERTA para aquela chave, não cria outra (schema + `migration_ai_os.sql`
  adicionam a coluna + índice).
- **OPT-IN duplo**: só age com o KV `configuracao.ai_gera_os` = '1' (padrão DESLIGADO) **E** dentro do
  worker `auto_ai`, que já respeita o módulo `ai`. O `auto_ai` chama `fromFindings()` após
  `analyze()`/`record()` (a MESMA lista de descobertas; sem reprocessar). Prioridade: crítico→urgente
  (prazo +1 dia), alerta→alta (+3 dias). Evento de sistema na OS registra a descoberta. **Sem worker
  novo** (reusa o `auto_ai`).
- **UI**: chip **"IA gera OS: Ligada/Desligada"** no topo do `/os` com botão liga/desliga
  (`POST /os/ai-toggle`, `OsController::aiToggle`, `os.manage`, via `SettingsRepository`); a `os_show`
  mostra **"Origem → IA de operação"** quando `origem='ia'`.
- **Diferença p/ a preventiva (§5.28)**: a preventiva é por CALENDÁRIO (plano recorrente); esta é por
  CONDIÇÃO (anomalia/tendência do ativo monitorado). As duas alimentam a MESMA fila de OS.

### 5.31 Materiais / Estoque / Compras (FASE 2.6, FEITO)
- **Almoxarifado da manutenção** (PRD §12.8-12.10), MÓDULO NOVO. 3 tabelas (schema +
  `migration_material.sql`): `material` (catálogo + estoque na MESMA linha: `estoque_atual`/
  `estoque_reservado`/`estoque_minimo`, custo, unidade, `imovel_id` soft, categoria),
  `material_movimento` (RAZÃO: entrada/saida/ajuste/reserva/libera; **FK CASCADE → material**; `os_id`
  soft p/ consumo) e `material_compra` (lista de compras flat). Abilities `material.view`
  (portaria/adm/func/admin) e `material.manage` (adm/admin).
- **Estoque muda SÓ por movimento, em TRANSAÇÃO** (`MaterialRepository::addMovimento`): entrada soma,
  saída subtrai (`GREATEST(0,…)`), ajuste DEFINE o saldo, reserva/libera mexem no reservado. A edição do
  catálogo (`updateMaterial`) NÃO toca em `estoque_atual`/`reservado` (só `estoque_minimo`). Disponível =
  atual − reservado (calculado). **Baixo estoque** = `estoque_minimo > 0 AND estoque_atual <= minimo`
  (badge + filtro + sugestões de compra). HY093-safe (INSERT e UPDATE com placeholders distintos).
- **Web**: `Web\Repositories\MaterialRepository` (CRUD + `addMovimento` transacional + `movimentos` +
  `lowStock`/`countLow` + `options` + `forOs`) e `CompraRepository` (CRUD + `setStatus`); controllers
  `MateriaisController` (`/materiais*`, movimento em `/materiais/{id}/movimento`) e `ComprasController`
  (`/compras*`). Rotas literais antes de `{id}`. Menu: **Materiais** e **Compras** em Manutenção.
- **Lista de compras** (`/compras`): itens pendente→comprado→recebido/cancelado; **sugestões
  automáticas** dos materiais em baixo estoque (um clique adiciona à lista). **Ao marcar RECEBIDO**, se
  o item está ligado a um material do catálogo, gera uma **ENTRADA de estoque** (`addMovimento` entrada)
  — fecha o ciclo compra→estoque.
- **Integração com a OS**: card **"Materiais usados"** na `os_show` (lista `MaterialRepository::forOs` +
  form de consumo → `POST /os/{id}/material`, `OsController::materialUse`, gate `material.manage`, lança
  uma SAÍDA com `os_id`). O consumo fica preso à OS; cada movimento aparece na ficha do material com link
  para a OS. **Números BR** (`decv`/`floatn`) nas quantidades/custos; quantidade em `decimal(12,3)`.

### 5.32 Equipes (FASE 2.7, FEITO)
- **Grupos de execução da manutenção** (PRD §12.11), MÓDULO NOVO. 2 tabelas (schema +
  `migration_equipe.sql`): `equipe` (nome, especialidade, supervisor/imóvel SOFT, ativo) e
  `equipe_membro` (**FK CASCADE → equipe**, `pessoa_id` SOFT, `papel` membro|lider; UNIQUE
  equipe+pessoa). A OS ganha `os_tarefa.equipe_id` (SOFT; a migração adiciona a coluna + índice).
  Reusa `os.view`/`os.manage` (sem ability nova).
- **Web**: `Web\Repositories\EquipeRepository` (CRUD + `members`/`addMember` (ON DUP KEY)/`removeMember`
  + `options` p/ o select da OS + `pessoaOptions`) e `EquipesController` (`/equipes*`, membros em
  `/equipes/{id}/members` e `/equipes/{id}/member-remove`). Rotas literais antes de `{id}`. Menu:
  **Equipes** em Manutenção.
- **Integração com a OS**: select **Equipe** no `os_form` (`OsController::formView` carrega
  `EquipeRepository::options`; `osInput`/`OsRepository::values` incluem `equipe_id`); a `os_show` mostra
  a equipe (link) — `OsRepository::selectBase` faz `LEFT JOIN equipe`. Membros SOFT (pessoa_id): a lista
  sobrevive a mudanças no cadastro de pessoas.

### 5.33 QR por tarefa + impressão da OS (FASE 2.8, FEITO)
- **QR na `os_show`** (card "Etiqueta e impressão"): `qrcodejs` (cdnjs) client-side apontando para
  `window.location.origin + /os/{id}` — escanear abre a OS (INTERNA, gated `os.view`; NÃO é público como
  a etiqueta do ativo). Sem coluna/token novo.
- **Impressão**: `GET /os/{id}/print` (`OsController::printView`, `os.view`) renderiza um **fragmento
  auto-contido** (`os/print`, sem o layout do app; CSS próprio + `@media print`) com cabeçalho, grade de
  detalhes, descrição, materiais usados, pendências, histórico e linha de assinatura. Botão "🖨️ Imprimir
  OS" no card da `os_show` abre em nova aba. Rota literal `/os/{id}/print` (2 segmentos, não colide com
  `/os/{id}`). Sem migração (leitura pura).

### 5.34 Checklist de aceite na OS + templates por tipo (FASE 2.8, FEITO)
- **Critérios de aceite verificados na validação** (PRD §15.1). Tabela `os_checklist` (schema +
  `migration_checklist.sql`): `os_id` **FK CASCADE → os_tarefa**, `texto`, `feito`/`feito_em`/`feito_por`,
  `ordem`. Métodos no `OsRepository` (checklist/checklistProgress/addChecklistItem/toggleChecklist/
  removeChecklist/maxChecklistOrder). Reusa `os.view`/`os.manage` (sem ability nova).
- **Templates por tipo de serviço em CÓDIGO** (sem tabela de template): `OsController::$checklistTemplates`
  (pintura/elétrica/ar-condicionado/aquecedor/hidráulica/limpeza/jardim/geral, a partir dos exemplos do
  PRD §13). Botão "Usar modelo de {tipo}" no card semeia os itens quando o checklist está vazio.
- **UI na `os_show`**: card "Checklist de aceite (feitos/total)" — cada item com **marcar/desmarcar**
  (`POST /os/{id}/checklist-toggle`, gate `os.view` — executor/validador; sem JS, funciona por form),
  **adicionar item** e **usar modelo** (`POST /os/{id}/checklist`, `os.manage`) e **remover**
  (`POST /os/{id}/checklist-remove`, `os.manage`). O checklist também sai no **`/os/{id}/print`**.
- **Rotas** `checklist`/`checklist-toggle`/`checklist-remove` são de 2 segmentos (não colidem com
  `/os/{id}`). Toggle carimba `feito_em`/`feito_por`.

### 5.35 Ocorrência de obra (FASE 2.9, FEITO)
- **Incidente de campo** (PRD §12.6) — dano/acidente/imprevisto/clima/etc. — MÓDULO NOVO e **DISTINTO da
  ocorrência do morador** (§5.8, tabela `ocorrencia`). Tabela `obra_ocorrencia` (schema +
  `migration_obra_ocorrencia.sql`): código `OC-AAAA-NNNN`, tipo/gravidade/status (registrada→em_analise→
  acao_necessaria→resolvida/arquivada), vínculos SOFT a imóvel/local/ativo, `acao_imediata`/
  `proxima_acao`/`pessoas_envolvidas`, e **os_id/pendencia_id** (o que foi gerado). Reusa `os.view`/`os.manage`.
- **Web**: `Web\Repositories\ObraOcorrenciaRepository` (CRUD + filtros/summary + changeStatus +
  setOsId/setPendenciaId; resiliente/HY093-safe) e `ObraOcorrenciasController` (`/obra-ocorrencias*`).
  Menu: **Ocorrências de obra** em Manutenção. Evidências pelo GED (`entidade_tipo='obra_ocorrencia'`, no
  allowlist do `DocumentosController::store`/`backTo` + `documentos_index`).
- **Gera OS/pendência**: botões na `obra_ocorrencias/show` — **"Abrir OS"** (`open-os`) cria uma OS
  corretiva (origem `ocorrencia`, prioridade derivada da gravidade) e guarda `os_id`; **"Gerar pendência"**
  (`open-pendencia`) cria uma pendência (severidade = gravidade) e guarda `pendencia_id`. Server-side
  (`OsRepository::createOs`/`PendenciaRepository::createPendencia`), sem prefill frágil por query.

### 5.36 Status operacional × gerencial da OS (FASE 2.9, FEITO)
- **Dois eixos de status** (PRD §14.2/§14.3): o `os_tarefa.status` OPERACIONAL (aberta/em andamento/…, que
  dirige Kanban/Calendário/Gantt e o ciclo de validação) e o **`os_tarefa.status_gerencial` OPCIONAL**
  (rascunho/aguardando liberação/aguardando material/liberada/não iniciada/…/reprovada/reaberta) — campo
  informativo que o gestor define no `os_form` e aparece como badge (⚙) no `os_show`.
  `OsController::$statusesGerencial`; `OsRepository::values()`/`osInput` incluem o campo (nullable).
  **NÃO altera** a lógica existente (botões e visões seguem no status operacional). Coluna adicionada no
  schema + `migration_obra_ocorrencia.sql` (mesma migração da Fase 2.9).

### 5.37 Painel do tempo `/clima` (previsão completa)
- **Tela dedicada** de previsão (ability `weather.view` — TODOS os papéis; menu **Clima** em Início) que
  enriquece o subsistema de tempo (ver §3 "Tempo/desastres MULTI-FONTE"). **NÃO altera o `snapshot()`**
  (que segue enxuto p/ as regras de alerta) — acrescenta uma camada RICA. Sem tabela nova.
- **DICAS contextuais `App\Support\WeatherTips::from($panel, $max)`** (lógica PURA, testável —
  `api/scripts/tests_weather_tips.php`, espelho Python 15/15): do painel (current/today/daily/hourly +
  rio/avisos) tira frases amigáveis com emoji e `tom` (danger|warn|good|info), ORDENADAS por prioridade:
  rio subindo/aviso oficial → guarda-chuva (chuva nos próximos dias / amanhã / logo) → UV alto/extremo →
  calor/frio/vento → **lavar roupa** (1º dia Hoje/Amanhã com pouca chuva e céu limpo). REUSADA pelo
  **`/clima`** (faixa de cards coloridos após o hero), pelo **tablet** (`TabletController::clima` manda
  `dicas`; pílulas no card do tempo + no modal de previsão) e pelo **card de Tempo do dashboard**
  (`DashboardController::clima` anexa `$w['dicas']`). Ao mudar as CHAVES do painel, revisar os limiares
  aqui. É App puro (nunca `Web\`), então serve qualquer tela.
- **⚠ CARD DE TEMPO DO DASHBOARD (rev. 2026-07):** a view lia chaves ANTIGAS do `MuralRepository::
  weather()` (`current.texto`/`temp_max`/`temp_min`, `chuva.prox_mm`, `rio.nivel_m`/`subindo`,
  `clima.avisos`) que o `weather()` novo (§5.23.1) NÃO tem mais — então condição, máx/mín, chuva, rio e
  avisos vinham EM BRANCO. Corrigido para as chaves reais (`current.condicao`, `today.max/min/sunrise/
  sunset`, `chuva.prevista_mm/medida_mm/prob`, `rio.nivel/trend`, `alerts`) e enriquecido: grade de
  métricas (vento/umidade/UV/nascer/pôr), sensação, chuva+rio e as **dicas** do `WeatherTips`. Ao mexer
  no shape do `weather()`, revisar o card do dashboard junto (as chaves têm que casar).
- **Dados ricos em `App\Services\WeatherService::panel($snap=null)`**: uma chamada ao **Open-Meteo**
  (`openMeteoRich`) traz atual + **horária 48h** + **diária 7d** com nascer/pôr do sol, duração do dia,
  vento (velocidade/**direção**/rajada), umidade, pressão, nuvens, UV e códigos WMO; isso é **mergeado**
  com a **chuva medida + nível de rio + avisos oficiais** do `snapshot()` brasileiro (Defesa Civil SC/INMET/
  S2ID). Estáticos puros `WeatherService::wmo($code, $isDay=true)` (→ `[texto PT, emoji]`) e `windDir($deg)`
  (→ rosa dos ventos) — usados também pela view. **App puro**, best-effort (Open-Meteo fora → cai no resumo
  do snapshot).
  - **⚠ ÍCONE DIA/NOITE:** `wmo()` recebe **`$isDay`** — à noite (`is_day=0` do Open-Meteo) troca ☀️/🌤️/⛅
    por 🌙/🌙/☁️ (não faz sentido "sol" à noite). A horária do Open-Meteo agora inclui **`is_day`** por hora;
    a view `/clima` (hero + tira horária) e o `MuralRepository::weather()` (condição atual) passam o
    `is_day`. Diária mantém emoji de dia (é resumo do dia). Retrocompatível (`$isDay` default true).
  - **⚠ MURAL NÃO RECALCULA — então o worker NÃO pode gravar painel incompleto (bug do "mural sem
    horária").** A TV só LÊ a KV `weather_panel`. Se o `auto_weather` grava um painel `ok` mas SEM `hourly`
    (Open-Meteo fora naquele ciclo), ele **sobrescreve** o painel completo que o `/clima` recuperou e a TV
    fica sem previsão horária/diária até o Open-Meteo voltar. Correção: o `auto_weather` só grava
    `weather_panel` se tiver `hourly`; sem ele, **preserva** o painel completo já salvo (só grava incompleto
    se não houver um completo). Casa com a guarda do `WeatherRepository::panel()` (§acima).
- **Sem chamada externa no request**: o worker `auto_weather` (~15 min) persiste o painel na KV
  `configuracao.weather_panel` (irmão do `mural_weather`). `Web\Repositories\WeatherRepository::panel()` lê
  essa KV (+ `idade_seg`); se ainda vazia (worker nunca rodou), calcula AO VIVO uma vez e cacheia.
  - **⚠ CACHE "ok" NÃO BASTA — precisa estar COMPLETO (bug corrigido 2026-07).** O `panel()`
    do WeatherService devolve `ok=true` MESMO quando o Open-Meteo caiu (entra só o resumo BR do
    snapshot, com `hourly`/`daily` VAZIOS). Isso acontecia porque o worker rodava no php8.4.0 sem
    cacert (§6) → Open-Meteo falhava → gravava um painel "ok" pela metade. O `WeatherRepository::
    panel()` servia QUALQUER `ok=true` e a tela `/clima` ficava sem previsão horária/diária/sol
    "para sempre". Correção: só serve o cache se `!empty($cached['hourly'])`; senão RECALCULA ao
    vivo (o Apache alcança o Open-Meteo e grava o painel COMPLETO). Guarda de anti-hammer: cache
    incompleto porém RECENTE (`idade_seg < 300`) é servido assim mesmo (Open-Meteo provavelmente
    fora). **Regra reusável: cache de painel deve validar que a CARGA PRINCIPAL veio, não só um
    flag `ok`** — senão uma falha parcial vira um cache envenenado permanente.
- **UI** `WeatherController` (`/clima` + `/clima/dados` JSON) + view `clima/panel.php`: hero (temp/sensação/
  condição/máx-mín), tiles (vento+bússola/rajada, umidade, pressão, nuvens, UV, sensação), cartões de sol
  (nascer/pôr/duração) e chuva/rio, tira das próximas 24h + **gráfico Chart.js** (temp + prob. de chuva,
  eixo duplo; CDN cdnjs com fallback, mesmo padrão de medidores/água) e tabela de 7 dias. Ícone novo
  `cloud` em `Helpers::icon` (+ mapa `/clima`).

### 5.38 Energia — CELESC (contas/consumo) + SolarMan (usina solar)
- **Duas integrações por ENGENHARIA REVERSA** (não há API oficial p/ nenhuma; é a conta do usuário
  lendo os próprios dados, como o InControl). **SÓ LEITURA** — nunca paga conta nem controla a usina.
  Provado por HAR (2 capturas): auth, endpoints E formatos de resposta confirmados. Guias leigos:
  `deploy/INTEGRACAO-CELESC.md` e `deploy/INTEGRACAO-SOLARMAN.md`; plano: `deploy/PLANO-INTEGRACAO-ENERGIA.md`.
- **Clientes self-contained** (cURL puro, token em cache de arquivo — espírito do `OmadaClient`):
  `App\Energy\CelescClient` (login `POST /auth/login` → token **Bearer opaco**; GraphQL `/graphql` com
  header `execution-requester` + `channelCode`/`target` nas vars; `contracts()`=allContracts,
  `bills()`=getAllBills com `compensation`→situação paga/aberta/vencida, consumo, bandeira, pix) e
  `App\Energy\SolarmanClient` (login `POST /oauth2-s/oauth/token` form-urlencoded, `password=SHA-256(senha)`
  + `clear_text_pwd`, → **Bearer JWT**; `realtime()`=operating/system+fast/system → `networkStatus`
  online/`generationPower` W/`generationValue` kWh hoje; `stats(month|year|total)`→kWh+R$; `alerts()`,
  `devices()`). Config `api/config/celesc.php`/`solarman.php` + `.env` (`CELESC_*`/`SOLARMAN_*`; inertes
  sem `_ENABLED`). CELESC é **multi-UC** (o titular tem 15).
- **Orquestrador** `App\Services\EnergyService` (App puro, best-effort, respeita módulo+`_ENABLED`):
  `celescPanel()` soma as UCs SELECIONADAS (situação, próxima a vencer, consumo, histórico) e
  `solarmanPanel()` (estático+tempo real+geração+alertas+inversores). **Seleção de UCs pelo usuário**:
  `celescSelection()` lê o CSV da KV `configuracao.celesc_ucs` (fallback `.env` CELESC_INSTALLATIONS;
  vazio=todas); `celescAllUCs()` lista todas para o seletor.
- **Módulos** `celesc`/`solarman` no kill-switch (`App\Support\Modules`, grupo Integrações): efetivo =
  `Modules::enabled() E *_ENABLED`.
- **Worker `energy_sync`** (~30 min; `install/uninstall_workers.bat` + `WorkerHealthRepository`): monta os
  panels, grava na KV `celesc_panel`/`solarman_panel` (o painel/Mural leem daqui, sem chamada externa no
  request), guarda histórico em `energia_conta`/`energia_geracao` (schema + `migration_energia.sql`) e
  dispara **alertas com dedup** (KV `energy_alertts_*` + cooldown) via `AlertService::send`: conta a
  vencer/vencida, usina offline, geração 0 W em horário de sol (`solarman.sun_from/to`), falha de inversor
  (nível ≥ 2).
- **UI** `/energia` (ability `energy.view`; menu **Energia** em Início, ícone novo `bolt`):
  `Web\Repositories\EnergyRepository` (lê KV + fallback ao vivo, igual ao clima) + `EnergyController`
  (`/energia`, `/energia/dados` JSON, `/energia/unidades` GET+POST = seletor de UCs, `energy.manage`).
  View `energia/panel.php`: card usina (online/potência/hoje/mês/total/alertas), card contas (tudo pago /
  abertas / próxima a vencer / consumo), gráfico Chart.js de consumo 12m, tabela por UC (badges de
  situação). Seletor `energia/ucs.php` (checkboxes → KV).
- **Mural**: tela `energia` em `screensLogged` + `$publicScreens` (energia agregada do condomínio, sem PII);
  `MuralRepository::energy()` lê as mesmas KVs; view kiosk `mural/screen_energia.php` (dark, auto-poll, selo
  ao vivo↔sem-conexão). **CLIs de validação** `api/scripts/celesc_test.php` / `solarman_test.php` (rodam com
  `_ENABLED=false`).
- **Telas de DETALHE** (abas no topo: *Visão geral · Contas · Usina*). Diferente do painel, estas fazem
  chamada **AO VIVO** (são sob demanda, não é a home): `EnergyService::celescDetail()` / `solarDetail()` /
  `celescBillPdf()` — cada parte é best-effort e falha sozinha.
  - **`/energia/celesc`** (`EnergyController::celesc`, view `energia/celesc.php`): seletor de UC, **histórico
    completo de faturas** (situação/valor/consumo/bandeira), **tendências** (média 12m, variação vs mês
    anterior e vs mesmo mês do ano passado), gráfico consumo×valor (+ energia gerada, se a UC compensa),
    **parcelamentos** (`getInstallmentBills`) e **datas de leitura** (`getReadingDates`). Se a CELESC estiver
    fora, cai para o histórico salvo em `energia_conta` (flag `offline` → esconde ações que precisam da API).
  - **`/energia/celesc/fatura/{codigo}`** (view `energia/fatura.php`): detalhe da conta (consumo, média, dias,
    medidor, bandeira, valores, energia injetada/compensada) + **Pix copia-e-cola** e **código de barras**
    (`data-copy`) — o sistema **NÃO paga**, só mostra.
  - **`/energia/celesc/fatura/{codigo}/pdf`**: baixa a **2ª via em PDF** (`CelescClient::billPdf` →
    mutation `duplicateBill` → `invoiceBase64`). É mutation do portal mas **não altera a conta** (só renderiza
    o PDF); **NÃO** chamamos o `createLogProtocol` que o site usa, para não gerar protocolo à toa. Serve com
    `Content-Type: application/pdf` + `nosniff` e registra auditoria (`baixar`/`celesc_fatura`).
  - **`/energia/solar`** (view `energia/solar.php`): **curva de potência do dia** (`history/power/{id}/record`
    → `generationPower`/`dateTime`), **geração por dia do mês** e **por mês do ano** (os endpoints
    `stats/month` e `stats/year` devolvem `statistics` + **`records[]`**), navegação mês a mês, inversores
    (SN/estado/potência) e alertas com nível/inversor/data.
  - **ROTAS**: `/energia/celesc/fatura/{codigo}/pdf` vem **ANTES** de `/energia/celesc/fatura/{codigo}`, e
    `/energia` fica por último (regra do roteador: literal/mais específica primeiro).

#### 5.38.1 Pix/boleto em toda fatura aberta + comparativo entre UCs (2026-07)

- **PAGAMENTO onde a fatura aparece.** Componente único `app/Views/components/fatura_acoes.php`
  (Copiar **Pix** · Copiar **boleto** · **2ª via** PDF · Detalhes), incluído em `/energia` (painel),
  `/energia/celesc` (linha da tabela) e no **Dashboard**. O `fatura.php` mantém a versão completa
  (com os códigos visíveis). O Nexus **não paga nada** — só entrega o código.
  - **⚠ VALIDADO NO HAR:** a CELESC só preenche `qrCode` (Pix) e `codigoDeBarras` nas faturas
    **EM ABERTO** — nas pagas vêm string vazia. Ou seja: os códigos JÁ VÊM no `getAllBills`,
    **sem chamada extra**. Por isso o `celescPanel` passou a levar `pix`/`codigo_barras` dentro de
    `resumo.pendentes` — é o que permite copiar o Pix direto do painel/dashboard.
  - **⚠ No Dashboard os botões ficam FORA da âncora.** O item de ação é um `<a>` inteiro; `<button>`
    dentro de `<a>` é HTML inválido e engole o clique. O `$add()` do `DashboardController` ganhou um
    5º parâmetro `$extra` (hoje só `fatura`), e a view renderiza o link + os botões como irmãos.
- **`/energia/comparativo` (ability `energy.view`)** — consumo das UCs lado a lado, mês a mês:
  linha por UC (Chart.js), total do condomínio (kWh em barra + R$ em linha, eixo duplo), tabela com
  total/média/último/variação, e o painel de energia. **Lê do BANCO** (`energia_conta`, mantido pelo
  `energy_sync`): abrir a tela NÃO dispara 15 chamadas à CELESC. Quantos meses vêm depende do
  **`CELESC_MONTHS`** do `.env`. `EnergyRepository::comparativo()` pivota UC × período; mês sem
  fatura vira **`null`** (falha na linha), **nunca 0** — zero mentiria "consumo zero" onde o que há
  é ausência de dado (`spanGaps:false` no gráfico).
- **ANO A ANO é o gráfico que importa** (`EnergyRepository::comparativoAnual($uc)`): eixo X = Jan…Dez,
  **uma linha por ANO** + a **média histórica** de cada mês (tracejada). A série contínua (mês após
  mês) NÃO responde "gastei mais neste julho que no julho passado?" — ela mistura sazonalidade com
  tendência. Aqui a comparação é justa (julho × julho) e a **curva típica** aparece: é ela que
  dimensiona a geração solar e mede se uma política de economia pegou. Filtro por UC (ou todas
  somadas) no seletor. Tabela mês × ano com a variação YoY do ano mais recente.
- **⚠ KV DE PAINEL PRECISA DE INVALIDAÇÃO (bug que parecia três bugs).** O `EnergyRepository::celesc()`
  servia o KV `celesc_panel` sempre que `ok`, **sem nunca invalidar**. Consequências que pareciam não
  ter relação entre si: (1) trocar a **seleção de UCs** não mudava a tela (ficava nas UCs antigas até o
  worker rodar, ~30 min); (2) os **apelidos** novos não apareciam; (3) os botões de **Pix/boleto**
  sumiam em `/energia` — porque o KV fora gravado ANTES de `pendentes` passar a levar
  `pix`/`codigo_barras`. Solução: **impressão digital** `EnergyService::celescFingerprint()` =
  `md5(PANEL_VERSION | seleção | apelidos)`, carimbada em `celescPanel()['fp']`. O repositório compara
  com a `fp` atual e **reconstrói ao vivo** se divergir; salvar seleção/apelidos ainda chama
  `invalidarCelesc()` (apaga o KV) para refletir na hora. **Ao mudar o FORMATO do payload de um
  painel, suba a `PANEL_VERSION`** — senão o KV velho é servido para sempre.
- **⚠ `EnergyRepository::readKv()` JÁ DEVOLVE ARRAY DECODIFICADO** (e injeta `idade_seg`). Fazer
  `json_decode((string) $this->readKv(...))` transforma o array na string `"Array"`, o decode falha e
  o resultado é **sempre vazio, em silêncio** — foi assim que o comparativo ignorou os apelidos. Para
  ler uma KV CRUA (não-painel), use o `SettingsRepository`, a mesma porta pela qual o `EnergyService`
  grava.
- **⚠ GERAÇÃO: duas grandezas DIFERENTES, não somar.**
  - **Gerada (real)** = o que a usina PRODUZIU, medido no inversor → vem do **SolarMan**
    (`energia_geracao`, diária; `EnergyRepository::geracaoUsinaPorMes()` soma por mês).
  - **Compensada / saldo** = o que a **CELESC ABATEU** da fatura → campos `generatedConsumption` /
    `reservedConsumption` / `reservedGeneratedConsumption` do `getAllBills` (mapeados como
    `gerado_kwh` / `reservado_kwh` / `saldo_kwh`; colunas novas em `energia_conta` —
    `migration_energia_gd.sql`).
  - **⚠ Estes campos vêm ZERADOS nas UCs sem compensação.** Validado no HAR: nas 3 UCs inspecionadas
    (inclusive uma cadastrada como geradora, `getGenerationType` = tipo "B") **todos** os campos de
    geração vêm `0.00`. Por isso o painel de energia do comparativo só aparece quando ALGUMA fonte
    tem valor, com estado vazio explicando — **nunca inventamos número**. Para descobrir qual UC
    (se alguma) compensa: **`php api/scripts/celesc_test.php geracao`** varre TODAS as UCs.
  - **NÃO chamamos `sendGenerationReport`** (o "Relatório de Geração" do portal): é **mutation**,
    devolve **PDF** (não dado estruturado) e **gera protocolo na conta** do usuário. Mesma razão pela
    qual já evitávamos o `createLogProtocol`.
- **⚠ REANÁLISE DO HAR (2026-07) — o que estava ERRADO e virou regra:**
  1. **SolarMan: o login por SENHA NÃO FUNCIONA — e não é bug.** O portal exige **CAPTCHA**
     (slider/Cloudflare) e responde **HTTP 412 `AUTH_SLIDE_ERROR` / "invalid slider"** a qualquer
     tentativa sem o token do captcha. Eu tinha descartado isso olhando `channelEnable:"0"` do
     `captcha/config` — o campo que mandava era o **`slideEnable:"1"`** do `captcha/enable?scene=LOGIN`.
     **A integração autentica por `refresh_token`** (`SOLARMAN_REFRESH_TOKEN`): o usuário loga UMA
     vez no site (resolvendo o captcha como humano), copia o refresh e cola no `.env`. Ele **não
     passa por captcha**, **NÃO rotaciona** (provado: 6 renovações no HAR devolveram o mesmo `jti`)
     e vale **~6 meses**; com ele renovamos um access de 24h indefinidamente. `SolarmanClient::
     refreshExpiresAt()` lê o `exp` do JWT e o `energy_sync` **alerta 30 dias antes** — senão a
     usina morreria calada. CLI: `solarman_token.php <refresh>` (valida e guarda) e
     `solarman_diag.php` (mostra status HTTP + corpo cru). Caminhos: login `{base}/mdc-eu/oauth2-s/
     oauth/token`; **renovação e dados vão SEM o prefixo** (`{base}/oauth2-s/oauth/token`).
  2. **CELESC: `getReadingDates` NÃO EXISTE** — foi uma operação que eu inventei; o GraphQL dava
     erro, o best-effort engolia e o card "Datas de leitura" ficava vazio para sempre. Os dados de
     leitura **já vêm no `getAllBills`** (`billingPeriod`, `totalDays`, `readType`, `positionRead`).
     `CelescClient::readingDates(array $bills)` agora DERIVA das faturas — zero chamada nova.
     **Regra: só chame operação GraphQL que exista no HAR.** Operações reais do portal:
     `allContracts`, `getAllBills`, `getInstallmentBills`, `getBillsAnalysis`, `duplicateBill`,
     `allAvailableDueDates`, `getGenerationType`, `getAllRequestTrackings`, `createLogProtocol`.
  3. **Paginação/filtro vão na QUERY, não no corpo:** `alert/search` precisa de
     `order.direction=DESC&order.property=alertTime&page=1&size=20` (sem isso a ordem é indefinida)
     e `device-list` precisa de `?deviceType=INVERTER` (senão mistura coletor com inversor).
     `energy-saved` recebe `{"systemId": "<id>"}` — não é array, não é `stationIds`.
  4. **Dados que existiam e não usávamos, agora ligados na `/energia/solar`:** impacto acumulado
     (`energy-saved` → CO₂ evitado, carvão, árvores, receita), ficha da usina numa chamada só
     (`operating/station/information` → capacidade instalada, endereço, **clima e temperatura NO
     LOCAL**, geração dia/mês/ano/total) e, por inversor, `collectionTime` = **última comunicação**
     (é o que denuncia inversor MUDO: estado "ok" mas parou de reportar) + `generationTotal`.

---

## 6. Deploy / Operação (o que se perde)

- **Dois mundos:** app web no **Windows WAMP** (Apache mpm_winnt) + o **InControl,
  câmeras e dispositivos na LAN 172.16.0.x**. O Windows alcança toda a LAN.
- **Banco = LOCAL no próprio WAMP (MariaDB 11.4).** Conexão em `127.0.0.1:3307`
  (base `controle_veiculos`), config no `.env` (`DB_HOST`/`DB_PORT`). ⚠️ **O servidor
  antigo `172.16.0.100` (MariaDB 5.5.68, CentOS) foi DESLIGADO/descontinuado** — não
  existe mais "banco remoto". **NÃO desenvolva/gere DDL para a stack antiga (5.5):**
  o alvo é sempre **MariaDB 11.4** (aceita `DATETIME DEFAULT current_timestamp()`,
  tipo `JSON`, índices utf8mb4 longos). Os `migration_*.sql`/`DEPLOY-2026-07.md` em
  estilo 5.5 são **legado** (ver "Nota histórica" abaixo) e não são o padrão. Ao
  exportar/dumpar, use o `mariadb-dump` do WAMP (ou `db_estrutura.bat`/`db_dump.php`);
  o `mysqldump` do cliente MySQL 8.x quebra no MariaDB (`--column-statistics=0` contorna).
- **Nada de cron:** jobs de segundo plano rodam pelo **Agendador de Tarefas do
  Windows**, via `.bat` em `api/scripts/`. Guia: `deploy/INSTALACAO-TAREFAS.md`.
  Jobs: monitor_bridge (contínuo), import_events, retention_cleanup, os
  `auto_*` da automação, e **camera_monitor** (checa as câmeras ALPR marcadas
  p/ monitorar via HTTP e atualiza `camera_config.mon_*`).
- **O instalador TESTA os workers (2026-07):** depois de criar as tarefas, o
  `install_workers.bat` dispara cada worker periódico UMA VEZ pelo próprio Agendador
  (conta SYSTEM — exatamente como vão rodar), espera 45 s e chama
  `api/scripts/workers_check.php`, que lê o **heartbeat** (`worker_heartbeat`) e diz
  quem rodou, quem falhou e com que erro. **Motivo:** "a tarefa existe no Agendador"
  não quer dizer que ela funciona — ela pode existir e falhar em todo ciclo (foi o que
  aconteceu com os 4 workers do parse error), e o operador só descobriria dias depois.
  O `workers_check.php` é ferramenta one-shot (não tem `.bat`, não entra no check 7).
- **`install_workers.bat` + `uninstall_workers.bat` SEMPRE completos (regra):** TODO
  worker/serviço necessário para o sistema rodar — inclusive os CONTÍNUOS
  (`monitor_bridge`, `mqtt_bridge`, `go2rtc`) — tem que estar no `install_workers.bat`
  (agendados por intervalo; contínuos como `/SC ONSTART` + iniciados na hora) e ser
  removível pelo `uninstall_workers.bat` (`schtasks /End` + `/Delete`). **Ao
  adicionar/remover um `.bat` de worker em `api/scripts/`, atualize os DOIS.** NÃO são
  workers (não entram): `install_workers`, `uninstall_workers`, `deploy_go2rtc`,
  `import_automacao` (ferramentas one-shot). O `static_check.php` (check 7) verifica
  essa cobertura automaticamente. Binários externos: **go2rtc** em
  `C:\wamp64\go2rtc\go2rtc.exe`; **Mosquitto** (MQTT) em `C:\Program Files\Mosquitto\mosquitto.exe`.
- **⚠ `.bat` TEM QUE SER CRLF (regra inviolável — já quebrou produção):** arquivo `.bat`
  gravado com quebra de linha **LF (Unix)** roda **pela metade** no Windows, **sem dar erro**.
  Comandos sequenciais até funcionam, mas o CMD localiza LABEL por **offset de byte** no
  arquivo — com LF ele perde a posição e o script **MORRE NO MEIO**. Foi exatamente isso que
  quebrou o `install_workers.bat` (que usa `call :task` + `goto :eof`): criava só as **8
  primeiras** de 18 tarefas e encerrava calado. O `uninstall_workers.bat`, que **não usa
  label**, rodava inteiro — mascarando o bug. **Cuidado:** editor/ferramenta rodando em
  Linux/macOS grava LF por padrão. Ao criar/editar `.bat`, garanta CRLF (`unix2dos`, ou
  Notepad++ → Editar → Conversão EOL → Windows). O `static_check.php` (**check 8**) agora
  falha se algum `.bat` tiver LF; o **check 9** garante que o `install_workers.bat` só chame
  `.bat` que existem.
- **Guias de instalação/configuração = formato PARA LEIGOS (obrigatório):** todo guia
  em `deploy/` que ensina a INSTALAR/CONFIGURAR algo (`INSTALACAO-*`, `INTEGRACAO-*`)
  segue o padrão do `deploy/INTEGRACAO-MQTT.md` (MODELO de referência): passo a passo
  numerado, clique-a-clique ("Menu Iniciar → digite X → …"), comandos prontos para
  **copiar e colar** em bloco de código, uma confirmação **"✅ Deu certo se…"** ao fim
  de cada passo, uma seção **"Deu problema?"** com as dúvidas comuns, e **zero jargão**
  (explique cada termo numa frase). Ao criar guia novo, já gere nesse formato; ao
  editar um existente, migre para ele.
- **Status dos workers (heartbeat):** o app **não enxerga** o Agendador do Windows,
  então cada worker registra um batimento: chama
  `App\Support\WorkerHeartbeat::begin()` no início e `ok()/fail()` no fim
  (best-effort, NUNCA lança; grava em `worker_heartbeat` — schema; base existente:
  `migration_worker_heartbeat.sql`). `App\Repositories\WorkerHealthRepository`
  (registro interno: cadência e tolerância por worker) lê e classifica em
  **ok / atrasado / falha / sem_dados**. Aparece **ao vivo na `/central`** (card
  "Serviços do sistema"; atrasado/falha entram nos "problemas") e como grupo no
  **`/diagnostics`**. `sem_dados` só vira aviso p/ worker **obrigatório**
  (`monitor_bridge`); os `auto_*`/câmera são opcionais (sem alarme se não agendados).
  O `monitor_bridge` (contínuo) bate a cada ~60s no loop e `fail` ao cair/reconectar;
  os demais batem ao rodar (early-exit "desabilitado"/"tabelas ausentes" conta como ok).
- **Watchdog dos contínuos + degradação graciosa:** `App\Services\WatchdogService` (worker
  `auto_watchdog`, ~2 min) reinicia via `schtasks /End`+`/Run` um worker CONTÍNUO que
  reporta heartbeat e está `atrasado`/`falha` (travou/caiu e o laço do `.bat` não
  relançou). Vigia `monitor_bridge` (sempre) e `mqtt_bridge` (se `mqtt.enabled`); cooldown
  10 min por worker (`configuracao.watchdog_<worker>_last`); best-effort (no-op sem `exec`).
  go2rtc/ALPR não são vigiados (binário se relança pelo `.bat`; ALPR é via Apache).
  **Degradação graciosa** (padrão do sistema): toda chamada externa é best-effort com
  timeout + fallback + kill-switch/banner — o local continua funcionando quando a
  nuvem/InControl/broker MQTT cai (nada de dependência dura).
- **Migrações:** base nova = `schema.sql`; base existente = rode a
  `migration_*.sql` correspondente. Depois, confirme que o `schema.sql` já
  reflete a mudança (regra 1.3). Runbook de release: `deploy/DEPLOY-2026-07.md`
  (script aditivo `api/database/update_20260703.sql`). Migração **com dados** do
  servidor de produção + automação legada: `api/database/schema_migracao.sql` —
  cria as 13 tabelas novas e popula `auto_*` via `INSERT...SELECT FROM asgard.*`
  (restaurar o dump completo antes; idempotente). **Vídeo:**
  `migration_video_camera_monitor.sql` adiciona `video_camera.monitor` (escolher a
  câmera que aparece no `/monitor`) — bases já instaladas precisam rodar; o código é
  resiliente (`VideoCameraRepository::hasMonitorColumn()`), o recurso fica inerte sem a coluna.
  **Chaves compartilháveis:** `migration_door_share.sql` cria `door_share` (links
  públicos que abrem uma ou mais `access_door` por token, sem login — `/k/{token}`,
  deslize p/ destravar; ability `door.share`, gestão em `/doors/shares`). **PIN
  opcional** (`door_share.pin_hash`, `password_hash`): se preenchido, a página pública
  pede o PIN antes de destravar e o servidor revalida em `/k/{token}/open`
  (`DoorShareRepository::requiresPin/verifyPin`). Bases já com a tabela: rodar
  `migration_door_share_pin.sql` (o código é resiliente via `hasPinColumn()` — fica
  inerte sem a coluna). **Histórico de usos** em `/doors/shares/{id}/uses` lê da
  auditoria (`entidade='door_share'`, `entidade_id='{id}#door{doorId}'`).
- **Banco: MariaDB 11.4** (servidor novo). Aceita `DATETIME DEFAULT
  CURRENT_TIMESTAMP`, tipo `JSON`, índices utf8mb4 longos, FKs InnoDB. As colunas
  de data automática do schema usam `timestamp ... current_timestamp()` (padrão
  consistente em toda a base; funciona no 11.4) — mantenha esse estilo ao adicionar
  colunas de data. Charset `utf8mb4` / collation `utf8mb4_unicode_ci` (cross‑compat).
  Instalação nova: `schema.sql` (30 tabelas). Guia: `deploy/INSTALACAO-STACK-MODERNO.md`.
  **Nota histórica:** o servidor antigo era MariaDB 5.5.68 (não aceitava `DATETIME
  DEFAULT`, sem `JSON`, índice utf8mb4 ≤ 191) — por isso `deploy/DEPLOY-2026-07.md`
  e os `migration_*.sql` do 5.5 usam `TIMESTAMP`; são legado.
- **`.env`:** todas as chaves usadas estão em `api/config/*.php` (DB_*, INCONTROL_*,
  AUTOMACAO_*, PUSHOVER_*, SESSION_TTL, TOKEN_*, etc.). `DB_PERSISTENT=true` p/ perf.
  **⚠ CHAVE EM BRANCO ≠ CHAVE AUSENTE (já quebrou a integração da usina):** o
  `loadEnv` guarda `CHAVE=` como `''` e o `Config::env` fazia `array_key_exists` →
  devolvia `''` e **o default nunca entrava**. Com `SOLARMAN_CLIENT_ID=` em branco, o
  login do SolarMan saía com `client_id` vazio e o servidor OAuth2 respondia
  `unauthorized` — sintoma que parecia senha errada. **Corrigido no `Config::env`**:
  valor vazio + default não-nulo ⇒ usa o **default**. Regra de uso: em `.env`, branco
  significa "não configurei"; para DESLIGAR algo escreva `false`, não deixe em branco.
  Em código, `?? 'x'` **não** protege (só pega `null`) — use `?: 'x'`. O
  `static_check.php` (**check 14**) lista chave em branco cujo config espera default
  não-vazio.
- **PHP no servidor:** a versão instalada é **8.3.14** (phpinfo). ⚠ Os worker `.bat`
  **NÃO hardcodam mais a versão**: acham o php.exe por curinga
  (`for /d %%V in ("C:\wamp64\bin\php\php*") do if exist "%%~V\php.exe" set "PHP=..."`) —
  à prova de update do WAMP. **Instalação em `C:\wamp64\www\Nexus`** (não em `www\` puro):
  os `.bat` usam `C:\wamp64\www\Nexus\api\scripts\...` e `...\Nexus\logs\...`. (Bug já
  ocorrido: os `.bat` nasceram com `php8.3.28` + `C:\wamp64\www\api\...` sem o `\Nexus\`,
  e TODO worker morria com exit 0x1. Se recriar `.bat`, use curinga p/ o PHP e o caminho
  real do projeto — melhor ainda: `%~dp0` p/ ficar portátil.) OPcache ligado.
- **⚠ A pasta `logs/` TEM que existir ANTES do 1º agendamento (bug do "0x1 que o
  diag_workers conserta"):** cada worker `.bat` faz `"%PHP%" "%SCRIPT%" >> "…\Nexus\logs\
  x.log" 2>&1`. Se `logs\` NÃO existe, o `cmd` falha ao ABRIR o arquivo do `>>` e a tarefa
  retorna **0x1 SEM NEM RODAR O PHP** — então o PHP nunca cria a pasta (o `Logger` faz
  `@mkdir` no 1º log, mas ele nem chega a rodar) e TODO worker periódico dá 0x1 no
  Agendador. Rodar o `.php` direto (o `diag_workers.bat`) executa o PHP → `Logger` cria
  `logs\` → daí os agendamentos passam a funcionar (o "conserto mágico"). **Correção
  DEFINITIVA (2026-07):** (1) cada worker `.bat` cria a pasta antes do redirect —
  `for %%D in ("%LOG%") do if not exist "%%~dpD" md "%%~dpD"` logo após o `set "LOG="`; (2) o
  `install_workers.bat` cria `%S%..\..\logs` logo no começo. Os contínuos
  (`monitor_bridge`/`mqtt_bridge`/`go2rtc`) NÃO redirecionam p/ `logs\` (por
  isso nunca deram esse 0x1). **Ao criar worker `.bat` novo com `>> logs\`, replique o
  `md` do `%LOG%`.**
- **⚠ `STDOUT`/`STDERR` podem NÃO existir (bug "Undefined constant STDOUT", 2026-07):** o
  `php.exe` de console define STDIN/STDOUT/STDERR automaticamente, mas o **`php-win.exe`**
  (mesmo SAPI `cli`, porém sem console) e qualquer execução não-console **NÃO** — e um
  `fwrite(STDOUT, ...)` num worker vira **FATAL** que derruba a tarefa (foi o que quebrou
  `auto_sun`/`auto_contas`/`auto_preventiva`/`retention_cleanup`; os frequentes re-rodaram
  num contexto bom e mascararam, os diários/6h ficaram vermelhos). **Correção DEFINITIVA:**
  o `api/bootstrap.php` (incluído por TODO worker) agora garante as constantes com
  `@fopen('php://stdout'…)` — à prova de qualquer SAPI (se o stream não existir, `@fopen`
  devolve false e a constante não é definida, sem erro; o web não é afetado). **Ao escrever
  worker, pode usar `fwrite(STDOUT,…)` normalmente** — o bootstrap cobre.
- **⚠ cURL/HTTPS precisa de CA bundle (WAMP não vem com um):** sem ele, TODA integração
  HTTPS (Winker/CELESC/SolarMan/TTLock/Omada) falha com **`SSL certificate problem: unable
  to get local issuer certificate`**. Baixe o `cacert.pem` (https://curl.se/ca/cacert.pem →
  ex.: `C:\wamp64\cacert.pem`) e aponte no `php.ini` **do CLI E do Apache**:
  `curl.cainfo="C:\wamp64\cacert.pem"` + `openssl.cafile="C:\wamp64\cacert.pem"`; reinicie o
  Apache. NÃO desligar `verify_ssl` (inseguro, ainda mais com o servidor exposto).
- **Cliente do banco (dump/backup):** `C:\wamp64\bin\mariadb\mariadb11.4.9\bin\mariadb-dump.exe`
  (e `mariadb.exe` na mesma pasta). Use SEMPRE o `mariadb-dump` do WAMP — o `mysqldump`
  do cliente MySQL 8.x quebra no MariaDB (erro `COLUMN_STATISTICS`; contorno:
  `--column-statistics=0`). Os `.bat` `db_estrutura`/`db_dump` acham a pasta por curinga
  `mariadb*`/`php*` (sobrevivem a troca de versão). Ex.: exportar só estrutura →
  `mariadb-dump --no-data --routines --triggers --events -h 127.0.0.1 -P 3307 -u guilherme -p controle_veiculos`.

---

## 7. Protocolo de verificação (garantir que "não tem erro")

No ambiente de dev **não há `php -l`**; use o verificador estático do repo. No
**servidor** (tem PHP 8.3) rode-o para checagem completa (inclui `php -l`).

Rode **após qualquer alteração** e antes de dar por concluído:

```
php api/scripts/static_check.php        # no servidor (PHP 8.3): tudo, com php -l
```

O que ele cobre (e o que revisar manualmente se não puder rodar):
1. **Sintaxe (PHP 8.3):** `php -l` em todo `.php`. Sem PHP disponível: cheque
   balanceamento de `()[]{}`. (Sintaxe moderna do 8.x é aceita.)
2. **Rotas → métodos:** todo `$do('Controller','metodo')` aponta p/ método público existente.
3. **Permissões:** toda ability de rota (`$perm('x')`) e de menu existe no `PermissionService`.
4. **Views:** todo `$this->view('a/b')` / `View::fragment('a/b')` tem arquivo em `app/Views/a/b.php`.
5. **Placeholders:** nenhum `:nome` repetido no mesmo `prepare(...)` (HY093).
6. **Tabelas:** tabelas referenciadas em SQL existem no `schema.sql`.
7. **Comentário de bloco (check 13 — JÁ DERRUBOU 4 WORKERS):** **NUNCA** escreva markdown
   de **negrito** colado numa barra dentro de um comentário PHP (`**` + `/`): essa sequência
   **é o fechamento do comentário**. O comentário morre ali, o resto da frase vira código e
   o arquivo para de compilar. Aconteceu no `AutomationRepository` (a frase "a tela
   `**`/rede`**` já mostra" num docblock) e derrubou `auto_poll`, `auto_schedule`,
   `auto_water` e `auto_sun` de uma vez com *syntax error, unexpected identifier "rede",
   expecting "function"* — e a web continuou de pé, então só o autoteste denunciou. Dentro
   de comentário, **use crase** (`` `/rede` ``), nunca negrito antes de barra. O check 13
   acha isso **sem compilar** (olha o token colado no fechamento — imune a strings de regex).
8. **Workers cobertos:** todo `api/scripts/*.bat` de worker está no `install_workers.bat`
   **e** no `uninstall_workers.bat` (exceto os one-shot `deploy_go2rtc`/`import_automacao`/
   `db_dump`/`db_estrutura` e os próprios instaladores; a lista de exceções fica em
   `static_check.php` `$skipBat`).
9. **Auto-cura do schema (check 16 — bug LATENTE encontrado em julho/2026):** toda coluna
   declarada num `CREATE TABLE` da seção 1 **tem que ter** um `ALTER TABLE ... ADD COLUMN
   IF NOT EXISTS` na seção 2. O `CREATE TABLE IF NOT EXISTS` **pula a tabela inteira** se
   ela já existe — então, numa base instalada, uma coluna nova declarada só na seção 1
   NUNCA é criada, e o sistema quebra com *Unknown column* na primeira vez que a usar.
   As 3 tabelas do ALARME (`alarme_central`/`alarme_zona`/`alarme_evento`) estavam só na
   seção 1. Não quebrou nada porque o CREATE e a `migration_alarme.sql` nasceram idênticos
   — mas a PRÓXIMA coluna do alarme morreria em silêncio. Corrigido (79/79 tabelas), e o
   check 16 agora falha se voltar a acontecer.
   **⚠ A seção 2 NÃO ALTERA COLUNA QUE JÁ EXISTE.** `ADD COLUMN IF NOT EXISTS` é no-op se
   a coluna está lá, **mesmo que o TIPO tenha mudado**. Foi o caso do `configuracao.valor`:
   no schema ele já é `mediumtext`, mas numa base antiga continua `text` (64 KB) e o painel
   de energia não cabe. **Mudança de TIPO exige migração dedicada** (com `MODIFY`, que é
   proibido no schema pela regra 1.3) — aqui, `migration_kv_mediumtext.sql`. O autoteste
   (`/diagnostics`) checa o tipo e aponta a migração.
10. **View usando alias sem `use` (check 17 — bug do "/listas dava 500", julho/2026):** um
   arquivo de VIEW é incluído por `View::partial()` e **NÃO herda os `use` de quem o inclui**
   (aliases de `use` são por-ARQUIVO). Sem `use Web\Support\Helpers as H;` no topo da própria
   view, `H::icon()` vira `\H::icon()` (classe global inexistente) e **estoura FATAL na
   renderização** — 500. A `listas/index.php` foi publicada assim e só quebrou quando o menu a
   tornou alcançável. O check 4 confere que o ARQUIVO da view existe, não que ela RODA — e não
   há `php -l` no dev. O check 17 varre `app/Views` e falha se alguma usar `H::`/`Csrf::`/
   `Flash::`/`Session::`/`View::` **pelado** (sem `\` na frente) sem o `use` correspondente.
   **Regra:** toda view que usa um helper com `::` declara o `use` no TOPO do próprio arquivo.

**Regra de ouro:** balanceamento OK + rotas↔métodos↔views↔permissões OK +
sem placeholder repetido = mudança segura de publicar. Sem isso, não conclua.

---

## 8. Checklist ao adicionar módulo / rota / tabela

- [ ] Rota em `app/routes_modules.php` (literais antes de `{id}`), com `[$auth, $csrf(se POST), $perm('ability')]`.
- [ ] Método público no controller correspondente.
- [ ] Ability nova? adicione no `PermissionService::$map`.
- [ ] View existe e usa `H::e()` + `Csrf::field()` + `H::old()` onde couber.
- [ ] Item de menu em `nav_items.php` (se for tela de topo) com ability válida.
- [ ] Tabela/coluna nova? migração **e** `schema.sql`.
- [ ] SQL sem placeholder repetido; transação onde precisa (`beginTransaction/commit/rollBack`).
- [ ] Worker novo? criou o `.bat` e adicionou no `install_workers.bat` **e** no `uninstall_workers.bat`.
- [ ] Guia de `deploy/` (instalação/config) novo ou editado? no formato PARA LEIGOS do `INTEGRACAO-MQTT.md`.
- [ ] Estilo consistente com o arquivo editado (recursos PHP 8.x são OK; não reescrever o legado sem motivo).
- [ ] Rodou o `static_check.php` (ou fez as 7 checagens à mão) e está limpo.
- [ ] Atualizou este `CLAUDE.md` se mudou alguma convenção.

### 5.39 Motor de REGRAS — "SE → ENTÃO" (formatação condicional da automação)

- **MÓDULO NOVO e SEPARADO do motor de alertas (§3).** No `alert_rule` as ações são SEMPRE
  mandar mensagem; aqui **"notificar" é apenas UMA das ações** — a regra também liga
  dispositivo, roda cena, **ABRE PORTA**, publica MQTT e chama webhook. O motor de alertas
  segue **intacto em produção** (nada foi migrado); os dois convivem.
- **4 tabelas** (schema + `migration_regra.sql`): `regra` (nome, `gatilho` + `gatilho_config`
  JSON, `modo_cond` todas|qualquer, `condicoes` JSON, `acoes` JSON, `cooldown_seg`),
  `regra_estado` (**FK CASCADE**; watermark `evt`/`evt_ic`, último valor visto p/ gatilho de
  MUDANÇA, dedup diária, flag `armado` da borda), `regra_fila` (**FK CASCADE**; ações com
  ATRASO) e `regra_log` (**FK CASCADE**; histórico do que disparou **e do que NÃO** disparou).
  Ability `rules.manage` (administracao/admin_sistema — abre porta, poder alto). Módulo
  `regras` no kill-switch + KV `configuracao.regras_enabled` (botão "Pausar motor").
- **Lógica PURA em `App\Support\RuleMatch`** (sem banco/rede, testável): `compare($v,$op,$ref,$ref2)`
  é o CORAÇÃO (ops `=` `!=` `>` `>=` `<` `<=` `entre` `contem` `verdadeiro` `falso`; numérico
  quando ambos os lados são numéricos, senão texto; `null` nunca casa), `condMatches`,
  `allMatch` (E/OU), `timeMatches`, `sunMatches`, `render` (variáveis `{pessoa}` `{ponto}`…),
  **`passageExtras`** (filtros `dispositivo`/`detalhe` do InControl, que o `AlertMatch` não tem)
  e **`incontrolAcesso($detalhe)`** (o InControl NÃO manda liberado/negado num campo — vem no
  TEXTO do `detalhe`; derivamos daí). Testes: `api/scripts/tests_regras.php` (roda sem banco;
  51 asserções, validadas por espelho em Python).
- **Gatilhos** (`RuleEngine::$gatilhos`): `passagem` (ALPR+InControl, filtros pessoa/placa/câmera/
  ponto/**dispositivo**/**detalhe**/resultado), `horario` (HH:MM + dias), `sol`
  (SUNRISE/SUNSET ± offset — **reusa a tabela `auto_sun`**, a MESMA fonte do `auto_schedule`,
  p/ o horário bater com as agendas), `dispositivo` (ligou/desligou/mudou/ficou offline/online),
  `sensor` (qualquer fonte numérica + comparador), `clima` (reusa `AlertMatch::weatherMatches`),
  `mqtt` (reusa `topicMatches`+`mqttMatches`), `presenca` (nº de clientes na rede),
  `alarme` (evento da AMT 8000 em TEMPO REAL — `RuleMatch::alarmeMatches`, filtros
  central/severidade/código Contact ID/zona/partição/qualificador; §5.42) e `manual`.
- **Ações** (`RuleActions::$tipos`): `dispositivo` (on/off/**toggle**), `cena`, `porta`
  (`TriggerService::open`), `notificar` (canais Pushover/Resend/Z-API/WebPush × destinos
  **global | pessoa | TODOS OS MORADORES | número avulso**), `alarme` (armar/desarmar/stay/
  sirene/limpar numa central+partição, via `AlarmService`), `mqtt` (**`EventBus::publishRaw`**,
  tópico LITERAL do aparelho, sem o prefixo `nexus/`) e `webhook` (GET/POST/PUT). Cada ação é
  **ISOLADA**: uma falha NÃO impede as seguintes (a porta abre mesmo se o WhatsApp cair).
  `atraso_seg` > 0 → vai para `regra_fila` em vez de rodar agora.
- **Dois caminhos de execução** (igual ao motor de alertas):
  - **TEMPO REAL:** `onPassage()` no `AlprService::flush` (ALPR) e no **`monitor_bridge`**
    (InControl — que agora repassa `dispositivo`/`detalhe`/`sentido` + o `acesso` derivado
    **SÓ para o RuleEngine**; o que o `AlertEngine` recebe **não mudou**, para não alterar
    silenciosamente o comportamento de regras de alerta antigas). `onMqtt()` no `mqtt_bridge`
    (que assina os tópicos das regras e reassina as novas a cada ciclo). `onAlarme()` no
    **`alarme_bridge`** (§5.42) para cada evento Contact ID — SEM watermark (o evento é
    efêmero, chega uma vez; o cooldown da regra evita repetição).
  - **PERIÓDICO:** worker **`auto_regras`** (~1 min; no `install/uninstall_workers.bat` e no
    `WorkerHealthRepository`) → `runPeriodic()` avalia horario/sol/dispositivo/sensor/clima/
    presenca **e executa a FILA** dos atrasos.
- **BORDA, não repetição:** sensor/clima/presença disparam ao **ENTRAR** na condição (flag
  `armado` em `regra_estado`), não a cada minuto enquanto ela durar; re-armam ao sair. Sem isso
  um nível baixo de água abriria o portão a cada minuto.
- **Pausar o motor para TUDO** (inclusive a fila de atrasos): decisão consciente — uma ação
  agendada disparar depois do "Pausar" assustaria o operador.
- **UI** `/regras` (`RulesController` + `Web\Repositories\RuleRepository`): lista com resumo
  SE/ENTÃO + "▶ Executar agora" (ignora cooldown, **as ações acontecem de verdade**) + pausar
  por regra + kill-switch global; **editor dinâmico** (`regras/form.php`) que monta 3 JSONs
  (gatilho_config / condicoes / acoes) com campos que mudam por gatilho e por tipo de ação;
  `/regras/logs` mostra **também os não-disparos** (`condicao`/`cooldown`/`erro`). Menu
  **Regras** em Automação. Guia leigo: `deploy/INTEGRACAO-REGRAS.md`.
- **Semente de regras** `api/database/regras_seed.sql` (idempotente, casa por `nome`, roda
  quantas vezes quiser): cria as tabelas se faltarem, **recria as 2 regras-base** (caixa
  d'água baixa escalonada 30/15% e dispositivo offline 10/60 min — LIGADAS) e semeia um
  **catálogo de 19 regras DESLIGADAS** (sol/horário, passagem→porta, negados, clima severo,
  rio, energia, worker, IA→OS, presença, MQTT/Frigate, manual). Os IDs de dispositivo/porta/
  cena são **adivinhados por nome** (`@DEV_LUZ`/`@DEV_BOMBA`/`@PORTA`/`@CENA`, `COALESCE(...,0)`);
  não achou → 0, e a regra (desligada) espera o operador escolher o alvo. ⚠ `SELECT ... FROM
  DUAL WHERE NOT EXISTS` — o `FROM DUAL` é **obrigatório** no MariaDB (sem ele, `SELECT` com
  `WHERE` e sem `FROM` é erro de sintaxe).

#### 5.39.1 Motor de regras v2 — PARIDADE + REMOÇÃO do motor de alertas (2026-07)

- **O motor de REGRAS é O motor. O antigo `AlertEngine` foi APOSENTADO e depois REMOVIDO
  (2026-07).** Já rodava desligado (KV `alerts_engine_enabled='0'`) havia tempo e sem uso —
  então foi apagado de vez. **O QUE SAIU:** `api/app/Services/AlertEngine.php`,
  `app/Controllers/AlertRulesController.php`, `app/Views/alerts/*`,
  `api/app/Repositories/AlertRuleRepository.php`, o worker `auto_alert` (`.php`+`.bat`, tirado do
  `install/uninstall_workers.bat` e do `WorkerHealthRepository`), o script one-shot
  `api/scripts/migrar_alertas.php`, as rotas `/alerts*`, a ability `alerts.manage`, o item de menu
  "Alertas (legado)" e o link em `/settings`. Os **hooks** do AlertEngine saíram de `AlprService`
  (`evaluatePassage`), `monitor_bridge` (`evaluatePassage`), `mqtt_bridge` (`evaluateMqtt`/
  `mqttRuleTopics`) e `auto_weather` (`evalWeather`) — o `RuleEngine` já rodava ao lado deles e
  assumiu sozinho.
- **O QUE FICOU (de propósito):** **`AlertMatch`** (lógica PURA — o `RuleEngine`/`RuleMatch` delega a
  ela passagem/MQTT/clima/escalonamento; testes `tests_alerts.php`) e **`AlertService`** (ENTREGA nos
  canais Pushover/Resend/Z-API/WebPush — o `RuleActions` usa). As 3 tabelas `alert_rule`/
  `alert_rule_state`/`alert_log` seguem no `schema.sql` como **ÓRFÃS** (não-destrutivo; marcadas
  OBSOLETO no schema; nenhum código as lê) — podem ser DROPADAS na mão numa manutenção. O
  `ConfigBackup` trocou `alert_rule` por **`regra`** no allowlist (agora o backup de config leva as
  regras novas). A migração histórica `alert_rule → regra` (`regra.origem_alert_id`) já foi feita;
  o runbook `deploy/MIGRACAO-ALERTAS-PARA-REGRAS.md` vira histórico.
- **PARIDADE (o que faltava e foi adicionado)** — `migration_regra_v2.sql` (colunas `escalonamento`,
  `modo`, `origem_alert_id`):
  - **`escalonamento`** (degraus): JSON `[{quando:N, acoes:[...]}]`. **Mais poderoso que o antigo**
    (lá o degrau só trocava canal/prioridade; aqui cada degrau tem **ações próprias** — "offline há
    30 min → abre OS urgente"). Degrau sem ação usa as ações do bloco ENTÃO.
  - **Gatilho `offline`** (dispositivo offline HÁ X min, `device_id` 0 = todos): estado **por
    dispositivo** (`dev{id}`), cada degrau dispara 1× por episódio, **re-arma ao voltar online**.
  - **Gatilho `nivel`** (valor cruzou limiares; `fonte` + `direcao` abaixo|acima): dispara o degrau
    **mais severo ainda não disparado**, re-arma ao sair de todos. O `acima` é o **espelho** do
    `abaixo` (inverte sinal e reordena) — reusa a MESMA função pura, sem duplicar regra.
  - **Gatilho `negados`** (N negados em M min + filtro de câmera; cooldown padrão 30 min).
  - **`modo` once/always** (estado `once` em `regra_estado`).
  - **Destino POR CANAL** na ação `notificar` (`destino: 'canais'` + `pushover_user`/`resend_to`/
    `zapi_phone`) → passa direto p/ `AlertService::dispatch($canais,...,$destinos)`.
  - **Varredura de segurança** das passagens no `runPeriodic` (`scanPassages`): relê `historico_passagem`
    e `monitor_evento` acima do watermark — rede de segurança se o tempo real perder um evento.
  - **Prévia (dry-run)** `previewPassagem()` + `POST /regras/preview`: conta quantas passagens dos
    últimos 7 dias casariam. SÓ LEITURA.
  - **REUSA `AlertMatch::offlineFires`/`waterTarget`/`passageMatches`/`weatherMatches`/`mqttMatches`**
    (lógica pura JÁ TESTADA). ⚠ Por isso as chaves do gatilho `clima` são as do AlertMatch
    (`rain_mm`, `rain_prob`, `wind_kmh`, `temp_max`, `temp_min`, `river_level`, `river_rise`,
    `alert_any`) — renomear quebra o casamento.
- **INCREMENTOS (além da paridade):**
  - **Ação `os`**: abre ORDEM DE SERVIÇO (origem `regra`), com **DEDUP por `origem_chave`** — sem isso
    "bomba offline" abriria uma OS por ciclo do worker. Mesmo mecanismo do `PredictiveMaintenance`.
  - **Ação `regra`**: encadeia OUTRA regra (`RuleEngine::dispararRegra`), com **anti-laço**
    (não reentra na cadeia; profundidade máx. 5).
  - **Gatilhos `worker`** (heartbeat atrasado/falha), **`energia`** (usina offline / conta a vencer /
    vencida, das KVs do `energy_sync`) e **`ia`** (descoberta nova do `ai_finding`, com watermark).
  - **UI**: duplicar regra (nasce PAUSADA), prévia no editor, editor de degraus aninhado.
- **Testes**: `api/scripts/tests_regras.php` (roda sem banco) cobre compare/E-OU/horário/sol/render +
  `passageExtras`/`incontrolAcesso`/`alarmeMatches` + os degraus (offlineFires/waterTarget). Validados
  por espelho em Python.

#### 5.39.3 Variáveis, MODELOS e prévia de mensagem (2026-07)

- **CATÁLOGO DE VARIÁVEIS = fonte única `RuleEngine::$variaveis`** (array puro, por gatilho + `_global`;
  cada item `v`/`d`/`ex` = nome/descrição/**exemplo**). Alimenta 3 coisas: os **chips clicáveis** do
  editor, a **prévia ao vivo** da mensagem e a documentação. ⚠ **As chaves TÊM que casar com o `$ctx`
  que o gatilho produz** (passageCtx/evalOffline/onAlarme…) — ao mudar o ctx de um gatilho, atualize o
  catálogo, senão o chip promete uma variável que sai vazia. Um teste mecânico confere que toda chave
  de topo do catálogo é um gatilho real e vice-versa.
- **Variáveis novas** (o pedido "Dispositivo X offline há Y horas" / "Queda de tensão no dispositivo
  Z"): `_global` ganhou **`{data_hora}`**; `offline` ganhou **`{horas}`** (derivado de `{minutos}`, com
  vírgula BR); `sensor` ganhou **`{fonte}`** e **`{nome}`** (nome AMIGÁVEL resolvido de
  `dispositivo.N`/`medidor.N` via `RuleEngine::fonteNome()`, best-effort). Todas as ações que já
  passavam por `RuleMatch::render` (notificar título/mensagem, OS título/descrição, MQTT payload,
  webhook corpo/URL) enxergam o catálogo inteiro.
- **Editor (`app/Views/regras/form.php`)**: (1) **chips de variáveis** que mudam com o gatilho — clique
  insere `{var}` no ÚLTIMO campo de texto focado (título/mensagem/descrição/payload/corpo/URL);
  (2) **prévia ao vivo** por ação `notificar` (renderiza título+mensagem com os EXEMPLOS do catálogo,
  espelho client-side do `render`); (3) **"Começar de um modelo"** (só no `/regras/new`).
- **MODELOS DE REGRA** em código: **`RulesController::templates()`** (11 modelos cobrindo sol/horário/
  água-escalonada/offline-com-OS/acesso→porta/negados/clima/presença→arma-alarme/alarme→luzes/worker/
  IA→OS). O modelo preenche o formulário INTEIRO (gatilho + condições + ações + degraus); os IDs de
  alvo (dispositivo/porta/central) ficam em **0 de propósito** — o operador escolhe e salva. **Nada é
  gravado** até "Salvar regra". Os `regras_seed.sql` (semear no banco) e esses modelos (preencher o
  form) são coisas DIFERENTES e complementares.

#### 5.39.2 Auditoria de código (2026-07) — bugs corrigidos e travas novas

Revisão geral do sistema. Achados que viraram REGRA (para não repetir):

- **⚠ WATERMARK DE REGRA NOVA (bug grave, corrigido nos DOIS motores).** A varredura de
  segurança do worker relê passagens com `WHERE id > :watermark ... LIMIT N`. Se o watermark
  de uma regra RECÉM-CRIADA fosse `0`, ela leria as **N passagens MAIS ANTIGAS do histórico**
  e **dispararia em todas** — no motor de regras, cujas ações incluem **abrir porta**, uma
  regra "acesso liberado → abre o portão" abriria o portão centenas de vezes no primeiro
  ciclo. **Correção:** quando NÃO existe linha de estado para `evt`/`evt_ic`, o watermark
  nasce com o `MAX(id)` ATUAL da tabela de eventos — a regra só reage ao que vier DAQUI PARA
  A FRENTE. Fica em `RuleEngine::getPassageState()` e em `AlertRuleRepository::getState()`.
  **Ao criar qualquer gatilho novo baseado em watermark, semeie-o com o MAX atual.**
- **Varredura de passagem roda UMA VEZ por ciclo, não uma vez por regra.** O `onPassage()`
  já percorre TODAS as regras e cuida do watermark de cada uma; chamar a varredura por regra
  era O(n²) e contava disparo em dobro. Ver `RuleEngine::scanPassagesAll()`.
- **A FILA de ações com atraso confere se a regra ainda está ATIVA** (`LEFT JOIN regra`).
  Sem isso, pausar uma regra "acende a luz e, 10 min depois, abre o portão" NÃO impediria o
  portão de abrir. Ação de regra pausada vira `cancelado` e aparece no histórico.
- **Degrau "mais severo" depende da DIREÇÃO.** Em `offline` e `nivel/acima`, o mais severo é o
  MAIOR limiar; em `nivel/abaixo` (água), é o MENOR (15% é pior que 30%). O "Executar agora"
  (`RuleEngine::runNow`) escolhe conforme o gatilho.
- **`notificar` com destino pessoa/moradores só funciona em WhatsApp e Web Push** (Pushover e
  e-mail só têm destinatário global). Os canais ignorados agora são registrados no histórico
  em vez de sumirem em silêncio.
- **`RuleRepository::hasV2()`**: sem o `migration_regra_v2.sql`, o save degrada (grava sem
  escalonamento/modo) e a tela `/regras` mostra um banner — em vez de estourar SQL cru.
- **`.env.example`** passou a existir (116 chaves, geradas dos `api/config/*.php`, sensíveis
  em branco, integrações inertes por padrão). Antes não havia modelo nenhum.
- **`static_check.php` ganhou os checks 10 e 11** (assinatura de método de rota; POST sem
  CSRF) — os dois bugs acima passaram justamente porque o verificador não olhava para isso.
- Relatório completo: `deploy/AUDITORIA-CODIGO-2026-07.md`.


### 3.2 MARCA — Nexus (renomeação de Asgard/sgci.io → Nexus, 2026-07)

- **Nome:** **Nexus** — *Casa Inteligente*. Slogan: *Sua casa, inteligente*. O projeto
  chamou-se **Asgard** e depois **sgci.io**; virou **Nexus** ao deixar de ser "cérebro do
  condomínio" para ser o **cérebro de uma única residência** (ver plano da Fase 2).
- **FONTE ÚNICA em `api/config/app.php`** (`app.brand`, `app.brand_full`, `app.brand_tag`).
  **NUNCA escreva "Nexus" na mão** numa view, e-mail ou mensagem — use os helpers:
  `H::brand()`, `H::brandFull()`, `H::brandTag()`, `H::brandMark($px)` (só o símbolo, SVG
  inline que herda a cor do tema) e `H::brandLogo($px)` (símbolo + wordmark). O `brandLogo`
  acentua o sufixo após um ponto (ex.: `.io`); marca sem ponto (`Nexus`) sai só com o
  wordmark, sem acento — degrada de boa. Trocar a marca de novo = **uma linha**.
- **Símbolo:** monograma "hub" — um nó central ligado a quatro nós (um **nexus** = elo/ponto
  de encontro; também a metáfora do cérebro da casa ligando os dispositivos). Tile `#3b6ef5`
  = a `--primary` do design system, então nunca destoa. Vetor em
  `public/assets/img/brand/nexus-mark.svg`; os PNG do PWA (192/512/maskable/apple/favicon)
  são **gerados** dele (script Pillow, supersampling 4×) — se mudar o SVG, regere.
- **⚠ RENOMEAÇÃO TOTAL (decisão 2026-07): tudo virou Nexus, marca E identificadores técnicos.**
  Como a instalação nova ainda não tinha sido feita, não havia integração em produção a
  preservar, então renomeamos **também** os identificadores internos que antes ficavam como
  `asgard`/`Asgard`:
  - **tópico MQTT** → `nexus/...` (`MQTT_BASE_TOPIC` padrão `nexus`; o literal `nexus/alarme`
    no `AlarmService` acompanha);
  - **pasta do Agendador** → `Nexus\` (`set "PREFIX=Nexus"` nos .bat; `WatchdogService::PREFIX
    = 'Nexus'` — os três TÊM que casar, senão o watchdog não acha as tarefas);
  - **pacotes** → `deploy/homebridge-nexus/` e `deploy/nexus-mcp/` (env `NEXUS_URL`/`NEXUS_API_KEY`).
  - **⚠ A ÚNICA EXCEÇÃO — banco legado `asgard`.** É o banco do sistema de automação ANTIGO,
    do qual o `import_automacao.php`/`schema_migracao.sql` só **LEEM** dados uma vez
    (`FROM asgard.*`, `--db=asgard`). Esse nome é de um banco **EXTERNO** (não é nosso);
    renomeá-lo apontaria o import para um banco que não existe. Por isso permanece `asgard`
    **apenas** em `api/scripts/import_automacao.php`, `api/database/schema_migracao.sql` e
    `api/database/migration_automacao.sql`. Em QUALQUER outro lugar, `asgard` é bug.
  Guia: `deploy/MARCA.md`.

### 3.2.1 FASE 2 — de "cérebro do condomínio" para "cérebro de um APARTAMENTO" (2026-07)

O sistema deixou de gerenciar um condomínio multi-unidade para ser o cérebro de uma
**única residência**. Plano completo: `docs/PLANO-NEXUS-APARTAMENTO.md`. **Módulos já
REMOVIDOS** (código, rotas, views, serviços, repositórios, workers, menu, abilities):

- **Alarme AMT 8000** (§5.42 vira HISTÓRICO) — inclui gatilho/ação `alarme` do motor de
  regras, workers `alarme_bridge`/`auto_alarme`, `AmtProtocol`, módulo `alarme`.
- **Vídeo-porteiro / SIP — PVIP 2216** (§5.21 vira HISTÓRICO) — `IntercomService`,
  `public/pvip.php`, card em `/settings`, módulo `sip`.
- **Água / cisterna** — `WaterService`, worker `auto_water`, `/automation/water`, sensor de
  água no HomeKit, fonte de água da IA/Assistente. A Automação (luzes/tomadas/cenas) FICA.
- **ALPR / veículos / portaria** — o maior corte: camadas **Web (`app/`) E App (`api/app/`)**
  — `AlprService`, `Vehicles/Plates/Visitors/PreRegistrations/AccessLogs/Monitor/Cerberus/
  Cameras(ALPR)/Merge/Cleanup` + os controllers/services de domínio, receptores
  (`itsapi.php`/`controle_veiculo.php`), worker `camera_monitor`, escopos de API.
- **Serviços de condomínio** — `Assemblies` (assembleias/votação), `Reservations`+`Areas`
  (reserva de áreas comuns), `Providers` (prestadores), `Parcels` (encomendas), `Tickets`
  (ocorrências morador↔síndico) + repos/views/rotas/abilities (`reservation.*`/`parcel.*`/
  `ticket.*`/`provider.*`/`assembly.*`). **Convites** (VisitConvite) e **Pânico** FICAM; o
  hub `/inicio` foi enxugado (Minha Casa/Convites/Minha Foto). As ocorrências virarão
  tarefas no "Trello" (pendente).

**O que FICA do acesso (decisão-chave):** o **InControl** (fechaduras/credenciais/pessoas),
o worker **`monitor_bridge`**, a tabela **`monitor_evento`** e `RuleEngine::onPassage(...,
'incontrol')` — é o histórico de acesso da PORTA (`/events`). Só o lado de VEÍCULO saiu.
Câmeras de **vídeo** (go2rtc, `/live`, dashboard), `camera.manage` e `history.view_all` ficam.
`visitor.*` fica (o módulo de **Convites** o reusa). O gatilho `passagem` das Regras segue,
mas só pela fonte `incontrol`.

**⚠ TABELAS ÓRFÃS (mesmo padrão do AlertEngine, §5.39.1).** O `schema.sql` continua
NÃO-DESTRUTIVO: as tabelas dos módulos removidos permanecem lá (o `static_check` check 6
exige que toda tabela citada em SQL exista no schema, e alguns leitores best-effort ainda as
CONSULTAM devolvendo vazio — `StatsRepository`/`GlobalSearchRepository`/`RuleEngine` viraram
resilientes com try/catch). Para DROPAR de fato numa base existente, rode **`api/database/
limpeza_apto.sql`** (o ÚNICO arquivo autorizado a `DROP TABLE`). Tabelas mortas: `alarme_*`,
`intercom_event`, `auto_water*`, `historico_passagem`, `pessoa_veiculo`, `veiculo_placa`,
`camera_config`, `pre_cadastro`, `assembleia*`, `area_comum`, `area_reserva`,
`prestador_agenda`, `encomenda`, `ocorrencia*`. **NÃO** dropar `monitor_evento` (InControl),
`access_door`, nem `obra_ocorrencia` (Manutenção, §5.35).

**"Trello" — parte de LISTAS FEITA (§5.53):** módulo novo `Listas` (`ListaRepository`/
`ListasController`, `/listas`, ability `list.use` = dono/morador, tabelas `lista`/
`lista_item`, `migration_lista.sql`) — compras, lembretes, "trazer de fora", livres.
Item marca/desmarca por form POST (funciona sem JS). Tile no hub `/inicio` e menu.

**QUADRO DE TAREFAS estilo Trello/Monday — o "dia a dia" (§5.55, 2026-07):** o módulo
pesado de OS/Pendências/Preventivas SAIU DO MENU (código preservado, só não navegável) e o
dia a dia virou um **quadro Kanban** em `/tarefas` (`BoardController`/`BoardRepository`,
ability **`task.manage`** = dono+morador). Ver §5.55. A seção de menu "Manutenção" foi
renomeada **"Tarefas"** (Quadro + Ativos + Documentos); OS/Pendências/Preventivas não têm
mais item de menu (as rotas `/os`,`/pendencias`,`/planos` continuam válidas).

**FASE 2 essencialmente COMPLETA.** OS/Manutenção simplificada: removidos **Equipes**,
**Ocorrências de obra**, **Materiais/Compras** e o **multi-imóvel** (o módulo Imóveis saiu;
`pat_imovel` fica só como vínculo OPCIONAL do ativo; `os_tarefa.equipe_id` vira coluna
órfã). O quadro `/os/kanban` + Pendências + Preventivas + Ativos + Documentos seguem como o
"Trello" do apto.

**Resíduos de módulos antigos removidos (2026-07):** tirados os últimos itens que ainda
apareciam (ex.: na lista de "Serviços do sistema" da Central/Diagnóstico): o worker
**`camera_monitor`** (Monitor de câmeras ALPR — só resíduo no `WorkerHealthRepository`, sem
arquivos) e a integração **ELAN/Nice** INTEIRA (worker `elan_bridge` + `ElanBridge` +
`ElanDriver` + receptor `public/elan.php` + rota `.htaccess` `^elan` + template do driver +
config `automation.elan_bridge_*` + `ELAN_BRIDGE_*` do `.env`/`.env.example` + o driver `elan`
no `DriverManager` (`get`/`isValid`/lista) + vigília no `WatchdogService`). Nada disso era usado
no apê. Ao remover um worker: tire de `WorkerHealthRepository`, `install/uninstall_workers.bat`,
`WatchdogService` (se contínuo) e apague o `.bat`/`.php` (senão o `static_check` check 7 acusa).

**Revisão geral "nada ficou para trás" (2026-07)** — 2ª varredura, nos CONSUMIDORES
compartilhados e nas telas de auto-teste (que a remoção módulo-a-módulo não alcançou).
Consertados 2 bugs LATENTES: (1) `CentralController` indexava `$out['acessos']` que virou
`null` (aviso a cada poll de `/central/data`); (2) `Assistant::contexto()` chamava
`$this->agua()`, método que saiu com a Água (fatal quando o Ollama está ligado). Resquícios
limpos: `/people` (subconsulta de contagem de veículos + `vehiclesOf`/`recentHistory`/
`vehicleCount` + guarda de exclusão + limpeza de `pre_cadastro`), intents mortos do
Assistente (passagens/ocorrências/encomendas) + `AssistantIntents` + `tests_assistant`,
coleta de PLACAS no link público `/atualizar/{token}`, `deniedToday`/`pendingPreRegistrations`
no Dashboard e no sino (`NotificationsController`), card `camera.view`→`camera.manage` + link
`/cameras` em `/settings`, tela `monitor` do Mural, flag `/monitor` da câmera de vídeo
(badge/botão/rota/método), o smoke-test de rotas do `DiagnosticsService` (9 rotas mortas +
IDs-amostra), o `Helpers::moduleIcon`, as buscas mortas de veículo/placa/câmera-ALPR do
`GlobalSearchRepository` e a view órfã `registrations/create.php`. Verificado: toda
rota→controller existe, toda view resolve, toda ability de menu existe no `PermissionService`,
zero href morto. Falta só o selo `php -l` (rodar `php api/scripts/static_check.php` no servidor).

### 3.3 MENU — navegação (reorganização 2026-07; v2 "apartamento" 2026-07)

- **Fonte única:** `app/Views/components/nav_items.php` (`['label','url','ability','icon']`;
  ícone via `Helpers::icon($name)`). Ele define `$groups` (bruto), **`$navGroups`** (já
  filtrado por `$perm` — seção vazia some) e `$groupMeta[<seção>] = ['icon','key']` (a
  `key` é a CHAVE ESTÁVEL do localStorage; **não renomeie** sem aceitar que o usuário
  perde a seção lembrada). ⚠ **NÃO quebre o shape de `$navGroups`** (nome => itens): o
  `SearchController::screensMatching` reusa este arquivo para a busca global, e o
  `bottom_nav.php` reusa o `$icon`.
- **9 seções, POR TAREFA na CASA** (o que o usuário quer FAZER), casa primeiro e o prédio
  depois: **Início** (Início/Dashboard/Central/Mural/Assistente — pontos de partida) ·
  **Casa** (o que ACIONA: Automação/Minha Casa/Regras) · **Acesso** (minhas portas e câmeras
  num lugar só: Fechadura/Abrir porta/Câmeras/Eventos/Relatórios) · **Monitoramento** (OBSERVAR,
  só-leitura: Energia/Consumo/Rede/Clima/Operação IA) · **Dia a dia** (Tarefas/Listas/Convites +
  Ativos/Documentos) · **Meu Prédio** (interface com o edifício via Winker — chaves/câmeras do
  prédio, reservas, boletos, certidões, prestação de contas) · **Pessoas** · **Configurações** ·
  **Sistema**.
  **⚠ v2 "apartamento" (2026-07) — o menu deixou de cheirar a condomínio:** "Portaria" (guarita)
  virou **Acesso** e a **Fechadura** (PADO) veio de Automação para cá; "Automação" virou **Casa**;
  "Condomínio" virou **Meu Prédio** e DESCEU (não compete com as coisas de casa); "Convites"/
  "Listas" saíram do prédio para **Dia a dia** (são pessoais); "Cadastros" virou **Pessoas**;
  "Tarefas" virou **Dia a dia**. **Configurações ENXUGOU:** os itens CORPORATIVOS do InControl
  (Departamentos/Zonas de tempo/Áreas/Pontos de acesso — conceitos de prédio/empresa) **saíram do
  menu** (rotas e telas seguem por URL / pela tela `/incontrol`; nenhuma ability/rota removida —
  só o item de menu). "Chaves" que aparecia 2× virou **"Abrir porta"** (Acesso) e **"Chaves e
  links"** (Configurações). O motor `/alerts` legado já não existe (§5.39.1) — **Regras** (em
  **Casa**) é o único. As CHAVES de `$groupMeta` mudaram nesta v2 (o usuário perde a seção
  lembrada uma vez). O `bottom_nav.php` acompanha (morador: Início/Minha Casa/Fechadura/Convites).
- **Accordion de seção ÚNICA:** abrir uma **fecha as outras** (`initNavAccordion` no
  `app.js`, aplicado a todo container `[data-nav-accordion]` — a `sidebar.php` do desktop
  e o `menu_drawer.php` do mobile usam o MESMO código; antes havia dois scripts inline
  duplicados). A seção da tela atual sempre abre; fora dela, reabre a última que o usuário
  abriu (`localStorage` **`nav:group`** — uma chave só; o mapa antigo `nav:groups` foi
  abandonado). Fechar tudo é permitido e lembrado. **Sem JS não regride:** o servidor já
  entrega o `<details>` só da seção ativa aberto.
- **⚠ O cabeçalho de seção é um RÓTULO, não um item.** Ele **não tem ícone à esquerda**
  (só os itens têm), **não ganha pílula de fundo no hover** e usa cor/tamanho próprios
  (uppercase .68rem, `--text-faint`; **`--accent-text` quando aberto** — é o único
  cabeçalho colorido, então o olho acha em que seção está). Os itens ficam num **trilho**
  (`border-left` no `.nav-group-items`). Isso corrige a confusão original: com ícone +
  pílula, o título era visualmente idêntico a uma opção e as pessoas clicavam nele
  esperando navegar. **Ao mexer no menu, não devolva ícone/pílula ao `summary`.**

### 5.40 Energia — ajustes da usina, mural sem contas, e a noite (2026-07)

- **⚠ BUG CORRIGIDO — "tudo pago" com conta em aberto.** A CELESC devolve
  `compensationDate = "0000-00-00"` (NÃO string vazia) quando a conta não foi paga. O
  `situacao()` testava `!== ''` e marcava **toda conta aberta como PAGA** — o painel
  anunciava "todas pagas" com fatura vencendo. `CelescClient::dataOuVazio()` normaliza
  data zerada para `''` NA ORIGEM, e o `situacao()` passou a confiar no campo explícito
  `compensation` ("Paga" | "Em aberto"), com a NEGAÇÃO testada primeiro (senão
  `strpos('nao paga','paga')` acha "paga" dentro da negação). Validado contra o HAR: 2
  contas reais estavam sendo dadas como pagas.
- **A NOITE não é falha.** Ao anoitecer o inversor desliga e leva o datalogger junto: a
  usina reporta OFFLINE e 0 W. O sistema disparava "Usina solar OFFLINE" toda noite —
  alarme que toca todo dia treina a pessoa a ignorar o alarme. `EnergyService::solarNight()`
  usa o nascer/pôr do sol REAIS da tabela `auto_sun` (mesma fonte das agendas e das regras
  de sol; margem de 30 min nas pontas) e cai na janela fixa `solarman.sun_from/sun_to` se o
  `auto_sun` não rodou. O flag `noite` vai no `solarman_panel` e é respeitado pelo
  `energy_sync` (não alerta), pelo painel, pelo Mural e pelo dashboard ("Em repouso").
- **Ajustes da usina** `/energia/usina` (`energy.manage`, `EnergyController::usina|saveUsina`,
  view `energia/usina`): (1) **quais INVERSORES contam** — KV `solarman_inversores` (CSV de
  SNs; vazio = todos). Um inversor TROCADO continua no portal para o histórico e poluía o
  painel; desmarcado, some do painel e para de alertar. A coluna "última comunicação"
  (`collectionTime`) é o que denuncia qual é o aposentado. (2) **quais EVENTOS alertam** — KV
  `solarman_alertas_ignorados` (CSV de `code`). "PV Isolation Protection", "DC Bus unbalance"
  são registro de rotina neste equipamento: continuam no histórico, mas não notificam nem
  deixam a usina "em atenção". `SolarmanClient::alerts()` passou a devolver o campo **`codigo`**
  (`code`) — sem ele não há como filtrar por tipo. O `solarmanPanel()` aplica os dois filtros;
  o `energy_sync` só vê o que sobrou.
- **Mural: a tela `energia` é SÓ A USINA.** As contas da CELESC saíram (valor, vencimento,
  "em aberto" são dado FINANCEIRO — e o Mural é um telão, que ainda pode ser liberado por
  link público). `MuralRepository::energy()` não devolve mais `celesc`; a tela `sistemas`
  também parou de mostrar "Contas CELESC". Contas ficam em `/energia`, atrás de login.
  A tela kiosk ganhou: potência + **% da capacidade instalada**, **curva do dia em SVG puro**
  (sem CDN — TV precisa acender sozinha), ano/total, receita, CO₂/árvores, temperatura NO
  LOCAL da usina e os inversores com **última comunicação** (denuncia inversor mudo).
- **⚠ ACESSO NEGADO NÃO É PROBLEMA.** É o portão barrando quem não tem permissão — ou seja,
  funcionando. Vinha como cartão VERMELHO no dashboard e como "ponto de atenção" na Central,
  todo dia. Alarme de rotina ensina o operador a ignorar o vermelho, e aí o alerta que importa
  passa batido. Agora é **informação** (pílula neutra `.c-info`/`.d-info`, chave `informacoes`
  no payload da Central) — visível, clicável, sem alarme.

### 5.41 Dashboard (redesenho 2026-07)

- **A ordem da tela = a ordem das perguntas de quem abre o sistema:** (1) **precisa de mim?**
  → bloco `acoes`, uma lista só, ordenada por gravidade, cada item com link (água baixa,
  dispositivo/rede offline, worker com falha, pré-cadastro, encomenda, ocorrência, prestador,
  visitante vencido, OS atrasada, conta a vencer/vencida, descoberta crítica da IA). Sem nada
  pendente, vira "Tudo sob controle". (2) **o que está acontecendo agora?** → Portaria hoje +
  últimas passagens. (3) **como está a casa?** → água, usina, medidores, dispositivos, cenas,
  clima. (4) **e a infraestrutura?** → rede e serviços.
- **Números que não pedem nada foram para o RODAPÉ** ("Pessoas ativas: 233", veículos): eram
  o que deixava a tela "espalhada", dividindo o topo com o que exigia ação.
- Cada bloco é filtrado por ABILITY e é best-effort (módulo ausente some, não quebra). O
  `DashboardController::casa()` tem cache estático — é chamado 2x no request (por `acoes()` e
  pela view); guarda o resultado INCLUSIVE quando é null (devolver `false` ali estourava a view).

### 5.42 Alarme Intelbras AMT 8000 (casa, cerca virtual, áreas)

- **DUAS conversas com a mesma central**, ambas na LAN (protocolo NÃO criptografado —
  só rede confiável): (1) **ISECNet2, porta 9009** — NÓS conectamos para ler status e
  mandar comando (armar/desarmar/sirene/bypass); (2) **Contact ID sobre TCP** — a
  CENTRAL conecta no NOSSO Receptor IP e empurra os eventos (disparo, arme, falta de
  energia) em tempo real. As centrais aqui são AUTÔNOMAS (sem empresa de monitoramento):
  o Nexus é o Receptor 1. A AMT 8000 fala com 2 receptores, então dá para conviver com
  uma central de monitoramento se um dia houver.
- **Protocolo PURO e testável** em `App\Support\AmtProtocol` (sem rede/banco, espírito do
  RuleMatch/WebPushCrypto): checksum (XOR invertido; pacote+checksum fecha em 0), framing
  ISECNet2 (`dst(2) src(2) len(2) cmd(2) payload checksum(1)`, len = payload+2), auth
  (senha em Contact ID, sw_type 0x02), `parseStatus` (partições/zonas por mapa de bits),
  Contact ID decode + tabela de eventos + severidade (crítico/alerta/info). Testes:
  `api/scripts/tests_amt.php` (roda sem central; espelho Python 25/25).
- **Cliente** `App\Alarm\Amt8000Client` (sockets puros; conecta→autentica→1 comando→bye).
  A central atende UM comando por conexão. **Serviço** `App\Services\AlarmService` (camada
  App, fonte única, best-effort, respeita módulo `alarme`): status ao vivo + persiste,
  comandos, registra evento (casa a central por MAC/conta) e dispara alerta
  (`AlertService::send` + publica `nexus/alarme`). Só severidade crítico/alerta notifica.
- **Workers:** `alarme_bridge` (CONTÍNUO, ONSTART — Receptor IP: `stream_select` p/ várias
  centrais; responde heartbeat 0xf7→0xfe, data/hora, identificação; grava evento + alerta)
  e `auto_alarme` (~2 min — lê o status das centrais e persiste, detecta offline). Ambos no
  `install/uninstall_workers.bat` e no teste de fogo (o contínuo fica de fora do teste).
  Config `api/config/alarme.php`/`.env` (`ALARME_RECEPTOR_PORT` padrão 9010 — ≠ 9009 da
  central; `ALARME_RECEPTOR_ENABLED`).
- **UI** `/alarme` (`AlarmController` + `Web\Repositories\AlarmRepository`, resiliente):
  painel com cards por central + eventos; detalhe com partições/zonas ao vivo, botões de
  armar/desarmar (gate `alarm.arm`), cadastro de central e **nomes de zona** (`alarm.manage`),
  "Ler agora". Abilities `alarm.view`/`alarm.arm`/`alarm.manage`. Menu **Alarme** em
  Monitoramento. A **Central** mostra "EM DISPARO" como problema (danger). Módulo `alarme`
  no kill-switch. 3 tabelas: `alarme_central`/`alarme_zona`/`alarme_evento` (schema +
  `migration_alarme.sql`). Guia leigo: `deploy/INTEGRACAO-AMT8000.md`.
- **No MOTOR DE REGRAS (§5.39), o alarme é FONTE e DESTINO:**
  - **GATILHO `alarme`** (tempo real): o `alarme_bridge` chama `RuleEngine::onAlarme($ev)`
    para cada evento. `RuleMatch::alarmeMatches` filtra por central/severidade/código Contact
    ID/zona/partição/qualificador (vazio = qualquer). SEM watermark (evento efêmero). Ex.:
    "disparo (cód 130) na cerca virtual → acende as luzes + WhatsApp a todos".
  - **AÇÃO `alarme`** (`RuleActions::doAlarme` → `AlarmService::comando`): armar/desarmar/
    stay/sirene/limpar numa central+partição. Ex.: "ninguém na rede (presença = 0) → arma
    tudo"; "às 23h → arma a cerca virtual". `RulesController` injeta as centrais no editor
    (`centralOptions`), e o `regras/form.php` tem os campos do gatilho e da ação.
  - Testes: `tests_regras.php` cobre `alarmeMatches` (16 asserções, espelho Python).

### 5.43 Documentação técnica dentro do sistema — `/docs` (2026-07)

- **Aba só do `admin_sistema`** (ability nova **`docs.view` = `['admin_sistema']`**; item
  **Documentação** na seção Sistema do menu + botão no `/diagnostics`, que é de onde o
  problema costuma aparecer). Catálogo + leitor dos guias de integração, manuais de
  instalação, auditorias e a referência do sistema. **Só leitura**: não há edição, upload
  nem exclusão — quem edita a doc é o deploy.
- **A FONTE DA VERDADE SÃO OS ARQUIVOS `.md` DO REPOSITÓRIO** (`deploy/*.md`, `docs/*.md`,
  `CLAUDE.md`, `README.md`, `api/README.md`) — **não** há tabela, não há migração, não se
  copia guia para o banco. Assim a doc que a tela mostra é EXATAMENTE a que veio junto com
  o código publicado: corrigiu o guia no deploy, a tela já mostra a versão nova, sem
  ninguém "atualizar o cadastro". Hoje são **32 documentos**.
- **⚠ O `/docs/{slug}` NUNCA RECEBE UM CAMINHO.** O leitor recebe um **slug** e o resolve
  numa **allowlist** (`DocsRepository::all()` = slug → caminho real, montada pela varredura
  das pastas conhecidas). Slug fora do mapa ⇒ `null` ⇒ 404. Como o caminho jamais vem do
  usuário, `../../api/.env` não tem por onde entrar — nem precisamos de `realpath` (o
  conjunto de arquivos legíveis é fechado). É a mesma disciplina do download de documentos
  do patrimônio (§5.25), aplicada na entrada em vez da saída.
- **`App\Support\Markdown`** (camada App, PURA — sem banco, sem rede, **sem Composer**, no
  espírito de `WebPushCrypto`/`AmtProtocol`/`RuleMatch`): `toHtml()`, `sumario()` (h2/h3 →
  sumário lateral), `titulo()`, `slugId()`. Cobre o que os nossos guias usam: títulos com
  âncora, blocos de código cercados, código em linha, listas (aninhadas por indentação),
  tabelas, citações, `---`, negrito/itálico/riscado e links.
  - **⚠ REGRA DE SEGURANÇA — ESCAPA TUDO ANTES DE TRANSFORMAR.** O `toHtml()` roda
    `htmlspecialchars` no arquivo INTEIRO e só depois aplica as transformações. Logo o HTML
    final só contém as tags que **nós** geramos: um `<script>` dentro de um `.md` vira texto
    visível, nunca código. Um renderizador que transforma primeiro e escapa depois (ou que
    "deixa HTML passar", como o Markdown padrão permite) seria um XSS esperando um `.md`
    hostil. Também **barramos `javascript:`** em link (só `http(s)://`, `/`, `#`, `mailto:`).
  - **Blocos de código são extraídos ANTES** (viram placeholder) e devolvidos no fim — senão
    um `# comentário` de shell dentro de ``` viraria `<h1>`, e `**algo**` viraria negrito.
  - Testes: **`api/scripts/tests_markdown.php`** (roda sem banco: 27 asserções, incluindo as
    de segurança). Validado também por espelho em Python contra **os 34 `.md` reais** do
    repositório: nenhuma tag fora da allowlist, tags balanceadas, nenhum placeholder órfão.
- **`Web\Repositories\DocsRepository`**: varre as fontes, categoriza pelo **prefixo do nome**
  (`INTEGRACAO-*` → Integrações, `INSTALACAO-*`/`DEPLOY-*`/`ATUALIZAR-*`/`MIGRACAO-*` →
  Instalação e operação, `AUDITORIA-*`/`INSPIRACAO-*` → Auditorias e decisões, `PLANO-*`/
  `PRD-*` → Planos e projetos; o resto → Referência do sistema), extrai título (1º `#`) e
  resumo (1º parágrafo), e faz **busca no CONTEÚDO** de todos os arquivos (devolve o trecho
  com a ocorrência). ⚠ A **ordem** dos grupos vem do nome da CATEGORIA (`$categorias`), não
  do prefixo (`$prefixos`) — senão o `MARCA.md` cairia no fim do grupo "Referência" só por
  ser o 10º prefixo da lista.
- **`Web\Controllers\DocsController`**: `/docs` (catálogo + busca), `/docs/{slug}` (leitor com
  sumário fixo, anterior/próximo dentro da categoria) e `/docs/{slug}/raw` (baixa o `.md`).
  **Rotas**: literal `/docs` antes de `/docs/{slug}`, e `/docs/{slug}/raw` (2 segmentos) antes
  de `/docs/{slug}`. Tudo `GET` (nada de POST ⇒ nada de CSRF).
- **Leitor**: cada bloco de código ganha um botão **Copiar** (o JS acrescenta) — os guias são
  cheios de comando para colar no servidor, e copiar à mão erra caractere.

### 5.44 Energia — auditoria de julho/2026 (a CELESC sumiu da tela; a usina alertava sozinha)

Três bugs INDEPENDENTES que se disfarçavam um de outro. Ficam aqui como REGRA, porque a
classe de erro se repete.

- **⚠ REGRA: PAINEL PERSISTIDO É RESUMO, NÃO DESPEJO DE DADOS.** O `celesc_panel` ia INTEIRO
  para `configuracao.valor`, que era **`text` = 65.535 bytes**. Cada UC leva as faturas (com
  `CELESC_MONTHS=25` são ~13 KB por UC: 25 faturas × 22 campos, incluindo um Pix copia-e-cola de
  ~300 chars). O teto era atingido com **3 UCs**; com as 15 deste titular o JSON dá **~220 KB**.
  O INSERT estourava (erro 1406 *Data too long*), **o `try/catch` engolia**, e o KV **nunca era
  gravado**: o worker `energy_sync` dizia "ok", e a `/energia` caía no caminho de reconstruir AO
  VIVO — 15 chamadas GraphQL em sequência a cada acesso — estourando o tempo do request. Sintoma
  final: **a CELESC simplesmente não aparecia**, sem erro em lugar nenhum. (Era também a causa
  real do antigo "só aparecem 4 UCs nos totalizadores": 3 cabiam, a 4ª estourava.)
  **Correção em três camadas, todas necessárias:**
  1. **`EnergyService::slimPanel()`** — o que vai para o KV é só o que a tela desenha (série de
     consumo + conta mais recente, `PANEL_MESES = 13`, Pix/boleto só em fatura ABERTA): **6× menor**
     (13,1 KB → 2,1 KB por UC; 15 UCs = 53 KB). O histórico completo já vive em `energia_conta` —
     é de lá que o `/energia/comparativo` lê, e é para ele que serve o `CELESC_MONTHS` (**não
     confundir** com `PANEL_MESES`).
  2. **`migration_kv_mediumtext.sql`** — `configuracao.valor` vira **`mediumtext`** (16 MB). O
     `schema.sql` já cria assim (base nova). A coluna guarda TODOS os painéis (`weather_panel`,
     `mural_network`, `solarman_panel`…) — o teto de 64 KB era uma bomba para todos.
  3. **`EnergyRepository::writeKv()` DEVOLVE O ERRO** (antes era `catch (\Throwable $e) {}` mudo).
     Confere inclusive se o banco **truncou** (fora do modo estrito o MariaDB não recusa: corta e
     segue, e o JSON cortado não decodifica). O motivo sobe até a tela (`cache_erro`) e o
     `energy_sync` **falha o heartbeat**. **Cache que falha tem que gritar** — cache que some em
     silêncio faz tudo "funcionar", só que devagar e errado.
  O **autoteste** (`/diagnostics` → Autoteste) agora checa o TIPO da coluna e aponta a migração.
- **⚠ REGRA: API DE ALERTA COSTUMA DEVOLVER HISTÓRICO, NÃO "ALERTAS ATIVOS".** O
  `alert/search` do SolarMan devolve os N eventos mais recentes que a usina JÁ registrou. O
  `energy_sync` pegava a lista e, para cada item de nível ≥ 2, chamava `alertOnce` com cooldown de
  12 h — ou seja, um "PV Isolation Protection" de **três dias atrás, já resolvido, era reenviado
  duas vezes por dia, para sempre**. Não era alerta novo: era o mesmo evento velho ressuscitando.
  Correção: **`EnergyService::alertaRecente()`** (`SOLARMAN_ALERT_MAX_AGE_H`, padrão 24 h) — o
  painel marca cada alerta com `recente`, e **só o recente notifica** (e só ele pinta a usina de
  vermelho na tela; o resto é histórico, e vive em `/energia/solar`). A assinatura de dedup passou
  a incluir a DATA do evento. Sem carimbo de data ⇒ tratado como ANTIGO (nunca notificar por um
  dado cuja idade não se conhece). O `alert_page_size` subiu para 100: com janela curta, um tipo
  raro nunca aparecia em `/energia/usina` e **não havia como silenciá-lo**.
- **⚠ KV de painel PRECISA de invalidação — de novo (agora a usina).** O `solarman_panel` era
  servido sempre que `ok`, sem `fp`. Aposentar um inversor ou marcar um evento como "só registro"
  não mudava NADA na tela por ~30 min, e o operador achava que o botão não pegou. Agora
  `solarFingerprint()` (inversores + ignorados + `SOLAR_PANEL_VERSION`) + `invalidarSolar()` no
  `saveUsina`. É a MESMA lição do `celescFingerprint` (§5.38.1) — repetiu porque não foi aplicada
  ao painel irmão. **Ao criar um painel-KV novo, já nasça com `fp` + invalidação.**
- **A NOITE também engana o MOTOR DE REGRAS.** O gatilho `energia/usina_offline` olhava só
  `online` — uma regra "usina offline → avisar" dispararia TODO fim de tarde. Agora respeita o flag
  `noite` do painel (mesma guarda do worker, §5.40).
- **Números que mentiam, corrigidos:** (a) *consumo do último mês* somava `bills[0]` de cada UC e
  rotulava com o período da PRIMEIRA — as UCs **não faturam todas no mesmo mês** (ciclo de leitura
  diferente), então misturava o junho de uma com o maio de outra. Agora agrupa POR PERÍODO, usa o
  mais recente e diz **quantas unidades entraram** (`ucs_no_periodo`). (b) `energia_geracao.receita`
  (linha DIÁRIA) recebia a receita do MÊS inteiro — agora fica NULL: **melhor coluna vazia do que
  dado que mente**. (c) `panel.php` reusava `$sv` (bool "solar ok") como variável do laço das
  faturas; funcionava só porque o card da usina vinha antes.
- **`api/scripts/energy_diag.php`** (novo, só-leitura) responde num comando o que a tela não
  responde: o tipo/limite da coluna do KV, se os painéis estão gravados e com que idade, tamanho do
  painel cheio × enxuto, UCs selecionadas, e **a tabela de quais eventos da usina SERIAM notificados
  agora e por quê** (nível baixo / evento antigo / silenciado / SIM).

#### 5.44.1 Ritmo da sincronização — a CELESC saiu do `energy_sync` (2026-07)

- **⚠ REGRA: O RITMO DO WORKER TEM QUE SEGUIR O RITMO DO DADO.** A CELESC emite **UMA
  fatura por mês, por unidade**. Ela vinha junto do `energy_sync`, que roda a cada 30 min
  → **48 varreduras/dia × 15 UCs ≈ 720 consultas GraphQL/dia** ao portal da distribuidora,
  para trazer um número que muda uma vez a cada 30 dias. Desperdício nosso e carga
  desnecessária no serviço deles. A **usina (SolarMan)**, essa sim, muda o tempo todo
  (potência instantânea, geração do dia) e continua no ritmo de 30 min.
  **Ao plugar uma integração nova, pergunte com que frequência o dado REALMENTE muda** —
  não copie a cadência do worker vizinho.
- **Dois workers, duas cadências** (o miolo é compartilhado em **`App\Services\EnergySync`**,
  camada App — os dois `.php` são finos):
  - **`energy_sync`** (~30 min) → SOLARMAN: painel, geração do dia, alertas da usina.
  - **`celesc_sync`** (**2×/dia**) → CELESC: painel, histórico `energia_conta`, alertas de conta.
    No Agendador são **DUAS tarefas** apontando para o MESMO `.bat` (`celesc_sync` às 08:00 e
    `celesc_sync_noite` às 20:00) — `schtasks` não faz dois horários numa tarefa só sem
    `/RI`+`/DU`, que tem comportamento de borda. Duas tarefas é explícito e à prova de bala.
    Ambas estão no `install_workers.bat` **e** no `uninstall_workers.bat`; o **teste de fogo**
    dispara só UMA (é o mesmo script — disparar as duas chamaria a CELESC 2× seguidas).
- **Duas travas, ambas necessárias:**
  - **Intervalo mínimo** (`CELESC_MIN_INTERVAL_H`, padrão 6h): mesmo disparado na mão em laço,
    ou com tarefa duplicada no Agendador, não falamos com a CELESC mais que isso.
    `celesc_sync.bat --force` ignora (uso manual).
  - **Resgate** (`CELESC_RESCUE_AFTER_H`, padrão **26h**): o `energy_sync` (30 min) chama
    `celescResgate()`, que é **no-op** enquanto os dados estiverem frescos. Se passarem de 26h
    sem sincronizar — sinal de que a tarefa diária não está rodando — ele sincroniza **UMA vez**
    e **avisa no log**. É auto-cura, **não** um segundo agendamento (por isso o valor é > 24h;
    se fosse 6h, viraria 4 sincronizações/dia e o ganho evaporaria).
  - Efeito colateral bom: **no primeiro boot** o `celesc_last_sync` não existe ⇒ o resgate roda
    na hora, sem esperar as 08:00.
- Carimbo de sucesso na KV **`celesc_last_sync`**. O `energy_diag.php` mostra a última
  sincronização e acusa quando a tarefa diária parou. Saúde: `celesc_sync` no
  `WorkerHealthRepository` com cadência 12h e tolerância 14h (o atraso normal do Agendador não
  pode virar alarme).
- **Resultado medido:** 720 → **30 chamadas GraphQL/dia** (−96%).

### 5.45 Câmeras ao vivo no dashboard (2026-07)

- **Flag por câmera**: `video_camera.dashboard` (schema + `migration_video_camera_dashboard.sql`).
  **Não confundir com `monitor`**: `monitor` é EXCLUSIVA (a única câmera do `/monitor` da
  portaria); `dashboard` admite **1 a 4**. Botão "🏠 Mostrar no dashboard" em `/video-cameras`
  (`camera.manage`, `POST /video-cameras/{id}/dashboard` → `setDashboard`).
- **⚠ PERMISSÃO — a decisão que importa. O MORADOR NÃO PODE VER CÂMERA.** O `/dashboard` é
  visto por TODOS os papéis, o morador inclusive. Câmera de portaria/área comum na tela de
  entrada do app dele seria vigilância dos vizinhos. O gate do card é **`history.view_all`** —
  o MESMO que já protege o `/live` **e o endpoint `/video-cameras/{id}/snapshot`**. Isso é
  essencial: se o card usasse uma ability e o snapshot outra, bastaria adivinhar a URL para
  o quadro estático vazar. **Ao mexer aqui, mantenha as duas abilities iguais.**
- **O limite de 4 é aplicado no SERVIDOR** (`VideoCameraRepository::toggleDashboard`), não por
  constraint: assim o operador lê *"já são 4 — tire uma antes"* em vez de um erro de banco.
  - **⚠ FURO FECHADO no `setActive()`:** as contagens só olham câmeras ATIVAS. Dava para
    inativar uma das 4, marcar uma 5ª (a contagem caíra para 3) e reativar a antiga —
    ficando com 5 marcadas e ativas. Agora, ao REATIVAR uma câmera cujo lugar já foi tomado,
    ela **sai do dashboard** (segue ativa; perde só o holofote). O `LIMIT` do
    `dashboardCameras()` é rede de segurança para lixo vindo direto do banco, **não** a regra.
- **UI (`dashboard/index.php`)**: grade 16:9 — 1 câmera ocupa a largura toda, 2/3/4 vão em duas
  colunas; no celular, sempre uma por linha. `go2rtc` via iframe; **sem go2rtc, cai para
  SNAPSHOT** (foto a cada 2s) em vez de ficar preto. Três cuidados no JS, cada um com motivo:
  (1) **`IntersectionObserver`** — o dashboard é a página mais visitada; não subimos 4 streams
  WebRTC quando o card sequer está na área visível; (2) **botão Pausar, lembrado em
  `localStorage`** (`dash:cams`) — quem está em máquina fraca precisa poder tirar o vídeo do
  caminho sem perder o dashboard; (3) **a célula sempre DIZ o que está havendo**
  (`data-msg` já vem preenchido no HTML) — retângulo preto e mudo é indistinguível de câmera
  quebrada, e sem JS o card ainda linka para `/live`.
- **Resiliente**: sem a coluna, `hasDashboardColumn()` devolve false, o botão some, a tela avisa
  qual migração rodar e o resto funciona.

### 5.46 Auditoria geral — julho/2026 (2ª rodada)

Varredura mecânica (507 arquivos PHP, 452 rotas, 86 abilities, 27 `.bat`) + revisão de
lógica. O que virou REGRA:

- **⚠ O WATCHDOG JÁ ESTEVE MORTO por PREFIX divergente — hoje unificado em `Nexus`.**
  Historicamente o `WatchdogService::PREFIX` ficou fora de sincronia com o `PREFIX=` dos
  `.bat` (um dizia `sgci.io`, o outro `Asgard`): o `schtasks /End /TN "<prefix>\monitor_bridge"`
  não achava a tarefa, o erro caía no best-effort e **nenhum worker contínuo travado era
  reiniciado**. Na renomeação para Nexus (2026-07) os TRÊS pontos foram unificados em
  **`Nexus`**: `WatchdogService::PREFIX = 'Nexus'`, `set "PREFIX=Nexus"` no
  `install_workers.bat` E no `uninstall_workers.bat`. **REGRA:** esses três TÊM que casar —
  ao mexer no prefixo do Agendador, mude os três juntos.
- **Workers do ALARME eram INVISÍVEIS.** `alarme_bridge` e `auto_alarme` batiam heartbeat
  mas **não estavam no `WorkerHealthRepository`** — logo nunca apareciam na Central nem no
  Diagnóstico. Num módulo de alarme, um worker invisível é o pior tipo de silêncio: o
  receptor morre, o disparo não chega, e ninguém sabe. Agora estão registrados, e o
  `alarme_bridge` (contínuo) entrou no **watchdog**, junto do `elan_bridge`.
  **Worker que bate heartbeat TEM que estar no registro** — senão o batimento não serve
  para nada.
- **⚠ REGRA: AJUSTE QUE O USUÁRIO SALVA NÃO PODE FALHAR EM SILÊNCIO.** Varri os
  `catch (\Throwable $e) {}` em volta de gravações (27 sites) e separei o best-effort
  legítimo (carimbos de dedup, estado do motor, logs) do que é **ordem explícita do
  usuário**. Estes últimos gravavam mudo e a tela dizia "Salvo": kill-switch do **motor de
  regras** (o pior — a tela diria "PAUSADO" com o motor ainda abrindo portão), seleção de
  **UCs da CELESC**, **apelidos**, **inversores/eventos silenciados** da usina, e **telas do
  Mural**. Todos passaram a devolver `bool`; os controllers mostram `Flash::error` e
  registram `falha` na auditoria. **Um botão de segurança que mente é pior que não ter
  botão.**
- **Cache de painel que falha TEM QUE GRITAR (de novo).** O `auto_weather` gravava
  `mural_weather` e `weather_panel` com `catch {}` mudo — a MESMA armadilha que deixou a
  CELESC invisível (§5.44). Agora falha o heartbeat e diz o tamanho + a migração a rodar.
- **`.env.example` estava com CREDENCIAIS REAIS** (senha do banco, `TOKEN_SECRET`, senha do
  InControl, tokens do Pushover, client secret do Omada). Um `.example` é template e
  acompanha qualquer cópia do projeto. Saneado. E **o projeto não tinha `.gitignore`** —
  criado, com `api/.env`, `*.har`, `Dump*.sql` (PII!) e caches de token. **Segredo que entra
  no histórico do git não sai mais: tem que ser ROTACIONADO.**
- **Paginação no cliente + markup duplicado:** `users/index.php` é uma das 7 telas legadas
  que duplicam markup (`desktop-only` = tabela, `mobile-only` = list-cards). Paginar só a
  tabela deixava o CELULAR com a lista inteira — e a barra, irmã da tabela, escondida
  dentro do `.desktop-only`. **Nas telas de markup duplicado, o `data-page-size` vai nos
  DOIS.** O paginador aceita container cujos filhos sejam `.list-card`.
- **Fallback offline da CELESC perdia a geração:** `contasSalvas()` não pedia
  `gerado_kwh`/`reservado_kwh`/`saldo_kwh` no SELECT, embora as colunas existam desde a
  `migration_energia_gd`. Com a CELESC fora do ar, o gráfico de energia injetada sumia — o
  dado estava no banco, só não era lido.

### 5.47 Ícone do dispositivo escolhido pelo operador (2026-07)

- **Coluna `auto_device.icone`** (schema + `migration_auto_device_icone.sql`; `varchar(40)`,
  NULL = automático). Guarda o **nome do arquivo** no acervo de tipos. Antes o ícone era
  deduzido só da CATEGORIA — o que acerta o genérico (luz → lâmpada) e erra o específico:
  um healthcheck de servidor, uma cerca elétrica e um portão caíam no mesmo desenho.
- **O acervo é o do Omada** (`public/assets/img/omada/tipos/`, 81 artes), mas serve para
  QUALQUER dispositivo — é só arte. `Helpers::iconChoices()` **lê o DISCO** e agrupa/rotula
  em português (`$iconGroups`); arte nova baixada pelo `omada_icons.php` aparece sozinha no
  seletor (cai em "Outros" com o nome embelezado). Nenhum dos dois lados fica órfão.
  - ⚠ O acervo é **inconsistente**: vários tipos só existem com sufixo `-dark`
    (`laptop-dark`, `mobile-dark`…). O catálogo usa o nome **LÓGICO** (sem `-dark`) e o
    `typeImgSrc()` faz o caminho de volta (tenta `{n}.png`, depois `{n}-dark.png`).
- **`Helpers::deviceThumb($tipo, $fallback, $size, $escolha = '')`** — o 4º parâmetro é a
  escolha do operador e SOBREPÕE o automático. Se a arte escolhida sumir do disco, **cai no
  automático** em vez de mostrar quadro quebrado.
- **⚠ ALLOWLIST NO SALVAR, NÃO REGEX.** O valor acaba virando o `src` de um `<img>`.
  `Helpers::iconExists()` responde a uma pergunta objetiva — *este arquivo existe no
  acervo?* — olhando o disco. Nome forjado no POST vira `''` (automático), nunca um
  caminho. Uma expressão regular deixaria passar qualquer coisa.
- **⚠ APARELHO DO OMADA NÃO TEM ÍCONE ESCOLHIDO** (driver `omada`): a arte dele vem do
  **MODELO real do produto** (`DeviceModel` + `omada_icons.php`). O formulário nem oferece o
  seletor, e o controller zera o campo mesmo se vier no POST. É a mesma regra da §3.1
  ("foto errada é pior que nenhuma foto"): o operador confere o parque PELA IMAGEM, então
  deixar alguém trocar o desenho de um EAP650 seria mentir sobre o parque.
- **Componente reutilizável** `app/Views/components/icon_picker.php` (busca ao vivo + grade
  agrupada + prévia do escolhido). **Sem JS continua utilizável**: os itens são `<label>`
  sobre `<input type="radio">` — HTML puro; o JS só acrescenta a busca e o realce.
  `auto_room.icone` e `auto_scene.icone` **já existem no banco** (o controller até as salva)
  mas nunca tiveram campo na tela — o mesmo componente serve para elas quando quisermos.
- **Resiliente**: sem a coluna, `AutomationRepository::hasIconeColumn()` devolve false, o
  `deviceSave` monta o SQL sem ela (nada de *Unknown column*), o formulário avisa qual
  migração rodar, e tudo mais funciona como antes.

### 5.48 Alertas EMBUTIDOS visíveis em /regras (2026-07)

- **O problema real:** o operador recebia notificação de **conta vencida**, **falha na
  usina** e **anomalia da caixa d'água** ("variou fora do padrão: X% — média Y%"), ia
  procurar em `/regras` para entender ou desligar, e **não achava nada**. Esses 9 alertas
  nascem DENTRO dos workers (`celesc_sync`, `energy_sync`, `auto_ai`, o alarme), onde o
  dado está — nunca passaram pelo motor. Eram **invisíveis e inegociáveis**.
  **Notificação que a pessoa não consegue nem ver de onde vem, nem calar, é a receita
  para ela silenciar TUDO** — e aí o alerta que importa se perde junto.
- **`App\Support\BuiltinAlerts`** (mesmo padrão do `Modules`: registro em código + KV
  `configuracao.alerta_<chave>` + cache TTL 30s para os workers contínuos pegarem o
  toggle). Cada item declara `nome`, `quando` (o que dispara, em português), `origem`
  (qual worker), `repete` (cooldown) e `modulo`. Card **"Alertas do sistema"** em
  `/regras`: **só leitura** (para mudar comportamento, o caminho é criar uma REGRA) com
  **liga/desliga** por alerta.
- **NÃO é uma migração para o motor de regras** — e não deve virar uma. Estes alertas
  continuam onde estão: é lá que eles têm o dado na mão. O que mudou é que ficaram
  VISÍVEIS e DESLIGÁVEIS.
- **Ponto ÚNICO de gate** nos alertas de energia: o `EnergySync::alertOnce()` passou a
  receber **duas chaves** — `$alerta` (a do catálogo, ESTÁVEL, é o que o operador
  desliga) e `$dedup` (que muda a cada ocorrência: inclui a data, ou o md5 do evento).
  **Misturar as duas era o que tornava impossível desligar um alerta**: não havia nome
  fixo para agarrar. Um gate no `alertOnce` é melhor que seis `if` espalhados — que é
  onde alguém esquece um.
- **Decisões de segurança nos DEFAULTS** (testadas, 18/18):
  - **Padrão LIGADO** para todos: quem nunca mexer não perde alerta nenhum.
  - **Alerta DESCONHECIDO devolve `true`**: se alguém criar um ponto de disparo novo e
    esquecer de registrá-lo, o comportamento seguro é continuar avisando — nunca
    silenciar por engano.
  - **Banco fora ⇒ avisa.** Um alerta que some porque o banco piscou é pior que um
    alerta a mais.
  - **PÂNICO tem `sempre => true`** — não desligável. É função de segurança acionada por
    uma pessoa em apuros; um interruptor que a desliga é um risco, não um recurso.
    Aparece na lista (a lista tem de ser honesta e completa), só não tem botão — e o
    controller recusa mesmo se alguém forjar o POST. É o mesmo princípio do `Modules`,
    onde o NÚCLEO não entra no registro.
- **Desligar silencia a NOTIFICAÇÃO, não a análise.** A IA continua descobrindo (aparece
  em `/operacao`), o alarme continua gravando o evento e alimentando os gatilhos.
- **Módulo desligado ⇒ o alerta aparece como "Módulo desligado"**, não como "Ligado":
  ele está inerte, não ativo — dizer "ligado" ali seria enganoso.
- **⚠ ROTA:** `POST /regras/alerta-toggle` (2 segmentos, com HÍFEN). `/regras/alerta/toggle`
  teria a MESMA FORMA de `/regras/{id}/toggle` e só funcionaria enquanto a literal fosse
  declarada antes — depender da ordem aqui é armadilha esperando acontecer. Mesmo padrão
  do `/os/ai-toggle`.

### 5.49 Filtro de eventos do InControl — quais NÃO registrar (2026-07)

- **O problema:** o InControl manda eventos de SISTEMA que não interessam ao condomínio —
  o clássico é `action.trigger.RemoteExitButtonOpen` (dois leitores em paralelo, um reconhece
  a abertura do outro). Sozinhos, enchem o `monitor_evento` de ruído e escondem as passagens
  reais.
- **`App\Support\EventFilter`** (lógica pura + KV cacheada, no espírito do `BuiltinAlerts`/
  `Modules`): `matches($detalhe, $padroes)` é PURA e testável (`tests_eventfilter.php`, 22
  asserções + espelho Python). Padrão sem `*` = casamento EXATO (o que os checkboxes
  produzem); com `*` = curinga glob (`action.trigger.*`). `ignored($detalhe)` lê a KV
  `configuracao.incontrol_eventos_ignorados` com **cache TTL 30s** — o `monitor_bridge` chama
  isto no LAÇO QUENTE (um evento por vez) e não pode bater no banco a cada evento.
- **⚠ É LISTA DE BLOQUEIO, NÃO DE PERMISSÃO — de propósito.** Numa allowlist, um tipo de
  evento NOVO/raro que o operador esqueceu de marcar seria descartado EM SILÊNCIO — e num
  sistema de CONTROLE DE ACESSO jogar fora uma passagem real por engano é inaceitável. Com
  bloqueio, só se perde o que foi escolhido; o desconhecido continua sendo registrado. Sem
  banco, `matches` NÃO bloqueia nada (mesma filosofia: na dúvida, registra). É a mesma regra
  do `BuiltinAlerts` (desconhecido → mantém).
- **PONTO ÚNICO de filtro: `MonitorEventRepository::upsert()`** pula o evento ignorado
  (retorna false, como no id duplicado). Como TANTO o `monitor_bridge` (tempo real) QUANTO o
  `import_events` (histórico REST via `EventArchiveService`) passam por aí, o filtro cobre os
  dois de uma vez. O `monitor_bridge` AINDA checa antes do upsert e faz `continue` — assim um
  evento de ruído não aciona nem o motor de regras nem o barramento.
- **Só vale DAQUI PARA A FRENTE** (decisão do Gui): o filtro não apaga o que já foi gravado.
  A tela deixa isso explícito.
- **Tela `/settings/eventos`** (`settings.manage`, atalho no `/settings`): DESCOBERTA —
  `MonitorEventRepository::distinctDetalhe($dias)` lista os tipos de evento que o InControl de
  fato mandou (com contagem, quantos tinham pessoa/placa e a última vez), para o operador
  MARCAR o ruído em vez de digitar `action.trigger.RemoteExitButtonOpen` na mão. Tipos com
  muitos "com pessoa/placa" ganham o selo **"parece acesso real"** (pensar duas vezes antes de
  barrar). + caixa de texto para padrões manuais/curinga. Salvar não falha em silêncio
  (`EventFilter::save` devolve bool; o controller mostra `Flash::error`). A tabela de
  descoberta é paginada no cliente (`data-page-size`) DENTRO do form — os checkboxes de outras
  páginas continuam enviados (o paginador esconde com `display:none`, não remove do DOM).
- Sem tabela/migração nova: a lista mora na KV `configuracao`.

### 5.50 eWeLink/Sonoff agora é OAuth 2.0 (2026-07)

- **Trocamos o login por e-mail+senha pelo fluxo OAuth** do console de desenvolvedor da
  eWeLink (o app pessoal já nasce **OAuth2.0/Standard Role**; a eWeLink desencoraja o login
  por senha em apps de terceiros). O `App\Automation\Cloud\EwelinkClient` foi **reescrito**:
  `buildLoginUrl` → `exchangeCode` (`POST /v2/user/oauth/token`) → `refresh`
  (`POST /v2/user/refresh`). A assinatura é `base64(HMAC-SHA256(msg, app_secret))`; no login
  a msg é `"{app_id}_{seq}"`, na troca/refresh é o **corpo JSON exato** que vai no POST.
  **⚠ VALIDADO por espelho** contra a implementação de referência (ewelink-api-next): o sign do
  login E o do corpo batem byte a byte (o corpo tem de ser `{redirectUrl,code,grantType}` nessa
  ordem, `JSON_UNESCAPED_SLASHES`). A URL de login (`c2ccdn.coolkit.cc/oauth/index.html`) é
  montada **sem url-encode e nessa ordem de parâmetros** — replica a referência à risca (a
  página valida lendo a query crua). Base da API por região:
  `https://{region}-apia.coolkit.{cn se cn/test, senão cc}`.
- **Config do canal (`auto_account.config_json`)** passou a ser `{region, app_id, app_secret}` +
  os tokens PERSISTIDOS pelo próprio cliente: `at, rt, at_exp, rt_exp, api_key, token_region`
  (o modelo em `_config_templates.php` perdeu email/password). **O refresh ROTACIONA o par
  at/rt**, então os tokens têm de sobreviver a reinício do WAMP → são gravados no banco por
  `AutomationRepository::mergeAccountConfig($id,$patch)` (+ cache em arquivo temp para o atalho
  rápido). Para o cliente saber QUAL canal atualizar, o id vai no config como **`_account_id`**,
  injetado em `AutomationService::driverFor` e no `accountDevices` do controller. **Ao criar
  driver de nuvem que precise persistir estado de runtime, use o mesmo `_account_id`.**
- **Fluxo de autorização (Web):** `GET /automation/accounts/{id}/ewelink/connect` monta a
  `redirect_uri` FIXA `https://{host}/automation/ewelink/callback` (a eWeLink exige **https** —
  emitimos https SEMPRE; em dev vale o truque https→http nos 30 s), guarda um `state` aleatório
  na sessão e redireciona ao login; `GET /automation/ewelink/callback` valida o `state`
  (hash_equals), troca o `code` e persiste. **Rotas GET sem CSRF de propósito**: o callback vem
  da eWeLink (não dá para ter token), e o `state` em sessão é a proteção (padrão OAuth). O
  cookie viaja porque a sessão é `SameSite=Lax` e o retorno é navegação GET de topo.
- **UI:** painel "Conexão eWeLink" no `account_form` (edição) com a **Redirect URL a registrar**
  (botão copiar) + status (Conectado/Não conectado/Falta configurar) + **Conectar/Reconectar**;
  e badges + botão Conectar na LISTA de canais. **⚠ Os tokens NÃO aparecem no textarea de
  credenciais** (são secretos e enormes): o `account_form` os ESCONDE do JSON exibido, e o
  `accountSave` **preserva** os tokens gravados ao salvar uma edição — MAS os descarta se
  `app_id`/`app_secret` mudarem (App novo ⇒ tokens velhos não valem ⇒ reconectar).
- **Migração:** canais eWeLink antigos (com email/senha no config) continuam válidos como
  registro, mas ficam **"Não conectado"** até o operador clicar em **Conectar** — o cliente novo
  ignora email/senha. Guia leigo reescrito: `deploy/INTEGRACAO-EWELINK.md`. **Sem migração de
  banco** (config_json é `longtext`; os tokens cabem).
- **PASSO A PASSO na tela (wizard, 2026-07):** o `account_form` mostra um **painel-guia por
  provedor** (`.wiz-panel` com `data-wiz=ewelink|tuya|hue`; JS troca conforme o `#provSel`),
  com os passos leigos + os botões da vez: eWeLink → **Conectar** (OAuth) + **Buscar
  dispositivos**; **Tuya** → criar Cloud Project, copiar Access ID/Secret, escolher região e
  **vincular a conta do app** (a Tuya NÃO tem redirect-OAuth p/ dispositivos — é credencial +
  vínculo; guia `deploy/INTEGRACAO-TUYA.md`) + **Buscar dispositivos**; Hue → IP + **Parear**
  (§5.18). "Buscar dispositivos" leva ao `accountDiscover` existente (listar → **+ Adicionar**
  1 a 1; sem importação em massa — decisão do produto).

### 5.51 Log DEDICADO de integrações (2026-07)

- **O problema:** as chamadas de nuvem (eWeLink, Tuya, Omada, SolarMan, CELESC, InControl, Hue)
  falhavam **sem deixar rastro** — o operador via "não conecta" e não tinha onde diagnosticar.
- **`App\Support\IntegrationLog`** (best-effort, NUNCA lança) grava UMA linha por chamada em
  **`logs/integracoes.log`** (irmão do `api.log`): `ok()` (notável: login/refresh/comando/
  descoberta), `fail()` (sempre — e ESPELHA no `Logger::error`, então também aparece no tail do
  `/diagnostics`) e `info()`. **Redige segredos** (token/senha/app_secret/`code`/PIN → `***`),
  **tira a query string** de URLs (uma API pode levar chave no `?...`), encurta valores longos e
  **rotaciona** para `.1` ao passar de 3 MB. **Regra:** só FALHA registra em leitura de rotina
  (poll de status) — senão o `auto_poll` (1 min) afogaria o arquivo e esconderia o erro; sucesso
  só quando é notável.
- **Instrumentação na fronteira HTTP** de cada cliente (`raw()`/`http()`): captura status HTTP +
  `ms` + errno/erro. Feito em `EwelinkClient`, `TuyaClient`, `OmadaClient`, `SolarmanClient`,
  `CelescClient`, `HueClient` e `InControlService::raw` (neste, só transporte + 5xx — o
  apiRequest re-autentica em 401/403 e logar o 4xx seria ruído). **Ao criar cliente de
  integração novo, logue a fronteira HTTP pelo `IntegrationLog`.**
- **Visor `/diagnostics/integracoes`** (`DiagnosticsController::integracoes`, `diagnostics.view`;
  botão no `/diagnostics`): lê a CAUDA do arquivo (≤256 KB), parseia, mostra mais recentes
  primeiro, filtro por integração + "só falhas" + contador de falhas em 24 h. Só leitura (os
  segredos já saem redigidos da origem). Sem tabela/migração — é arquivo.
- **⚠ TRACE DETALHADO (corpo cheio, LIGÁVEL — 2026-07):** o resumo acima encurta valores a 150
  chars; para **MAPEAR os dados** das nuvens (evoluir tipos/cômodos/estado) há o `IntegrationLog::
  trace()` — grava o **request + response INTEIROS** (JSON pretty) em `logs/integracoes-trace.log`
  (rotaciona a 10 MB). **Redige só SEGREDOS** (token/senha/app_secret/app_key/sign/code — via
  `redactDeep`), **preserva os DADOS**; cada corpo é limitado a 32 KB. **DESLIGADO por padrão** (KV
  `configuracao.integration_trace`; `traceEnabled()` com cache 30s — chamar o `trace()` na fronteira
  HTTP é barato quando off). Instrumentado em `EwelinkClient::httpJson`, `TuyaClient::raw`,
  `HueClient::http` (os 3 canais de automação). Liga/baixa/limpa no visor (`integracoesTrace{Toggle,
  Raw,Clear}`, POST/GET `/diagnostics/integracoes/trace*`). Fluxo: **Ligar → reproduzir a ação →
  Baixar → Desligar**. ⚠ Trace é PII/verboso: nunca deixar ligado em produção. Para instrumentar
  outro cliente, some um `IntegrationLog::trace($integr, $method.' '.$path, ['method','url','http',
  'ms','req'=>$body,'resp'=>$raw])` na fronteira HTTP.

### 5.54 Portal do condomínio — WINKER (engenharia reversa, 2026-07)

- **Integra o portal Winker** (que administra o condomínio do apê) por ENGENHARIA REVERSA da
  API do app oficial (`api.winker.com.br`) — a conta do MORADOR lendo os próprios dados, mesmo
  espírito de InControl/CELESC/SolarMan. Base de tudo: capturar HAR e LER (nunca inventar
  endpoint). Cliente self-contained `App\Winker\WinkerClient` (cURL, JWT em cache de arquivo);
  config `api/config/winker.php`/`.env` (`WINKER_*`, inerte sem `WINKER_ENABLED`); módulo
  `winker` no kill-switch; CLI `api/scripts/winker_test.php`. Guia + mapa completo da API:
  `deploy/INTEGRACAO-WINKER.md`.
- **⚠ QUATRO PARTICULARIDADES da API** (confirmadas por HAR; estão em `WinkerClient`):
  1. **Login devolve um ENVELOPE base64(JSON)** e o token real é o **JWT** no campo `token`
     desse envelope (`POST /v1/auth/login {username,password,key,version,device}`). ⚠ O corpo
     tem 5 campos NESSA ordem e o **`key` (chave do dispositivo) é OBRIGATÓRIO** — sem ele a
     Winker devolve **HTTP 400** (o mesmo 400 de senha errada). Configure `WINKER_KEY` no `.env`.
  2. O JWT viaja em **`Authorization: <JWT>` — SEM "Bearer"** (o do servidor de câmera, esse
     sim, usa `Bearer`). Headers obrigatórios: `device` (JSON), `app-version`,
     `X-Requested-With: br.com.winker` e **`has-category`** (o app manda em TODA req: vazio em
     256, `access_control` em 5 — os POSTs `access-report`/`occurrence-report`/`input-request/
     execute`). Espelhamos também os headers de WebView (User-Agent, `sec-ch-ua*`, `Origin/
     Referer: https://localhost`, `Sec-Fetch-*`) e **forçamos HTTP/1.1** (`CURL_HTTP_VERSION_1_1`).
  3. **⚠ HEADER DE VALOR VAZIO NO cURL É DESCARTADO (bug que custou o login inteiro; COMPROVADO).**
     No `CURLOPT_HTTPHEADER`, `'Nome: '` (com espaço) **e** `'Nome:'` (só dois-pontos) o libcurl
     **REMOVE** — o header nunca vai. Para mandar `has-category:` PRESENTE e vazio (como o app), a
     forma é **ponto-e-vírgula**: `'has-category;'`. Mandávamos `'has-category: '` → o header sumia
     → 400. Regra: **valor vazio em header de cURL = `'Nome;'`, nunca `'Nome: '`.** Diagnóstico que
     dispara o login e mostra os headers ENVIADOS + resposta crua: `api/scripts/winker_login_diag.php`.
  4. **TODO corpo de resposta é base64(JSON)** → `WinkerClient::decodeBody()` faz base64→JSON
     antes de JSON puro. Reautentica UMA vez em 401/403. Auth é só o JWT do envelope (sem cookie).
- **MULTI-PORTAL** (como o multi-UC da CELESC): o titular tem 3 portais — **Unique Place**
  (13949, acesso: chaves/câmeras/booking/gatekeeper) e **Unique Place Residence** (14152,
  financeiro: boletos/certidões) são o MESMO apê partido em dois; **9675** é outra propriedade.
  Cada rota leva `?id_portal=`. `WINKER_ID_PORTAL` fixa o portal de ACESSO (chaves/câmeras);
  **boletos/documentos/certidões/contas AGREGAM vários portais** (`WinkerRepository::
  billingOverview()`/`documentsAll()`/`certificateGroups()`/`ledger()` iteram `portals()`).
  ⚠ **`WINKER_ID_PORTAIS`** (CSV, allowlist da casa — ex.: `13949,14152`) restringe esse
  `portals()`: sem ele, as telas financeiras puxariam TAMBÉM os OUTROS condomínios a que o
  titular tem acesso (o 9675). Vazio = todos (padrão antigo). Filtro em
  `WinkerRepository::parsePortais()`.
- **Camada Web:** `Web\Repositories\WinkerRepository` (gate `enabled()` = módulo E `winker.enabled`
  E `configured()`; tudo best-effort → vazio em erro) + `Web\Controllers\WinkerController`.
  Abilities: `winker.view`/`winker.door` (dono+morador), `winker.billing` (dono — financeiro),
  `winker.docs` (dono+morador). Menu: seção **Condomínio**.
- **Features (rotas `/winker/*`):** **chaves** (`/chaves` lista + `/chaves/abrir` POST abre a
  porta), **câmeras** (`/cameras` + `/cameras/{id}/snapshot`), **boletos** (`/boletos` +
  `/boletos/{id}/pdf`), **documentos** (`/documentos` + `/documentos/{id}/download`),
  **reservas** (`/reservas` — lista + **reservar** `/reservas/book` + **cancelar**
  `/reservas/{id}/cancelar`), **acessos+visitantes** (`/historico` — lista + **cadastrar/
  convidar** `/visitantes` + **excluir** `/visitantes/{id}/excluir`), **mural** (`/mural`,
  recados `/v1/message`), **certidões** (`/certidoes` — lista + **emitir** `POST /v1/certificate`,
  win. `winker.docs`), **prestação de contas** (`/contas` — receita/despesa/saldo mensal do
  portal financeiro via `general_ledger_transaction/realize_by_month`, gráfico Chart.js, `winker.billing`).
- **ESCRITA (confirmada por 2º HAR — `winker2.har`), ability `winker.visit` (dono+morador):**
  - **Visitante:** `POST /v1/gatekeeper` (cadastra; corpo fiel ao app em
    `WinkerRepository::visitorBody`, com `id_unit` resolvido via `get-units`), `POST
    /v1/gatekeeper/list` (**gera o texto+link de convite do WhatsApp** — `{text}` na resposta,
    mostrado na tela com copiar + `wa.me`), `DELETE /v1/gatekeeper/{id}`.
  - **Reserva:** `POST /v1/booking/book` (a taxa vem de `getBookingReservationFee`, a unidade
    de `get-units`; datas no formato `{AAAA-MM-DD}T03:00:00.000Z` + `start/end_date`),
    `DELETE /v1/booking/{id}/cancel`.
  - **⚠ Datas -03:** o app manda a data local 00:00 como `…T03:00:00.000Z` (Brasília). O
    `visitorBody`/`createBooking` reproduzem isso; se mudar de fuso, revisar.
- **⚠ DECISÕES DE SEGURANÇA (importantes):**
  - **Abrir porta REVALIDA no servidor:** `openDoorByIds()` refaz a lista de portas do morador e
    só aciona se `(id_server,id_device,id_zone)` casar — **nunca** confia num objeto vindo do
    navegador para acionar hardware. Com confirmação + auditoria (`winker_porta_aberta`).
  - **Snapshot é PROXY:** o token da Winker fica no SERVIDOR (`WinkerController::snapshot` →
    `WinkerClient::snapshot`), o navegador nunca o vê — mais seguro que o próprio app.
  - **⚠ PROXY PARALELO EXIGE `Session::close()` (regra reusável; câmeras carregavam 1 a cada
    16s).** A grade pede os 16 snapshots ao mesmo tempo, mas o PHP mantém o arquivo de sessão
    TRAVADO durante a requisição → pedidos do MESMO usuário SERIALIZAM (1 por vez). O
    `WinkerController::snapshot`/`boletoPdf`/`documentoDownload`/`certidaoBaixar` chamam
    **`Session::close()` (novo em `Web\Support\Session`) logo após o `authorize`** — libera o lock,
    o navegador dispara 6 em paralelo. Depois disso a sessão é só-leitura. Vale para QUALQUER
    proxy lento/repetido (grade de imagens, download).
  - **STREAM SUAVE (quase-vídeo) por double-buffer, sem endpoint de vídeo (2026-07).** O servidor
    de câmera só serve JPEG por índice (`image-640x480.jpg`) — o próprio app Winker faz polling de
    snapshot (não há MJPEG no HAR). A view `winker/cameras` deixou de recarregar o `<img>` a cada 3s
    (piscava) e passou a **pré-carregar o próximo quadro num `new Image()` e só trocar o `src` quando
    ele chega** — troca instantânea, sem flicker — reencadeando no `onload` até um piso de fps
    (`FLOOR_GRID` 200ms na grade). `IntersectionObserver` só transmite câmeras visíveis. **Clicar
    amplia UMA câmera** num modal com stream rápido (`FLOOR_FAST` 70ms) e **pausa a grade** (toda a
    banda vai para a câmera ampliada). Continua tudo pelo PROXY (token no servidor) + `Session::close`.
  - **Boletos/documentos** baixam por PROXY também (`fetchFile` → serve atrás do login do Nexus,
    `nosniff`, `inline` p/ pdf/imagem). O Nexus NÃO paga nada (só mostra linha digitável + 2ª via).
  - **⚠ IDOR nas câmeras (achado, não é recurso):** o servidor de stream (`189.90.58.186:18085`)
    serve `/api/servers/{GUID}/cameras/{N}/image-640x480.jpg` por ÍNDICE sequencial, autorizado
    por um token COMPARTILHADO `{"userName":"admin"}` — não checa por-câmera, então dá para
    enumerar 0–15. Como são as câmeras de ÁREA COMUM do próprio condomínio (o morador pode ver),
    integramos as 16 via `WINKER_CAMERA_IDS` (padrão "0-15"), derivando GUID+token+nomes de
    `/v1/camera`. **Mas é uma falha da Winker** (token compartilhado num IP público) — vale
    divulgação responsável ao condomínio/Winker.
- **Reservas — DIAS OCUPADOS:** `WinkerClient::busyDays($idResource,$ym)` →
  `GET /v1/booking/{id}/busyDays?id_portal=&date=AAAA-MM&visualization_type=month&from_calendar=1&with_billing=1`
  (params confirmados no HAR; devolve lista de "AAAA-MM-DD HH:MM"). A tela `/winker/reservas`
  tem um **calendário por recurso** (JS) que busca via `GET /winker/reservas/ocupados` (JSON) e
  pinta os dias ocupados (vermelho) + clica num livre para preencher a data.
- **Certidão — VER/IMPRIMIR:** a certidão emitida (`GET /v1/certificate`) traz `id_certificate`,
  `type` (CND), `emission_date`, `token` (código de verificação), `unit` e **`data`** (JSON
  condominio/divisao/unidade). O app monta o documento CLIENT-SIDE a partir do `data` (o `token`
  NÃO vira URL e não há request de PDF no HAR) — então fazemos o MESMO: `/winker/certidoes/{id}/baixar`
  renderiza um fragmento imprimível (`winker/certidao_print`, print→PDF) com os dados + token. Há
  `id_file` na certidão, mas o endpoint do PDF oficial não foi capturado; se um dia quiserem o PDF
  exato da Winker, capturar um HAR abrindo a certidão.
- **O que resta (recurso de síndico / sem HAR):** o `access-control/occurrence-report` dá
  **500 "Você não possui acesso"** (recurso de síndico — o nosso `access-report`, que
  funciona, é outro). Detalhe do recado do mural, o **PDF oficial** da certidão (temos o
  imprimível a partir do `data`) e as transações item-a-item da prestação de contas
  (`list_transactions`, com anexos em `app.winker.com.br/viewer`) ficam para um HAR que os
  capture. A VISÃO de mural/certidões/contas já está integrada. O **histórico de acessos** usa
  `POST /v1/access-control/access-report` (200), não o occurrence-report.
- **Estilo:** o WinkerClient é o ÚNICO ponto que fala com a API (métodos por feature); ao
  adicionar feature nova, some método no cliente + método resiliente no repo + rota/ability/menu.
  Instrumentar a fronteira HTTP pelo `IntegrationLog` (§5.51) — já feito no `raw()`/`login()`.

### 5.55 Quadro de TAREFAS (Kanban Trello/Monday) — o dia a dia (2026-07)
- **Substitui, no MENU, o módulo pesado de OS/Pendências/Preventivas** (que continua no
  código — rotas `/os`,`/pendencias`,`/planos` válidas — só saiu da navegação; ver §3.3). O
  dia a dia (casa, manutenções leves, compromissos, compras) agora é um **quadro Kanban**
  simples e bonito em **`/tarefas`**. Decisão do Gui: colunas configuráveis (Trello), cartão
  com título+descrição+**etiquetas coloridas+prazo+checklist+responsável**, poucos campos.
- **4 tabelas** (schema + `migration_board.sql`): `board_coluna` (listas do quadro, ordem),
  `board_etiqueta` (marcadores coloridos configuráveis; `cor` de uma paleta fixa —
  `BoardRepository::cores()`), `board_cartao` (título, descrição, **`etiquetas` = CSV de
  `board_etiqueta.id`**, `prazo` date, **`responsavel_id` SOFT→pessoa**, `ordem`, `arquivado`;
  **FK CASCADE → board_coluna**) e `board_checklist` (sub-tarefas; **FK CASCADE → board_cartao**).
  **Seeds só quando o quadro está VAZIO** (`WHERE NOT EXISTS` — não ressuscita o que o usuário
  apagar): colunas *A fazer/Fazendo/Feito* + etiquetas *Casa/Compras/Manutenção/Compromisso/Urgente*.
- **Ability `task.manage` = `['dono','morador']`** (a família usa; convidado não). Menu: a seção
  "Manutenção" virou **"Tarefas"** (Quadro + Ativos + Documentos). Tile "Tarefas" no hub
  `/inicio` com badge = nº de cartões com prazo vencido/hoje (`countVencendo()`).
- **`Web\Repositories\BoardRepository`** (resiliente: sem as tabelas, `tableExists()` false e a
  tela avisa a migração; HY093-safe): `board()` (colunas com cartões + progresso do checklist +
  etiquetas resolvidas + nome do responsável via LEFT JOIN pessoa), `card()` (detalhe p/ o
  modal), CRUD de coluna/cartão/checklist/etiqueta, `moveCartao($id,$col,$ordemIds)` (transação:
  muda a coluna + reindexa a ordem da coluna destino) e `reorderColunas($ids)`.
- **`Web\Controllers\BoardController`** — a tela é um quadro AO VIVO; as mutações são **AJAX →
  JSON** (criar/mover/editar/excluir/arquivar cartão, colunas, checklist, etiquetas), todas
  `[$auth, $csrf, task.manage]`. **ROTAS**: literais e rotas de 2+ segmentos ANTES das de `{id}`
  (`/tarefas/cartao/{id}/mover|excluir|arquivar|check` **antes** de `/tarefas/cartao/{id}`;
  `/tarefas/coluna/{id}/excluir` antes de `/tarefas/coluna/{id}`; `/tarefas/colunas/ordem` é
  literal, ≠ `coluna`).
- **View `board/index.php`** — Kanban com **drag-drop nativo** (HTML5, sem lib): cartões entre/
  dentro de colunas (calcula a nova ordem pela posição do mouse e faz `mover` otimista, sem
  reload) e **colunas reordenáveis** pelo cabeçalho. Quick-add de cartão (digita título+Enter),
  **modal de edição** (título/descrição/etiquetas-toggle/prazo/responsável/checklist + arquivar/
  excluir), criar etiqueta na hora. **Padrão de atualização:** só o drag-drop é otimista; o resto
  faz `location.reload()` após o POST (simples e correto p/ um quadro doméstico). CSRF via
  `data-csrf` no `#tbBoard` (token de `Csrf::token()`), enviado em `FormData._csrf` — mesmo
  padrão do `os/kanban`. Design com tokens (§3.1), dark-compatible; cor de etiqueta é decorativa
  (hex ok). JS validado por `node --check`.
- **ANEXOS + COMENTÁRIOS + ARQUIVADOS (2026-07):** 2 tabelas a mais (`board_anexo`,
  `board_comentario`; FK CASCADE → `board_cartao`; mesma `migration_board.sql`/schema).
  **Anexos EM DISCO** fora do docroot (`<root>/storage/board/AAAA/MM/`, `BOARD_STORAGE_PATH`/
  `BOARD_MAX_UPLOAD_MB` no `.env`, padrão 15 MB), servidos só por `/tarefas/anexo/{id}/download`
  com **allowlist de extensão** (SVG/HTML/JS/PHP fora), nome aleatório, **guarda de path
  traversal** por `realpath`+`strncmp` e `nosniff` — ESPELHO exato do GED (§5.25). O modal mostra
  thumb p/ imagem e link p/ o resto; upload/exclusão por AJAX. **Comentários**: thread com autor
  (`Context::name()`) + data; `Session::close()` no download (proxy). O card face ganhou 📎/💬 com
  contagem. **Arquivar** manda o cartão para `/tarefas/arquivados` (`arquivado=1`, some do quadro);
  lá dá para **Restaurar** (`unarchive`) ou **Excluir de vez**. ROTAS: `/tarefas/cartao/{id}/anexo`
  `|comentario|restaurar` são 2 segmentos e ficam ANTES de `/tarefas/cartao/{id}` (o save "pelado");
  `/tarefas/anexo/{id}/download|excluir` e `/tarefas/comentario/{id}/excluir` são standalone.
- **FILTRO + COR POR COLUNA (2026-07):** barra de filtro **client-side** (texto no título +
  etiqueta + responsável); esconde/mostra cartões (`data-titulo/data-etq/data-resp` no card face)
  e a contagem da coluna passa a contar só os VISÍVEIS — sem reload nem round-trip. **Cor por
  coluna**: `board_coluna.cor` (nullable; `hasColunaCor()` no repo torna a leitura resiliente a
  base sem a coluna); acento `border-top` colorido + botão-bolinha no cabeçalho que abre um
  **popover de paleta** (`POST /tarefas/coluna/{id}/cor`, vazio = sem cor) e atualiza a coluna ao
  vivo. Cores = `BoardRepository::cores()` (paleta fixa; hex decorativo resolvido no JS/PHP).
- **CONCLUIR RÁPIDO + tarefas RECORRENTES (2026-07):** cada card do quadro (e do tablet, §5.59) tem
  um **botão ✓** que CONCLUI a tarefa — move para a ÚLTIMA coluna (feito) — sem abrir o modal
  (`POST /tarefas/cartao/{id}/concluir` → `BoardController::cartaoConcluir`; no tablet
  `POST /tablet/tarefa-concluir`). **Fonte ÚNICA: `BoardRepository::concluir($id)`** (move p/ feito
  e, se recorrente, CLONA a próxima ocorrência na 1ª coluna com o novo prazo + checklist zerado).
  **Recorrência** em 3 colunas NOVAS de `board_cartao` (`recorr_tipo`/`recorr_num`/`recorr_unid`;
  schema seções 1 **e** 2 + `migration_board_recorrencia.sql`; `hasRecorr()` torna tudo resiliente a
  base sem a migração — a coluna não aparece e nada quebra): **`fixo`** = a cada N a partir do PRAZO
  (cadência de calendário); **`apos`** = a cada N após a CONCLUSÃO (`proximaData()`: base = hoje);
  unidade `dias`/`semanas`/`meses` (via `DateTime::modify('+N …')`). O `updateCartao` grava a
  recorrência (validada: tipo∈{fixo,apos}, num 1..999, unidade válida — senão zera); o modal de
  `/tarefas` tem os campos "Repetir" (tipo + número + unidade) e o card mostra **🔁**. ⚠ O botão ✓ é
  **decorativo verde**, com `stopPropagation` para não abrir o modal; no tablet é gated por
  `PODE_TAREFA` (o endpoint sempre confere `task.manage`). Diferença da **preventiva** (§5.28, que é
  do módulo OS por CALENDÁRIO e GERA OS): a recorrência do quadro é a MESMA tarefa se repetindo no
  Kanban doméstico, sem tocar em OS.

### 5.56 Fechadura TTLock / PADO (API aberta oficial + webhook)
- **PADO = whitelabel do TTLock**; a fechadura fala com a **API ABERTA oficial**
  (`euapi.ttlock.com`, doc em `euopen.ttlock.com/doc`) — diferente de Winker/CELESC, aqui
  **NÃO é engenharia reversa** (API documentada). SÓ LEITURA + comandos que o próprio app faz
  (destrancar, senha). Cliente self-contained `App\Ttlock\TtlockClient` (token em cache de
  arquivo; **auth OAuth2 Resource Owner Password**: `POST /oauth2/token` com `clientId`+
  `clientSecret` da conta DEV + `username`+`password` do app, **senha em MD5 hex minúsculo**;
  toda chamada leva `clientId`+`accessToken`+`date` em ms; a API devolve **HTTP 200 mesmo em
  erro** → conferir `errcode`, 0=ok; **unlock remoto exige GATEWAY ONLINE**). Config
  `api/config/ttlock.php`/`.env` (`TTLOCK_*`, inerte sem `TTLOCK_ENABLED`+credenciais); módulo
  `ttlock` no kill-switch; CLI `api/scripts/ttlock_test.php` (autentica + lista fechaduras —
  **valida o whitelabel**: se a conta PADO estiver num cloud separado, tentar `api.ttlock.com`).
  Guia: `deploy/INTEGRACAO-TTLOCK.md`.
- **⚠ O `callback_url` do console (open.ttlock.com/manager) é um WEBHOOK**, não redirect OAuth
  (o password grant não usa callback). A nuvem TTLock faz **POST form-urlencoded** a cada
  operação: `lockId`, `notifyType`, `admin`, `lockMac` e **`records`** (STRING com um ARRAY JSON
  de registros: `lockId`, `lockMac`, `electricQuantity`, `serverDate`(ms), `lockDate`(ms),
  `recordType`, `username`, `success`). Exige **HTTPS público com cert VÁLIDO + resposta 200**
  (não-200 = re-tenta). Códigos confirmados: 1=app, 4=senha, 11=tranca app, 30/31=sensor porta,
  45=auto-lock (o resto cai no fallback genérico — não inventar).
- **Receptor** `public/ttlock.php` (rota `.htaccess` `^ttlock(/.*)?$`, SEM login, como o
  `homebridge.php`/`itsapi.php`): parser PURO `App\Ttlock\TtlockWebhook::parse()` (testado por
  `api/scripts/tests_ttlock.php`, roda sem banco) → grava em `ttlock_evento`
  (`App\Repositories\TtlockEventRepository`; schema + `migration_ttlock.sql`) e publica
  `nexus/ttlock/evento` (best-effort). **SEMPRE responde 200**, gate pelo módulo (desligado =
  aceita e ignora). URL a cadastrar no console: **`https://SEU-DOMINIO/ttlock`**.
- **Tela `/fechadura` (FEITO):** `LockController` + `Web\Repositories\LockRepository` (Web,
  best-effort — a fechadura é a fonte da verdade do hardware, então lê AO VIVO do `TtlockClient`
  e cruza com o `ttlock_evento` do webhook). Abilities novas **`lock.view`** (dono+morador — ver
  estado/registros), **`lock.unlock`** (dono+morador — destrancar/trancar) e **`lock.manage`**
  (dono — senhas). Índice = cartões com **estado ao vivo** (0=trancada/1=destrancada/2=?),
  **bateria** (barra), **gateway online?** (destrancar remoto exige) + botões Destrancar/Trancar.
  Detalhe `/fechadura/{id}` = status + ações + **registros** (nuvem, `records()`, rótulo via
  `TtlockWebhook::tipoLabel`) + **senhas** (listar/criar permanente|uso único|período/excluir).
  Comandos: CSRF + `data-confirm` + auditoria (`destrancar`/`trancar`/`senha_criar`/`senha_excluir`,
  entidade `ttlock`). Poll `GET /fechadura/{id}/estado` (**leve** — só `openState`+gateway, a cada
  30 s; NÃO relê bateria/detalhe pra não acordar a fechadura toda hora). Menu: **Fechadura** em
  Automação. ROTAS: literais/+segmentos (`/estado`,`/abrir`,`/trancar`,`/senha`,`/senha/{pid}/excluir`)
  ANTES de `/fechadura/{id}`. Validado no servidor: PADO autentica na API aberta, 1 fechadura +
  gateway ONLINE (destrancar remoto funciona).
- **⚠ DOIS bugs corrigidos (2026-07):** (1) **gateway "offline" FALSO** — `gatewayOnline()` usava
  `/v3/gateway/listByLock`, que **NÃO devolve `isOnline`**; trocado por `/v3/gateway/list`
  (`gateways()`, traz `isOnline` — confirmado no `ttlock_test`). Num apê com 1 gateway, "algum
  online" = "o meu online". (2) **tipo de senha invertido** — o mapa OFICIAL do TTLock
  (`euopen.ttlock.com/doc`) é **keyboardPwdType 1=One-time, 2=Permanent, 3=Period** (4=delete,
  5-14=cíclicas); eu tinha 1=permanente/2=único, então uma senha permanente da PADO aparecia como
  "uso único". `passcodeTypeLabel` e o form corrigidos (default Permanente=2). ⚠ **A busca web deu
  o mapa TROCADO** — só o doc oficial resolveu; NÃO confiar em resumo de busca para isto. Senha por
  **período** (3) usa faixa em **HORA cheia** (o controller faz `floorHour`; a TTLock ignora
  minuto) e toda senha nova precisa ser usada **1× em 24h** ou a própria TTLock a invalida (aviso
  na tela).

- **Cartões, digitais e senhas CÍCLICAS (2026-07):** confirmados por doc oficial —
  `/v3/identityCard/list`+`/delete`, `/v3/fingerprint/list`+`/delete` (`TtlockClient::cards/
  deleteCard/fingerprints/deleteFingerprint`). **⚠ ADICIONAR cartão/digital é FÍSICO** (enrolamento
  BLE na fechadura pelo app da PADO) — não há caminho remoto puro; a tela só **lista + exclui**
  (`deleteType=2` = via gateway, exige gateway online). A `/fechadura/{id}` ganhou as seções
  **Cartões** e **Digitais** (gate `lock.manage`, `/fechadura/{id}/cartao/{cid}/excluir` e
  `/digital/{fid}/excluir`). **Senha CÍCLICA**: `keyboardPwdType` 5-14 (5=fim de semana, 6=todo dia,
  7=dias úteis, 8-14=Seg…Dom — `LockRepository::cyclicTypes()`); o form mostra uma **janela de HORA
  diária** (`hora_inicio`/`hora_fim`, `LockController::horaHoje` → hoje+hora cheia) em vez do
  datetime de período. `passcodeTypeLabel` cobre 1-14.

### 5.57 Combos de ACESSO — câmera + chave (/acessos)
- **Casa uma CÂMERA com uma CHAVE/PORTA** para ver e abrir do mesmo lugar — no dashboard e nas
  regras. **Polimórfico (prédio + casa):** câmera = **winker** (índice) | **local** (video_camera.id);
  porta = **winker** ("idServer:idDevice:idZone") | **incontrol** (access_door.id) | **pado**
  (lockId). Tabela `acesso_combo` (schema + `migration_acesso_combo.sql`; câmera/porta são
  `tipo`+`ref`+`nome` porque os itens Winker **não têm ID local** — vêm ao vivo). Ability nova
  **`combo.view`**/**`combo.open`** (dono+morador) e **`combo.manage`** (dono).
- **⚠ ABRIR fica na camada APP** (`App\Services\AccessComboService::openCombo`): o motor de REGRAS
  (App) não pode depender de `Web\`, e os três clientes de porta são App (`WinkerClient` revalidado,
  `TriggerService`, `TtlockClient::unlock`). A Web (controller `/acessos/{id}/abrir`) chama o MESMO
  serviço (Web PODE chamar App) — fonte única. Best-effort, nunca lança; devolve `{ok,txt}`.
- **Web** `Web\Repositories\AccessComboRepository` (CRUD resiliente + HY093-safe; os 4 pickers
  `camerasWinker/camerasLocal/portasWinker/portasIncontrol/portasPado` reusam os repos existentes) +
  `AccessCombosController` (`/acessos`: listar, salvar, excluir, toggle dashboard, abrir, e o **proxy
  `/acessos/{id}/camera`** da câmera Winker com `Session::close()`). Menu: **Câmeras + chaves** em Acesso.
- **⚠ A IMAGEM respeita a permissão da CÂMERA, não do combo** (senão vazaria câmera para o morador,
  §5.45): câmera **winker** → `winker.view`; câmera **local** → `history.view_all`. Sem a permissão,
  o combo aparece só com a porta (sem imagem). Vale no `/acessos` E no dashboard.
- **Dashboard:** combos marcados (`dashboard=1`, teto `AccessComboRepository::MAX_DASHBOARD`=6) viram
  card no `/dashboard` (`DashboardController::combos`, gate `combo.view`; imagem gated pela câmera;
  botão Abrir gated `combo.open`). Poll do snapshot a cada 5 s.
- **Regras:** ação nova **`combo`** (`RuleActions` `$tipos` + `doCombo` → `AccessComboService::
  openCombo`). O editor recebe `comboOptions()` (mapa `{id:nome}` só dos combos COM porta — o `opts()`
  do form itera chave→rótulo, como `doorOptions`). Ex.: "às 7h abrir a Portaria". A câmera é a
  companhia visual (não entra na regra). Rotas: literais/+segmentos antes de `/acessos/{id}`.
- **⚠ EVOLUÍDO p/ N câmeras + N chaves por combo (2026-07):** tabela filha **`acesso_combo_item`**
  (`combo_id` **FK CASCADE**, `papel` camera|porta, `tipo`/`ref`/`nome`/`ordem`; schema +
  `migration_acesso_combo_item.sql`). As colunas ÚNICAS antigas de `acesso_combo` viram **legado**
  (não-destrutivo): `camerasOf()`/`portasOf()`/`portaItems()` leem os ITENS e **caem na coluna única
  se não houver item** (combo pré-evolução). `save` faz **DELETE+INSERT** do conjunto (arrays
  `camera[]`/`porta[]` + `_nome[]` paralelos). **`openCombo` abre TODAS as portas** (isolado, ok=alguma
  abriu); **`openItem($combo,$item)`** abre UMA (item 0/legado → abre tudo). UI: form multi-linha
  (adicionar/remover, edição via `?edit=`), card com grade de câmeras + **um botão por porta**
  (`/acessos/{id}/porta/{item}/abrir`). Proxy da câmera Winker por índice: **`/acessos/{id}/cam/{idx}`**
  (valida que o índice é MESMO câmera winker do combo — não deixa enumerar). Dashboard idem (multi-câmera).
- **Polimento (2026-07):** (1) **reordenar câmeras/chaves ARRASTANDO** a alça de cada linha do form
  (HTML5 drag, só dentro do mesmo container; a ordem salva segue a do DOM — `saveItems` grava `ordem`
  pela posição, sem backend novo); (2) **tile "Câmeras + chaves"** no hub `/inicio` do morador
  (`ResidentHomeController`, gate `combo.view`); (3) **câmera ampliada em MODAL** — clicar numa câmera
  (em `/acessos` e no dashboard) abre grande com poll mais rápido (~1,2 s), fecha por clique/Esc. O
  poll do grid e o modal rodam mesmo para quem só VÊ (não dependem do form de gestão).
- **Resta** (fase seguinte, opcional): tile no hub `/inicio`, senhas com HORÁRIO cíclico (a API
  tem `keyboardPwdType` mais tipos), e cartões/digitais (outros endpoints do `TtlockClient`).

### 5.58 Amazon Alexa — Custom Skill "Nexus" (voz)
- **Custom Skill** (invocação "nexus") com endpoint HTTPS NO PRÓPRIO SERVIDOR
  (`public/alexa.php`, rota `.htaccess` `^alexa`, como os outros receptores) — SEM AWS. A Alexa
  faz POST JSON assinado a cada comando; validamos e roteamos por voz (pt-BR). Config
  `api/config/alexa.php`/`.env` (`ALEXA_*`, inerte sem `ALEXA_ENABLED`); módulo `alexa` no
  kill-switch. Guia leigo + modelo de interação: `deploy/INTEGRACAO-ALEXA.md` e
  `deploy/alexa/interaction-model-ptBR.json`.
- **⚠ SÓ o que é do Nexus — NÃO controla dispositivos.** Decisão do Gui: luzes/tomadas/cenas já
  vão à Alexa pelas skills DOS FABRICANTES (eWeLink/Tuya/Hue), então o skill do Nexus faz
  **listas** (criar/adicionar/ler/marcar — `ListaRepository`), **tarefas** (criar/ler/**mover** —
  `BoardRepository`, `moveCartao` acha o cartão pelo título e a coluna pelo nome), **status da
  casa**, **rede** (`AutomationApiService`/`NetworkService`), **clima** (`WeatherRepository::panel`)
  e **energia** (`EnergyRepository::solarman/celesc`). Tudo LEITURA + as ações do próprio Nexus
  (listas/tarefas). Não há intent de LIGAR/DESLIGAR/cena de dispositivo (evita dois lugares
  comandando o mesmo aparelho). Intents: `AddListItemIntent`/`ReadListIntent`/`MarkListItemIntent`/
  `CreateListIntent`/`AddTaskIntent`/`ReadTasksIntent`/`MoveTaskIntent`/`HouseStatusIntent`/
  `NetworkStatusIntent`/`ClimateIntent`/`EnergyStatusIntent`/**`AlertsIntent`** ("o que precisa de
  mim" — agrega tarefas vencendo + contas de luz + workers com falha + IA de operação, as MESMAS
  fontes do bloco `acoes` do dashboard).
  **Fase 2 (Smart Home skill + Lambda-ponte) DESCARTADA** pelo mesmo motivo.
- **⚠ SEGURANÇA — validação de assinatura self-contained** `App\Alexa\AlexaVerifier` (só `openssl`,
  espírito do WebPushCrypto): (1) `SignatureCertChainUrl` TEM que ser
  `https://s3.amazonaws.com/echo.api/...:443` (trava de SSRF — `certUrlOk`, testada); (2) timestamp
  do corpo dentro de 150s (anti-replay); (3) baixa a cadeia PEM por TLS verificado (cache 24h);
  (4) folha dentro da validade + SAN `echo-api.amazon.com`; (5) cadeia→raiz confiável (best-effort
  onde há cacert, §6); (6) assinatura `Signature-256` (RSA-SHA256; cai p/ SHA1 no header antigo
  `Signature`) do CORPO CRU confere com a chave pública. **NUNCA desligar `ALEXA_VERIFY_SIGNATURE`
  em produção** (o toggle só serve ao CLI). Além da assinatura, o `applicationId` TEM que casar o
  `ALEXA_SKILL_ID` (senão é outro skill) — o receptor recusa com não-200 (a Alexa trata como falha).
- **`App\Alexa\AlexaRequest`** (parser PURO: type/intent/slots/applicationId/timestamp; `slot()`
  prefere o CANÔNICO das `resolutions`) e **`Web\Services\AlexaSkill`** (roteador; camada Web pois
  usa repos Web + serviços App; best-effort, nunca lança — fala de erro > 500). Casamento de nome
  falado × cadastro por `norm()` (minúsculas + sem acento) + aproximação. Testes:
  `api/scripts/tests_alexa.php` (parse + guard de SSRF, sem rede). CLI de intent:
  `api/scripts/alexa_test.php IntentName slot=valor` (roteia contra o banco, sem Alexa).

### 5.59 Modo TABLET — painel de parede kiosk (/tablet)
- **Página full-screen** para um tablet no hall/balcão (`TabletController`, ability nova
  **`tablet.view`** = dono+morador; menu **Modo tablet** em Início). Mostra **tarefas pendentes**
  (1ª coluna do quadro), **status** ("Tudo certo" OU os pontos de atenção), **previsão do tempo**
  e **outros** (usina/dispositivos). Relógio ao vivo. É uma página HTML **autocontida** (dark,
  `clamp()` responsivo), servida por `View::fragment` — mesmo molde das telas do Mural.
- **⚠ Regra do telão (§5.23): NUNCA faz chamada externa no request.** `/tablet/dados` (poll ~30s)
  lê só BANCO/KV que os workers mantêm: tarefas/dispositivos do banco (`BoardRepository`/
  `AutomationApiService`), clima e energia de KV (`WeatherRepository::panel`/`EnergyRepository`).
- **⚠ BOTÕES EM BLOCO, NÃO FLUTUANDO (redesign 2026-07):** o "+" e o microfone eram FABs
  `position:fixed` que **cobriam o conteúdo** — viraram botões numa **toolbar no cabeçalho**
  (`.toolbar`: selo ao vivo + flag + **🎤 Voz** + **＋ Adicionar**). O "+" abre um **dropdown ancorado**
  (Nova tarefa / Item na lista / Nova lista) → modais que fazem `fetch` POST
  (`/tablet/tarefa`|`/tablet/item`|`/tablet/lista`) e **não saem da tela** (fecha + re-poll). Só
  MODAIS e a barra de voz TRANSITÓRIA são overlay (esperado); nada de botão fixo persistente. Gated
  pelas abilities (`task.manage`/`list.use`/`voice.use`).
- **RICO + CLICÁVEL (2026-07):** **clima** mostra métricas (sensação/vento/umidade/UV/sol) e, ao
  TOCAR, abre um **modal** com próximas 12h + 7 dias (emoji resolvido no servidor por
  `WeatherService::wmo`); **tarefas** trazem etiquetas (dot), checklist, prazo, 📎/💬 e **badge de
  responsável**, e ao tocar abrem um **modal de detalhe com AÇÕES** (Concluir, Mover para coluna,
  marcar checklist — `/tablet/tarefa/{id}`, `/tablet/tarefa-mover`, `/tablet/tarefa-check`,
  `/tablet/tarefa-concluir`); **status** ganhou mais tiles (usina W+hoje, contas, dispositivos
  on/off, **rede** = clientes conectados via `MuralRepository::network()` — DB/KV, sem chamada externa —
  itens de lista, tarefas). Cada card tem **✓ concluir** rápido (§5.55) e **🔁** se recorrente.
  ⚠ As etiquetas vêm com o NOME da cor (azul/verde…) — o kiosk converte p/ hex (`corOf`), como o
  `/tarefas`.
- **⚠ FONTE ÚNICA do "precisa de mim": `Web\Services\HomeStatus`** (`alertas()` estruturado
  {nivel,texto} das MESMAS fontes do bloco `acoes` do dashboard — tarefas vencendo, contas de luz,
  workers com falha, dispositivos offline, IA de operação). O **tablet** usa `alertas()`; a **Alexa**
  (`AlexaSkill::alerts`) usa `HomeStatus::textos()`. Não duplicar a regra em cada tela.
- **VISUAL "glass" + INTERATIVO (redesign 2026-07):** fundo em **aurora** (radiais suaves que derivam),
  painéis **glass** (`backdrop-filter`), **cabeçalho compacto** (rev. 2026-07): **hora + data na MESMA
  linha** (`.timebox`, `align-items:baseline`) e **sem saudação** — o "Bem-vindo" foi removido para baixar
  a altura do cabeçalho e sobrar espaço para os itens; o relógio em degradê. **Hero do tempo REATIVO**: `TabletController::sky($wcode,$isDay)`
  → classe `clear-day|clear-night|cloud|rain|storm|snow|fog` no `data-sky` do painel; CSS pinta o wash +
  sol pulsante (dia) / chuva animada (rain/storm). Tarefas com **borda-acento da 1ª etiqueta** + **anel de
  progresso** (conic-gradient) do checklist + avatar do responsável + ✓. Tiles com **ícone em chip
  colorido** (campos `ico`/`cor` do `outros()`). **Burst de check** ao concluir. Respeita
  `prefers-reduced-motion`. **⚠ POLL SEM PISCAR (change-detection):** o `render()` guarda o JSON de cada
  seção (`last.t/s/c/o/l`) e **só re-renderiza a seção que mudou** — o innerHTML não é mais reescrito a cada
  30 s (antes re-animava/piscava tudo). Ao adicionar uma seção nova ao poll, some uma chave no `last`.
- **⚠ LAYOUT EM DUAS FAIXAS 70/30 (rev. 2026-07 — Listas | Status):** `main` é **flex-column**:
  **`.maintop`** (`flex 7`) = Tarefas (`flex 1.5`) | Tempo/hero (`flex 1`); **`.mainbottom`** (`flex 3`)
  = **Listas** (`.p-listas`, `flex 1.5`) | **Status** (`.p-status`, `flex 1`). A MESMA proporção 1.5:1 das
  duas faixas faz o **Status ficar exatamente da largura do Clima e alinhado à direita** (sob o hero), e as
  **Listas** ocuparem o espaço à esquerda (sob as Tarefas). Cada área de conteúdo é `.scroll`
  (`flex:1;min-height:0;overflow-y:auto`) DENTRO do painel (`overflow:hidden` p/ respeitar o raio). No
  estreito (<860px) `.maintop`/`.mainbottom` viram coluna e empilham. **⚠ NÃO usar `.top`/`.bottom` como
  classe de LAYOUT** — o card de tarefa já tem `.tk .top`; por isso as faixas são `.maintop`/`.mainbottom`.
- **⚠ RODAPÉ ENXUTO (rev. 2026-07) — sem redundância com as Tarefas:** o Status parou de repetir o que já
  se vê ao lado. O `outros()` agora traz **só** dispositivos e **rede** (presença/estado ao vivo); saíram
  os tiles de **usina agora/hoje** (não acionável), **contas em aberto** (já viram tarefa via BillTasks) e
  os **contadores** de tarefas/itens de lista. A lista de atenção (`#status`) usa **`HomeStatus::alertas(false)`**
  no tablet — o parâmetro novo **pula "tarefas vencendo"** (as tarefas aparecem em detalhe ao lado; Alexa/voz
  seguem com `alertas()`=true). Atenção vira **linhas** (`.st`/`.allok`), não tiles; a Casa fica em `.tiles`
  compactos abaixo, no mesmo painel estreito.
- **LISTAS no rodapé esquerdo (rev. 2026-07):** `listasPainel()` (banco, itens inline) → cada lista é um
  `.lcard` (nome + badge de pendentes + prévia dos itens não-feitos). **Clicar abre o modal `#mListaView`**
  (itens com checkbox → **marcar/desmarcar** por `POST /tablet/item-toggle` → `ListaRepository::toggleItem`,
  toggle **otimista** + resposta devolve as `listas` já atualizadas; e **adicionar item** reusando
  `POST /tablet/item`). O modal reabre com os dados do próprio poll (`LV_OPEN` + `fillListaView` no change-detection),
  sem novo round-trip. A rota `item-toggle` é literal de 2 segmentos (ANTES de qualquer `{id}`; não colide
  com `/tablet/tarefa/{id}`).
- **ROTAS**: `/tablet`, `/tablet/dados`, `/tablet/tarefa/{id}` (GET, `tablet.view`/`task.manage`);
  `/tablet/tarefa`, `/tablet/tarefa-mover`, `/tablet/tarefa-check`, `/tablet/tarefa-concluir` (POST,
  `task.manage`); `/tablet/item`, `/tablet/lista` (POST, `list.use`). Literais/2+ segmentos
  (`tarefa-mover`/`-check`/`-concluir`) ANTES de `/tablet/tarefa/{id}`. Sem tabela nova no tablet.

### 5.60 Assistente de VOZ do sistema (/voz) — falar e o Nexus FAZ
- **Falar um comando no NAVEGADOR e o sistema EXECUTA a ação** (ex.: "adicione leite na lista de
  compras", "crie a tarefa pagar a conta", "mova a tarefa X para feito", "o que precisa de mim",
  "como está o tempo"). Distinto da Alexa (§5.58, que precisa de um Echo): aqui é voz DENTRO do app —
  ideal no tablet de parede (§5.59) ou no celular. Ability nova **`voice.use`** (dono + morador — a
  família usa listas/tarefas). Menu **Voz** em Início. Sem tabela nova. Guia leigo:
  `deploy/INTEGRACAO-ASSISTENTE-VOZ.md`.
- **Transcrição NO NAVEGADOR (Web Speech API, pt-BR)** — decisão do Gui: sem servidor de áudio, sem
  nuvem paga. **⚠ PRIVACIDADE: o áudio NUNCA vai ao servidor — só o TEXTO já transcrito.** O TTS de
  resposta é o `speechSynthesis` do navegador. Melhor no Chrome/Edge; **degrada** sem Web Speech
  (`NexusVoice.supported=false` → a tela oferece DIGITAR o comando, mesmo endpoint). Módulo
  reutilizável `public/assets/js/voice.js` (`NexusVoice.listen/stop/speak/send/errorText`), usado
  pela página `/voz` E pelo botão de microfone do `/tablet`.
- **⚠ FONTE ÚNICA das ações: `Web\Services\CommandExecutor`** (extraído do AlexaSkill). Recebe
  parâmetros JÁ EXTRAÍDOS (item/lista/tarefa/coluna) e EXECUTA reusando os repos existentes
  (`ListaRepository`/`BoardRepository`/`WeatherRepository`/`EnergyRepository` + `AutomationApiService`/
  `NetworkService`/`HomeStatus`). Devolve SEMPRE `['fala'=>string, 'continua'=>bool]` (`continua`=true
  = faltou um dado, a Alexa mantém a sessão aberta e a voz do navegador segue ouvindo). **O
  `AlexaSkill` foi refatorado para DELEGAR aqui** (parse intent+slots → `CommandExecutor` → embrulha na
  resposta Alexa) — a regra de cada ação (adicionar item, criar/mover tarefa, status…) mora num lugar
  só. NÃO duplicar a lógica de ação no VoiceAssistant.
- **NLU determinística PURA `App\Support\VoiceIntents::detect($texto)`** (no espírito do
  `AlertMatch`/`RuleMatch`): frase → `['intent'=>..., 'slots'=>[...]]` ou null. Testada por
  `api/scripts/tests_voz.php` (roda sem banco; 43 asserções, validadas por espelho em Python com o
  módulo `regex`). ORDEM importa: AÇÃO antes de LEITURA/STATUS, e o específico (criar lista, criar
  tarefa, mover) antes do genérico (adicionar). Trabalha na frase em minúsculas com acentos (`/u` +
  `\p{L}`), então os slots preservam "pão"/"açúcar".
  - **⚠ LIÇÃO (bug pego no teste): verbo -CAR muda o radical no imperativo (c→qu).** "colocar" vira
    **"coloque"** e "marcar" vira **"marque"** — então `coloc\p{L}*`/`marc\p{L}*` NÃO casam a forma
    falada. Use `colo(?:c|qu)\p{L}*` e `mar(?:c|qu)\p{L}*` (o "risque" já vinha coberto por
    `risqu\p{L}*`). Ao adicionar verbo -car/-gar novo, cubra as duas formas.
- **`Web\Services\VoiceAssistant::handle($texto)`** → `['fala','ok','fonte'=>intent|ollama|fallback,
  'continua']`. (1) tenta o `VoiceIntents` → `CommandExecutor`; (2) se não casar E o Ollama estiver
  ligado (`assistant.ollama_enabled` E módulo `ia_local`), **LLM OPCIONAL** classifica a frase numa
  ação conhecida (Ollama `/api/generate` com `format:json` → `{intent,slots}`) e executa; (3) fallback
  "não entendi" com exemplos. O determinístico é o núcleo (funciona SEM IA); o LLM é só tempero para
  frases livres. NUNCA lança (best-effort).
- **`Web\Controllers\VoiceController`**: `GET /voz` (página com o botão de microfone) e
  `POST /voz/comando` (`{texto}` → `VoiceAssistant` → JSON `{fala,ok,fonte,continua}`; auditoria
  `voz_comando`). Ability `voice.use` nos dois; o POST tem CSRF. ROTAS: `/voz` e `/voz/comando` (2
  segmentos, sem colisão). A página `voz/index.php` (layout do app) tem microfone pulsante + o que
  "você disse"/"Nexus respondeu" + **chips de exemplo** (tocar executa) + caixa de DIGITAR (fallback).
  No `/tablet`, um FAB de microfone (verde) abre a barra de voz, transcreve, executa, **fala** e faz
  re-poll da tela (só aparece com `voice.use`, passado ao kiosk como `$podeVoz`).
- **MICROFONE FLUTUANTE EM TODO O APP** (`app/Views/components/voice_fab.php`, incluído no
  `layouts/app.php` gated por `$perm('voice.use')`): botão redondo fixo no canto inferior direito,
  **ACIMA do botão de notificações** (push) — ambos em `right:16px` respeitando `--bottom-nav-h`+
  `safe-area` para não cobrir a bottom-nav (o `push.js` fica em `+16px`, o microfone em `+84px`,
  empilhados). Toque → ouve → `/voz/comando` → **fala** a resposta num popover que se auto-esconde.
  Reusa o `voice.js`; **degrada** (sem Web Speech, `wrap.style.display='none'`). ⚠ Componente incluído
  declara o próprio `use Web\Support\Helpers as H;` (check 17). Sem colisão de z-index (FAB 46 <
  modais 60/70). Aparece para dono e morador (a família), em qualquer tela do app.

### 5.61 CONTAS (luz/condomínio) viram TAREFAS — `App\Services\BillTasks` + `auto_contas`
- **Ideia:** conta em ABERTO detectada → vira um **cartão no quadro** (`board_cartao`) na 1ª coluna,
  com **vencimento = prazo**, valor e **link da 2ª via** (+ Pix/linha digitável) na descrição. **Pagou**
  (a conta some da lista de abertas) → o cartão é **movido para a última coluna (feito)**. **Não pagou**
  → o prazo passa e o cartão fica **atrasado** sozinho (borda vermelha — a lógica de `estado='over'` que
  já existe). Sem tabela nova de conta: reusa o quadro.
- **`App\Services\BillTasks`** (camada App — o worker usa; **NUNCA** referencia `Web\`; grava no
  `board_cartao` por SQL direto, mesmo padrão do `PreventiveMaintenance` que escreve `os_tarefa`):
  - **ENERGIA (CELESC):** lê a **KV `celesc_panel`** (`resumo.pendentes`, que o `celesc_sync` já
    mantém 2x/dia) — **SEM chamada externa** aqui, respeitando o ritmo da distribuidora (§5.44.1).
    `null` se a KV não é autoritativa (sem `resumo.pendentes`) → não reconcilia pago.
  - **CONDOMÍNIO (Winker):** lê AO VIVO via `WinkerClient` (portals→billingUnits→billings; allowlist
    `WINKER_ID_PORTAIS`). Boleto **pago** (`situation` contém `paid`/`pag`) NÃO vira tarefa. `ok=false`
    se a leitura foi INCOMPLETA (alguma chamada falhou) → não reconcilia pago (evita marcar pago por engano).
- **⚠ DEDUP por `board_cartao.origem='conta'` + `origem_chave`** (colunas novas; schema seções 1 **e** 2
  + `migration_board_conta.sql`; `hasOrigem()` torna resiliente a base sem a migração). Chave estável:
  `celesc:{codigo}` / `winker:{idPortal}:{idBilling}`. **Já existe cartão para a chave (em QUALQUER
  coluna, mesmo arquivado) ⇒ NÃO recria** — respeita quem arquivou/concluiu na mão. **A reconciliação de
  PAGO só roda para a fonte que CONFIRMOU a lista** naquele ciclo (energia KV válida / winker completo) —
  senão uma leitura vazia marcaria tudo como pago.
- **Worker `auto_contas`** (`api/scripts/auto_contas.php`+`.bat`, ~a cada **6 h**; no `install/uninstall_
  workers.bat` e no `WorkerHealthRepository`, cadência 6h/tolerância 8h, opcional). Heartbeat begin/ok/fail.
  Kill-switch: KV `configuracao.contas_tarefas` (padrão **ligado**) + os módulos `celesc`/`winker`.
- **Etiqueta "Conta"** (amarela) criada automaticamente e posta no cartão (marcador visual no quadro/tablet).
- **UI:** chip **"Contas → tarefas: Ligado/Desligado"** no cabeçalho de `/tarefas`
  (`BoardController::contasToggle`, `POST /tarefas/contas-toggle`, KV via `SettingsRepository`).
- **Link absoluto:** `Config::env('APP_URL')` prefixa a 2ª via (energia `/energia/celesc/fatura/{cod}/pdf`,
  condomínio `/winker/boletos/{id}/pdf?...`); sem `APP_URL` o caminho fica relativo (o Pix/linha digitável,
  que é o que paga, vai na descrição de qualquer forma). Guia leigo: `deploy/INTEGRACAO-CONTAS-TAREFAS.md`.

#### 5.61.1 CONTAS DE CONDOMÍNIO viram ALERTAS (como a energia) — `App\Services\CondoBills` (2026-07)
- **O pedido:** a conta de condomínio (vencida/a vencer) tinha que virar **alerta** como a de energia —
  o item "precisa de você" do dashboard (*"1 conta(s) de energia a vencer — próxima 10/08/2026 (R$ 280,17)"*)
  **e** a notificação. A energia já fazia os dois; o condomínio só virava TAREFA (§5.61), nunca alertava.
- **ESPELHO EXATO da energia (§5.44/§5.48).** Energia tem DUAS superfícies: a **KV `celesc_panel`** (o
  dashboard/HomeStatus leem RÁPIDO, sem chamada externa) e o **alerta** (`EnergySync::alertOnce` +
  `BuiltinAlerts`). O condomínio ganhou o par: **`App\Services\CondoBills`** (camada App; NUNCA `Web\`)
  lê os boletos Winker AO VIVO, classifica **vencida / a_vencer (≤ `WINKER_ALERT_DAYS`, padrão 5) / aberta
  (longe)**, **persiste a KV `winker_contas`** (`{ts, ok, resumo:{abertas,vencidas,a_vencer,valor_*,proxima,
  pendentes[]}}`; pendentes = só vencida+a_vencer, ordenadas por vencimento) e **dispara alerta** por
  `alertOnce` (dedup diário `condo_alertts_<Ymd>` + gate `BuiltinAlerts` + cooldown 20 h; vencida prio 1,
  a vencer prio 0) — a MESMA lógica do `EnergySync::alertaContas`, com as MESMAS mensagens.
- **Roda no worker `auto_contas`** (~6 h), ANTES do `BillTasks`, e **independe do toggle `contas_tarefas`**
  (aquele liga/desliga a virada em TAREFA; o alerta/painel é outro recurso — só o módulo `winker` os une).
  Best-effort: leitura Winker incompleta **não sobrescreve** o KV bom com vazio (§5.44) nem alerta falso.
- **`BuiltinAlerts`**: `condominio_vencida` + `condominio_avencer` (grupo "Condomínio — contas", módulo
  `winker`) — VISÍVEIS e DESLIGÁVEIS em `/regras`, como os da energia.
- **Leitura Web (rápida, sem chamada externa):** `WinkerRepository::contasResumo()` lê a KV `winker_contas`
  (+ `idade_seg`, via `TIMESTAMPDIFF` em `atualizado_em` — o `ts` no payload garante que o `ON UPDATE
  current_timestamp()` dispare). Gated por `enabled()`. Consumida por:
  - **Dashboard** `acoes()` (vencidas 1 a 1 até 4, depois agrupa; a vencer agrupado "próxima…") + `infos()`
    (aberta longe do vencimento = informação), gate **`winker.billing`** (= `dono`; é dado financeiro),
    ícone `home`, link `/winker/boletos`. Espelha o bloco da energia.
  - **`HomeStatus::alertas()`** (fonte única do "precisa de mim") → **tablet, Alexa e voz** ganham o item
    de graça (vencida=danger, a vencer=warn).
- **Sem tabela/migração** (KV em `configuracao`). Sem novo worker (reusa `auto_contas`, cujo rótulo de
  saúde virou "Contas (tarefas + alertas)").

### 5.62 Canais de nuvem — TIPO + ícone + cômodo automáticos, import em bulk e autosync (2026-07)
- **Descoberta INTELIGENTE nos canais eWeLink/Tuya/Hue:** ao listar os dispositivos da nuvem, o Nexus já
  deduz a **categoria** (switch/light/outlet/blind/ac/pump/sensor/other), sugere o **ícone** do acervo e o
  **rótulo do tipo**, e casa/cria o **cômodo** (`auto_room`). Fotos de PRODUTO no eWeLink pelo modelo.
- **Mapeador PURO `App\Support\DeviceType`** (sem banco/rede, testável — `tests_devicetype.php`): `ewelink($uiid,
  $nome,$model)` (tabela de UIIDs), `tuya($category,$nome)` (códigos kg/cz/dj/dd/pir/cl…), `hue($type,$archetype)`
  e `byName()` (fallback por palavra pt/en). ⚠ **ORDEM do `byName`: específicos ANTES dos genéricos** —
  "Fechadura da porta"/"Portão" contêm "porta"; lock/garagem vêm antes do sensor de porta. Devolve SEMPRE
  uma categoria válida (`DeviceType::CATS`) e um ícone que EXISTE no acervo (senão o `deviceThumb` cai no
  automático). Cada `listDevices()` (Ewelink/Tuya/HueClient) passou a incluir `categoria`/`icone`/`tipo`/
  `room` (+ eWeLink `produto`=modelo). **Cômodos: Hue via `/groups` type Room** (100% documentado); eWeLink/
  Tuya best-effort (só se a resposta trouxer o nome do cômodo).
- **Ícones/fotos (extraídos das libs, §deploy/device-icons/NOTICE.md):** acervo de TIPOS `public/assets/img/
  omada/tipos/` cresceu para ~309 (25 autorais + 34 hass-hue **MIT** + 168 SF Symbols de casa — estes são
  **Apple**, uso pessoal; ver caveat de licença) — **todos pickáveis** no seletor de ícone do dispositivo.
  Fotos de produto Sonoff/eWeLink em `public/assets/img/sonoff/devices/` (146, por modelo) — servidas por
  `Helpers::sonoffProduct($model)` (casa exato + token no nome longo; foto errada é pior que nenhuma, §3.1).
- **Import em BULK + reconcile ESPELHO** `App\Services\CloudDeviceSync::reconcile($accountId,$espelho=true)`
  (App): lista → **expande** (eWeLink multi-canal vira 1 device/outlet; single **sem** outlet) → **cria** os
  novos (categoria/ícone/cômodo automáticos, sensor=monitor-only) → **reativa** os que voltaram → **desativa**
  (flag `config.auto_removed`+`ativo=0`) os que sumiram da nuvem, **respeitando** o que foi desativado na mão e
  **preservando** nome/ícone/cômodo de quem já existe. Casa por chave estável (`keyOf`: ewelink device_id+outlet,
  hue kind+id, tuya device_id). Botão **"Importar todos (espelho)"** em `/automation/accounts/{id}/discover`
  (`accountImportAll`, `POST /automation/accounts/{id}/import`). Repo: `devicesOfAccount`/`deviceReconcileFlag`/
  `ensureRoom`.
- **AUTOSYNC por canal** (colunas `auto_account.autosync`/`autosync_intervalo`/`ultimo_sync`; schema seções 1
  **e** 2 + `migration_auto_cloud_sync.sql`; `hasAutosyncColumns()` torna tudo resiliente a base sem a migração).
  Form do canal: checkbox **"Sincronizar automaticamente"** + intervalo (15 min/1 h/6 h/12 h/1 dia). O
  `accountSave` só grava autosync **quando a chave vem no POST** (senão uma edição normal ZERARIA o autosync).
  Worker **`auto_cloud_sync`** (`App\Services\CloudAutoSync`, ~5 min; no `install/uninstall_workers.bat` +
  `WorkerHealthRepository`, cadência 5 min/tolerância 20 min, opcional) reconcilia os canais **cujo intervalo
  venceu** (roda logo após o boot → cobre "ao iniciar"); `runAll()` força todos. Best-effort por canal.
- **Resiliência:** sem as colunas/migração, o autosync some do form e o worker não age; sem a foto/ícone, cai
  no genérico. Verificação: `tests_devicetype.php` (server) + espelho Python do `byName` (8/8); `.bat` CRLF.

#### 5.62.1 PERSIANA/MOTOR eWeLink (DUALR3 modo motor) — categoria `blind` (2026-07)
- **Um DUALR3 em MODO MOTOR é UM device de persiana (abrir/parar/fechar), não 2 canais.** `EwelinkClient::
  coverProtoFromParams($params,$uiid)` detecta pelo PARAMS (não pelo uiid): `motorTurn`/`currLocation` →
  `dualr3`; `setclose` → `switch` (KingArt/BINTHEN); `curtainAction`/`curPercent` → `zigbee`; `electromotor`/
  `percentageControl` → `t5`; uiid 126 + `workMode=2` → `dualr3`. Cover ⇒ `listDevices` força
  `categoria='blind'`, `multi=false`, `outlets=1` e devolve `cover`/`cover_proto`; o `CloudDeviceSync::expand`
  cria UM device com `config {device_id, cover:true, cover_proto}` (sem outlet), casando a chave `device_id:single`
  (reimportar um DUALR3 que veio errado como 2 switches desativa os `:0`/`:1` no espelho).
- **Protocolo CONFIRMADO no SonoffLAN/cover.py** (projeto aberto — NÃO inventado; o HAR do Sonoff não tinha
  cover). `dualr3`: abrir `{motorTurn:1}`, fechar `{motorTurn:2}`, parar `{motorTurn:0}`, posição `{location:N}`
  (0=fechado,100=aberto), status `currLocation`+`motorTurn`. Os 4 protocolos em `EwelinkClient::coverParams`/
  `coverState` (PUROS, testados por `tests_ewelink_cover.php` + espelho Python 21/21).
- **Driver** `EwelinkDriver` cover-aware por `config.cover`: `actions()` → abrir/parar/fechar; `apply()` roteia
  para `coverCommand`; `status()` → `getCoverStatus` (posição → rótulo ABERTA/FECHADA/N%). **UI**: `panel.php`
  e `mine.php` renderizam ▲Abrir/■Parar/▼Fechar quando `categoria==='blind'` (o `paint()` só destaca 'ON', então
  a persiana só mostra o texto do estado — não quebra). O caminho de ação é o MESMO (`/action` → `applyDevice`;
  `monitorOnly` não bloqueia blind).
- **POSIÇÃO por SLIDER (2026-07):** card de persiana tem um `<input type=range>` (0-100) — arrastar mostra o %,
  soltar (`change`) envia `action=position`+`position=N` pelo MESMO handler `data-action-form`. `applyDevice`
  ganhou um 5º parâmetro **`$extra`** (mesclado no config em runtime, sem persistir): o controller injeta
  `_position` e o `EwelinkDriver` chama `coverCommand(...,'position',$proto,$pos)`. Vale em `/automation` e `/casa`.
- **PERSIANA no motor de REGRAS (2026-07):** a ação `dispositivo` (`RuleActions::doDevice`) detecta blind
  (`categoria==='blind'` ou `config.cover`) e aceita **open/stop/close/position** (campo `posicao` no editor,
  `regras/form.php`), além de on/off/toggle; `toggle` numa persiana = abrir se estava FECHADA, senão fechar. O
  **gatilho** `dispositivo` (ligou/desligou/mudou) já serve à persiana (o estado vira ABERTA/FECHADA/N%). **TODO**:
  cover **Tuya** (`cl`/`am`, outro protocolo — DP codes).
