From 8894fa949c3011f0797952cafb2b9efcbe089a02 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Thu, 23 Apr 2026 19:24:59 -0300 Subject: [PATCH] Add Codex Home Assistant dashboards --- AGENTS.md | 53 +++++++ configs/home-assistant/codex_status.py | 88 ++++++++++++ configs/home-assistant/ui-lovelace.yaml | 175 ++++++++++++++++++++++++ configs/home-assistant/ui-overview.yaml | 175 ++++++++++++++++++++++++ home/ltadeu6.nix | 2 +- hosts/Nixos/configuration.nix | 127 +++++++++++++++++ 6 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 configs/home-assistant/codex_status.py create mode 100644 configs/home-assistant/ui-lovelace.yaml create mode 100644 configs/home-assistant/ui-overview.yaml diff --git a/AGENTS.md b/AGENTS.md index d0a2bf3..2d06219 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,9 @@ Este arquivo deve refletir o estado atual do repo. Se a estrutura mudar, atualiz - `configs/hypr/`: fontes de verdade do Hyprland e asset do wallpaper. - `configs/waybar/`: configs e scripts do Waybar. - `configs/doom/`: configuracao do Doom Emacs versionada no repo. +- `configs/home-assistant/`: scripts auxiliares consumidos por servicos do host para alimentar o Home Assistant. + - `configs/home-assistant/codex_status.py`: exportador do status local do Codex. + - `configs/home-assistant/ui-lovelace.yaml`: dashboard principal do Home Assistant em YAML. - `configs/wofi/`: configuracao e tema do launcher Wofi. - `secrets/secrets.nix`: regras do agenix. - `secrets/*.age`: segredos criptografados. @@ -558,6 +561,56 @@ Itens que um agente deve verificar antes de mexer: - `Home Assistant` local e `~/.config/secrets/ha_token` sao dependencias externas do controle de ar. - `open-webui` depende do `ollama` configurado no host. +### Home Assistant / Codex status + +Arquivo principal: + +- `configs/home-assistant/codex_status.py` + +Estado atual: + +- O host exporta o status local do Codex a partir de `~/.codex/sessions/.../*.jsonl`. +- O export e feito pelo timer `codex-status-export`, que gera `/var/lib/hass/codex_status.json`. +- O Home Assistant le esse JSON via sensores `command_line`. +- Sensores expostos hoje: + - `sensor.codex_5h_left` + - `sensor.codex_5h_reset` + - `sensor.codex_5h_reset_formatted` + - `sensor.codex_weekly_left` + - `sensor.codex_weekly_reset` + - `sensor.codex_weekly_reset_formatted` + +Cuidados: + +- Nao tente obter esses dados via API de billing da OpenAI sem necessidade; o estado atual usa apenas arquivos locais do Codex. +- Se mover a origem dos logs do Codex, ajuste `CODEX_SESSION_ROOT` no servico `codex-status-export`. +- O dashboard do Home Assistant continua em storage mode; a fonte declarativa aqui cobre os sensores, nao os cards da UI. + +### Home Assistant / dashboard do Codex + +Arquivo principal: + +- `configs/home-assistant/ui-lovelace.yaml` +- `configs/home-assistant/ui-overview.yaml` + +Estado atual: + +- A dashboard principal `Home` e declarativa e vem de `ui-lovelace.yaml`. +- Os recursos custom do Lovelace sao declarados em `services.home-assistant.config.lovelace.resources`. +- O painel do Codex existe na dashboard principal `Home` e tambem na dashboard extra `Visão Geral`. +- O painel escolhido para o Codex e o minimalista. + +Dependencias visuais atuais: + +- `lovelace-mushroom` +- `button-card` + +Cuidados: + +- Se algum desses recursos nao estiver instalado no HACS, a dashboard pode abrir com erro de `custom element doesn't exist`. +- Mudancas pela UI na dashboard principal nao sao a fonte de verdade; o repo sobrescreve `ui-lovelace.yaml`. +- Recursos Lovelace relevantes devem ser mantidos na configuracao declarativa, nao apenas pela UI. + ## Arquivos gerados que NAO devem ser editados diretamente - `~/.config/hypr/hyprpaper.conf` diff --git a/configs/home-assistant/codex_status.py b/configs/home-assistant/codex_status.py new file mode 100644 index 0000000..82b882e --- /dev/null +++ b/configs/home-assistant/codex_status.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from tempfile import NamedTemporaryFile + + +def iso_from_epoch(value): + if value is None: + return None + return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat() + + +def clamp_percent(value): + value = max(0.0, min(100.0, float(value))) + return round(value, 2) + + +def extract_status(session_root): + files = sorted(session_root.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True) + + for path in files: + try: + with path.open("r", encoding="utf-8") as handle: + lines = handle.readlines() + except OSError: + continue + + for line in reversed(lines): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + if event.get("type") != "event_msg": + continue + + payload = event.get("payload") or {} + if payload.get("type") != "token_count": + continue + + info = payload.get("info") or {} + rate_limits = payload.get("rate_limits") or {} + + primary = rate_limits.get("primary") or {} + secondary = rate_limits.get("secondary") or {} + + if "used_percent" not in primary or "used_percent" not in secondary: + continue + + return { + "plan_type": rate_limits.get("plan_type"), + "five_hour_left_percent": clamp_percent(100.0 - float(primary["used_percent"])), + "five_hour_resets_at": iso_from_epoch(primary.get("resets_at")), + "weekly_left_percent": clamp_percent(100.0 - float(secondary["used_percent"])), + "weekly_resets_at": iso_from_epoch(secondary.get("resets_at")), + "exported_at": datetime.now(timezone.utc).isoformat(), + "source_file": str(path), + } + + return None + + +def main(): + output_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/var/lib/hass/codex_status.json") + session_root = Path(os.environ.get("CODEX_SESSION_ROOT", str(Path.home() / ".codex" / "sessions"))) + + status = extract_status(session_root) + if status is None: + print(f"No Codex token_count event found under {session_root}", file=sys.stderr) + return 1 + + output_path.parent.mkdir(parents=True, exist_ok=True) + with NamedTemporaryFile("w", encoding="utf-8", dir=output_path.parent, delete=False) as handle: + json.dump(status, handle, ensure_ascii=False, separators=(",", ":")) + handle.write("\n") + temp_path = Path(handle.name) + + temp_path.replace(output_path) + output_path.chmod(0o644) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/configs/home-assistant/ui-lovelace.yaml b/configs/home-assistant/ui-lovelace.yaml new file mode 100644 index 0000000..d4a42cd --- /dev/null +++ b/configs/home-assistant/ui-lovelace.yaml @@ -0,0 +1,175 @@ +title: Home +views: + - path: default_view + title: Home + type: masonry + cards: + - type: weather-forecast + entity: weather.forecast_casa + show_forecast: true + forecast_type: daily + + - type: thermostat + entity: climate.ar + features: + - style: icons + type: climate-hvac-modes + hvac_modes: + - "off" + - heat + - cool + - dry + - fan_only + + - type: custom:button-card + entity: sensor.pixel_7a_battery_level + icon: mdi:battery + name: Bateria Pixel 7a + show_icon: true + show_name: true + show_state: true + show_label: false + tap_action: + action: none + hold_action: + action: none + state_display: > + [[[ + const value = Math.max(0, Math.min(100, Number(entity.state || 0))); + return `${Math.round(value)}%`; + ]]] + custom_fields: + bar: > + [[[ + const value = Math.max(0, Math.min(100, Number(entity.state || 0))); + let color = '#50fa7b'; + if (value < 20) color = '#ff5555'; + else if (value < 50) color = '#ffb86c'; + return ` +
+
+
+ `; + ]]] + styles: + card: + - padding: 18px + - border-radius: 22px + - background: var(--ha-card-background, var(--card-background-color, #1c1c1c)) + - box-shadow: none + grid: + - grid-template-areas: '"i n s" "bar bar bar"' + - grid-template-columns: min-content 1fr min-content + - grid-template-rows: min-content min-content + - column-gap: 12px + - row-gap: 12px + img_cell: + - justify-self: start + - align-self: center + - width: 34px + - height: 34px + icon: + - color: "#50fa7b" + - width: 24px + - height: 24px + name: + - justify-self: start + - align-self: center + - color: white + - font-size: 22px + - font-weight: 600 + state: + - justify-self: end + - align-self: center + - color: white + - font-size: 18px + - font-weight: 700 + custom_fields: + bar: + - margin-top: 2px + + - type: custom:button-card + entity: sensor.codex_5h_left + triggers_update: + - sensor.codex_5h_reset_formatted + - sensor.codex_weekly_left + - sensor.codex_weekly_reset_formatted + icon: mdi:robot-outline + name: Uso do Codex + show_icon: true + show_name: true + show_state: false + show_label: false + tap_action: + action: none + hold_action: + action: none + custom_fields: + fiveh: > + [[[ + const value = Math.max(0, Math.min(100, Number(states['sensor.codex_5h_left']?.state || 0))); + const reset = states['sensor.codex_5h_reset_formatted']?.state || 'indisponível'; + let color = '#50fa7b'; + if (value < 20) color = '#ff5555'; + else if (value < 50) color = '#ffb86c'; + return ` +
+ Janela de 5h + ${Math.round(value)}% +
+
+
+
+
Reinicia em ${reset}
+ `; + ]]] + weekly: > + [[[ + const value = Math.max(0, Math.min(100, Number(states['sensor.codex_weekly_left']?.state || 0))); + const reset = states['sensor.codex_weekly_reset_formatted']?.state || 'indisponível'; + let color = '#8be9fd'; + if (value < 30) color = '#ff5555'; + else if (value < 65) color = '#ffb86c'; + return ` +
+ Limite semanal + ${Math.round(value)}% +
+
+
+
+
Reinicia em ${reset}
+ `; + ]]] + styles: + card: + - padding: 18px + - border-radius: 22px + - background: var(--ha-card-background, var(--card-background-color, #1c1c1c)) + - box-shadow: none + grid: + - grid-template-areas: '"i n" "fiveh fiveh" "weekly weekly"' + - grid-template-columns: min-content 1fr + - grid-template-rows: min-content min-content min-content + - column-gap: 12px + - row-gap: 14px + img_cell: + - justify-self: start + - align-self: center + - width: 34px + - height: 34px + icon: + - color: "#8be9fd" + - width: 24px + - height: 24px + name: + - justify-self: start + - align-self: center + - color: white + - font-size: 22px + - font-weight: 600 + custom_fields: + fiveh: + - margin-top: 4px + weekly: + - margin-top: 2px diff --git a/configs/home-assistant/ui-overview.yaml b/configs/home-assistant/ui-overview.yaml new file mode 100644 index 0000000..e93ba75 --- /dev/null +++ b/configs/home-assistant/ui-overview.yaml @@ -0,0 +1,175 @@ +title: Visão Geral +views: + - path: default_view + title: Visão Geral + type: masonry + cards: + - type: weather-forecast + entity: weather.forecast_casa + show_forecast: true + forecast_type: daily + + - type: thermostat + entity: climate.ar + features: + - style: icons + type: climate-hvac-modes + hvac_modes: + - "off" + - heat + - cool + - dry + - fan_only + + - type: custom:button-card + entity: sensor.pixel_7a_battery_level + icon: mdi:battery + name: Bateria Pixel 7a + show_icon: true + show_name: true + show_state: true + show_label: false + tap_action: + action: none + hold_action: + action: none + state_display: > + [[[ + const value = Math.max(0, Math.min(100, Number(entity.state || 0))); + return `${Math.round(value)}%`; + ]]] + custom_fields: + bar: > + [[[ + const value = Math.max(0, Math.min(100, Number(entity.state || 0))); + let color = '#50fa7b'; + if (value < 20) color = '#ff5555'; + else if (value < 50) color = '#ffb86c'; + return ` +
+
+
+ `; + ]]] + styles: + card: + - padding: 18px + - border-radius: 22px + - background: var(--ha-card-background, var(--card-background-color, #1c1c1c)) + - box-shadow: none + grid: + - grid-template-areas: '"i n s" "bar bar bar"' + - grid-template-columns: min-content 1fr min-content + - grid-template-rows: min-content min-content + - column-gap: 12px + - row-gap: 12px + img_cell: + - justify-self: start + - align-self: center + - width: 34px + - height: 34px + icon: + - color: "#50fa7b" + - width: 24px + - height: 24px + name: + - justify-self: start + - align-self: center + - color: white + - font-size: 22px + - font-weight: 600 + state: + - justify-self: end + - align-self: center + - color: white + - font-size: 18px + - font-weight: 700 + custom_fields: + bar: + - margin-top: 2px + + - type: custom:button-card + entity: sensor.codex_5h_left + triggers_update: + - sensor.codex_5h_reset_formatted + - sensor.codex_weekly_left + - sensor.codex_weekly_reset_formatted + icon: mdi:robot-outline + name: Uso do Codex + show_icon: true + show_name: true + show_state: false + show_label: false + tap_action: + action: none + hold_action: + action: none + custom_fields: + fiveh: > + [[[ + const value = Math.max(0, Math.min(100, Number(states['sensor.codex_5h_left']?.state || 0))); + const reset = states['sensor.codex_5h_reset_formatted']?.state || 'indisponível'; + let color = '#50fa7b'; + if (value < 20) color = '#ff5555'; + else if (value < 50) color = '#ffb86c'; + return ` +
+ Janela de 5h + ${Math.round(value)}% +
+
+
+
+
Reinicia em ${reset}
+ `; + ]]] + weekly: > + [[[ + const value = Math.max(0, Math.min(100, Number(states['sensor.codex_weekly_left']?.state || 0))); + const reset = states['sensor.codex_weekly_reset_formatted']?.state || 'indisponível'; + let color = '#8be9fd'; + if (value < 30) color = '#ff5555'; + else if (value < 65) color = '#ffb86c'; + return ` +
+ Limite semanal + ${Math.round(value)}% +
+
+
+
+
Reinicia em ${reset}
+ `; + ]]] + styles: + card: + - padding: 18px + - border-radius: 22px + - background: var(--ha-card-background, var(--card-background-color, #1c1c1c)) + - box-shadow: none + grid: + - grid-template-areas: '"i n" "fiveh fiveh" "weekly weekly"' + - grid-template-columns: min-content 1fr + - grid-template-rows: min-content min-content min-content + - column-gap: 12px + - row-gap: 14px + img_cell: + - justify-self: start + - align-self: center + - width: 34px + - height: 34px + icon: + - color: "#8be9fd" + - width: 24px + - height: 24px + name: + - justify-self: start + - align-self: center + - color: white + - font-size: 22px + - font-weight: 600 + custom_fields: + fiveh: + - margin-top: 4px + weekly: + - margin-top: 2px diff --git a/home/ltadeu6.nix b/home/ltadeu6.nix index 211de15..415a9db 100644 --- a/home/ltadeu6.nix +++ b/home/ltadeu6.nix @@ -387,7 +387,7 @@ fi if [ -d "$dst_dir/$preferred_profile" ]; then - python3 - "$dst_dir" "$preferred_profile" <<'PY' + ${pkgs.python3}/bin/python3 - "$dst_dir" "$preferred_profile" <<'PY' import configparser import sys from pathlib import Path diff --git a/hosts/Nixos/configuration.nix b/hosts/Nixos/configuration.nix index 713a4c1..16196f8 100644 --- a/hosts/Nixos/configuration.nix +++ b/hosts/Nixos/configuration.nix @@ -6,6 +6,7 @@ let username = "ltadeu6"; homeDir = "/home/${username}"; + codexStatusPath = "/var/lib/hass/codex_status.json"; zenExtension = shortId: guid: { name = guid; value = { @@ -223,6 +224,11 @@ in { enable = true; openFirewall = true; extraComponents = [ "lg_thinq" ]; + extraPackages = python3Packages: [ + python3Packages.getmac + python3Packages.pymetno + python3Packages.aiogithubapi + ]; config = { homeassistant = { name = "Casa"; @@ -231,6 +237,97 @@ in { }; default_config = { }; + + command_line = [ + { + sensor = { + name = "Codex 5h Left"; + unique_id = "codex_5h_left"; + command = "${pkgs.coreutils}/bin/cat ${codexStatusPath}"; + value_template = "{{ value_json.five_hour_left_percent }}"; + unit_of_measurement = "%"; + scan_interval = 60; + icon = "mdi:timer-sand"; + }; + } + { + sensor = { + name = "Codex 5h Reset"; + unique_id = "codex_5h_reset"; + command = "${pkgs.coreutils}/bin/cat ${codexStatusPath}"; + value_template = "{{ value_json.five_hour_resets_at }}"; + device_class = "timestamp"; + scan_interval = 60; + icon = "mdi:timer-refresh"; + }; + } + { + sensor = { + name = "Codex Weekly Left"; + unique_id = "codex_weekly_left"; + command = "${pkgs.coreutils}/bin/cat ${codexStatusPath}"; + value_template = "{{ value_json.weekly_left_percent }}"; + unit_of_measurement = "%"; + scan_interval = 60; + icon = "mdi:calendar-week"; + }; + } + { + sensor = { + name = "Codex Weekly Reset"; + unique_id = "codex_weekly_reset"; + command = "${pkgs.coreutils}/bin/cat ${codexStatusPath}"; + value_template = "{{ value_json.weekly_resets_at }}"; + device_class = "timestamp"; + scan_interval = 60; + icon = "mdi:calendar-refresh"; + }; + } + ]; + + template = [ + { + sensor = [ + { + name = "Codex 5h Reset Formatted"; + unique_id = "codex_5h_reset_formatted"; + state = + "{{ as_timestamp(states('sensor.codex_5h_reset')) | timestamp_custom('%d/%m/%Y %H:%M', true) }}"; + icon = "mdi:timer-refresh"; + } + { + name = "Codex Weekly Reset Formatted"; + unique_id = "codex_weekly_reset_formatted"; + state = + "{{ as_timestamp(states('sensor.codex_weekly_reset')) | timestamp_custom('%d/%m/%Y %H:%M', true) }}"; + icon = "mdi:calendar-refresh"; + } + ]; + } + ]; + + lovelace = { + dashboards = { + overview-dashboard = { + mode = "yaml"; + title = "Visão Geral"; + icon = "mdi:view-dashboard"; + show_in_sidebar = true; + require_admin = false; + filename = "/etc/home-assistant/ui-overview.yaml"; + }; + }; + resources = [ + { + url = "/hacsfiles/lovelace-mushroom/mushroom.js"; + type = "module"; + } + { + url = "/hacsfiles/button-card/button-card.js"; + type = "module"; + } + ]; + }; }; }; dbus.enable = true; @@ -708,6 +805,12 @@ in { users.groups.libvirtd.members = [ username ]; + services.home-assistant.lovelaceConfigFile = + ../../configs/home-assistant/ui-lovelace.yaml; + + environment.etc."home-assistant/ui-overview.yaml".source = + ../../configs/home-assistant/ui-overview.yaml; + users.users.${username} = { isNormalUser = true; description = "Lucas Tadeu"; @@ -784,6 +887,30 @@ in { ''; }; + systemd.services.codex-status-export = { + description = "Export Codex status for Home Assistant"; + after = [ "home-assistant.service" ]; + serviceConfig = { + Type = "oneshot"; + }; + script = '' + set -euo pipefail + export HOME=${homeDir} + export CODEX_SESSION_ROOT=${homeDir}/.codex/sessions + ${pkgs.python3}/bin/python3 ${../../configs/home-assistant/codex_status.py} ${codexStatusPath} + ''; + }; + + systemd.timers.codex-status-export = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "2min"; + OnUnitActiveSec = "1min"; + Persistent = true; + Unit = "codex-status-export.service"; + }; + }; + systemd.timers."nix-flake-update" = { wantedBy = [ "timers.target" ]; timerConfig = {