Sign inSign up

goordonchen/m8-smart-charging

By goordonchen

•Updated 4 months ago

Image
0

1.3K

goordonchen/m8-smart-charging repository overview

⁠goordonchen/m8-smart-charging

基于天气和车型策略的智能充电提醒服务,支持月度 BMS 校准提醒、多车型电量策略切换、Bark 推送通知、Docker 多架构构建,适配家用与车载自动化场景。


⁠📁 目录结构

m8-smart-charging/
├── Dockerfile
├── entrypoint.sh                  ← 容器启动入口(环境变量校验 + 进程管理)
├── m8-smart-charging-enhanced.py  ← 主脚本(自动提醒、校准分析)
├── health_check.py                ← 健康检查服务(纯标准库,无额外依赖)
├── requirements.txt
└── logs/                          ← 启动后自动创建

⁠1️⃣ 前置条件

  • Docker ≥ 19.03,已启用 Buildx
  • 如需在 x86 构建 arm64 镜像,先注册 QEMU:
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

⁠2️⃣ 文件准备

⁠2.1 主功能脚本(m8-smart-charging-enhanced.py)

根据天气 API 提供的夜间温度,结合车型策略计算推荐充电上限,并每日通过 Bark 推送提醒。

特性:

  • 多车型支持(电量 kWh 精准计算百分比)
  • 月度 BMS 校准期独立提醒,与温度策略解耦,校准窗口期内始终推送
  • 温度不适宜执行校准时,自动降级为常规充电建议
  • 支持 Gist 动态城市 ID 和城市名(配合 charge-location-sync 跨服务器更新位置)
  • Bark 推送含图标,消息归档保留 24 小时
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import sys
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import quote

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

BEIJING_TZ  = timezone(timedelta(hours=8))
NIGHT_HOURS: frozenset[int] = frozenset(range(21, 24)) | frozenset(range(0, 7))

USABLE_CAPACITY: dict[str, float] = {
    "m8_tri_ncm":         51.975,
    "model3_2020_sr_ncm": 51.8,
}
USABLE_CAPACITY["default"] = USABLE_CAPACITY["m8_tri_ncm"]

# 温度策略:List[(最低温阈值℃, 目标充入kWh)]
# 非单调说明:0℃ 以下析锂风险上升需降档;5–25℃ 活性最佳可提升上限。
CHARGE_STRATEGIES: dict[str, list[tuple[float, float]]] = {
    "m8_tri_ncm": [
        (25.0, 36.38),   # ≥25℃ → 70%
        (15.0, 41.58),   # ≥15℃ → 80%
        ( 5.0, 44.18),   # ≥ 5℃ → 85%(活性区间峰值)
        ( 0.0, 31.19),   # ≥ 0℃ → 60%(低温警戒)
        (-99.0, 25.99),  # 极寒  → 50%
    ],
    "model3_2020_sr_ncm": [
        (25.0, 36.26),
        (15.0, 41.44),
        ( 5.0, 44.03),
        ( 0.0, 31.08),
        (-99.0, 25.90),
    ],
}
CHARGE_STRATEGIES["default"] = CHARGE_STRATEGIES["m8_tri_ncm"]

_OFF_PEAK_RAW: list[tuple[tuple[int, ...], str]] = [
    ((3, 4, 5, 6, 10, 11), "22:00 — 08:00"),
    ((7, 8, 9),            "01:00 — 07:00"),
    ((12, 1, 2),           "00:00 — 08:00"),
]
OFF_PEAK_BY_MONTH: dict[int, str] = {
    month: period
    for months, period in _OFF_PEAK_RAW
    for month in months
}

WEATHER_API_URL  = os.getenv("WEATHER_API_URL", "")
WEATHER_API_KEY  = os.getenv("WEATHER_API_KEY", "")
WEATHER_LOCATION = os.getenv("WEATHER_LOCATION", "101270101")
BARK_BASE_URL    = os.getenv("BARK_BASE_URL", "")
BARK_KEY         = os.getenv("BARK_KEY", "")
ICON_URL         = os.getenv("ICON_URL", "")
VEHICLE_MODEL    = os.getenv("VEHICLE_MODEL", "m8_tri_ncm")
# Gist raw 地址,由 charge-location-sync 维护;为空则读 WEATHER_LOCATION 环境变量
LOCATION_URL     = os.getenv("LOCATION_URL", "")

CALIBRATION_WINDOW_START = 13
CALIBRATION_WINDOW_END   = 17
CALIBRATION_TEMP_MIN     = 10.0
CALIBRATION_TEMP_MAX     = 25.0


def get_weather_location() -> tuple[str, str]:
    """
    优先从 Gist 拉取城市信息,格式:城市ID|城市名。
    失败或未配置时 fallback 到 WEATHER_LOCATION 环境变量,城市名返回空字符串。
    """
    if LOCATION_URL:
        try:
            r = requests.get(LOCATION_URL, timeout=5)
            r.raise_for_status()
            raw = r.text.strip()
            if raw:
                parts     = raw.split("|", 1)
                city_id   = parts[0].strip()
                city_name = parts[1].strip() if len(parts) > 1 else ""
                logger.info("从 Gist 读取城市:%s(%s)", city_name or "未知", city_id)
                return city_id, city_name
            logger.warning("Gist 内容为空,使用环境变量城市 ID")
        except Exception as exc:
            logger.warning("Gist 拉取失败,使用环境变量城市 ID:%s", exc)
    logger.info("使用环境变量城市 ID:%s", WEATHER_LOCATION)
    return WEATHER_LOCATION, ""


def validate_env() -> None:
    missing = [v for v in ["WEATHER_API_KEY", "BARK_BASE_URL", "BARK_KEY"] if not os.getenv(v)]
    if missing:
        logger.error("必填环境变量未设置:%s", ", ".join(missing))
        sys.exit(1)


def fetch_hourly_weather(location: str) -> list[dict]:
    url = f"{WEATHER_API_URL}?location={location}&key={WEATHER_API_KEY}&gzip=n"
    try:
        res = requests.get(url, timeout=10)
        res.raise_for_status()
        return res.json().get("hourly", [])
    except Exception as exc:
        logger.error("天气接口请求失败:%s", exc)
        return []


def extract_night_min_temp(hourly: list[dict]) -> Optional[float]:
    temps: list[float] = []
    for entry in hourly:
        fx_time, temp_str = entry.get("fxTime", ""), entry.get("temp")
        if not fx_time or temp_str is None:
            continue
        try:
            if datetime.fromisoformat(fx_time).hour in NIGHT_HOURS:
                temps.append(float(temp_str))
        except (ValueError, TypeError):
            pass
    return min(temps) if temps else None


def suggest_charge_limit(temp: float, model: str) -> str:
    strategy = CHARGE_STRATEGIES.get(model, CHARGE_STRATEGIES["default"])
    usable   = USABLE_CAPACITY.get(model, USABLE_CAPACITY["default"])
    for threshold, target_kwh in strategy:
        if temp >= threshold:
            return f"建议充电至 {round(target_kwh / usable * 100)}%(约 {target_kwh:.1f} kWh)"
    _, last_kwh = strategy[-1]
    return f"建议充电至 {round(last_kwh / usable * 100)}%(约 {last_kwh:.1f} kWh)"


def build_calibration_advice(temp: Optional[float]) -> str:
    if temp is None:
        return (
            "📆 本周为月度校准期,但天气数据获取失败。\n"
            "请手动确认室温是否适合执行 BMS 校准\n"
            "(建议在 10–25℃ 环境下放电至 20% 后充满至 100%)"
        )
    if CALIBRATION_TEMP_MIN <= temp <= CALIBRATION_TEMP_MAX:
        return (
            f"📆 本周为月度校准期,今晚最低气温约 {temp:.1f}℃,温度适宜。\n"
            "🔁 建议执行 BMS 校准:放电至 20% 以下,再充满至 100%"
        )
    reason = "过低" if temp < CALIBRATION_TEMP_MIN else "过高"
    return (
        f"📆 本周为月度校准期,但今晚最低气温约 {temp:.1f}℃(温度{reason})。\n"
        "⏸️ 暂不建议执行深度校准,本次按常规充电策略执行"
    )


def get_off_peak_period(month: int) -> str:
    return OFF_PEAK_BY_MONTH.get(month, "未知时段(请核查电价政策)")


def push_bark(title: str, body: str) -> None:
    url = f"{BARK_BASE_URL}/{BARK_KEY}/{quote(title.encode('utf-8'))}/{quote(body.encode('utf-8'))}"
    params: dict = {"isArchive": "1", "ttl": "86400"}
    if ICON_URL:
        params["icon"] = ICON_URL
    logger.info("推送目标:%s", url)
    try:
        r = requests.get(url, params=params, timeout=5)
        logger.info("推送结果:%s %s", r.status_code, r.text)
        if r.status_code != 200:
            raise RuntimeError(f"Bark 返回非 200 状态码:{r.status_code}")
    except requests.RequestException as exc:
        raise RuntimeError(f"Bark 网络请求失败:{exc}") from exc


def main() -> None:
    validate_env()
    now = datetime.now(BEIJING_TZ)
    in_calibration_window = CALIBRATION_WINDOW_START <= now.day <= CALIBRATION_WINDOW_END

    location, city_name = get_weather_location()
    hourly = fetch_hourly_weather(location)
    temp   = extract_night_min_temp(hourly)
    logger.info("夜间最低温:%s℃", temp if temp is not None else "获取失败")

    off_peak  = get_off_peak_period(now.month)
    title     = "🔋 今日充电提醒"
    city_line = f"📍 {city_name}\n" if city_name else ""

    if in_calibration_window:
        calibration_text = build_calibration_advice(temp)
        if temp is not None and not (CALIBRATION_TEMP_MIN <= temp <= CALIBRATION_TEMP_MAX):
            body = (
                f"{city_line}{calibration_text}\n"
                f"⚡ {suggest_charge_limit(temp, VEHICLE_MODEL)}\n"
                f"🕰️ 低谷充电时段:{off_peak}"
            )
        else:
            body = (
                f"{city_line}{calibration_text}\n"
                f"🕰️ 低谷充电时段:{off_peak}\n"
                "⚠️ 校准期间请在低谷时段开始充电,避免满电长时间搁置"
            )
    elif temp is not None:
        body = (
            f"{city_line}"
            f"🌡️ 今晚最低气温约 {temp:.1f}℃\n"
            f"⚡ {suggest_charge_limit(temp, VEHICLE_MODEL)}\n"
            f"🕰️ 低谷充电时段:{off_peak}"
        )
    else:
        body = (
            f"{city_line}"
            "⚠️ 天气数据获取失败,请手动设定充电上限\n"
            f"🕰️ 低谷充电时段:{off_peak}"
        )

    logger.info("推送内容:\n%s", body)
    try:
        push_bark(title, body)
    except RuntimeError as exc:
        logger.error("推送失败:%s", exc)
        sys.exit(1)


if __name__ == "__main__":
    main()

⁠2.2 健康检查服务(health_check.py)

使用 Python 标准库实现,无需 Flask,减少镜像体积与依赖风险。

from http.server import HTTPServer, BaseHTTPRequestHandler

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/health":
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"OK")
        else:
            self.send_response(404)
            self.end_headers()

    def log_message(self, *_):
        pass

if __name__ == "__main__":
    HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()

⁠2.3 entrypoint.sh
#!/bin/sh
set -e

# 必填变量缺失时拒绝启动
for var in WEATHER_API_KEY BARK_BASE_URL BARK_KEY; do
    eval val=\$$var
    if [ -z "$val" ]; then
        echo "[ERROR] 必填环境变量 $var 未设置,容器退出" >&2
        exit 1
    fi
done

mkdir -p /app/logs
touch /app/logs/charging.log

# 写入 cron 任务
echo "${CRON_SCHEDULE:-30 18 * * *} python /app/m8-smart-charging-enhanced.py >> /app/logs/charging.log 2>&1" \
    > /etc/crontabs/root

# 后台启动健康检查
python /app/health_check.py &

# 后台启动 crond
crond -f -l 8 &

# tail -f 作为前台进程,docker logs 读取其输出
# 捕获 SIGTERM 优雅退出
trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM INT
tail -f /app/logs/charging.log

⁠2.4 Dockerfile
FROM python:3.12-alpine

RUN apk add --no-cache tzdata busybox-suid curl \
    && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && echo "Asia/Shanghai" > /etc/timezone

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY m8-smart-charging-enhanced.py health_check.py entrypoint.sh ./
RUN mkdir -p /app/logs \
    && touch /app/logs/charging.log \
    && chmod +x /app/entrypoint.sh

ENTRYPOINT ["/app/entrypoint.sh"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

⁠2.5 requirements.txt
requests==2.32.3

⁠2.6 docker-compose.yml
services:
  m8-smart-charging:
    image: goordonchen/m8-smart-charging:latest
    container_name: m8-smart-charging
    restart: unless-stopped
    environment:
      TZ:               Asia/Shanghai
      CRON_SCHEDULE:    "30 18 * * *"
      WEATHER_API_URL:  "https://xxxxx.qweatherapi.com/v7/weather/24h"
      WEATHER_API_KEY:  "你的API_KEY"
      WEATHER_LOCATION: "101270101"
      BARK_BASE_URL:    "https://api.day.app"
      BARK_KEY:         "你的BARK_KEY"
      ICON_URL:         "https://fgw2world.site/tuchuang/AITO_logo.png"
      VEHICLE_MODEL:    "m8_tri_ncm"
      LOCATION_URL:     ""   # Gist raw 地址,配合 charge-location-sync 跨服务器更新城市
    volumes:
      - ./logs:/app/logs
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

⁠3️⃣ 构建与部署

⁠3.1 构建镜像(多架构)
# 注册 QEMU(仅首次需要)
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

# 创建多平台 builder(仅首次需要)
docker buildx create --name multiarch --driver docker-container --bootstrap --use

# 构建并推送,同时打两个标签
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t goordonchen/m8-smart-charging:1.0.1 \
  -t goordonchen/m8-smart-charging:latest \
  --push \
  .
⁠3.2 启动容器

通过 Compose(推荐):

docker-compose up -d

通过 docker run:

docker run -d \
  --name m8-smart-charging \
  --restart unless-stopped \
  -e TZ=Asia/Shanghai \
  -e CRON_SCHEDULE="30 18 * * *" \
  -e WEATHER_API_URL="https://xxxxx.qweatherapi.com/v7/weather/24h" \
  -e WEATHER_API_KEY="你的API_KEY" \
  -e WEATHER_LOCATION="101270101" \
  -e BARK_BASE_URL="https://api.day.app" \
  -e BARK_KEY="你的BARK_KEY" \
  -e ICON_URL="https://example.com/icon.png" \
  -e VEHICLE_MODEL="m8_tri_ncm" \
  -e LOCATION_URL="" \
  -v "$(pwd)/logs:/app/logs" \
  goordonchen/m8-smart-charging:latest

⁠4️⃣ 日志与调试

# 实时日志
docker logs -f m8-smart-charging

# 查看日志文件
tail -f logs/charging.log

# 手动触发一次推送
docker exec -it m8-smart-charging python /app/m8-smart-charging-enhanced.py

# 查看当前 cron 任务
docker exec -it m8-smart-charging crontab -l

⁠5️⃣ 环境变量说明

变量必填默认值说明
WEATHER_API_URL✅—和风天气 24h 预报接口完整地址
WEATHER_API_KEY✅—和风天气 API Key
WEATHER_LOCATION101270101初始城市 ID,LOCATION_URL 拉取失败时的 fallback
BARK_BASE_URL✅—Bark 服务地址,如 https://api.day.app
BARK_KEY✅—Bark 设备推送 Key
ICON_URL—推送消息图标 URL(可选)
VEHICLE_MODELm8_tri_ncm车型,见策略模型说明
CRON_SCHEDULE30 18 * * *cron 表达式,默认每天 18:30 执行
LOCATION_URL—Gist raw 地址,配合 charge-location-sync 跨服务器动态更新城市
TZAsia/Shanghai容器时区

⁠6️⃣ 策略模型说明

VEHICLE_MODEL电池类型可用容量 (kWh)温度策略
m8_tri_ncm三元锂51.975≥25℃→70%,≥15℃→80%,≥5℃→85%,≥0℃→60%,极寒→50%
model3_2020_sr_ncm三元锂51.8同上,数值略有差异
default同 m8_tri_ncm51.975与 m8_tri_ncm 相同

策略说明: ≥5℃ 时建议上限最高(85%)并非笔误。0℃ 以下锂电池析锂风险上升,充电量需主动降低;5–25℃ 是电池活性最佳区间,可适当提高上限延长续航。


⁠7️⃣ BMS 月度校准机制

校准窗口期为每月 13–17 日,独立于温度策略,窗口期内每日必然推送校准相关提醒。

温度条件推送内容
10–25℃(适宜)建议执行校准:放电至 20% 以下,充满至 100%
< 10℃ 或 > 25℃提示本周为校准期,温度不适宜,本次按常规策略执行
天气数据获取失败提示本周为校准期,建议人工判断室温后决定是否执行

校准期建议在低谷时段开始充电,且充满后尽快使用,避免满电长时间搁置。


⁠8️⃣ 低谷电价时段

适用于成都地区(四川电网),政策调整时需同步更新脚本。

月份低谷时段
3、4、5、6、10、11 月22:00 — 08:00
7、8、9 月01:00 — 07:00
12、1、2 月00:00 — 08:00

Tag summary

Content type

Image

Digest

sha256:6708b84e7…

Size

23.8 MB

Last updated

4 months ago

docker pull goordonchen/m8-smart-charging