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 = {