Add Claude usage applet for Home Assistant

Fetches 5h and 7-day rate limit windows from claude.ai/api and exports
them to a JSON file; adds HA sensors, template sensors, systemd
service/timer, and a button-card for both dashboards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-05-17 16:18:27 -03:00
parent 40fc96ffd7
commit f85cf6b2df
No known key found for this signature in database
GPG key ID: FB9FDAB6B6F3418D
4 changed files with 416 additions and 0 deletions

View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
import json
import os
import shutil
import sqlite3
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Optional
def clamp_percent(value):
value = max(0.0, min(100.0, float(value)))
return round(value, 2)
def iso_passthrough(value):
if value is None:
return None
return str(value)
def discover_zen_cookie_db(home_dir: Path) -> Optional[Path]:
zen_root = home_dir / ".zen"
if not zen_root.exists():
return None
for path in sorted(zen_root.glob("*/cookies.sqlite")):
if path.is_file():
return path
return None
def read_claude_cookies(cookie_db: Path, context_prefix: str) -> dict[str, str]:
with NamedTemporaryFile(delete=False) as handle:
temp_db = Path(handle.name)
try:
shutil.copy2(cookie_db, temp_db)
conn = sqlite3.connect(temp_db)
try:
rows = conn.execute(
"""
SELECT name, value
FROM moz_cookies
WHERE host LIKE ?
AND originAttributes LIKE ?
ORDER BY name
""",
("%claude.ai", f"{context_prefix}%"),
).fetchall()
finally:
conn.close()
finally:
temp_db.unlink(missing_ok=True)
return {name: value for name, value in rows}
def fetch_json(url: str, headers: dict[str, str]) -> dict:
request = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(request, timeout=20) as response:
return json.load(response)
def main():
output_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/var/lib/hass/claude_usage.json")
home_dir = Path(os.environ.get("HOME", str(Path.home())))
cookie_db = Path(
os.environ.get("CLAUDE_COOKIE_DB")
or discover_zen_cookie_db(home_dir)
or ""
)
context_prefix = os.environ.get("CLAUDE_COOKIE_CONTEXT_PREFIX", "^userContextId=1")
if not cookie_db.is_file():
print("No claude.ai cookie database found", file=sys.stderr)
return 1
cookies = read_claude_cookies(cookie_db, context_prefix)
if not cookies:
print("No claude.ai cookies found", file=sys.stderr)
return 1
cookie_header = "; ".join(f"{name}={value}" for name, value in cookies.items())
base_headers = {
"Accept": "application/json",
"Cookie": cookie_header,
"Referer": "https://claude.ai/settings/usage",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
}
try:
bootstrap = fetch_json("https://claude.ai/api/bootstrap", base_headers)
except (OSError, urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as e:
print(f"Failed to fetch bootstrap: {e}", file=sys.stderr)
return 1
account = bootstrap.get("account") or {}
memberships = account.get("memberships") or []
org_uuid = None
for membership in memberships:
org = membership.get("organization") or {}
if org.get("uuid"):
org_uuid = org["uuid"]
break
if not org_uuid:
print(f"No organization UUID in bootstrap. Keys: {list(bootstrap.keys())}", file=sys.stderr)
return 1
try:
usage_data = fetch_json(
f"https://claude.ai/api/organizations/{org_uuid}/usage",
base_headers,
)
except (OSError, urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as e:
print(f"Failed to fetch usage from org {org_uuid}: {e}", file=sys.stderr)
return 1
# Response shape:
# {
# "five_hour": {"utilization": 78.0, "resets_at": "2026-05-17T20:40:00+00:00"},
# "seven_day": {"utilization": 16.0, "resets_at": "2026-05-18T06:59:59+00:00"},
# ...
# }
five_hour = usage_data.get("five_hour") or {}
seven_day = usage_data.get("seven_day") or {}
if "utilization" not in five_hour or "utilization" not in seven_day:
print(f"Unexpected usage structure. Keys: {list(usage_data.keys())}", file=sys.stderr)
print(json.dumps(usage_data, indent=2), file=sys.stderr)
return 1
status = {
"five_hour_left_percent": clamp_percent(100.0 - float(five_hour["utilization"])),
"five_hour_resets_at": iso_passthrough(five_hour.get("resets_at")),
"seven_day_left_percent": clamp_percent(100.0 - float(seven_day["utilization"])),
"seven_day_resets_at": iso_passthrough(seven_day.get("resets_at")),
"exported_at": datetime.now(timezone.utc).isoformat(),
"source": "claude_web",
}
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

@ -245,3 +245,89 @@ views:
- margin-top: 4px
weekly:
- margin-top: 2px
- type: custom:button-card
entity: sensor.claude_5h_left
triggers_update:
- sensor.claude_5h_reset_formatted
- sensor.claude_7d_left
- sensor.claude_7d_reset_formatted
icon: mdi:head-cog-outline
name: Uso do Claude
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.claude_5h_left']?.state || 0)));
const reset = states['sensor.claude_5h_reset_formatted']?.state || 'indisponível';
let color = '#bd93f9';
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>
`;
]]]
sevenday: >
[[[
const value = Math.max(0, Math.min(100, Number(states['sensor.claude_7d_left']?.state || 0)));
const reset = states['sensor.claude_7d_reset_formatted']?.state || 'indisponível';
let color = '#bd93f9';
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 de 7 dias</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" "sevenday sevenday"'
- 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: "#bd93f9"
- 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
sevenday:
- margin-top: 2px

View file

@ -245,3 +245,89 @@ views:
- margin-top: 4px
weekly:
- margin-top: 2px
- type: custom:button-card
entity: sensor.claude_5h_left
triggers_update:
- sensor.claude_5h_reset_formatted
- sensor.claude_7d_left
- sensor.claude_7d_reset_formatted
icon: mdi:head-cog-outline
name: Uso do Claude
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.claude_5h_left']?.state || 0)));
const reset = states['sensor.claude_5h_reset_formatted']?.state || 'indisponível';
let color = '#bd93f9';
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>
`;
]]]
sevenday: >
[[[
const value = Math.max(0, Math.min(100, Number(states['sensor.claude_7d_left']?.state || 0)));
const reset = states['sensor.claude_7d_reset_formatted']?.state || 'indisponível';
let color = '#bd93f9';
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 de 7 dias</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" "sevenday sevenday"'
- 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: "#bd93f9"
- 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
sevenday:
- margin-top: 2px

View file

@ -8,6 +8,7 @@ let
homeDir = "/home/${username}";
repoDir = "${homeDir}/Projetos/Sistemas/nixos-config";
codexStatusPath = "/var/lib/hass/codex_status.json";
claudeUsagePath = "/var/lib/hass/claude_usage.json";
haAirConditioner = "climate.ar";
haLaundryDryingToggle = "input_boolean.secar_roupas";
haClimateAction = action: data: {
@ -568,6 +569,50 @@ in {
icon = "mdi:calendar-refresh";
};
}
{
sensor = {
name = "Claude 5h Left";
unique_id = "claude_5h_left";
command = "${pkgs.coreutils}/bin/cat ${claudeUsagePath}";
value_template = "{{ value_json.five_hour_left_percent }}";
unit_of_measurement = "%";
scan_interval = 60;
icon = "mdi:timer-sand";
};
}
{
sensor = {
name = "Claude 5h Reset";
unique_id = "claude_5h_reset";
command = "${pkgs.coreutils}/bin/cat ${claudeUsagePath}";
value_template = "{{ value_json.five_hour_resets_at }}";
device_class = "timestamp";
scan_interval = 60;
icon = "mdi:timer-refresh";
};
}
{
sensor = {
name = "Claude 7d Left";
unique_id = "claude_7d_left";
command = "${pkgs.coreutils}/bin/cat ${claudeUsagePath}";
value_template = "{{ value_json.seven_day_left_percent }}";
unit_of_measurement = "%";
scan_interval = 60;
icon = "mdi:calendar-week";
};
}
{
sensor = {
name = "Claude 7d Reset";
unique_id = "claude_7d_reset";
command = "${pkgs.coreutils}/bin/cat ${claudeUsagePath}";
value_template = "{{ value_json.seven_day_resets_at }}";
device_class = "timestamp";
scan_interval = 60;
icon = "mdi:calendar-refresh";
};
}
];
template = [
@ -587,6 +632,20 @@ in {
"{{ as_timestamp(states('sensor.codex_weekly_reset')) | timestamp_custom('%d/%m/%Y %H:%M', true) }}";
icon = "mdi:calendar-refresh";
}
{
name = "Claude 5h Reset Formatted";
unique_id = "claude_5h_reset_formatted";
state =
"{{ as_timestamp(states('sensor.claude_5h_reset')) | timestamp_custom('%d/%m/%Y %H:%M', true) }}";
icon = "mdi:timer-refresh";
}
{
name = "Claude 7d Reset Formatted";
unique_id = "claude_7d_reset_formatted";
state =
"{{ as_timestamp(states('sensor.claude_7d_reset')) | timestamp_custom('%d/%m/%Y %H:%M', true) }}";
icon = "mdi:calendar-refresh";
}
];
}
];
@ -1199,6 +1258,29 @@ in {
};
};
systemd.services.claude-usage-export = {
description = "Export Claude usage for Home Assistant";
after = [ "home-assistant.service" ];
serviceConfig = {
Type = "oneshot";
};
script = ''
set -euo pipefail
export HOME=${homeDir}
${pkgs.python3}/bin/python3 ${../../configs/home-assistant/claude_usage.py} ${claudeUsagePath}
'';
};
systemd.timers.claude-usage-export = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "5min";
Persistent = true;
Unit = "claude-usage-export.service";
};
};
systemd.timers."nix-flake-update" = {
wantedBy = [ "timers.target" ];
timerConfig = {