Appearance
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 后解析 |
204 | OPTIONS 预检成功 | 不含 CSV |
400 | 请求格式、请求体或请求头不合法 | 检查方法、路径、Content-Length 和请求头 |
404 | 请求路径不存在 | 修正路径 |
405 | 非 POST/OPTIONS 方法 | 使用 POST |
502 | ioctl 成功返回但版本、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。
| 列 | 字段 | 单位/类型 | 含义 |
|---|---|---|---|
| 1 | charge_type | 枚举 | 充电协议类型;1..15 分别为 SDP、DCP、CDP、ACA、C、PD、PD_DRP、PD_PPS、PD_SDP、APPLE_BKID、QC2、QC3、VOOC、SVOOC、UFCS |
| 2 | usb_online | 0/1 | 有线 USB 在线状态 |
| 3 | charge_mode | 枚举 | 充电阶段:0 恒流、1 恒压、2 涓流 |
| 4 | soc | % | 电池荷电百分比 |
| 5 | temp | 0.1 degC | 电池温度 |
| 6 | shell_temp | 0.1 degC | 电池壳温度 |
| 7 | real_temp | 0.1 degC | BMS 实际温度 |
| 8 | batt_num | 个 | 电芯数,通常为 1 或 2 |
| 9 | vbat1_mv | mV | 主电芯电压 |
| 10 | vbat2_mv | mV | 副电芯电压;单电芯设备通常无有效值 |
| 11 | curr_ma | mA | 电池实际电流,符号沿内核原始方向 |
| 12 | curr_set_ma | mA | 客户端当前设置/目标充电电流 |
| 13 | power_w | W | 当前协议/读取链路给出的充电功率 |
| 14 | power_cap_w | W | 协议功率上限 |
| 15 | power_max_w | W | 由 wired_vbus_mv * curr_set_ma 计算的电池功率上限 |
| 16 | thermal_level | 枚举 | 客户端温控等级 |
| 17 | thermal_reason | 枚举 | 温控原因:0 none、1 舒适温区限流、2 热点监视、3 热点限流、4 热点强制限流、5 极限温区限流、6 BTB 限流 |
| 18 | plc_status | 枚举 | 旁路充电状态:0 not_support、1 not_allow、2 disable |
| 19 | wired_vbus_mv | mV | 有线输入总线电压 |
| 20 | usb_l | 0.1 degC | USB 正面温度 |
| 21 | usb_r | 0.1 degC | USB 反面温度 |
| 22 | usb_btb | 0.1 degC | USB 排线座温度 |
| 23 | batt_btb | 0.1 degC | 电池 BTB 温度 |
| 24 | cycle_count | 次 | 电池循环次数 |
| 25 | battery_type | 文本 | 电池材料/型号类型字符串 |
| 26 | deep_support | 0/1 | 电池 deep support 标记 |
| 27 | shutdown_voltage_mv | mV | 配置的关机电压阈值 |
| 28 | battery_fcc_mah | mAh | 满充容量 FCC |
| 29 | battery_rm_mah | mAh | 剩余容量 RM |
| 30 | battery_soh | % | 电池健康度 SOH |
| 31 | battery_manu_date | 文本 | 电池生产日期 |
| 32 | qmax1 | mAh | 主电芯 Qmax |
| 33 | qmax2 | mAh | 副电芯 Qmax |
| 34 | qmax_total | mAh | Qmax 总量 |
| 35 | charger_reset_count | 次 | 客户端记录的充电器重置次数 |
| 36 | adv_protocol | 文本 | 高级协议名称 |
| 37 | adv_adapter_vendor | 文本 | 适配器厂商/识别字符串 |
| 38 | adv_adapter_power_w | W | 适配器额定或识别功率 |
| 39 | adv_cable_curr_ma | mA | 线材识别电流能力 |
| 40 | adv_line_res_mohm | mOhm | 线阻测量值 |
| 41 | pps_oplus_adapter | 0/1 | PPS 是否识别为 Oplus 适配器 |
| 42 | pps_adapter_power_w | W | PPS 适配器当前/标称功率 |
| 43 | pps_adapter_max_power_w | W | PPS 适配器最大功率 |
| 44 | pps_cpa_power_w | W | PPS CPA 协商功率 |
| 45 | pps_pdo_summary | 文本 | PPS PDO 摘要 |
| 46 | ufcs_oplus_adapter | 0/1 | UFCS 是否识别为 Oplus 适配器 |
| 47 | ufcs_adapter_id | 整数 | UFCS 适配器 ID |
| 48 | ufcs_vid | 整数 | UFCS 适配器厂商 ID(VID) |
| 49 | ufcs_adapter_power_w | W | UFCS 适配器功率 |
| 50 | ufcs_emark_curr_ma | mA | UFCS eMarker 线材电流能力 |
| 51 | ufcs_emark_type | 文本 | UFCS eMarker 类型 |
| 52 | ufcs_cable_info | 文本 | UFCS 线材信息摘要 |
| 53 | ufcs_pdo_summary | 文本 | UFCS PDO 摘要 |
| 54 | adv_line_res_limit_ma | mA | 高级协议按线阻得到的电流限制 |
| 55 | adv_line_res_limit_info | 文本 | 线阻限流原因/详情 |
| 56 | ufcs_reset_sample_ma | mA | UFCS 重置判定使用的输入电流采样值 |
| 57 | ufcs_reset_sample_source | 枚举 | 上述 UFCS 采样值来源 |
| 58 | ufcs_iin_fallback | 0/1 | UFCS 输入电流读取是否使用回退值 |
| 59 | ufcs_iin_last_errno | errno | UFCS 输入电流最近一次读取错误码 |
| 60 | svooc_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、endTs | Unix 毫秒 | 当前保留首尾点的时间 |
pointCount | 个 | 当前保留的原始点数 |
startSoc、endSoc | % | 首尾点 SOC |
peakPower | W | 电池包功率绝对值峰值 |
batt_num、epoch_id | 整数 | 电芯数、连续区间身份 |
truncated | boolean | 前部已被循环回收;当前起点可能不是原始周期起点 |
图表查询:
| 字段 | 含义 |
|---|---|
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 列:
| 列 | 字段 | 单位/类型 | 含义 |
|---|---|---|---|
| 1 | ts_ms | Unix 毫秒 | 采样时间 |
| 2 | temp_decic | 0.1 degC | 温度,显示时除以 10 |
| 3 | vbat1_mv | mV | 主电芯电压 |
| 4 | vbat2_mv | mV | 副电芯电压,仅双电芯时使用 |
| 5 | curr_ma | mA | 有符号电流,负为充入、正为放出 |
| 6 | soc | % | 电池百分比 |
| 7 | batt_num | 个 | 该历史点的电芯数 |
| 8 | epoch_id | 整数 | 连续区间身份 |
| 9 | seq | 整数 | 采样序号 |
| 10 | usb_online | 0/1 | USB 在线状态 |
| 11 | session_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 中的采集错误单独处理 |
400 | invalid_query,参数错误 | 修正参数 |
410 | cycle_expired,周期或游标已完整淘汰 | 重新读取周期列表 |
429 | queue_full,历史请求队列已满 | 减少并发,稍后重试 |
503 | snapshot_changed,回收影响本次查询 | 在总超时内有限重试 |
503 | storage_error 或 format_error | 检查存储或数据库格式,不自动删库 |
504 | deadline_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 支持这个格式。 切换日期或周期时取消旧请求,避免旧响应覆盖新选择。

