Add Codex Home Assistant dashboards

This commit is contained in:
ltadeu6 2026-04-23 19:24:59 -03:00
parent 8004d5ca0d
commit 8894fa949c
No known key found for this signature in database
GPG key ID: FB9FDAB6B6F3418D
6 changed files with 619 additions and 1 deletions

View file

@ -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`

View file

@ -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())

View file

@ -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 `
<div style="height:10px;background:rgba(255,255,255,0.12);border-radius:999px;overflow:hidden;">
<div style="height:100%;width:${value}%;background:${color};border-radius:999px;"></div>
</div>
`;
]]]
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 `
<div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:6px;">
<span style="color:rgba(248,250,252,0.88);font-size:14px;font-weight:600;">Janela de 5h</span>
<span style="color:#ffffff;font-size:16px;font-weight:700;">${Math.round(value)}%</span>
</div>
<div style="height:10px;background:rgba(255,255,255,0.12);border-radius:999px;overflow:hidden;">
<div style="height:100%;width:${value}%;background:${color};border-radius:999px;"></div>
</div>
<div style="margin-top:6px;color:rgba(226,232,240,0.72);font-size:12px;">Reinicia em ${reset}</div>
`;
]]]
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 `
<div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:6px;">
<span style="color:rgba(248,250,252,0.88);font-size:14px;font-weight:600;">Limite semanal</span>
<span style="color:#ffffff;font-size:16px;font-weight:700;">${Math.round(value)}%</span>
</div>
<div style="height:10px;background:rgba(255,255,255,0.12);border-radius:999px;overflow:hidden;">
<div style="height:100%;width:${value}%;background:${color};border-radius:999px;"></div>
</div>
<div style="margin-top:6px;color:rgba(226,232,240,0.72);font-size:12px;">Reinicia em ${reset}</div>
`;
]]]
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

View file

@ -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 `
<div style="height:10px;background:rgba(255,255,255,0.12);border-radius:999px;overflow:hidden;">
<div style="height:100%;width:${value}%;background:${color};border-radius:999px;"></div>
</div>
`;
]]]
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 `
<div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:6px;">
<span style="color:rgba(248,250,252,0.88);font-size:14px;font-weight:600;">Janela de 5h</span>
<span style="color:#ffffff;font-size:16px;font-weight:700;">${Math.round(value)}%</span>
</div>
<div style="height:10px;background:rgba(255,255,255,0.12);border-radius:999px;overflow:hidden;">
<div style="height:100%;width:${value}%;background:${color};border-radius:999px;"></div>
</div>
<div style="margin-top:6px;color:rgba(226,232,240,0.72);font-size:12px;">Reinicia em ${reset}</div>
`;
]]]
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 `
<div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:6px;">
<span style="color:rgba(248,250,252,0.88);font-size:14px;font-weight:600;">Limite semanal</span>
<span style="color:#ffffff;font-size:16px;font-weight:700;">${Math.round(value)}%</span>
</div>
<div style="height:10px;background:rgba(255,255,255,0.12);border-radius:999px;overflow:hidden;">
<div style="height:100%;width:${value}%;background:${color};border-radius:999px;"></div>
</div>
<div style="margin-top:6px;color:rgba(226,232,240,0.72);font-size:12px;">Reinicia em ${reset}</div>
`;
]]]
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

View file

@ -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

View file

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