Files
eink-push/data.py
YANG JIANKUAN 14359c8726 feat: 版式重构为三段 + 接入 Claude Usage(只读本地) + 定位江宁
将仪表盘从「四区(时钟/天气/今日实时/30天趋势)」重构为上中下三段:
- A 顶部:大时钟+日期 / 天气(定位改南京·江宁)
- B 中部主角 Claude Usage:5h/7d 用量进度条 + 大号百分比 + 时间维度对比 pace
  (pace=用量%−时间%,超前↑红/节余↓黑,参考 claude-statusline);用量≥阈值该条转红预警
- C 底部 橘喵今日实时:三列版式对齐主分支(名F12/值F24粗/环比同比偏移 +18/+34/+64/+82)
- 近30天趋势图隐藏(_trend_chart 及依赖保留、未调用,可挂回)

用量只读本地、不发网络请求:新增 usage_local.py 读取 claude-statusline 落盘的
/tmp/claude/statusline-usage-cache.json(utilization + resets_at)。utilization 可能滞后,
但 resets_at 为绝对时间,故 pace 每次按当前时间现算、始终准确;文件缺失回退 "--"。

其他:
- 天气定位 秦淮→江宁(config 默认 118.840,31.953)
- 进度条边框随条色(红条即红框);行内元素按墨迹竖直中心对齐(_mid)
- 新增 F13/F16 字号(非整数倍,fontmode=1 保持纯色)
- 全图仍严格三色(黑/白/红)、无灰阶不抖动;底部留 ~11px 下边距
- 同步更新 CLAUDE.md / README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:55:46 +08:00

234 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据层 —— 汇总仪表盘所需的全部数据。
橘喵今日经营 + 近30天趋势来自 jm_apijm-devops 后端统计接口,免鉴权 GET
天气来自 weather_api和风天气定位南京·秦淮。日期/时间用真实系统时间。
任何接口失败时相关数据以占位符 "--" 呈现,不回退 mock。
"""
import json
from datetime import datetime
import config
import jm_api
import weather_api
import usage_local
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
TREND_DAYS = 30 # 近 N 天趋势
FIVE_HOUR, SEVEN_DAY = 5 * 3600, 7 * 86400 # 两个滚动窗口长度(秒),用于算「时间已流逝%」
def _read_weather_cache():
try:
with open(config.WEATHER_CACHE_PATH, encoding="utf-8") as f:
return json.load(f) # {"ts": float, "data": {...}}
except Exception:
return None
def _write_weather_cache(ts, data):
try:
with open(config.WEATHER_CACHE_PATH, "w", encoding="utf-8") as f:
json.dump({"ts": ts, "data": data}, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] 天气缓存写入失败:{e}")
def _read_trend_cache():
try:
with open(config.TREND_CACHE_PATH, encoding="utf-8") as f:
return json.load(f) # {"date": "YYYY-MM-DD", "data": {...}}
except Exception:
return None
def _write_trend_cache(date_key, data):
try:
with open(config.TREND_CACHE_PATH, "w", encoding="utf-8") as f:
json.dump({"date": date_key, "data": data}, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] 趋势缓存写入失败:{e}")
def _trend(now):
"""近30天趋势按日期缓存recent-days 每天只更新一次。
当天已有缓存(且有数据)→ 直接复用,不发请求;当天还没有 → 拉取并写缓存。
拉取失败时沿用任意旧缓存(哪怕非当天),仍无缓存才回退空。
API 降序 → 升序:左旧右新。
"""
date_key = now.strftime("%Y-%m-%d")
cached = _read_trend_cache()
if cached and cached.get("date") == date_key and cached.get("data", {}).get("series"):
return cached["data"]
try:
asc = list(reversed(jm_api.get_recent_days(TREND_DAYS)))
trend = {
"dates": [d["date"][5:].replace("-", "/") for d in asc], # yyyy-MM-dd → MM/DD
"series": [
{"k": "单量", "style": "solid", "data": [int(d["orderCount"]) for d in asc]},
{"k": "流水", "style": "dashed", "data": [float(d["payAmount"]) for d in asc]},
{"k": "毛利", "style": "dotted", "data": [float(d["grossProfit"]) for d in asc]},
],
}
_write_trend_cache(date_key, trend)
return trend
except Exception as e:
print(f"[警告] 近30天趋势拉取失败{e}")
if cached and cached.get("data", {}).get("series"): # 失败沿用旧缓存,避免趋势图整片 "--"
print("[警告] 沿用上次趋势缓存")
return cached["data"]
return {"dates": [], "series": []}
def _weather(now):
"""和风天气(南京·秦淮),带 WEATHER_TTL 文件缓存。
缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。
过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"
"""
ts = now.timestamp()
cached = _read_weather_cache()
if cached and ts - cached.get("ts", 0) < config.WEATHER_TTL:
return cached["data"]
out = {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}
ok = False
try:
w = weather_api.get_now()
out["cond"] = w.get("text") or "--"
out["temp"] = f"{w['temp']}°"
out["icon"] = w.get("icon") or "" # 和风图标代码,供 renderer 映射图标
ok = True
except Exception as e:
print(f"[警告] 实时天气拉取失败:{e}")
try:
today = weather_api.get_today()
out["range"] = f"{today['tempMin']}°~{today['tempMax']}°"
ok = True
except Exception as e:
print(f"[警告] 今日温区拉取失败:{e}")
if ok:
_write_weather_cache(ts, out)
return out
if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--"
print("[警告] 天气全部拉取失败,沿用上次缓存")
return cached["data"]
return out
# ---------- 格式化 ----------
def _fmt_count(n):
return f"{int(n):,}" # 千分位,如 1,284
def _fmt_money(yuan):
"""元金额 → 紧凑展示≥1万显示「X.XX万」(两位小数),否则取整元。"""
v = float(yuan)
return f"{v / 10000:.2f}" if abs(v) >= 10000 else f"{v:.0f}"
def _fmt_rate(rate):
"""增长率字符串 → (方向, 文本)。null/空 → (None, '--'),方向 None 表示不画三角。
数值统一两位小数;整数部分不足两位用前导 0 补齐(6.5→06.50),便于环比/同比上下对齐;
三/四位数(≥100%)原样完整输出不截断。"""
if rate is None or rate == "":
return (None, "--")
v = float(rate)
return ("down" if v < 0 else "up", f"{abs(v):05.2f}%")
def _col(k, v, wow, yoy):
return {"k": k, "v": v, "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy)}
def _placeholder_col(k):
return {"k": k, "v": "--", "hb": (None, "--"), "tb": (None, "--")}
# ---------- 橘喵业务数据 ----------
def _mao(now):
upd = now.strftime("%H:%M")
# 今日经营realtime
try:
rt = jm_api.get_realtime()
upd = (rt.get("dataTime") or "")[11:16] or upd
cols = [
_col("单量", _fmt_count(rt["todayOrderCount"]), rt["orderCountWowRate"], rt["orderCountYoyRate"]),
_col("流水", _fmt_money(rt["todayPayAmount"]), rt["payAmountWowRate"], rt["payAmountYoyRate"]),
_col("毛利", _fmt_money(rt["todayGrossProfit"]), rt["grossProfitWowRate"], rt["grossProfitYoyRate"]),
]
except Exception as e:
print(f"[警告] 今日经营拉取失败:{e}")
cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")]
# 近30天趋势按日期缓存每天只拉一次
trend = _trend(now)
return {"upd": upd, "cols": cols, "trend": trend}
# ---------- Claude Code 用量 ----------
def _parse_iso_ts(s):
"""ISO 时间串 → epoch 秒;兼容结尾 'Z'。失败返回 None。"""
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
except Exception:
return None
def _window_time_pct(resets_at, window, now):
"""该滚动窗口「已流逝时间百分比」= (window 距重置剩余) / window ×100。失败返回 None。"""
reset = _parse_iso_ts(resets_at)
if reset is None:
return None
remaining = max(0, min(window, reset - now.timestamp()))
return int(round((window - remaining) / window * 100))
def _usage(now):
"""Claude Code 用量5h / 7d——只读本地 statusline 缓存(见 usage_local.py**不发任何请求**。
time_pct(时间已流逝%) 与 pace 每次按当前时间**现算**resets_at 为绝对时间,始终准确);
utilization 取自本地缓存、可能滞后。文件缺失/损坏 → 回退占位 pct=Nonerenderer 显示 "--")。
结构契约renderer 依赖):{title, warn, bars:[{k, pct(0~100 或 None), time_pct(0~100)?}]}
pct ≥ warn 时该条进度条与百分比转红pace = pct time_pct>0 超前↑ / <0 节余↓)。
"""
wins = {}
try:
raw = usage_local.get_usage()
for key, label, win in (("five_hour", "5h", FIVE_HOUR), ("seven_day", "7d", SEVEN_DAY)):
seg = raw.get(key) or {}
wins[label] = {"pct": int(round(float(seg.get("utilization", 0)))),
"resets_at": seg.get("resets_at"), "win": win}
except Exception as e:
print(f"[警告] Claude 用量读取失败:{e}")
bars = []
for label in ("5h", "7d"):
w = wins.get(label) or {}
pct = w.get("pct")
bar = {"k": label, "pct": pct}
tp = _window_time_pct(w.get("resets_at"), w.get("win"), now)
if pct is not None and tp is not None:
bar["time_pct"] = tp
bars.append(bar)
return {"title": "Claude Usage", "warn": config.USAGE_WARN, "bars": bars}
def get_dashboard_data(now=None):
"""返回渲染所需的完整数据字典。"""
now = now or datetime.now()
return {
"date": {
"greg": now.strftime("%Y/%m/%d"),
"week": WEEK_CN[now.weekday()],
"time": now.strftime("%H:%M"),
},
"weather": _weather(now),
"mao": _mao(now),
"usage": _usage(now),
}