这是什么What it is
这是平台对外的一小组只读接口。你的系统主动来拉,我们被动应答 —— 拉哪些设备、多久拉一次,都由你的代码决定。
| 告警回调(Webhook) | 开放 API(本文档) | |
|---|---|---|
| 方向 | 我们推给你 | 你拉我们 |
| 触发 | 设备告警时立即 | 你调用时 |
| 内容 | 一条告警事件 | 全部设备的实时快照 |
| 需要 | 一个公网可访问的地址 | 一把 AccessKey |
| 适合 | 事件驱动:立刻派单、打电话 | 定时巡检:画大屏、抄表、留档 |
两者可以同时用,互不影响。常见组合:用回调做「立刻响应」, 用开放 API 做「每 30 秒刷新一次的看板」。
开放 API 不需要在服务器上做任何配置,也没有独立开关。你的账号下有设备、 建了一把启用的 AccessKey,就能立刻调用。
拿到 AccessKeyGet your key
在手机 App 里自助创建,不需要联系我们。每把 key 可以单独命名 (「消防平台」「财务看板」),也可以单独停用或删除 —— 一个集成出问题, 停它一把不影响别的。
创建后会立刻得到两样东西:
| 名称 | 样子 | 怎么对待 |
|---|---|---|
| AccessKey ID | gj_ak_3f9a2b7c1d8e4a5b | 相当于用户名。可以放在日志、配置里,泄露了也不要紧 |
| AccessKey Secret | kJ8xQw3fR7mN2pL5tZ9vB1cD4eF6… | 相当于密码,43 个字符。泄露等于别人能读你全部设备数据 |
创建成功后的那个弹窗,是你唯一一次看到 secret 的机会。 关掉之后,任何接口、任何人(包括我们)都再也取不回它 —— 数据库里只存了它的哈希值。
请当场复制到你的配置或密钥管理里。找不回就只能删掉重建一把, 然后用新 secret 重新配置你的系统。
如果你同时在对接阿里云短信或语音,请注意:本平台的 AccessKey 与阿里云的 AccessKey 没有任何关系,两者不能混用、也不通用。名字撞车纯属巧合 —— 填的时候认准本平台 App 里生成的那一对。
创建接口不是幂等的:每点一次就多一把新 key。手抖双击、或请求超时后 重试,会留下两把同名的 key —— 其中一把的 secret 谁也看不到(它只出现在那次 没送达的响应里),而且两把都占配额。超时了先刷新列表看看,再决定要不要重试。
接口地址Endpoints
一共两个接口,都是 GET,都走 HTTPS:
# 名下全部设备(推荐:设备多的时候一次拿完)
GET https://openapi.doumaisupermarket.online/v1/devices
# 指定一台设备,按序列号(SN)
GET https://openapi.doumaisupermarket.online/v1/devices/{sn}
{sn} 换成设备的序列号,形如
GJ-354560230B34343043335435。它在设备标签上,App 的设备详情页也能看到。
接口返回的永远是你账号名下的设备。传一个不属于你的 SN,
会得到 404 设备不存在 —— 和「这个 SN 根本不存在」是同一个回答。
这是刻意的:不告诉你「这台是别人的」,避免被用来探测别人的设备。
认证Authentication
每次请求带上两个 HTTP 头,值就是上一节拿到的那一对:
| Header | 填什么 |
|---|---|
| X-Access-Key-Id | AccessKey ID,如 gj_ak_3f9a2b7c1d8e4a5b |
| X-Access-Key-Secret | AccessKey Secret,43 个字符 |
两个头的名字很容易对调(一个叫 Id 一个叫 Secret),对调后一律
401。复制粘贴时前后不要带空格或换行 ——
secret 里带个换行是这类问题最常见的原因。
一个完整的例子
curl -s -i \
-H "X-Access-Key-Id: gj_ak_3f9a2b7c1d8e4a5b" \
-H "X-Access-Key-Secret: kJ8xQw3fR7mN2pL5tZ9vB1cD4eF6gH8jK0lM2nO4pQw" \
"https://openapi.doumaisupermarket.online/v1/devices?fields=temperature,smoke"
成功时返回 200:
{
"code": 200,
"msg": "success",
"data": {
"list": [
{
"sn": "GJ-354560230B34343043335435",
"name": "一号库房",
"status": "online",
"lastSeen": "2026-09-22T10:31:02",
"temperature": 24.5,
"smoke": 0
}
],
"total": 1
}
}
这是开放 API 独有的约定(平台内部接口是另一套)。第三方按 HTTP 状态码或 body 的 code 写错误分支都行,两者永远相等。
字段说明The fields
| 字段 | 类型 | 含义 |
|---|---|---|
| sn | string | 设备序列号。恒返回 |
| name | string | 设备名称(你在 App 里起的)。恒返回 |
| status | string | 在线状态,三种取值见下表。恒返回 |
| lastSeen | string | 最后一次收到数据的时间。恒返回,可能是 null |
| temperature | number | 温度,摄氏度。可选 |
| smoke | integer | 烟雾,0 = 无 / 1 = 有。可选 |
| water | integer | 水浸,0 = 无 / 1 = 有。可选 |
status 的三种取值
| 值 | 什么情况下出现 |
|---|---|
| online | 最近 7 分钟内报过数据,且当前没有未恢复的告警 |
| alert | 有未恢复的告警(烟雾 / 水浸 / 温度超标)。告警恢复后自动变回 online |
| offline | 超过 7 分钟没收到数据。设备断电、断网、或模块掉线都会这样 |
这个状态与 App 里看到的完全同源 —— 不会出现「App 说离线、API 说在线」。
设备这一轮没上报某个读数时,接口的表现并不一致。 这是上游设备的缺省逻辑决定的,接消防联动前请务必看懂:
-
temperature没数据 → 响应里没有这个键 (不是 null,是整个键不存在)。 -
smoke/water没数据 → 键存在,值是0。 -
smoke/water连键都没有 → 只说明 这台设备连一条数据行都没有,从未上报过任何数据。
所以对 smoke / water 而言,
「设备这次没说」和「设备说没有」在接口上无法区分 —— 两者都是 0。
不能这么读。设备这一轮压根没提烟雾,返回的也是 0。
把 0 当作「已确认无烟」,等于把「没在监控」误当成「监控中且安全」。
要判断设备是不是还在正常工作,看 status 和 lastSeen,
不要只看读数。
lastSeen 只能证明设备报过东西,不能证明它报过温度 ——
设备可以只报烟雾、不报温度,此时 lastSeen 很新鲜而
temperature 依然没有键。
只要部分字段Sparse fieldsets
如果你只要温度,加上 ?fields= 就行 —— 少传点数据,也少占带宽:
# 只要温度和烟雾
/v1/devices?fields=temperature,smoke
# 只要指定设备的水浸
/v1/devices/GJ-354560230B34343043335435?fields=water
# 省略 fields = 全要(等价于 temperature,smoke,water)
/v1/devices
| 写法 | 结果 |
|---|---|
省略 fields | 三项全返回 |
?fields=(空值) | 三项全返回 |
?fields=temperature,smoke | 只返回这两项 |
?fields=temperature,temperature | 去重,等同只写一次 |
?fields=humidity | 400,见下 |
传了不在 temperature / smoke / water 里的值,
会返回 400 并告诉你哪个字段不认:
{
"code": 400,
"msg": "fields 不支持的字段:temperture(可用:temperature, smoke, water)",
"data": null
}
这是刻意的:如果静默忽略,你把 temperature 拼成
temperture 会拿到一个「HTTP 200 但少了字段」的响应,看起来一切正常,
只能在业务出问题时才发现。
另外,sn / name / status / lastSeen
恒返回,不受 fields 影响 —— 想让第三方把 status 筛掉是做不到的,
那等于允许把离线设备几小时前的温度当实时值去联动消防。
调用频率Rate limit
同一个 AccessKey 在 30 秒内第二次调用会直接返回 429。
这个窗口是每把 key 的,不是每台设备的 ——
你名下有 5 台柜子,也不代表可以 5 台各调一次。
正确与错误的做法
调 一次 GET /v1/devices 拿回全部 5 台,每 30 秒一次。
一次请求就把所有设备都给了,不需要按 SN 遍历。
循环调 5 次 /v1/devices/{sn} —— 第 2 次就是 429,
而且你要退避到 30 秒后才能继续,反而比调一次更慢。
只有在你确实只关心一台设备时,才用按 SN 的接口。 设备不止一台就一律用全量接口。
被拒了怎么办
429 的响应里带一个标准的 Retry-After 头,告诉你还要等几秒:
HTTP/1.1 429
Retry-After: 12
{
"code": 429,
"msg": "请求过于频繁,请在 12 秒后重试",
"data": null
}
照着 Retry-After 退避即可,不必去解析中文提示。
建议你的定时任务设成 30 秒或更长(比如 60 秒),
把网速抖动、时钟偏差都躲开。
距上次调用恰好 30.000 秒算通过,早 1 微秒算超频。 如果你设的定时器正好是 30 秒,会因为毫秒级的抖动偶尔吃一次 429 —— 留一点余量(30 秒以上)就没这个问题。
只有认证通过的调用才计入 30 秒窗口。也就是说,凭据填错导致的一串 401 不会把你锁在 429 里 —— 但反过来,401 也不是因为超频造成的, 别把两种情况混在一起查。
错误码Error codes
| HTTP | 意思 | 你该怎么做 |
|---|---|---|
| 401 | 凭据无效或缺失(AccessKey 停用以外的所有认证失败) | 检查两个头的名字有没有对调、值有没有带空格/换行;确认 key 没被删 |
| 403 | AccessKey 已被停用 | 去 App 里重新启用这把 key。不用换 secret |
| 429 | 调用过于频繁(距上次不足 30 秒) | 按响应里的 Retry-After 秒数退避,然后把定时间隔调大 |
| 404 | 设备不存在,或不属于你 | 核对 SN 是否抄错;确认这台设备绑在你这个账号下 |
| 400 | fields 里有不支持的字段名 |
按 msg 里列出的可用字段改正拼写 |
401 = 凭据本身不对(要查配置);
403 = 凭据是对的,但被停了(去后台点一下就好,不用改配置)。
把这两个分开接,能省掉很多「到底该改哪边」的来回。
代码示例Sample code
三份可直接改一改就用的实现。重点看错误处理 —— 尤其 429 的退避,那是长期运行必然会遇到的。
curl
# -i 显示响应头,方便看 Retry-After
curl -s -i \
-H "X-Access-Key-Id: $GJ_KEY_ID" \
-H "X-Access-Key-Secret: $GJ_KEY_SECRET" \
"https://openapi.doumaisupermarket.online/v1/devices?fields=temperature,smoke,water"
# 凭据放环境变量,别写进脚本、别提交进 git
# export GJ_KEY_ID="gj_ak_..."
# export GJ_KEY_SECRET="..."
Python(requests)
import os, time, requests
BASE = "https://openapi.doumaisupermarket.online/v1"
HEADERS = {
"X-Access-Key-Id": os.environ["GJ_KEY_ID"],
"X-Access-Key-Secret": os.environ["GJ_KEY_SECRET"],
}
def fetch_devices(fields="temperature,smoke,water"):
"""拉全部设备。返回 list;不可恢复的错误抛异常。"""
r = requests.get(f"{BASE}/devices",
headers=HEADERS,
params={"fields": fields},
timeout=10)
if r.status_code == 429:
# 退避的标准做法:听服务端的 Retry-After
wait = int(r.headers.get("Retry-After", 30))
raise RateLimited(wait)
if r.status_code in (401, 403):
# 401 查配置;403 去 App 里启用
raise AuthProblem(r.status_code, r.json().get("msg"))
r.raise_for_status()
return r.json()["data"]["list"]
# 主循环:30 秒以上一次,一次拿全部设备
while True:
try:
for d in fetch_devices():
# ⚠️ smoke 为 0 不等于「确认无烟」,判在线看 status/lastSeen
print(d["sn"], d["status"], d.get("temperature"))
time.sleep(30)
except RateLimited as e:
time.sleep(e.wait) # 按服务端说的秒数退避
except requests.RequestException:
time.sleep(10) # 网络抖动,稍后重试
Java(java.net.http,Java 11+)
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public class OpenApiClient {
private static final String BASE =
"https://openapi.doumaisupermarket.online/v1";
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final String keyId, keySecret;
public OpenApiClient(String keyId, String keySecret) {
this.keyId = keyId;
this.keySecret = keySecret;
}
/** 拉全部设备。429 会按 Retry-After 退避一次后重试。 */
public String fetchDevices(String fields) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/devices?fields=" + fields))
.header("X-Access-Key-Id", keyId)
.header("X-Access-Key-Secret", keySecret)
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> resp =
http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 429) {
// 听服务端给的秒数,别自己猜
String after = resp.headers().firstValue("Retry-After").orElse("30");
Thread.sleep(Long.parseLong(after) * 1000L);
resp = http.send(req, HttpResponse.BodyHandlers.ofString());
}
if (resp.statusCode() != 200) {
throw new IllegalStateException(
"开放 API 返回 " + resp.statusCode() + ": " + resp.body());
}
return resp.body();
}
}
三份示例都用环境变量。不要把 secret 硬编码进源码、 提交进 git、或写进前端页面 —— 它等同于你账号下全部设备数据的读权限。
没调通?按这个顺序查Troubleshooting
返回 401,但 key 看着没错
按这个顺序排:
- 两个头的名字是不是对调了 ——
Id头要填 ID,Secret头要填 secret。这是最常见的一个。 - 值有没有带空格或换行 —— 从 App 复制时很容易把行尾换行一起带上。把值打出来看长度:ID 是
gj_ak_加 16 个字符,secret 是 43 个字符。 - 这把 key 被删了吗 —— 删除即吊销,用它的调用立刻 401。去 App 列表里确认还在。
- 如果返回的是
403而不是 401,见下一条。
返回 403 已停用
这把 key 是被停用了(不是删除)。去 App 里把它重新启用即可, secret 不用换、你的配置也不用改 —— 这就是「停用」和「删除」的区别。
一直 429
先分清是「真调太快」还是「有别人在调」:
- 检查你的定时器间隔 —— 必须 ≥ 30 秒,且是按 key 算的。5 台设备循环调 5 次是最常见的成因。
- 改成一个请求拿全部:
GET /v1/devices。 - 如果确定自己 30 秒才调一次却还 429 —— 那说明同一把 key 还被别的程序在用(比如另一个人也配了同一把)。给每个系统单独建一把 key,既能避免互相挤,也能单独排查和吊销。
temperature 是 null,但设备明明在线
很可能这台设备从来没有上报过温度,或这一轮没报。 注意「没数据」的两种长相(见第五节):
- 压根没有
temperature这个键 → 设备从未报过温度,或本轮没报。 - 如果你没写
fields却看不到这个键,就是前者。
判断设备是否正常工作要看 status 与 lastSeen,
不要用某个读数是否存在来推断设备死没死。
status 是 offline,但设备明明通着电
offline 的判据是「超过 7 分钟没收到数据」,
不是「设备没电」。所以通电但报不上来,一样是 offline。按这个顺序查:
- 现场网络 —— 机柜处的路由器/交换机/4G 是否正常。
- 设备上的通信模块是否掉线(模块本身可能死机,断电重启可恢复)。
- 看
lastSeen停在了什么时候,能大致定位是哪一刻断的。
这条和 App 里显示的状态是同一个判定,不会两边不一致。
返回 404 设备不存在
两种可能,接口刻意不区分:
- SN 抄错了 —— 对着设备标签或 App 详情页核对一遍(注意大小写和中间的连字符)。
- 这台设备不属于你这个账号。开放 API 只返回你名下的设备。
返回 400 fields 不支持的字段
只有 temperature、smoke、water 三个可选字段,
拼写必须完全一致。msg 里会直接列出可用字段,照着改即可。
想确认「到底调没调通」
列表接口里有 lastRequestAt(上一次认证通过的调用时刻),
null 表示从未成功调用过。App 的 AccessKey 列表里能看到它 ——
客户说「我调了但没数据」时,先看这个字段比看日志快。
注意它只记录认证通过的调用:一串 401 不会更新它。
所以「lastRequestAt 是 null」通常意味着凭据那一关就没过。