irdbl/tesla-cam-client

GitHub: irdbl/tesla-cam-client

一个 Node.js 库,通过 Tesla Owner API 和 WebRTC 按需获取车辆摄像头静态图片和短视频,专为轮询式自动捕获而设计。

Stars: 0 | Forks: 0

# tesla-cam-client 通过 Node.js,经由 Owner API 和 Hermes WebRTC 路径,从 Tesla 车辆的摄像头获取**静态图像**和**短视频片段**。专为按需捕获和轮询(例如“每隔几分钟抓拍一张前置摄像头静态图像”)而设计,而非持续流媒体传输。 每次捕获都会从头开始重新连接 —— 唤醒 → 已签名会话 → Hermes → WebRTC → 捕获 → 拆除 —— 然后完全断开连接。调用之间没有持久会话,这样可以避免车辆一直处于唤醒状态,并防止在轮询期间耗尽网络摄像头的数据流量上限。 ## 目录 - [环境要求](#requirements) - [安装](#install) - [快速入门](#quick-start) - [一次性设置](#one-time-setup) - [认证:静态 token 与自动刷新](#auth-static-token-vs-auto-refresh) - [健壮的轮询器](#a-resilient-poller) - [摄像头视角](#camera-angles) - [延迟与唤醒周期](#latency--the-wake-cycle) - [CLI](#cli) - [API](#api) - [错误](#errors--teslacamerror) - [故障排除](#troubleshooting) - [注意事项与限制](#notes--limitations) ## 环境要求 - **Node 18+**(使用全局 `fetch`)且 **ffmpeg 需在 `PATH` 中**(用于将帧编码为 JPEG / MPEG-TS)。 - 车辆必须处于**停放**状态,并且能在 Hermes 上访问(与 Tesla 应用的实时摄像头视图所需的条件相同)。 - 下方的[一次性设置](#one-time-setup):在车辆上注册的 ROLE_OWNER 密钥、Owner API token 以及您的 VIN。 ## 安装 此包未发布到 npm。请从本地路径或代码检出安装: ``` # 从本地 checkout 安装(添加一个 file: 依赖) npm install /path/to/tesla-cam-client # 或者 clone 并安装其自身的 deps 以直接运行 CLI git clone tesla-cam-client cd tesla-cam-client && npm install ``` 然后导入它: ``` import { TeslaCamClient, TeslaTokenManager, TeslaCamError } from 'tesla-cam-client'; ``` ## 快速入门 ``` import { TeslaCamClient } from 'tesla-cam-client'; import { writeFile } from 'node:fs/promises'; const client = new TeslaCamClient({ accessToken: process.env.TESLA_ACCESS_TOKEN, // or getAccessToken (see Auth) vin: process.env.VIN, ownerKeyPath: './owner-key.pem', }); const { image } = await client.getStillImage({ camera: 'FRONT' }); // Buffer (JPEG) await writeFile('front.jpg', image); const { clip } = await client.getClip({ camera: 'BACK', durationMs: 5000 }); // Buffer (MPEG-TS) await writeFile('back.ts', clip); ``` ## 一次性设置 在任何调用生效之前,需要完成三件事。其中第一件最为繁琐。 ### 1. 在车辆上注册的 ROLE_OWNER 密钥 (`owner-key.pem`) 已签名的命令通过 EC **P-256 (prime256v1)** 密钥对进行身份验证,其公钥需以 **owner** 权限在车辆上注册。生成密钥: ``` openssl ecparam -genkey -name prime256v1 -noout -out owner-key.pem ``` 然后将其公钥以 owner 角色注册到车辆上。这与 Tesla 应用 / [`teslamotors/vehicle-command`](https://github.com/teslamotors/vehicle-command) 所使用的密钥配对流程相同(通过深度链接注册或在车内刷 NFC 卡片以批准密钥)。只有 `ROLE FM`/driver 权限的密钥在执行摄像头命令时会因权限不足被拒绝 —— 它必须是 **owner** 权限。 ### 2. Owner API token 您需要一个 Tesla OAuth **access token**(短期有效,约 8 小时),最好再准备一个 **refresh token**(约 90 天),以便长时间运行的脚本能够自动续期。 通过 Owner API OAuth2 PKCE 登录流程获取它们(`client_id=ownerapi`,`redirect_uri=tesla://auth/callback`) —— 任何能生成 Owner API token 的 Tesla token 生成器都可以。将它们放入 `.env` 中的 `TESLA_ACCESS_TOKEN` 和 `TESLA_REFRESH_TOKEN`(参见 `.env.example`)。**注意:** Owner API 目前仅接受*登录*获取的 token,而不接受刷新得到的 token —— 当约 8 小时的 access token 过期时,您需要重新登录(请参阅下方的刷新注意事项)。 ### 3. VIN 您车辆的 VIN 字符串,例如 `5YJ3E1EA4NF000000`。 ## 认证:token 与 Owner API 刷新注意事项 Access token 大约在 8 小时后过期。对于**一次性任务**或短脚本,静态的 `accessToken` 就足够了。您仍然可以传入 **`refreshToken`**(无害且具有前瞻性) —— 客户端在失败时会尝试刷新,如果刷新无济于事,则会抛出 `reauth_required`: ``` import { TeslaCamClient } from 'tesla-cam-client'; const client = new TeslaCamClient({ refreshToken: process.env.TESLA_REFRESH_TOKEN, accessToken: process.env.TESLA_ACCESS_TOKEN, // optional seed; reused until near expiry vin: process.env.VIN, ownerKeyPath: './owner-key.pem', // Optional: persist rotated tokens so restarts don't need a fresh login. onTokenRefresh: ({ accessToken, refreshToken }) => writeFileSync('tokens.json', JSON.stringify({ accessToken, refreshToken })), }); ``` 该 token 会一直使用,直到其真正过期;刷新仅在失败后(身份验证失败后)按需尝试,绝不主动进行。如果该刷新也失败(这是当前 Owner API 的行为),调用将拒绝并返回 `reauth_required` —— 请通过重新登录进行身份验证并更新您的 token。`client.refreshToken` 会暴露当前的 token。 **其他认证形式:** - 仅静态 token:传入 `accessToken`(不刷新)。 - 自带提供商:传入 `getAccessToken: async ({ forceRefresh }) => string`(如果您在其他地方管理 token)(与 `refreshToken`/`accessToken` 互斥)。 - 如果您想在多个客户端之间共享同一个刷新器,`TeslaTokenManager` 依然被导出;将其 `.getAccessToken` 作为 `getAccessToken` 传入即可。 ## 健壮的轮询器 一个完整的长时间运行示例:自动续期 token,在重启期间持久化保存它们,并在触发数据上限或车辆离线时进行退避。 ``` import { TeslaCamClient, TeslaCamError, TeslaCamErrorCode } from 'tesla-cam-client'; import { writeFileSync, readFileSync, existsSync } from 'node:fs'; const TOKENS = './tokens.json'; const saved = existsSync(TOKENS) ? JSON.parse(readFileSync(TOKENS, 'utf8')) : {}; const client = new TeslaCamClient({ refreshToken: saved.refreshToken ?? process.env.TESLA_REFRESH_TOKEN, accessToken: saved.accessToken ?? process.env.TESLA_ACCESS_TOKEN, vin: process.env.VIN, ownerKeyPath: './owner-key.pem', onTokenRefresh: (t) => writeFileSync(TOKENS, JSON.stringify(t)), // survive restarts }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); for (;;) { try { const { image } = await client.getStillImage({ camera: 'FRONT' }); writeFileSync(`snap-${Date.now()}.jpg`, image); await sleep(5 * 60_000); // every 5 minutes } catch (e) { const code = e instanceof TeslaCamError ? e.code : null; if (code === TeslaCamErrorCode.REAUTH_REQUIRED) { // A refresh won't help — a human must log in again. Alert and stop retrying. notifyMe('Tesla cam: reauth required — log in and update tokens'); break; } else if (code === TeslaCamErrorCode.DATA_LIMIT_REACHED) { await sleep(30 * 60_000); // cap hit — back off hard } else if (code === TeslaCamErrorCode.VEHICLE_OFFLINE) { await sleep(10 * 60_000); // car asleep/away — try later } else { console.error('capture failed:', code ?? e.message); await sleep(60_000); } } } ``` ## 摄像头视角 将 `camera` 传给任何捕获方法(不区分大小写,下划线/连字符不敏感,例如 `RIGHT_REPEATER` == `right-repeater`)。**可用的视角以及每个视角具体返回的内容取决于车型和固件。** 在 Model 3(2026 年中固件)上观察到的结果: | `camera` | 返回内容 | |---|---| | `FRONT` | 所有摄像头的 2×2 合成网格 | | `GRID` | 2×2 合成网格 | | `BACK` | 单个后视/倒车鱼眼镜头 | | `LEFT_REPEATER` | 单个左侧 repeater | | `RIGHT_REPEATER` | 单个右侧 repeater | | `LEFT_PILLAR` | 单个左侧 B 柱 | | `RIGHT_PILLAR` | 单个右侧 B 柱 | 请注意,这种映射并不统一 —— 某些 token(`FRONT`、`GRID`)返回的是合成画面,而另一些则返回单个画面源。无法识别的 token 通常会回退到默认视图,而不是报错。测试车辆上的帧大小为 456×342。 ## 延迟与唤醒周期 每次调用都会从头开始重新连接,其中主要的耗时在于唤醒车辆: - **冷启动**(车辆休眠):第一次调用可能需要 **30–45 秒**,因为车辆需要唤醒并上线。 - **热启动**(最近处于活动状态 / 刚刚捕获过):随后的调用端到端通常只需 **~4–8 秒**。 `getStills()` 利用了这一点:它通过**一次**唤醒捕获 N 帧图像,而不是为每张静态图像都付出唤醒的代价。每个结果都包含 `latencyMs`,方便您观察。 ## CLI ``` # 将静态图片保存到文件(默认读取当前目录下的 .env) node bin/tesla-cam-cli.mjs still --camera FRONT -o front.jpg # 从一次 session 中获取多张静态图片 -> still-0.jpg, still-1.jpg, ... node bin/tesla-cam-cli.mjs stills --camera FRONT --count 5 --interval 750 -o still # 5秒 clip node bin/tesla-cam-cli.mjs clip --camera BACK --duration 5000 -o back.ts # 将连续的实时画面通过管道传递给播放器(仅限 CLI 的便捷功能) node bin/tesla-cam-cli.mjs watch | mpv - # 指定特定的 .env(除非设置了 OWNER_KEY_PATH,否则会在其旁边解析 owner-key.pem) node bin/tesla-cam-cli.mjs still --env /etc/tesla/.env -o /tmp/front.jpg ``` CLI 会从 `.env`(或真实系统环境)中读取 `VIN`、`TESLA_ACCESS_TOKEN` 和/或 `TESLA_REFRESH_TOKEN`。如果存在 `TESLA_REFRESH_TOKEN`,它会自动刷新,并将轮换后的 token 写回 `.env`。退出代码:`2` = 达到网络摄像头**数据上限**,`3` = **需要重新认证**(再次登录),`1` = 任何其他错误,`0` = 成功。 ## API ### `new TeslaCamClient(options)` 提供唯一的认证来源:`refreshToken`、`accessToken` 或 `getAccessToken`。 | option | type | notes | |---|---|---| | `refreshToken` | `string` | 客户端自行管理 token 生命周期(按需刷新 + 重试)。 | | `accessToken` | `string` | 仅使用静态 token(不刷新),或作为 `refreshToken` 的种子。 | | `onTokenRefresh` | `(tokens) => void \| Promise` | 在内部刷新后被调用,带有 `{ accessToken, refreshToken, expiresAt }`。 | | `getAccessToken` | `async ({ forceRefresh }) => string` | 自带提供商;与 `refreshToken`/`accessToken` 互斥。 | | `vin` | `string` | **必填。** | | `ownerKeyPath` | `string` | PEM 文件的路径。提供此参数**或** `ownerKeyPem`。 | | `ownerKeyPem` | `string` | 内联的 PEM 内容。 | | `ownerApiBase` | `string` | 默认为 `https://owner-api.teslamotors.com`。 | | `hermesUri` | `string` | 默认为生产环境的 Hermes 信令 URL。 | | `userAgent` | `string` | `X-Tesla-User-Agent` 值。 | | `debug` | `boolean` | 将连接进度记录到 stderr。 | 构造函数**不执行任何 I/O** —— 密钥文件和 `@roamhq/wrtc` 原生模块会在首次捕获时延迟加载。 ### `await client.getStillImage({ camera?, timeoutMs? })` 返回 `{ image: Buffer /* JPEG */, width, height, latencyMs, camera }`。 ### `await client.getStills({ camera?, count?, intervalMs?, timeoutMs? })` 从**一次会话**(一次唤醒/连接)中捕获多张静态图像,间隔约为 `intervalMs` —— 相比多次单独调用 `getStillImage()`,这样成本更低且对车辆更友好。返回 `{ images: [{ image: Buffer /* JPEG */, width, height, capturedAt }], count, latencyMs, camera }`。 `count` 默认为 3,`intervalMs` 默认为 1000。 ``` const { images } = await client.getStills({ camera: 'FRONT', count: 5, intervalMs: 750 }); images.forEach((s, i) => fs.writeFileSync(`front-${i}.jpg`, s.image)); ``` ### `await client.getClip({ camera?, durationMs?, timeoutMs? })` 返回 `{ clip: Buffer /* MPEG-TS */, durationMs, latencyMs, camera }`。 `durationMs` 默认为 5000。 ### `await client.openLiveStream({ camera?, onFrame, timeoutMs? })` 底层的流式传输原语:连接一次并为每个解码后的 I420 帧(`{ width, height, data }`)调用 `onFrame(frame)`,直到您调用返回的 `handle.close()`。生命周期由您自行管理。 ### 行车记录仪 & 哨兵片段 浏览并下载存储在车辆 USB 驱动器中的行车记录仪/哨兵片段。这依赖于一个独立的原生 WebRTC DataChannel(而非实时摄像头的媒体通道);片段以 H.264 格式流式传输,并在客户端混合封装为 MP4。 ``` const dash = await client.openDashcam(); // one wake/connect for many ops const clips = await dash.listClips(); // [{ eventPath, eventName, type, isSentry }] const meta = await dash.getMetadata(clips[0].eventPath); const { video } = await dash.downloadClip({ // MP4 Buffer eventPath: clips[0].eventPath, camera: 'FRONT', startMs: 0, durationMs: 10000, }); await fs.writeFile('clip.mp4', video); await dash.close(); // one-shot convenience (each opens + closes its own session): const clips2 = await client.listClips(); const { video: v } = await client.downloadClip({ eventPath, camera: 'BACK', durationMs: 5000 }); ``` `getMetadata` 返回片段的事件详情,包括其位置以及(对于哨兵事件)触发它的原因: | field | example | notes | |---|---|---| | `name` | `SentryClips/2024-01-15_09-30-00` | 完整的事件路径 | | `city` | `San Francisco` | 车辆进行逆地理编码的结果 | | `lat` / `lon` | `37.7749` / `-122.4194` | 预估的事件位置 | | `reason` | `sentry_aware_object_detection` | 哨兵触发原因(普通行车记录仪则为 null) | | `totalDurationMs` | `643577` | 该事件可用的总录像时长 | | `eventEpochTimeMs` / `earliestClipTimeMs` | `1784465148000` | 事件 / 最早片段的时间戳 | | `cameras` | `["front","back",...]` | 此片段可用的摄像头视角 | | `thumbnail` | `` | 随元数据流式传输的预览 PNG(约 128×83),或为 `null` | | `raw` | `{...}` | 完整的解码后元数据对象 | - 如果您省略 `durationMs`,`downloadClip` 将获取该片段对应时长的元数据。 - Cameras:`FRONT`、`BACK`、`LEFT_REPEATER`、`RIGHT_REPEATER`、`LEFT_PILLAR`、`RIGHT_PILLAR`(每个片段的可用性见 metadata 的 `cameras`)。 - 需要车辆处于停放状态并插入 USB/闪存驱动器;`no_flash_drive` / `data_limit_reached` / `other_phone_connected` 是行车记录仪可能返回的状态。 - 设置 `TESLA_DASHCAM_DEBUG=1` 可获取数据通道的 NAL 级别跟踪信息(仅供协议调试)。 ### `new TeslaTokenManager(options)` | option | type | notes | |---|---|---| | `refreshToken` | `string` | **必填。** Owner API refresh token(约 90 天)。 | | `accessToken` | `string` | 可选的初始 access token,在临近过期前会一直复用。 | | `onRefresh` | `(tokens) => void \| Promise` | 每次刷新后调用,带有 `{ accessToken, refreshToken, expiresAt }` 以便持久化。 | | `refreshSkewSec` |number` | 在距离过期时间达到这么多秒时进行刷新。默认 300。 | | `clientId` / `authBase` | `string` | OAuth 覆盖项。 | `.getAccessToken` 已被预绑定 —— 可直接将其传给 `TeslaCamClient` 的 `getAccessToken`。`.refreshToken` 反映当前(可能已轮换)的 token。 ### 错误 — `TeslaCamError` 每次失败都会以 `TeslaCamError`(`error.code`)拒绝,绝不会导致进程崩溃。`TeslaCamErrorCode` 导出了这些字符串常量。 | code | meaning | |---|---| | `auth_failed` | Token 被拒绝且未配置刷新(静态 token) —— 请提供有效的 token。 | | `reauth_required` | 已进行刷新但 Owner API 仍拒绝了该 token(登录会话被标记 / refresh token 失效)。刷新无法解决问题 —— 请以交互方式重新认证。 | | `vehicle_offline` | 车辆未在重试时限内上线。 | | `session_failed` | 无法建立签名命令会话。 | | `hermes_connect_failed` | Hermes JWT 获取或 WebSocket 连接失败。 | | `webrtc_timeout` | 没有可用的回复 / setRemoteDescription 失败。 | | `ice_failed` | ICE 连接失败。 | | `data_limit_reached` | 达到了车辆的网络摄像头数据上限。 | | `camera_unavailable` | 车辆报告请求的摄像头不可用。 | | `capture_timeout` | 在帧/片段就绪前,总的 `timeoutMs` 已超时。 | ## 故障排除 | Symptom | Likely cause / fix | |---|---| | 每次调用都出现 `auth_failed` | 静态 token 已过期/无效且未配置刷新 —— 传入 `refreshToken`,或提供新的 token。 | | `reauth_required` | 刷新成功,但 Owner API 仍然返回 403 —— 登录会话被标记(通常在大量脚本化使用后发生)或 refresh token 失效。**重新登录**以获取新的会话并更新您的 token;仅靠刷新无法恢复。 | | `session_failed` / 权限错误 | owner 密钥未在车辆上注册为 **owner**(请参阅设置第 1 步)。 | | 约 90 秒后出现 `vehicle_offline` | 车辆休眠/失去连接且未及时唤醒。请稍后重试,或提高 `timeoutMs`。 | | `hermes_connect_failed` | 连接 Tesla 信令的网络出现问题,或临时的 token 问题。 | | 出现 `capture_timeout` 但连接日志看起来正常 | ICE 无法建立媒体通道,或车辆未发送可用的回复。请重试。 | | `data_limit_reached` | 您已达到车辆的网络摄像头数据上限。请在重试前退避(几分钟或更长时间)。 | | `failed to load @roamhq/wrtc` | 原生模块未在您的平台上安装 —— 请在目标操作系统/架构上重新安装依赖。 | | ffmpeg 错误 / 输出为空 | `ffmpeg` 不在 `PATH` 中,或版本太旧。请安装较新版本的 ffmpeg。 | | 第一次调用耗时 40 秒 | 符合预期的冷启动唤醒延迟 —— 请参阅[延迟](#latency--the-wake-cycle)。 | ## 注意事项与限制 - **按设计每次调用都会重新连接。** 每次 `getStillImage`/`getClip` 都会付出完整的唤醒+连接握手开销。如果需要通过一次唤醒获取多帧图像,请使用 `getStills()`。 - **数据上限。** 车辆会强制执行网络摄像头的数据限制;持续的高速轮询可能会触发 `data_limit_reached`。遇到此情况时请进行退避。(具体的上限/冷却时间因车辆而异,此处暂不详细说明。) - **需要 ffmpeg。** 帧通过生成的 `ffmpeg` 进程进行编码;片段为 MPEG-TS 格式,静态图像为 JPEG 格式。 - **Camera token 因车型而异** —— 请参阅[摄像头视角](#camera-angles)。 ## 测试 ``` npm test # node --test; no live vehicle calls (network/WebSocket/wrtc are mocked) ```
标签:GNU通用公共许可证, MITM代理, Node.js, REST API, WebRTC, 物联网, 网络调试, 自动化, 自定义脚本, 视频处理