Skip to content

opbatt_httpd 使用说明 ​

门禁说明

实时电池数据读取和历史采集需要设备通过 OPBATT 云端验证;门禁关闭后停止采集,已保存的历史仍可查询。

opbatt_httpd 是独立的 C HTTP bridge。用来读取 opbatt 内核中的状态,以及用户空间的历史记录。

opbatt模块启用后 ​

默认监听本机回环地址 127.0.0.1:28765:

启动成功后标准错误输出:

text
listening on 127.0.0.1:28765

进程接收 SIGTERM 或 SIGINT 后退出。服务只绑定 127.0.0.1,外部网络 地址不能直接连接。

HTTP 协议 ​

项目值
地址http://127.0.0.1:28765
路径/v1/chg-state
方法POST
请求体无;若发送 Content-Length,其值必须为 0
成功响应200 OK,Content-Type: text/csv
响应缓存头Cache-Control: no-store
连接策略Connection: close,每个请求完成后关闭连接
CORS请求带 Origin 时响应 Access-Control-Allow-Origin: *、Access-Control-Allow-Methods: POST, OPTIONS 和 Access-Control-Allow-Private-Network: true

服务拒绝 Transfer-Encoding、重复的 Content-Length 或 Origin、非空请求体、 超过 4096 字节的请求以及 1 秒内未收完的请求头。

响应码 ​

状态码含义常见处理
200成功返回一行 CSV校验列数为 60 后解析
204OPTIONS 预检成功不含 CSV
400请求格式、请求体或请求头不合法检查方法、路径、Content-Length 和请求头
404请求路径不存在修正路径
405非 POST/OPTIONS 方法使用 POST
502ioctl 成功返回但版本、CSV 长度或内容校验异常记录服务端和内核日志,下一次请求会重新读取
503/dev/opbatt_state 不可用、runtime gate 未有效或读取暂不可用等待客户端 gate 生效后重试

500ms 服务端缓存 ​

成功读取的完整 ioctl 结果会缓存一次。缓存年龄严格小于 500ms 时,服务直接 返回该 CSV,不再打开 /dev/opbatt_state;达到 500ms 时下一请求重新读取。

  • 命中缓存不会延长缓存有效期。
  • 读取失败会使缓存失效,也不会把旧 CSV 当作成功响应返回。
  • HTTP 响应仍声明 no-store,浏览器和代理不应自行缓存。

因此 WebUI 使用 500ms 刷新档位时,前一次请求已完成的后续请求会正常进入 HTTPD;HTTPD 是否命中其短缓存只取决于两次请求的实际间隔。

curl 请求 ​

读取 CSV ​

sh
curl --fail-with-body --silent --show-error \
  --connect-timeout 1 --max-time 5 \
  -X POST http://127.0.0.1:28765/v1/chg-state

查看状态行和响应头:

sh
curl --include --silent --show-error \
  -X POST http://127.0.0.1:28765/v1/chg-state

测试 CORS 预检:

sh
curl --include --silent --show-error \
  -X OPTIONS http://127.0.0.1:28765/v1/chg-state \
  -H 'Origin: http://127.0.0.1'

按位置读取几个常用字段:

sh
curl --fail-with-body --silent --show-error \
  -X POST http://127.0.0.1:28765/v1/chg-state |
  awk -F, '{printf "type=%s, online=%s, soc=%s%%, vbat1=%smV, curr=%smA\\n", $1, $2, $4, $9, $11}'

CSV 末尾带换行;awk -F,、cut -d, 等标准 CSV 按列工具可直接使用。文本字段 不使用 CSV 引号或转义,消费者必须按固定的 60 列协议解析。

JavaScript 请求 ​

最小请求只需要空体 POST;无需设置 Content-Type、Authorization 或自定义 header:

js
const response = await fetch("http://127.0.0.1:28765/v1/chg-state", {
    method: "POST",
    credentials: "omit",
    cache: "no-store"
});

if (!response.ok) {
    throw new Error(`读取监控数据失败: HTTP ${response.status}`);
}

const csv = (await response.text()).trim();
if (!csv) throw new Error("监控数据为空");

推荐使用单飞和超时,避免多个 UI 组件并发触发重复读取:

js
const STATE_ENDPOINT = "http://127.0.0.1:28765/v1/chg-state";
const STATE_TIMEOUT_MS = 5000;
let pendingStateCsv = null;

function readStateCsv() {
    if (pendingStateCsv) return pendingStateCsv;

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), STATE_TIMEOUT_MS);
    pendingStateCsv = fetch(STATE_ENDPOINT, {
        method: "POST",
        credentials: "omit",
        cache: "no-store",
        signal: controller.signal
    }).then(async (response) => {
        if (!response.ok) {
            throw new Error(`读取监控数据失败: HTTP ${response.status}`);
        }
        const csv = (await response.text()).trim();
        if (!csv) throw new Error("监控数据为空");
        return csv;
    }).finally(() => {
        clearTimeout(timer);
        pendingStateCsv = null;
    });
    return pendingStateCsv;
}

解析为对象:

js
const STATE_FIELDS = [
    "charge_type", "usb_online", "charge_mode", "soc", "temp", "shell_temp", "real_temp",
    "batt_num", "vbat1_mv", "vbat2_mv", "curr_ma", "curr_set_ma", "power_w", "power_cap_w",
    "power_max_w", "thermal_level", "thermal_reason", "plc_status", "wired_vbus_mv", "usb_l",
    "usb_r", "usb_btb", "batt_btb", "cycle_count", "battery_type", "deep_support",
    "shutdown_voltage_mv", "battery_fcc_mah", "battery_rm_mah", "battery_soh", "battery_manu_date",
    "qmax1", "qmax2", "qmax_total", "charger_reset_count", "adv_protocol", "adv_adapter_vendor",
    "adv_adapter_power_w", "adv_cable_curr_ma", "adv_line_res_mohm", "pps_oplus_adapter",
    "pps_adapter_power_w", "pps_adapter_max_power_w", "pps_cpa_power_w", "pps_pdo_summary",
    "ufcs_oplus_adapter", "ufcs_adapter_id", "ufcs_vid", "ufcs_adapter_power_w",
    "ufcs_emark_curr_ma", "ufcs_emark_type", "ufcs_cable_info", "ufcs_pdo_summary",
    "adv_line_res_limit_ma", "adv_line_res_limit_info", "ufcs_reset_sample_ma",
    "ufcs_reset_sample_source", "ufcs_iin_fallback", "ufcs_iin_last_errno", "svooc_voocphy_type"
];

const TEXT_FIELDS = new Set([
    "battery_type", "battery_manu_date", "adv_protocol", "adv_adapter_vendor",
    "pps_pdo_summary", "ufcs_emark_type", "ufcs_cable_info", "ufcs_pdo_summary",
    "adv_line_res_limit_info"
]);

function parseStateCsv(csv) {
    const columns = String(csv).trim().split(",");
    if (columns.length !== STATE_FIELDS.length) {
        throw new Error(`状态 CSV 字段数量异常: ${columns.length}/${STATE_FIELDS.length}`);
    }
    return Object.fromEntries(STATE_FIELDS.map((name, index) => [
        name,
        TEXT_FIELDS.has(name) ? columns[index].trim() : Number(columns[index])
    ]));
}

const state = parseStateCsv(await readStateCsv());
console.log(state.soc, state.vbat1_mv, state.curr_ma);

CSV 固定字段顺序 ​

该接口没有表头,始终输出 60 列。数值字段为有符号整数;读取不到的数值通常为 -1,文本字段通常为 -1。温度字段使用 0.1 degC,显示摄氏度时除以 10。

列字段单位/类型含义
1charge_type枚举充电协议类型;1..15 分别为 SDP、DCP、CDP、ACA、C、PD、PD_DRP、PD_PPS、PD_SDP、APPLE_BKID、QC2、QC3、VOOC、SVOOC、UFCS
2usb_online0/1有线 USB 在线状态
3charge_mode枚举充电阶段:0 恒流、1 恒压、2 涓流
4soc%电池荷电百分比
5temp0.1 degC电池温度
6shell_temp0.1 degC电池壳温度
7real_temp0.1 degCBMS 实际温度
8batt_num个电芯数,通常为 1 或 2
9vbat1_mvmV主电芯电压
10vbat2_mvmV副电芯电压;单电芯设备通常无有效值
11curr_mamA电池实际电流,符号沿内核原始方向
12curr_set_mamA客户端当前设置/目标充电电流
13power_wW当前协议/读取链路给出的充电功率
14power_cap_wW协议功率上限
15power_max_wW由 wired_vbus_mv * curr_set_ma 计算的电池功率上限
16thermal_level枚举客户端温控等级
17thermal_reason枚举温控原因:0 none、1 舒适温区限流、2 热点监视、3 热点限流、4 热点强制限流、5 极限温区限流、6 BTB 限流
18plc_status枚举旁路充电状态:0 not_support、1 not_allow、2 disable
19wired_vbus_mvmV有线输入总线电压
20usb_l0.1 degCUSB 正面温度
21usb_r0.1 degCUSB 反面温度
22usb_btb0.1 degCUSB 排线座温度
23batt_btb0.1 degC电池 BTB 温度
24cycle_count次电池循环次数
25battery_type文本电池材料/型号类型字符串
26deep_support0/1电池 deep support 标记
27shutdown_voltage_mvmV配置的关机电压阈值
28battery_fcc_mahmAh满充容量 FCC
29battery_rm_mahmAh剩余容量 RM
30battery_soh%电池健康度 SOH
31battery_manu_date文本电池生产日期
32qmax1mAh主电芯 Qmax
33qmax2mAh副电芯 Qmax
34qmax_totalmAhQmax 总量
35charger_reset_count次客户端记录的充电器重置次数
36adv_protocol文本高级协议名称
37adv_adapter_vendor文本适配器厂商/识别字符串
38adv_adapter_power_wW适配器额定或识别功率
39adv_cable_curr_mamA线材识别电流能力
40adv_line_res_mohmmOhm线阻测量值
41pps_oplus_adapter0/1PPS 是否识别为 Oplus 适配器
42pps_adapter_power_wWPPS 适配器当前/标称功率
43pps_adapter_max_power_wWPPS 适配器最大功率
44pps_cpa_power_wWPPS CPA 协商功率
45pps_pdo_summary文本PPS PDO 摘要
46ufcs_oplus_adapter0/1UFCS 是否识别为 Oplus 适配器
47ufcs_adapter_id整数UFCS 适配器 ID
48ufcs_vid整数UFCS 适配器厂商 ID(VID)
49ufcs_adapter_power_wWUFCS 适配器功率
50ufcs_emark_curr_mamAUFCS eMarker 线材电流能力
51ufcs_emark_type文本UFCS eMarker 类型
52ufcs_cable_info文本UFCS 线材信息摘要
53ufcs_pdo_summary文本UFCS PDO 摘要
54adv_line_res_limit_mamA高级协议按线阻得到的电流限制
55adv_line_res_limit_info文本线阻限流原因/详情
56ufcs_reset_sample_mamAUFCS 重置判定使用的输入电流采样值
57ufcs_reset_sample_source枚举上述 UFCS 采样值来源
58ufcs_iin_fallback0/1UFCS 输入电流读取是否使用回退值
59ufcs_iin_last_errnoerrnoUFCS 输入电流最近一次读取错误码
60svooc_voocphy_type枚举SVOOC VOOC PHY 类型;当前 WebUI 将 1 识别为 ADSP

历史记录 ​

历史记录由 httpd 自己采集,不再读取 /data/vendor/battery。USB 接入时每 1 秒采集一次, 未接入时每 5 秒一次。门禁关闭或读取失败时停止采集,设备休眠期间不唤醒补点。

数据保存在 /data/opbatt/history/history.sqlite3,使用 SQLite 和 Zstd 压缩存储, 默认上限 1 GiB。接近上限时回收较早记录,保留新的数据。

HTTP 协议 ​

项目值
地址http://127.0.0.1:28765
方法POST
请求体无;参数放在 URL 中
成功响应200 OK,Content-Type: application/json
客户端超时建议 65 秒;服务端排队、查询和成功响应发送共用 60 秒期限
CORS、缓存及连接与实时接口相同,支持 OPTIONS
路径用途参数
/v1/history/status采集状态和存储占用无
/v1/history/cycles充放电周期列表limit,可选 cursor、direction 或日期定位参数
/v1/history/range指定时间或周期的数据和统计必填 start_ms、end_ms,可选 max_points、cycle_id

时间参数都是 Unix 毫秒,起点不得晚于终点。数值只接受十进制非负整数, 上限为 9007199254740991;参数重复、名称错误或值不合法返回 400。 status 不接受查询参数。

curl 请求 ​

以下命令在设备 shell 中执行。历史接口返回 JSON,不使用 awk -F, 解析。

读取采集状态:

sh
curl --fail-with-body --silent --show-error \
  --connect-timeout 1 --max-time 65 \
  -X POST "http://127.0.0.1:28765/v1/history/status"

读取最近 10 个周期:

sh
curl --fail-with-body --silent --show-error \
  --connect-timeout 1 --max-time 65 \
  -X POST "http://127.0.0.1:28765/v1/history/cycles?limit=10"

limit 默认 10,范围 1 到 100。返回 cycles 数组和 has_more; 默认按采样顺序从新到旧排列。

读取最近 24 小时:

sh
END_MS=$(($(date +%s) * 1000))
START_MS=$((END_MS - 86400000))
curl --fail-with-body --silent --show-error \
  --connect-timeout 1 --max-time 65 \
  -X POST "http://127.0.0.1:28765/v1/history/range?start_ms=${START_MS}&end_ms=${END_MS}&max_points=12000"

读取全部已保留历史:

sh
curl --fail-with-body --silent --show-error \
  --connect-timeout 1 --max-time 65 \
  -X POST "http://127.0.0.1:28765/v1/history/range?start_ms=0&end_ms=9007199254740991&max_points=12000"

max_points 默认 12000,范围 2 到 12000。超出点数时返回抽样后的图表点, 统计仍使用选区内的原始数据。全范围查询可能耗时较长,平时按天或周期读取即可。

读取指定周期,将下面三个占位值替换成周期列表中的 id、startTs、endTs:

sh
CYCLE_ID='EPOCH_SEQ:SESSION_SEQ'
START_MS='START_TS_MS'
END_MS='END_TS_MS'
curl --fail-with-body --silent --show-error \
  --connect-timeout 1 --max-time 65 \
  -X POST "http://127.0.0.1:28765/v1/history/range?start_ms=${START_MS}&end_ms=${END_MS}&max_points=12000&cycle_id=${CYCLE_ID}"

选周期时带上 cycle_id,避免时钟回拨后混入同一时间段的其他周期。 正在采集的周期会增长,再次读取前刷新列表,取得最新的 endTs。

读取更早一页,CURSOR 使用当前页最后一个周期的 id:

sh
CURSOR='EPOCH_SEQ:SESSION_SEQ'
curl --fail-with-body --silent --show-error \
  --connect-timeout 1 --max-time 65 \
  -X POST "http://127.0.0.1:28765/v1/history/cycles?limit=10&cursor=${CURSOR}&direction=older"

向较新周期查找时把 direction 换成 newer,游标使用希望向前查找的周期 id。 游标周期本身不返回。方向按采样顺序判断,时钟回拨时不等同于日期顺序; 不传 cursor 或日期定位参数时,单独设置 direction 不改变默认列表顺序。

按日期找上一或下一周期,START_MS 和 END_MS 使用日期选区的起止毫秒:

sh
curl --fail-with-body --silent --show-error \
  --connect-timeout 1 --max-time 65 \
  -X POST "http://127.0.0.1:28765/v1/history/cycles?limit=1&anchor_start_ms=${START_MS}&anchor_end_ms=${END_MS}&direction=older"

older 找结束时间早于 START_MS 的最近周期,newer 找开始时间晚于 END_MS 的最近周期。 两个 anchor 参数必须一起传入,同时指定 direction,不与 cursor 混用。

查看响应头时加 --include。旧版 curl 若没有 --fail-with-body,去掉该选项, 加 --include 查看 HTTP 状态码。

电脑端先做 ADB 转发。以下单行命令也可在 Windows CMD 中使用:

bat
adb forward tcp:28765 tcp:28765
curl --include --silent --show-error --connect-timeout 1 --max-time 65 -X POST "http://127.0.0.1:28765/v1/history/status"
curl --silent --show-error --connect-timeout 1 --max-time 65 -X POST "http://127.0.0.1:28765/v1/history/cycles?limit=10"

其他查询同样使用电脑端的这个地址。CMD 中保留 URL 双引号, 上面的 shell 变量和反斜杠续行不用于 CMD。使用结束后执行 adb forward --remove tcp:28765。

返回字段 ​

成功响应带有 api_version:2 和 snapshot。snapshot 中的 cutoff_seq 是本次查询的采样序号上界, retention_revision 是回收修订号。不同请求的快照可能不同。

采集状态:

字段含义
enabled、recording采集是否有效、是否正在记录;不是持久配置开关
error、error_code当前采集或存储错误,正常时为 null
sample_interval_ms最近采样档位,USB 在线 1000 ms、离线 5000 ms
oldest_ts_ms、newest_ts_ms保留历史最早、最晚的墙钟时间;空历史两者都是 null
bytes、limit_bytes存储占用和容量上限,单位字节
dropped_samples当前进程累计丢样数
pending_points、paused尚未提交的点数、是否因存储故障暂停

recording=false 时仍可能有历史记录。查询包括尚未提交的内存尾块。

周期列表中的每一项:

字段单位/类型含义
id文本周期身份,格式为 epoch:USB会话首seq,原样用于 cursor 或 cycle_id
type文本USB 在线为 charge,离线为 discharge
startTs、endTsUnix 毫秒当前保留首尾点的时间
pointCount个当前保留的原始点数
startSoc、endSoc%首尾点 SOC
peakPowerW电池包功率绝对值峰值
batt_num、epoch_id整数电芯数、连续区间身份
truncatedboolean前部已被循环回收;当前起点可能不是原始周期起点

图表查询:

字段含义
columns、rows列名和逐行数据,按 columns 的顺序读取
total_points、returned_points选区内原始点数、实际返回点数
sampled是否减少了返回点数
effective_range实际选中点的 start_ms、end_ms;空选区两者为 null
stats.pointCount参与统计的原始点数
stats.durationMs、stats.activeDurationMs墙钟跨度、有效相邻点的累计时间,单位毫秒
stats.charge、stats.discharge按电流方向计算的充入、放出统计
stats.extrema电压、电流、功率、SOC、温度极值及对应样本身份

rows 固定为 11 列:

列字段单位/类型含义
1ts_msUnix 毫秒采样时间
2temp_decic0.1 degC温度,显示时除以 10
3vbat1_mvmV主电芯电压
4vbat2_mvmV副电芯电压,仅双电芯时使用
5curr_mamA有符号电流,负为充入、正为放出
6soc%电池百分比
7batt_num个该历史点的电芯数
8epoch_id整数连续区间身份
9seq整数采样序号
10usb_online0/1USB 在线状态
11session_type文本charge 或 discharge,由 httpd 标记

周期和样本的充放电标记按 USB 接入判断,WebUI 直接读取。 实际充入、放出按电流方向统计,所以 charge 周期内也可能出现放电量。

charge/discharge 统计中的 mah 是电流积分,wh 是电池包能量; displayMah 已按历史电芯数换算,不再乘一次电芯数。 percentDelta 是同向积分段的正向净 SOC 变化之和,segments 是积分段数。 fccMah=displayMah*100/percentDelta,没有 SOC 变化时为 null,不是 BMS 上报的 FCC。

极值中的 voltage 是主电芯 mV,current 是电流绝对值 mA,power 是电池包功率绝对值 W, soc 是百分比,temperature 是摄氏度。使用 minSeq/maxSeq 和 minEpoch/maxEpoch 定位极值点, 不要只按时间找点。

rows 按 seq 排序,时间可能回拨或重复。不同 epoch 或回拨处断开曲线, 同一 epoch 内因抽样造成的 seq 跳号不需要全部断线。统计直接使用 stats,不对抽样点重新积分。

响应码 ​

状态码含义常见处理
200查询成功,空数组也是正常结果检查结果;status 中的采集错误单独处理
400invalid_query,参数错误修正参数
410cycle_expired,周期或游标已完整淘汰重新读取周期列表
429queue_full,历史请求队列已满减少并发,稍后重试
503snapshot_changed,回收影响本次查询在总超时内有限重试
503storage_error 或 format_error检查存储或数据库格式,不自动删库
504deadline_exceeded,查询超时缩小时间范围或稍后重试

历史业务错误返回 error 和 error_code。公共 HTTP 协议层拒绝请求时可能返回纯文本。 空历史不算错误;降低 max_points 只减少返回点数,不保证减少扫描量。

JavaScript 请求 ​

使用空体 POST 读取 JSON,超时设为 65 秒:

js
async function readHistory(endpoint, parameters = {}) {
    const url = new URL(`/v1/history/${endpoint}`, "http://127.0.0.1:28765");
    url.search = new URLSearchParams(parameters).toString();

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 65000);
    try {
        const response = await fetch(url, {
            method: "POST",
            credentials: "omit",
            cache: "no-store",
            signal: controller.signal
        });
        const text = await response.text();
        let data;
        try { data = JSON.parse(text); } catch {
            throw new Error(`历史响应非 JSON: HTTP ${response.status}`);
        }
        if (!response.ok) {
            const error = new Error(data.error || `HTTP ${response.status}`);
            error.status = response.status;
            error.code = data.error_code;
            throw error;
        }
        if (data.api_version !== 2) throw new Error("历史接口版本不匹配");
        return data;
    } finally {
        clearTimeout(timer);
    }
}

const page = await readHistory("cycles", { limit: 10 });
const cycle = page.cycles[0];
if (cycle) {
    const range = await readHistory("range", {
        start_ms: cycle.startTs,
        end_ms: cycle.endTs,
        max_points: 12000,
        cycle_id: cycle.id
    });
    console.log(range.rows, range.stats);
}

URLSearchParams 会把周期 id 的冒号编码为 %3A,httpd 支持这个格式。 切换日期或周期时取消旧请求,避免旧响应覆盖新选择。

版权所有 @bybycode