这是什么
TeslaMate 是一个开源的特斯拉数据记录器:通过特斯拉官方 API 拉车辆数据(位置、电量、充电、行程、温度等),存进自己掌控的 PostgreSQL,再用 Grafana 出图表。它是车主完全自己拥有数据的一种选择。
但 TeslaMate 原项目为海外用户写,国内跑会撞上几堵墙:
- 默认反向地理编码用 OpenStreetMap Nominatim,国内不通
- 地图坐标用 WGS-84(GPS 原始),中国法律要求公开地图用 GCJ-02 偏移坐标系,直接叠加偏移 50-500 米
此外,原项目推荐 Docker Compose 部署,但我希望跑在手机上,Termux 跑不了 Docker。
本文记录把整套 TeslaMate 部署到一台 Android 手机(小米 Redmi)+ Termux 的实战,连带几个值得留意的细节。最终成果是手机里持续跑着 8 个服务,开车一公里数据立即可视化。
整体方案
硬件
- 设备:Redmi(Android 15)+ Termux app
- 架构:aarch64(64-bit ARM 原生,不经 PRoot/QEMU 翻译)
- 内存:7.3 GB,编译期峰值 4-5 GB
- 磁盘:30 GB+
- 网络:家用 Wi-Fi 即可,无需公网 IP
为什么选手机:本身就插着电、有屏幕、24 小时在线、aarch64 性能足够跑 Elixir/PostgreSQL,且数据完全留在内网。还以为,便宜不花钱。(用的是我废弃的手机)
软件栈
五个开源项目共享一个 PostgreSQL:
1 | Tesla API |
读写边界是安全约定:
- TeslaMate:写 drives / positions / charging_processes
- Grafana:只读
- CyberUI:只读(除了 ui_settings 表它自己写)
- fix_addrs:只写 addresses 表,不动 GPS 原始数据
- merge_daemon:只写 drive_merge_groups 侧表(自己建的,不动 TeslaMate 原表)
坐标系统:分两层处理,不可合并
数据库永远存 WGS-84。两个 UI 在不同层做 GCJ-02 纠偏:
| 组件 | 纠偏位置 |
|---|---|
| Grafana | PostgreSQL 里的 wgs84_to_gcj02() 存储过程,Grafana 查询时调用 |
| CyberUI | Go 后端代码里调用工具函数 |
不要把两者合并到任一层——数据库必须保持 WGS-84,因为 fix_addrs 调用腾讯 API 时用 coord_type=1 直接吃 WGS-84,跳过坐标转换。
网络:用蒲公英解决"不在家就访问不到"
手机蹲在家里 Wi-Fi 上,外出时访问不通。
我用蒲公英解决这个问题。蒲公英是 Oray 贝锐 出的 mesh VPN:所有装了客户端并登录同一账号的设备会自动组成一个虚拟局域网(默认 172.16.x.x 段),互相通过虚拟 IP 直连,不需要公网 IP、不需要路由器端口转发、自动 NAT 穿透。免费档 3 台设备,限速 1 Mbps,看 TeslaMate / Grafana / CyberUI 这种轻量应用完全够。
部署
- 手机端:安卓应用商店搜"蒲公英"装上(不是在 Termux 里跑——Termux 没权限创建 TUN 设备,但 Android app 通过系统 VpnService API 可以)。登录 → 加入网络 → 授权 VPN,手机就拿到一个
172.16.x.x固定虚拟 IP - 笔记本 / iPhone 客户端:官网下载 或对应应用商店,登同一账号、加入同一网络
- 使用:浏览器打
http://172.16.x.x:4000(TeslaMate)、:3000(Grafana)、:8080(CyberUI),SSH 用ssh -p 8022 root@172.16.x.x
为什么不用 Tailscale
我最初装的是 Tailscale,跑了几天就换掉了。Tailscale 设计上没毛病(基于 WireGuard,开源,全球节点,免费档 100 设备额度),但在大陆有结构性的水土不服。
打洞失败时回退海外 DERP 中继(东京 / 旧金山 / 法兰克福)。三大运营商家宽十之八九带 CGNAT,蜂窝数据网必然 CGNAT,双方都在 CGNAT 后面 P2P 极难打通,几乎必然回退中继。中继单程 80–200ms 起,叠加 WireGuard 封装后,TCP RTT 起步 150ms+,看 Grafana dashboard 加载要好几秒,看 CyberUI 实时地图直接劝退。蒲公英同样是 P2P + 中继架构,但中继节点在大陆,单程 30–50ms。
开工前的硬约束
部署前要先解决两件事:禁掉 Android 的 Phantom Process Killer,以及了解 Termux 跟标准 Linux 的几个不同。这两组都不是技术上的难点,是平台特性——绕不开,开工前先认清楚后面会顺很多。
Android Phantom Process Killer
PPK 是什么
Android 12+ 对每个 app 的"幻影子进程"数有上限(默认 32 个)。超出后系统会随机 SIGKILL 最老的子进程,无警告、无日志。"幻影"指原生 fork 出来、不在 Android lifecycle 里的 child—— sshd 子进程、bash、beam.smp、gcc、node 全是。
触发症状:长编译跑到一半,sshd / postgres / mosquitto 突然全死,但几个进程(启动较晚的 beam.smp、grafana-server)还在。不是内存,不是电池,纯粹是进程数。
原生 Android 的禁用方式
ADB 两条命令搞定:
1 | adb shell "settings put global settings_enable_monitor_phantom_procs false" |
验证:
1 | adb shell "settings get global settings_enable_monitor_phantom_procs" |
重启手机后保留。
小米/澎湃 OS 的两道暗门
我用的是 Redmi 手机,跑澎湃 OS。直接跑上面 ADB 会报:
1 | java.lang.SecurityException: Permission denial: writing to settings requires: |
第一道暗门:「USB 调试(安全设置)」开关
原生 Android 的 ADB 默认就有 WRITE_SECURE_SETTINGS。小米/澎湃 OS 要单独打开一个隐藏开关——「USB 调试(安全设置)」,位置在设置 → 开发者选项 → 往下滑找。
第二道暗门:必须插实体 SIM 卡
点了「USB 调试(安全设置)」会弹窗:
需要登录小米账号且手机已插入 SIM 卡才能开启
这是小米为了防止二手机倒卖后被远程刷机做的硬性反措施。
Termux 平台特性
路径完全不一样
| 标准 Linux | Termux |
|---|---|
/opt/... |
不存在,用 ~/teslamate/ 或 $PREFIX/var/... |
/etc/... |
$PREFIX/etc/... |
/usr/... |
$PREFIX/...($PREFIX=/data/data/com.termux/files/usr) |
/tmp |
$PREFIX/tmp 真实存在;但绝对路径 /tmp 在 Android 里不存在(TeslaMate 写死 TZDATA_DIR=/tmp,必须改) |
| 多用户、postgres user | 全是 Termux app 的单 user |
文件系统不支持硬链接
Termux 的 /data/data/com.termux/files/... 是 Android 内置存储,不允许 hardlink。mix deps.get 默认要 hardlink 做 compilation lock,会挂掉报:
1 | could not create hard link from ... permission denied. |
修法:所有 mix 调用前 export MIX_OS_CONCURRENCY_LOCK=0。
没有 systemd
Termux 没有 systemd,但 termux-services 包提供 runit,行为类似。也可以用 setsid + nohup 简单做。我最后选 setsid + nohup + Termux:Boot 应用,对单用户场景已经够用。
安装路线
pkg install 装齐全套
Termux 是 Android app 沙盒,没有 cgroup 写权限,Docker daemon 跑不起来。所以本方案走原生 pkg install + 源码编译路线:
1 | pkg install postgresql postgis erlang elixir golang nodejs nginx mosquitto \ |
Termux 仓库走 Cloudflare CN CDN,国内直连 1-2 秒一个包,不需要代理。
实测装到的版本(与 TeslaMate 官方 Dockerfile 对齐):
| 组件 | 版本 |
|---|---|
| PostgreSQL | 18.2 |
| PostGIS | 3.6.3 |
| Erlang/OTP | 28(带 JIT) |
| Elixir | 1.19.5 |
| Go | 1.26.3 |
| Node.js | 26.1.0 |
| Grafana | 12.3.3 |
| Python | 3.13.13 |
PostgreSQL:cube 和 earthdistance 必须从源码编
TeslaMate 重度依赖 ll_to_earth / earth_distance / earth_box 做地理围栏计算。这些函数由 earthdistance 扩展提供,它又依赖 cube。
Termux 默认 contrib 不带 cube 和 earthdistance——但好消息是 Termux 的 postgresql 包带完整 pgxs + dev headers + clang + flex + bison,可以 5 分钟从源码编出来:
1 | cd $HOME/teslamate/build |
产物落到 $PREFIX/share/postgresql/extension/ 和 $PREFIX/lib/postgresql/。然后:
1 | psql -h $PREFIX/var/run -p 5433 -d teslamate <<SQL |
PostgreSQL:单用户初始化
Termux 装出来的 PG 以 Termux app 用户身份跑,不像 Debian 有 postgres 用户。直接:
1 | initdb -D $PREFIX/var/lib/postgresql \ |
TeslaMate:编译 Elixir release
1 | cd $HOME/teslamate/src/teslamate |
整个过程在手机上 6 分钟左右(编译 cldr_utils 那段最慢,单核 100%)。
CyberUI
DeaglePC/TeslamateCyberUI 是一个 Go 后端 + React PWA 前端的 TeslaMate 手机端,赛博朋克风格,做得很漂亮。在国内场景下我做了几处适配(高德 Web JS API 安全密钥的完整接入、新部署 drives 表为空时的边界处理、补一些国内 Tesla API 上报的颜色字符串到配色表等),整理成 fork:KakaWanYifan/TeslamateCyberUI。具体改动见 fork main 分支的 commit log。
部署时直接用 fork:
1 | git clone https://github.com/KakaWanYifan/TeslamateCyberUI.git |
fix_addrs
TeslaMate 默认用 OSM Nominatim 做反向地理编码,国内访问不稳,导致 addresses 表多数字段为空。fix_addrs 是个补救脚本——读出 drives / charging_processes 里 address_id IS NULL 的记录,再调一次反向地理 API 把地址写回。
国内的反向地理 API,候选只有腾讯和高德。fix_addrs 选腾讯不选高德,唯一理由:腾讯 Web Service API 支持 coord_type=1 直接吃 WGS-84 坐标,跟数据库里 TeslaMate 写入的原始 GPS 坐标格式一致,跳过 WGS-84 → GCJ-02 的转换步骤。高德 Web Service 不支持 WGS-84 输入,必须先转 GCJ-02,多一步、多一个出错点。
顺便区分一下:CyberUI 前端用的高德 JS API 是地图瓦片服务,跟反向地理编码是两个完全不同的接口、不同的 Key 类型。别混淆。
部署时直接用 fork(fork 加了腾讯优先分支):
1 | 不要拉原仓库 |
补丁已 commit 在 fork 的 main 分支:
fix(geocoder): use Tencent for mode-0 fix-empty path when key is set
启动 fix_addrs 前还要去腾讯位置服务控制台做一步配额分配,否则会撞到 status=121,具体见后面「fix_addrs 三层独立的细节」。
merge_daemon:临停误切的合并
TeslaMate 用 IGN OFF(点火关闭)作为一段 drive 结束的信号。停车买杯咖啡 5 分钟再继续开,TeslaMate 会判定成两段独立 drive——明明一次连贯出行,行程列表却多出一行几百米的"碎片",体验很差。
merge_daemon 是后台 Python 守护进程,每分钟扫一遍 drives 表,把符合规则的相邻两段标记成"同一组":
- 同一辆车按
start_date, id排序的相邻两段 - 上一段终点到下一段起点 ≤ 50 米(
earthdistance算地表距离) - gap 内非充电闲置 ≤ 30 分钟(= 墙钟间隔 − 累计充电时长)且累计充电 ≤ 2 小时——单一墙钟阈值兼容不了"服务区充 30 分钟"(想合)和"公司停车 1.5 小时不充电"(不想合)这两个对立场景,拆两半各管一边
- 支持链式合并:A→B 合并后,C 与 B 满足规则就也归入 A 组
中途充电会让 start_ideal_range_km - end_ideal_range_km 变负数。视图 drives_merged 加 3 列 net_range_consumed_km / gap_charge_added_soc / gap_charge_added_kwh 把充电增量扣回去,UI 显示"中途充电 +X%"提示;电量 / 续航的绝对值保留车机真实读数。
daemon 不动 TeslaMate 原表,只 INSERT 自建的 drive_merge_groups 侧表 + drives_merged / positions_merged 视图,CyberUI 改读视图。误合就 DELETE 一行还原,调阈值就 TRUNCATE 重扫历史,TeslaMate 全程无感。Grafana 不切——49 个面板的 SQL 都 FROM drives,改不动;好在 SUM 类统计前后同值,只有 COUNT 行程数会少几行(反而提示"原始 drive 数 vs. 合并后行程数",有信息量)。
源码在 CyberUI fork 的 merge-daemon/ 目录,跟 CyberUI 一起 git pull 更新。部署:
1 | cd ~/teslamate/src/TeslamateCyberUI/merge-daemon |
跟 fix_addrs 完全解耦:merge_daemon 只看 GPS 和充电时长,fix_addrs 只写地址,互不依赖,起停顺序无所谓。
其他几个细节
TeslaMate:TZDATA_DIR=/tmp 写死
TeslaMate config/runtime.exs:189 写死:
1 | config :tzdata, :data_dir, System.get_env("TZDATA_DIR", "/tmp") |
Termux 下绝对路径 /tmp 不存在 → tzdata 启动找 /tmp/release_ets 失败 → BEAM 整个崩溃。
修法:提前 seed:
1 | mkdir -p $HOME/teslamate/run/tzdata/release_ets |
TeslaMate:mix release 不会自动跑 migration
Elixir release 完之后必须手工触发:
1 | HOME/teslamate/bin/teslamate-app/bin/teslamate eval 'TeslaMate.Release.migrate' |
99 个 migration 应该一次跑过。没跑 migration 直接 start 会因为 private.tokens 表不存在崩溃。
Grafana:datasource 的两个隐性细节
这是整套部署里最让人默默崩溃的环节——49 个仪表盘全空,但 Grafana 不报错。
细节 1:datasource UID 必须显式设成 TeslaMate
provisioning yaml 不写 uid: 字段时,Grafana 会自动生成一个随机 UID。但 chinese-dashboards 的 49 个仪表盘里每个 panel 都硬编码引用 {"uid": "TeslaMate"}——UID 不匹配则所有查询静默失败,页面所有 panel 都显示「无数据」。
细节 2:database 字段必须放 jsonData 下
症状:仪表盘所有 panel 红三角告警,hover 弹出:
1 | You do not currently have a default database configured for this data source. |
但 yaml 里明明写了 database: teslamate。/api/datasources/uid/<x> 返回顶层 database: "teslamate" 看着正确,Grafana 12 的 postgres datasource plugin 不再读这个字段。
根因:Grafana 11+ 的 postgres datasource 把 database 配置从顶层移到了 jsonData.database。老 yaml 格式 Grafana 接受但不生效(不报错,silent 失败)。
正确的 datasource yaml
1 | apiVersion: 1 |
验证:
1 | curl -u "admin:$GRAFANA_PASSWORD" \ |
期望 {"uid": "TeslaMate", "db": "teslamate"}。两者必须都不是 null。
CyberUI:后端必须装 PostGIS
CyberUI 后端的 GetStatus 用了 ST_Contains / ST_Buffer / ST_SetSRID / ST_MakePoint 算地理围栏匹配。不装 PostGIS 时整个 query 报 type "geometry" does not exist → silent 失败 → 返回的 status JSON 缺 latitude/longitude/insideTemp/outsideTemp/geofence 字段 → 前端 MapCard 显示「无位置信息」。
TeslaMate 本体不依赖 PostGIS,但 CyberUI 后端必须装。
CyberUI:PG 默认时区 PRC,60 分钟窗口失效
部署完一切看着都对,但 CyberUI 主页温度卡显示 --(不是数字)。
挖到的根因:TeslaMate 用 Ecto :utc_datetime_usec 写入 positions.date 列。这个列类型是 timestamp without time zone,存的是 UTC 数值但没带 TZ 元信息。
CyberUI 后端 stats_repository.go:181 查"最近 60 分钟温度":
1 | WHERE date >= (NOW() - INTERVAL '60 minutes') |
PG 比较 timestamp without tz 和 timestamptz 时,会用 session TZ 来解释 timestamp。initdb 默认拿系统时区,是 PRC (+08):
- 数据库存的:
04:59:38(UTC 数值) - PG 用 PRC 解释成:
04:59:38+08,相当于 UTC20:59:38 昨天 NOW():13:11:08+08=05:11:08 UTC 今天- 比较:04:59:38 < 12:11:08(也就是 60 分钟前)→ 不在窗口,过滤掉
数据真实只滞后 11 分钟,但 PG 当成 8 小时前的。
修法:
1 | ALTER SYSTEM SET timezone = 'UTC'; |
效果持久化在 $PREFIX/var/lib/postgresql/postgresql.auto.conf,重启 PG 也保留。
对 Grafana 的影响:无。Grafana 的 $__timeFilter() 宏用绝对 UTC 时间戳,chinese-dashboards 用 3-arg date_trunc(field, source, timezone) 显式带时区。两者都跟 PG session TZ 无关。
fix_addrs:三层独立的细节
部署完发现 CyberUI 行程列表里所有起点终点都是 Unknown:
1 | Unknown |
照理 fix_addrs 应该用腾讯地图把地址补全。挖下去发现卡在三层,少一层都跑不通。
第一层:fix_addrs 默认走 OSM,国内不通
原项目 hipudding/teslamate_fix_addrs 的"补空地址"路径默认走 OpenStreetMap Nominatim——国内不通。我 fork 一份加了腾讯优先分支:KakaWanYifan/teslamate_fix_addrs,配了 -k <腾讯 key> 参数时优先用腾讯,国内能跑通。补丁细节见 fork 的 commit log。
第二层:腾讯位置服务的"总额度 ≠ KEY 额度"
补丁打完,fix_addrs 终于调腾讯了。第一次调用成功返回了一个地址"上海外滩茂悦大酒店",紧接着第二次:
1 | {"status": 121, "message": "此key每日调用量已达到上限"} |
控制台「我的额度」里这个 KEY 显示今日调用量 0。怀疑过 QPS 限流、KEY 失效、试用 grace 之类。但 3 次直接 curl 间隔 ≥2 秒(远低于 5 RPS),次次 121。
根因:腾讯位置服务额度是两层结构:
- 账户总额度:个人开发者 = 逆地址解析 6000 次/日 + 5 并发
- 每个 KEY 单独的额度:默认 = 0,必须手动从总额度里分配
我创建 KEY 后只点了「申请」,没去「账户额度 → 配额分配」分配过,KEY 的可用额度就是 0。状态码 121 在这种情况是真实的——它的描述是「KEY 调用量已达到上限」,但 0 也算"达到上限"。
修法:
- 浏览器打开 腾讯位置服务 → 账户额度
- 找「逆地址解析」
/ws/geocoder/v1?location=*这一行 - 右侧点「配额分配」→ 给你的 KEY 填调用量 6000 + 并发 5(或一键分配)
- 保存。30 秒内生效
第三层:首次试用 grace 让人误判
腾讯对新申请未分配额度的 KEY 似乎有 1-2 次「试用 grace」额度,用掉就归 0。日志会显示「先成功后超额」的诡异序列,很容易让人以为是 QPS 限流。
验证不是限流:3 次 curl 间隔 ≥2 秒(远低于 5 RPS),看是不是次次都 121。如果是,就不是 QPS 而是上面的配额没分配。
1 | for i in 1 2 3; do |
修完三层后
腾讯配额分配完 30 秒内,fix_addrs 自动捡到那趟 drive:
1 | INFO - checking empty records... |
CyberUI 行程页刷新,Unknown 变成「上海外滩茂悦大酒店 → 上海外滩茂悦大酒店」(起点和终点距离 2 米,看着像没动)。
验收
部署完成后应满足:
| 端口 | 期望 |
|---|---|
:4000 |
TeslaMate 控制台 → 302 → /sign_in(粘 Tesla token 开始记录) |
:3000 |
Grafana → 302 → /login,49 个中文仪表盘可见,数据有 |
:8080 |
CyberUI 主页,赛博朋克风格,地图可见,行程地址非 Unknown |
:8899/ |
CyberUI 后端,404(根路径不定义;活着的标志) |
:5433 |
PostgreSQL,loopback only |
:1884 |
Mosquitto MQTT,loopback only |
一行命令验证:
1 | for p in 4000 3000 8080 8899; do |
致谢
部署中遇到的所有问题能挖出根因,靠的是 Claude Code(Anthropic 的官方 CLI Agent)一路 SSH 上手机、读日志、读源码、改代码、跑实验。整个部署从干净 Termux 到跑通,耗时约 2 小时,期间产出了三份文档和两个 fork 仓库的补丁。