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:
parent
40fc96ffd7
commit
f85cf6b2df
4 changed files with 416 additions and 0 deletions
162
configs/home-assistant/claude_usage.py
Normal file
162
configs/home-assistant/claude_usage.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue