提交api调用相关信息
Some checks failed
Mark & close stale issues / stale (push) Has been cancelled
Vulnerability Scan / trivy-scan (1.0) (push) Has been cancelled
Vulnerability Scan / trivy-scan (master) (push) Has been cancelled
License Scan / license-scan (push) Has been cancelled

This commit is contained in:
2026-06-18 18:00:17 +08:00
parent f6ae31c319
commit 2a425196e6
8 changed files with 2762 additions and 0 deletions

Binary file not shown.

746
hawkbit-compose/CURL-API.md Normal file
View File

@@ -0,0 +1,746 @@
# Hawkbit DDI API — curl 完整操作手册
基于实际验证的 Hawkbit DDI API v1 交互流程,所有命令均已跑通。
## 环境信息
```
Hawkbit Server: http://192.168.142.128:9090
Tenant: DEFAULT
Auth: GatewayToken my-gateway-key (DDI 接口)
admin:admin (Management 接口)
Controller ID: test-curl-001
DistributionSet: id=1 (r3576test:V1.0.0.0)
```
## 目录
1. [认证配置](#1-认证配置)
2. [自动注册与轮询](#2-自动注册与轮询)
3. [分配 Distribution Set](#3-分配-distribution-set)
4. [拉取部署详情](#4-拉取部署详情)
5. [上报设备属性](#5-上报设备属性)
6. [下载 artifact](#6-下载-artifact)
7. [SHA256 文件校验](#7-sha256-文件校验)
8. [上报执行状态](#8-上报执行状态)
9. [取消任务](#9-取消任务)
10. [确认任务流程](#10-确认任务流程)
11. [查询验证](#11-查询验证)
12. [完整流程速查](#12-完整流程速查)
---
## 1. 认证配置
### 1.1 开启 GatewayToken 认证(服务端,一次性)
```bash
# 设置 token key
curl -u admin:admin -X PUT \
-H "Content-Type: application/json" \
"http://192.168.142.128:9090/rest/v1/system/configs/authentication.gatewaytoken.key" \
-d '{"value": "my-gateway-key"}'
# 启用 gateway token 认证
curl -u admin:admin -X PUT \
-H "Content-Type: application/json" \
"http://192.168.142.128:9090/rest/v1/system/configs/authentication.gatewaytoken.enabled" \
-d '{"value": true}'
```
### 1.2 开启 TargetToken 认证(服务端,一次性)
```bash
curl -u admin:admin -X PUT \
-H "Content-Type: application/json" \
"http://192.168.142.128:9090/rest/v1/system/configs/authentication.targettoken.enabled" \
-d '{"value": true}'
```
### 1.3 查看当前所有认证配置
```bash
curl -s -u admin:admin \
"http://192.168.142.128:9090/rest/v1/system/configs" \
| python3 -m json.tool | grep -E "authentication\.(gateway|target)"
```
### 1.4 认证方式对比
```
DDI 请求头:
GatewayToken → Authorization: GatewayToken my-gateway-key
TargetToken → Authorization: TargetToken <设备securityToken>
Management API 请求头:
Basic Auth → -u admin:admin
```
---
## 2. 自动注册与轮询
### 2.1 首次轮询(自动注册)
```
GET /{tenant}/controller/v1/{controllerId}
```
设备 SN 在 Hawkbit 中不存在时,服务端自动调用 `findOrRegisterTargetIfItDoesNotExist()` 创建 target。
```bash
curl -v \
-H "Authorization: GatewayToken my-gateway-key" \
-H "Accept: application/json" \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001"
```
**响应(无待处理 action**
```json
{
"config": {"polling": {"sleep": "00:05:00"}},
"_links": {
"configData": {
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData"
}
}
}
```
**响应(有待处理 action**
```json
{
"config": {"polling": {"sleep": "00:05:00"}},
"_links": {
"deploymentBase": {
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15?c=424529173"
},
"configData": {
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData"
}
}
}
```
### 2.2 响应中的 HAL 链接含义
| 链接 rel | 含义 | 下一步动作 |
|----------|------|----------|
| `deploymentBase` | 有待执行的部署任务 | 拉取部署详情 → 下载 → 安装 |
| `cancelAction` | 有取消指令 | 确认取消 |
| `confirmationBase` | 有需要确认的任务 | 发送确认 |
| `configData` | 服务端要求上报属性 | PUT 属性数据 |
| `installedBase` | 有历史安装记录 | 可查询 |
---
## 3. 分配 Distribution Set
```
POST /rest/v1/distributionsets/{distributionSetId}/assignedTargets
```
> ⚠️ 这是 Management API用 `-u admin:admin` 认证。
```bash
curl -u admin:admin -X POST \
-H "Content-Type: application/json" \
"http://192.168.142.128:9090/rest/v1/distributionsets/1/assignedTargets" \
-d '[{"id": "test-curl-001"}]'
```
**可选参数(完整请求体)**
```json
{
"id": "test-curl-001",
"type": "forced",
"forcetime": 1682408575278,
"weight": 1000,
"maintenanceWindow": {
"schedule": "00:00-04:00",
"duration": "02:00:00",
"timezone": "+08:00"
},
"confirmationRequired": false
}
```
**响应**`200 OK`,返回 `MgmtTargetAssignmentResponseBody`
分配成功后,设备下次轮询就会拿到 `deploymentBase` 链接。
---
## 4. 拉取部署详情
```
GET /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}
```
```bash
curl -H "Authorization: GatewayToken my-gateway-key" \
-H "Accept: application/json" \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15"
```
**响应**
```json
{
"id": "15",
"deployment": {
"download": "forced",
"update": "forced",
"chunks": [
{
"part": "os",
"version": "V1.0.0.0",
"name": "r3576-OS-android",
"artifacts": [
{
"filename": "update.zip",
"hashes": {
"sha1": "223377ea691bd7fc47865cccfd802771ff1b3fca",
"md5": "28a17f5fb87d637756c0cc5d555d69b7",
"sha256": "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530"
},
"size": 195411,
"_links": {
"download-http": {
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
},
"md5sum-http": {
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip.MD5SUM"
}
}
}
]
}
]
}
}
```
### 字段说明
| 字段 | 说明 |
|------|------|
| `id` | action ID后续反馈操作的关键参数 |
| `deployment.download` | `forced`=强制下载 / `attempt`=尝<><E5B09D><EFBFBD>下载 / `skip`=跳过 |
| `deployment.update` | `forced`=强制安装 / `attempt`=尝试安装 / `skip`=跳过 |
| `chunks[].part` | 软件模块类型os / app / …) |
| `chunks[].version` | 软件版本号 |
| `chunks[].artifacts[].filename` | 要下载的文件名 |
| `chunks[].artifacts[].hashes` | SHA1 / MD5 / SHA256 校验值 |
| `chunks[].artifacts[].size` | 文件大小bytes |
| `_links.download-http.href` | **下载地址** |
### download / update handlingType 组合
| download | update | 含义 |
|----------|--------|------|
| `forced` | `forced` | 立即下载并安装 |
| `attempt` | `attempt` | 设备自行决定是否下载/安装 |
| `forced` | `skip` | 只下载不安装download only |
| `skip` | `skip` | maintenance window 未到,等待下次轮询 |
---
## 5. 上报设备属性
```
PUT /{tenant}/controller/v1/{controllerId}/configData
```
> ⚠️ 必须先轮询一次让 target 自动注册,再上报属性,否则 404。
```bash
curl -H "Authorization: GatewayToken my-gateway-key" \
-H "Content-Type: application/json" -X PUT \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData" \
-d '{
"mode": "merge",
"data": {
"osType": "linux",
"platform": "x86_64",
"kernel": "6.17.0-35-generic",
"hostname": "test-curl-001",
"hwRevision": "VMware Virtual Platform",
"manufacturer": "VMware, Inc.",
"swVersion": "V1.0.0.0"
}
}'
```
### mode 说明
| mode | 行为 |
|------|------|
| `merge` | 合并:只更新传入的 key保留已有属性推荐 |
| `replace` | 替换:完全覆盖已有属性 |
| `remove` | 删除:移除传入的 key |
**响应**`200 OK`(空 body
### 查询设备属性
```bash
curl -u admin:admin \
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/attributes"
```
---
## 6. 下载 artifact
```
GET /{tenant}/controller/v1/{controllerId}/softwaremodules/{softwareModuleId}/artifacts/{fileName}
```
### 6.1 完整下载
```bash
curl -H "Authorization: GatewayToken my-gateway-key" \
-o /tmp/test-update.zip \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
```
**响应**`200 OK`,二进制流。
### 6.2 断点续传Range 请求)
```bash
# 续传:从 byte 1048576 开始
curl -H "Authorization: GatewayToken my-gateway-key" \
-H "Range: bytes=1048576-" \
-o /tmp/test-update.zip.part --append \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
```
**响应**`206 Partial Content``Content-Range: bytes 1048576-195410/195411`
### 6.3 条件请求If-Match
```bash
curl -H "Authorization: GatewayToken my-gateway-key" \
-H "If-Match: 223377ea691bd7fc47865cccfd802771ff1b3fca" \
-o /tmp/test-update.zip \
"http://..."
# SHA1 不匹配 → 412 Precondition Failed
```
### 6.4 下载 MD5SUM
```bash
curl -H "Authorization: GatewayToken my-gateway-key" \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip.MD5SUM"
```
**响应**`28a17f5fb87d637756c0cc5d555d69b7 update.zip`
### 6.5 从 HAL 链接提取下载地址
artifact 的 `_links` 中有两个下载链接,优先级:
```
download → HTTPS如果有首选
download-http → HTTP回退方案
md5sum-http → MD5 校验文件HTTP
```
---
## 7. SHA256 文件校验
### 7.1 计算本地文件 SHA256
```bash
sha256sum /tmp/test-update.zip
```
### 7.2 对比部署详情中的哈希值
```bash
# 部署详情中的 SHA256:
# "sha256": "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530"
echo "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530 /tmp/test-update.zip" | sha256sum -c
# → /tmp/test-update.zip: OK
```
---
## 8. 上报执行状态
```
POST /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}/feedback
```
### 8.1 状态上报 JSON 格式
```json
{
"timestamp": 1750000000000,
"status": {
"execution": "download",
"result": {
"finished": "none",
"progress": {"cnt": 2, "of": 5}
},
"code": 200,
"details": ["Starting download"]
}
}
```
### 8.2 四步标准流程
```bash
# 定义变量,方便重复使用
D="http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15/feedback"
A="Authorization: GatewayToken my-gateway-key"
J="Content-Type: application/json"
# ① download — 开始下载
curl -H "$A" -H "$J" \
-d '{"status":{"execution":"download","result":{"finished":"none"},"details":["Starting download"]}}' \
"$D"
# ② downloaded — 下载完成
curl -H "$A" -H "$J" \
-d '{"status":{"execution":"downloaded","result":{"finished":"none"},"details":["Downloaded update.zip"]}}' \
"$D"
# ③ proceeding — 安装中
curl -H "$A" -H "$J" \
-d '{"status":{"execution":"proceeding","result":{"finished":"none"},"details":["Installing update"]}}' \
"$D"
# ④ closed — 最终结果
# 成功:
curl -H "$A" -H "$J" \
-d '{"status":{"execution":"closed","result":{"finished":"success"},"details":["Installation complete"],"code":200}}' \
"$D"
# 失败:
curl -H "$A" -H "$J" \
-d '{"status":{"execution":"closed","result":{"finished":"failure"},"details":["Installation failed"],"code":500}}' \
"$D"
```
### 8.3 execution 枚举值
| execution | Hawkbit Status | 说明 |
|-----------|---------------|------|
| `download` | `DOWNLOAD` | 开始下载 |
| `downloaded` | `DOWNLOADED` | 下载完成 |
| `proceeding` | `RUNNING` | 安装进行中 |
| `closed` | `FINISHED` / `ERROR` | 最终状态(配合 result.finished |
| `canceled` | `CANCELED` | 已取消 |
| `rejected` | `WARNING` | 已拒绝 |
| `scheduled` | `RUNNING` | 已计划 |
| `resumed` | `RUNNING` | 恢复执行 |
### 8.4 result.finished 枚举值
| finished | 含义 |
|----------|------|
| `none` | 尚未完成(中间状态) |
| `success` | 执行成功 |
| `failure` | 执行失败 |
### 8.5 带进度的上报
```bash
# 进度: 第2个/总共5个 artifact
curl -H "$A" -H "$J" \
-d '{"status":{"execution":"proceeding","result":{"finished":"none","progress":{"cnt":2,"of":5}},"details":["Downloading artifact 2/5"]}}' \
"$D"
```
### 8.6 取消反馈cancelAction feedback
```bash
curl -H "$A" -H "$J" \
-d '{"status":{"execution":"canceled","result":{"finished":"none"},"details":["Cancel acknowledged"]}}' \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/cancelAction/15/feedback"
```
### 8.7 确认反馈confirmationBase feedback
```bash
curl -H "$A" -H "$J" \
-d '{"confirmation":"confirmed","code":200,"details":["Auto-confirmed"]}' \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/confirmationBase/15/feedback"
```
### 8.8 常见错误
| HTTP 状态码 | 原因 | 解决 |
|------------|------|------|
| `401` | 认证未通过 | 检查 Authorization header 格式 |
| `404` | URL 路径错误或 target 不存在 | 确保 `/{tenant}/controller/v1/...` |
| `405` | feedback URL 带了 `?c=xxx` 参数 | POST 前剥离查询参数 |
| `410 Gone` | action 已不再活跃 | 已经 closed 或 canceled |
| `400 Body Not Readable` | JSON 格式错误 | `"final"` 应为 `"finished"``"progress":{}` 应为 `null` 或省略 |
---
## 9. 取消任务
### 9.1 服务端发起取消Management API
```bash
curl -u admin:admin -X DELETE \
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15"
```
### 9.2 设备端处理取消
```bash
# ① 轮询拿到 cancelAction
curl -H "Authorization: GatewayToken my-gateway-key" \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001"
# → _links.cancelAction.href
# ② 确认取消
curl -H "Authorization: GatewayToken my-gateway-key" \
-H "Content-Type: application/json" \
-d '{"status":{"execution":"canceled","result":{"finished":"none"},"details":["Acknowledged"]}}' \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/cancelAction/15/feedback"
```
---
## 10. 确认任务流程
### 10.1 开启确认流程(服务端)
```bash
curl -u admin:admin -X PUT \
-H "Content-Type: application/json" \
"http://192.168.142.128:9090/rest/v1/system/configs/user.confirmation.flow.enabled" \
-d '{"value": true}'
```
### 10.2 分配时要求确认
```bash
curl -u admin:admin -X POST \
-H "Content-Type: application/json" \
"http://192.168.142.128:9090/rest/v1/distributionsets/1/assignedTargets" \
-d '[{"id":"test-curl-001","confirmationRequired":true}]'
```
### 10.3 设备端自动确认
```bash
curl -H "Authorization: GatewayToken my-gateway-key" \
-H "Content-Type: application/json" \
-d '{"confirmation":"confirmed","code":200,"details":["Auto-confirmed"]}' \
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/confirmationBase/15/feedback"
```
---
## 11. 查询验证
### 11.1 查询 target 基本信息
```bash
curl -u admin:admin \
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001"
```
### 11.2 查询 target 所有 action
```bash
curl -u admin:admin \
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions"
```
### 11.3 查询指定 action 详情
```bash
curl -u admin:admin \
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15"
```
### 11.4 查询 action 状态历史
```bash
curl -u admin:admin \
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15/status"
```
**响应示例(完成全流程后)**
```json
{
"content": [
{"id":58, "type": "finished", "messages": ["Installation complete"], ...},
{"id":57, "type": "running", "messages": ["Installing update"], ...},
{"id":56, "type": "downloaded","messages": ["Downloaded update.zip"], ...},
{"id":55, "type": "download", "messages": ["Starting download"], ...},
{"id":54, "type": "download", "messages": ["Target downloads ..."], ...},
{"id":53, "type": "retrieved", "messages": ["Target retrieved ..."], ...},
{"id":52, "type": "running", "messages": ["Assignment initiated ..."]}
],
"total": 7, "size": 7
}
```
### 11.5 查询 target 属性
```bash
curl -u admin:admin \
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/attributes"
```
### 11.6 查询所有 target
```bash
curl -u admin:admin \
"http://192.168.142.128:9090/rest/v1/targets"
```
### 11.7 查询所有 Distribution Set
```bash
curl -u admin:admin \
"http://192.168.142.128:9090/rest/v1/distributionsets"
```
---
## 12. 完整流程速查
### 12.1 一次完整的 OTA 生命周期(复制粘贴即用)
```bash
# ═══ 变量设置 ═══
HOST="http://192.168.142.128:9090"
TENANT="DEFAULT"
CID="test-curl-001"
AUTH="Authorization: GatewayToken my-gateway-key"
ADMIN="admin:admin"
# ═══ Step 1: 轮询(自动注册)═══════════════════════════════════════
curl -s -H "$AUTH" -H "Accept: application/json" \
"$HOST/$TENANT/controller/v1/$CID" | python3 -m json.tool
# ═══ Step 2: 分配 Distribution SetManagement API═════════════════
curl -u $ADMIN -X POST -H "Content-Type: application/json" \
"$HOST/rest/v1/distributionsets/1/assignedTargets" \
-d '[{"id": "'$CID'"}]'
# ═══ Step 3: 轮询(拿到 deploymentBase═════════════════════════════
RESP=$(curl -s -H "$AUTH" -H "Accept: application/json" \
"$HOST/$TENANT/controller/v1/$CID")
echo "$RESP" | python3 -m json.tool
# 提取 actionId假设第一次分配取 deploymentBase URL 中的数字)
DEPLOY_URL=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['_links']['deploymentBase']['href'])")
AID=$(echo "$DEPLOY_URL" | grep -oP 'deploymentBase/\K\d+')
echo "Action ID: $AID"
# ═══ Step 4: 拉取部署详情 ══════════════════════════════════════════
DEPLOY=$(curl -s -H "$AUTH" -H "Accept: application/json" \
"$HOST/$TENANT/controller/v1/$CID/deploymentBase/$AID")
echo "$DEPLOY" | python3 -m json.tool
# 提取下载地址和 SHA256
DL_URL=$(echo "$DEPLOY" | python3 -c "
import sys,json
art = json.load(sys.stdin)['deployment']['chunks'][0]['artifacts'][0]
print(art['_links']['download-http']['href'])
")
SHA256=$(echo "$DEPLOY" | python3 -c "
import sys,json
print(json.load(sys.stdin)['deployment']['chunks'][0]['artifacts'][0]['hashes']['sha256'])
")
echo "Download: $DL_URL"
echo "SHA256: $SHA256"
# ═══ Step 5: 上报属性 ══════════════════════════════════════════════
curl -s -H "$AUTH" -H "Content-Type: application/json" -X PUT \
"$HOST/$TENANT/controller/v1/$CID/configData" \
-d '{"mode":"merge","data":{"osType":"linux","hostname":"'$CID'","swVersion":"V1.0.0.0"}}'
# ═══ Step 6: 下载 ══════════════════════════════════════════════════
curl -H "$AUTH" -o /tmp/update.zip "$DL_URL"
# ═══ Step 7: 校验 ══════════════════════════════════════════════════
echo "$SHA256 /tmp/update.zip" | sha256sum -c
# ═══ Step 8: 上报四步状态 ══════════════════════════════════════════
FB="$HOST/$TENANT/controller/v1/$CID/deploymentBase/$AID/feedback"
curl -s -H "$AUTH" -H "Content-Type: application/json" \
-d '{"status":{"execution":"download","result":{"finished":"none"},"details":["Starting"]}}' "$FB"
curl -s -H "$AUTH" -H "Content-Type: application/json" \
-d '{"status":{"execution":"downloaded","result":{"finished":"none"},"details":["Downloaded"]}}' "$FB"
curl -s -H "$AUTH" -H "Content-Type: application/json" \
-d '{"status":{"execution":"proceeding","result":{"finished":"none"},"details":["Installing"]}}' "$FB"
curl -s -H "$AUTH" -H "Content-Type: application/json" \
-d '{"status":{"execution":"closed","result":{"finished":"success"},"details":["Installation complete"],"code":200}}' "$FB"
# ═══ Step 9: 验证 ══════════════════════════════════════════════════
curl -s -u $ADMIN "$HOST/rest/v1/targets/$CID/actions/$AID/status" | python3 -m json.tool
echo "Done! Action $AID is now finished."
```
### 12.2 Hawkbit 状态流转图
```
管理员分配 DS
RUNNING WAITING_FOR_CONFIRMATION
│ │
▼ │ confirmed
RETRIEVED ◄─── deploymentBase ▼
│ (轮询返回) RUNNING
▼ │
DOWNLOAD ...
DOWNLOADED
PROCEEDING ──→ status: RUNNING
┌───┴───┐
▼ ▼
FINISHED ERROR ◄─── closed + success/failure
(成功) (失败)
任意阶段 ──cancel──→ CANCELING ──→ CANCELED
```
### 12.3 API 端点速查表
| 操作 | 方法 | 路径 | 认证 |
|------|------|------|------|
| 轮询 | GET | `/{tenant}/controller/v1/{controllerId}` | DDI |
| 部署详情 | GET | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}` | DDI |
| 上报反馈 | POST | `.../deploymentBase/{actionId}/feedback` | DDI |
| 取消反馈 | POST | `.../cancelAction/{actionId}/feedback` | DDI |
| 确认反馈 | POST | `.../confirmationBase/{actionId}/feedback` | DDI |
| 上报属性 | PUT | `/{tenant}/controller/v1/{controllerId}/configData` | DDI |
| 下载文件 | GET | `/{tenant}/controller/v1/{controllerId}/softwaremodules/{id}/artifacts/{file}` | DDI |
| 下载MD5 | GET | `.../softwaremodules/{id}/artifacts/{file}.MD5SUM` | DDI |
| 分配 DS | POST | `/rest/v1/distributionsets/{id}/assignedTargets` | Basic |
| 查询 target | GET | `/rest/v1/targets/{controllerId}` | Basic |
| 查询 action | GET | `/rest/v1/targets/{controllerId}/actions/{id}` | Basic |
| 查询状态 | GET | `/rest/v1/targets/{controllerId}/actions/{id}/status` | Basic |
| 查询属性 | GET | `/rest/v1/targets/{controllerId}/attributes` | Basic |
| 取消 action | DELETE | `/rest/v1/targets/{controllerId}/actions/{id}` | Basic |
| 配置管理 | GET/PUT/DELETE | `/rest/v1/system/configs/{key}` | Basic |

214
hawkbit-compose/README.md Normal file
View File

@@ -0,0 +1,214 @@
# Hawkbit DDI Client
轻量级纯 Python OTA 更新代理,零依赖(仅 Python 3 标准库),可在 **Android adb shell** 和 **Linux 主机**上运行。
## 功能
| 功能 | 说明 |
|------|------|
| **轮询** | `GET /{tenant}/controller/v1/{controllerId}` 查询部署任务,自动适配服务端建议的轮询间隔 |
| **断点下载** | HTTP `Range` 请求实现断点续传,临时 `.tmp` 文件保留至下载完成 |
| **SHA256 校验** | 下载完成后自动与部署元数据中的哈希值比对 |
| **状态上报** | 完整状态链:`download``downloaded``proceeding``closed(success/failure)` |
| **设备 SN 检测** | 自动检测序列号Android `ro.serialno` → DMI `product_serial``/etc/machine-id` → hostname |
| **设备属性上报** | 首次连接自动上报设备属性osType, platform, hwRevision, kernel…供 Hawkbit target filter 匹配 |
| **认证** | 支持 `GatewayToken``TargetToken`,也支持从文件读取:`--gateway-token @/etc/hawkbit/gateway.key` |
| **取消/确认** | 处理 `cancelAction`(取消任务)和 `confirmationBase`headless 自动确认) |
### 设备属性上报
脚本在**首次轮询**时自动调用 `PUT …/configData` 上报以下属性:
| 属性 | 来源 | 示例 |
|------|------|------|
| `osType` | `/system/build.prop` 或 uname | `android` / `linux` |
| `platform` | `platform.machine()` | `aarch64` / `x86_64` |
| `kernel` | `os.uname().release` | `6.17.0-35-generic` |
| `hwRevision` | DMI product_name + product_version | `VMware Virtual Platform` |
| `manufacturer` | DMI sys_vendor 或 Android ro.product.manufacturer | `VMware, Inc.` |
| `hostname` | `socket.gethostname()` | `device-001` |
| `swVersion` | `/etc/hawkbit/current_version`(可选) | `V1.0.0.0` |
**扩展属性**:创建 `/etc/hawkbit/device_attrs.json` 添加自定义属性:
```json
{"dept": "production", "region": "east", "owner": "team-a"}
```
这些属性在 Hawkbit 里就是 target 的 attributes管理员创建 target filter 时可以直接用:
```
属性 osType=android AND hwRevision=VMware → 自动分配 V2.0 更新
## 快速开始
```bash
# 网关令牌,每 30 秒轮询
python3 ddi-client.py -u https://hawkbit.example.com -t DEFAULT --gateway-token s3cret -i 30
# 网关令牌从文件读取(生产推荐)
echo "s3cret" > /etc/hawkbit/gateway.key
chmod 600 /etc/hawkbit/gateway.key
python3 ddi-client.py -u https://hawkbit.example.com -t DEFAULT \
--gateway-token @/etc/hawkbit/gateway.key -i 30
# 目标令牌,单次轮询
python3 ddi-client.py -u http://10.0.0.1:8080 -t DEFAULT --target-token tok --once
# 自定义设备 ID + 自定义属性文件,调试模式
python3 ddi-client.py -u https://hawkbit:8443 -t prod \
--gateway-token @/etc/hawkbit/gateway.key \
--controller-id edge-042 -d /data/ota -v
```
## 命令行参数
```
-u, --base-url Hawkbit 服务端地址(必填)
-t, --tenant 租户名称,如 DEFAULT必填
--gateway-token Gateway 安全令牌
--target-token Target 安全令牌
--controller-id 控制器 ID默认自动检测设备序列号
-d, --download-dir 下载目录,默认 /tmp/hawkbit
-i, --polling-interval 轮询间隔(秒),默认 60可被服务端覆盖
--no-verify 跳过 SHA256 校验
--no-resume 禁用断点续传
--no-ssl-verify 禁用 TLS 证书验证(不安全)
--once 仅轮询一次后退出
-v, --verbose 调试级日志
-h, --help 帮助信息
```
## DDI API 流程
```
┌─────────┐ ① GET /{tenant}/controller/v1/{controllerId} ┌──────────┐
│ │ ────────────────────────────────────────────────────→ │ │
│ 设备 │ ←──── HAL links (deploymentBase / cancel / …) │ Hawkbit │
│ │ │ Server │
│ │ ② GET …/deploymentBase/{actionId} │ │
│ │ ────────────────────────────────────────────────────→ │ │
│ │ ←──── chunks[].artifacts[] (filename, SHA256, │ │
│ │ download URL) │ │
│ │ │ │
│ │ ③ GET …/softwaremodules/{id}/artifacts/{file} │ │
│ │ ────────────────────────────────────────────────────→ │ │
│ │ ←──── 二进制流(支持 Range 断点续传) │ │
│ │ │ │
│ │ ④ POST …/deploymentBase/{actionId}/feedback │ │
│ │ ───── {execution, result, details} ────────────────→ │ │
│ │ ←──── 200 OK │ │
└─────────┘ └──────────┘
```
### 状态上报 JSON 格式
```json
{
"timestamp": 1750000000000,
"status": {
"execution": "download",
"result": {
"finished": "none"
},
"details": ["Starting download"]
}
}
```
| execution 值 | 含义 | result.finished |
|-------------|------|-----------------|
| `download` | 开始下载 | `none` |
| `downloaded` | 下载完成 | `none` |
| `proceeding` | 安装中 | `none` |
| `closed` | 最终状态 | `success` / `failure` |
| `canceled` | 已取消 | `none` |
| `rejected` | 已拒绝 | `none` |
## 认证配置
### Target Token推荐每设备独立令牌
在 Hawkbit Management API 中为目标设置 security token
```bash
curl -u admin:admin -X PUT \
-H "Content-Type: application/json" \
"http://<hawkbit>:9090/rest/v1/targets/<controllerId>" \
-d '{"securityToken": "my-device-token"}'
```
启用租户级 TargetToken 认证:
```bash
curl -u admin:admin -X PUT \
-H "Content-Type: application/json" \
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.targettoken.enabled" \
-d '{"value": true}'
```
### Gateway Token所有设备共享同一令牌
```bash
# 设置 token key
curl -u admin:admin -X PUT \
-H "Content-Type: application/json" \
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.gatewaytoken.key" \
-d '{"value": "my-gateway-key"}'
# 启用
curl -u admin:admin -X PUT \
-H "Content-Type: application/json" \
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.gatewaytoken.enabled" \
-d '{"value": true}'
```
## 设备 SN 检测优先级
| 优先级 | 来源 | 适用平台 |
|--------|------|---------|
| 1 | `getprop ro.serialno` | Android |
| 2 | `/sys/class/dmi/id/product_serial` | Linux (x86) |
| 3 | `/sys/devices/virtual/dmi/id/product_serial` | Linux (ARM / 嵌入式) |
| 4 | `/etc/machine-id` | systemd Linux |
| 5 | `socket.gethostname()` | 通用回退 |
## 安装钩子
下载完成后,脚本调用 `_install(files, handling)` 方法执行实际安装。**默认实现仅做文件存在性检查,生产环境必须覆盖此方法**。
### 方式一:子类继承
```python
from ddi_client import HawkbitDDIClient
class MyClient(HawkbitDDIClient):
def _install(self, files, handling):
import subprocess
for f in files:
subprocess.run(["unzip", "-o", f, "-d", "/data/ota"], check=True)
return True
```
### 方式二Monkey-Patch
```python
client = HawkbitDDIClient(...)
client._install = lambda files, h: do_my_install(files)
```
## 兼容性
- Python ≥ 3.7
- Android (adb shell, 需安装 Python)
- Linux (x86_64 / aarch64 / armv7l)
- macOS
- Hawkbit DDI API v1
## 文件结构
```
ddi-client.py # 脚本本体(单文件,可直接部署)
```
无其他依赖文件。

902
hawkbit-compose/RUNBOOK.md Normal file
View File

@@ -0,0 +1,902 @@
# Hawkbit DDI Client — 运行流程文档
## 目录
1. [整体架构](#1-整体架构)
2. [首次注册流程](#2-首次注册流程)
3. [守护进程轮询流程](#3-守护进程轮询流程)
4. [单次轮询流程](#4-单次轮询流程)
5. [OTA 部署流程](#5-ota-部署流程)
6. [认证流程](#6-认证流程)
7. [取消任务流程](#7-取消任务流程)
8. [确认任务流程](#8-确认任务流程)
9. [断点续传流程](#9-断点续传流程)
10. [错误处理流程](#10-错误处理流程)
11. [安装钩子定制](#11-安装钩子定制)
12. [部署与运维](#12-部署与运维)
---
## 1. 整体架构
```
┌──────────────────────────────────────────────────────────┐
│ ddi-client.py │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 属性检测 │ │ 认证模块 │ │ 轮询模块 │ │ 安装钩子 │ │
│ │ (SN/OS/ │ │ Gateway/ │ │ Poll/ │ │ _install()│ │
│ │ HW/Kernel│ │ Target │ │ Deploy/ │ │ 可覆盖 │ │
│ │ ...) │ │ Token │ │ Download │ │ │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ HTTP 层 │ │
│ │ (urllib + SSL context + Range 支持) │ │
│ └──────────────────────┬───────────────────────────┘ │
└─────────────────────────┼────────────────────────────────┘
┌──────────────────────┐
│ Hawkbit Server │
│ DDI API v1 (9090) │
└──────────────────────┘
```
### 数据流
```
属性文件 ──┐
├──→ 属性检测 ──→ PUT /configData ──→ Hawkbit 记录 target attributes
SN 检测 ──┘
轮询 ──→ GET /controller/v1/{SN}
←── deploymentBase / cancelAction / confirmationBase / configData / 无
部署 ──→ GET /deploymentBase/{id} ←── chunks[].artifacts[]
下载 ──→ GET /softwaremodules/{id}/... ←── 二进制流 (Range 支持)
反馈 ──→ POST /feedback ←── 200 OK
```
---
## 2. 首次注册流程
**触发条件**:设备 SN 在 Hawkbit 中不存在(或脚本首次连接后重启)
```
启动参数:
ddi-client.py -u http://192.168.142.128:9090 -t DEFAULT \
--gateway-token @/etc/hawkbit/gateway.key
═══════════════════════════════════════════════════════════════
Step 1: 读取 Gateway Token
═══════════════════════════════════════════════════════════════
输入: @/etc/hawkbit/gateway.key
动作: open("/etc/hawkbit/gateway.key") → read() → strip()
输出: Authorization: GatewayToken <文件内容>
备选: --gateway-token <明文key> 直接传入
--target-token <明文token> 直接传入
--target-token @/etc/hawkbit/target.token 文件读取
═══════════════════════════════════════════════════════════════
Step 2: 检测设备 SN
═══════════════════════════════════════════════════════════════
优先级:
① getprop ro.serialno → Android 序列号
② /sys/class/dmi/id/product_serial → Linux DMI (x86)
③ /sys/devices/virtual/dmi/id/... → Linux DMI (ARM/嵌入式)
④ /etc/machine-id → systemd 机器 ID
⑤ socket.gethostname() → 兜底
跳过条件: --controller-id 显式指定
输出: controllerId = "89aa36c5b6744dc2910479f7ecd6d2c3"
═══════════════════════════════════════════════════════════════
Step 3: 检测设备属性
═══════════════════════════════════════════════════════════════
自动检测:
osType → /system/build.prop 存在 ? "android" : "linux"
platform → platform.machine() # x86_64 / aarch64
kernel → os.uname().release # 6.17.0-35-generic
hostname → socket.gethostname()
hwRevision → DMI product_name + product_version
manufacturer→ DMI sys_vendor / Android ro.product.manufacturer
Android 额外属性:
androidVersion → ro.build.version.release
productModel → ro.product.model
buildNumber → ro.build.version.incremental
用户属性文件 (/etc/hawkbit/device_attrs.json):
{"dept": "production", "region": "east", "owner": "team-a"}
版本文件 (/etc/hawkbit/current_version) :
V1.0.0.0 → swVersion
═══════════════════════════════════════════════════════════════
Step 4: 上报属性
═══════════════════════════════════════════════════════════════
PUT http://host/DEFAULT/controller/v1/{SN}/configData
Body:
{
"mode": "merge",
"data": {
"osType": "linux",
"platform": "x86_64",
"kernel": "6.17.0-35-generic",
"hostname": "device-001",
"hwRevision": "VMware Virtual Platform",
"manufacturer": "VMware, Inc.",
"swVersion": "V1.0.0.0",
"dept": "production",
"region": "east"
}
}
mode 说明:
"merge" → 只更新传入的 key保留已有属性 (推荐)
"replace" → 完全替换
"remove" → 删除传入的 key
═══════════════════════════════════════════════════════════════
Step 5: 首次轮询(自动注册)
═══════════════════════════════════════════════════════════════
GET http://host/DEFAULT/controller/v1/{SN}
Hawkbit 行为:
findOrRegisterTargetIfItDoesNotExist(SN)
→ 不存在 → INSERT INTO target (controller_id, ip_address, ...)
→ 存在 → UPDATE last_target_query
响应:
无 action:
{"config": {"polling": {"sleep": "00:05:00"}}, "_links": {}}
→ sleep 5分钟
有 deployment:
"_links": {
"deploymentBase": {"href": "http://.../deploymentBase/1?c=..."},
"configData": {"href": "http://.../configData"}
}
→ 进入 OTA 部署流程
有 cancel:
"_links": {"cancelAction": {"href": "http://.../cancelAction/..."}}
→ 进入取消流程
有 confirmation:
"_links": {"confirmationBase": {"href": "http://.../confirmationBase"}}
→ 进入确认流程
```
---
## 3. 守护进程轮询流程
```
启动命令:
ddi-client.py -u http://192.168.142.128:9090 -t DEFAULT \
--gateway-token my-key -i 60
┌─────────────────────────────────────────────────┐
│ 守护模式 (daemon=True) │
├─────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ 启动/打印 │ ╔═══════════════════╗ │
│ │ banner │ ║ Server: host ║ │
│ └────┬─────┘ ║ Tenant: DEFAULT ║ │
│ │ ║ Device: <SN> ║ │
│ ▼ ║ Auth: Gateway ║ │
│ ┌──────────┐ ╚═══════════════════╝ │
│ │ 上报属性 │ PUT /configData │
│ │(首次) │ (仅一次, 成功即跳过) │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 轮询 │ GET /controller/v1/{SN} │
│ │ Poll │ │
│ └────┬─────┘ │
│ │ │
│ ┌───┴───┬───────────┬───────────┐ │
│ ▼ ▼ ▼ ▼ │
│ deploy cancel confirm 无任务 │
│ │ │ │ │ │
│ ▼ ▼ ▼ │ │
│ OTA流程 取消流程 确认流程 │ │
│ │ │ │ │ │
│ └───┴───┴─────────┴───────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ sleep(N 秒) │ N = max(server建议, │
│ │ │ 默认60s) │
│ └──────┬───────┘ │
│ │ │
│ └──────→ 回到轮询 ◄── 循环 │
│ │
│ 退出: Ctrl+C (KeyboardInterrupt) │
│ max_cycles 达到上限 │
└─────────────────────────────────────────────────┘
轮询间隔策略:
- 默认: --polling-interval 60 (或 -i 指定)
- 服务端可覆盖: response.config.polling.sleep → "00:05:00" → 5分钟
- 有活跃 action 时: 服务端可能缩短间隔 (getPollingTimeForAction)
- 轮询失败时: 退避到 min(polling_interval, 30s)
```
---
## 4. 单次轮询流程
```
启动命令:
ddi-client.py -u http://host:9090 -t DEFAULT \
--gateway-token my-key --once
┌───────────────────────────────────────────────────────────┐
│ 单次模式 (daemon=False, --once) │
├───────────────────────────────────────────────────────────┤
│ │
│ ① 上报属性 (首次) │
│ │ │
│ ▼ │
│ ② 轮询 GET /controller/v1/{SN} │
│ │ │
│ ┌───┴───┬───────────┬───────────┐ │
│ ▼ ▼ ▼ ▼ │
│ deploy cancel confirm 无任务 │
│ │ │ │ │ │
│ ▼ ▼ ▼ │ │
│ OTA流程 取消流程 确认流程 │ │
│ │ │ │ │ │
│ └───┴───┴─────────┴───────────┘ │
│ │ │
│ ▼ │
│ ③ sys.exit(0) │
│ │
│ 用途: │
│ - systemd timer / cron 定时触发 │
│ - 测试/调试 │
│ - 脚本化: && 链式调用后续操作 │
└───────────────────────────────────────────────────────────┘
cron 示例:
*/5 * * * * /usr/bin/python3 /opt/hawkbit/ddi-client.py \
-u https://hawkbit:9090 -t DEFAULT \
--gateway-token @/etc/hawkbit/gateway.key --once
systemd timer 示例:
[Unit]
Description=Hawkbit DDI poll
[Service]
ExecStart=/usr/bin/python3 /opt/hawkbit/ddi-client.py \
-u https://hawkbit:9090 -t DEFAULT \
--gateway-token @/etc/hawkbit/gateway.key --once
[Timer]
OnBootSec=30s
OnUnitActiveSec=300s
```
---
## 5. OTA 部署流程
```
前置条件: 轮询返回 _links.deploymentBase
┌───────────────────────────────────────────────────────────────┐
│ OTA 部署完整生命周期 │
├───────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Step A: 拉取部署详情 │ │
│ │ │ │
│ │ GET /deploymentBase/{actionId}?c=... │ │
│ │ ← { │ │
│ │ "id": "2", │ │
│ │ "deployment": { │ │
│ │ "download": "forced", │ │
│ │ "update": "forced", │ │
│ │ "chunks": [{ │ │
│ │ "part": "os", │ │
│ │ "version": "V1.0.0.0", │ │
│ │ "name": "r3576-OS-android", │ │
│ │ "artifacts": [{ │ │
│ │ "filename": "update.zip", │ │
│ │ "size": 195411, │ │
│ │ "hashes": { │ │
│ │ "sha256": "438b8f9e...", │ │
│ │ "md5": "28a17f5f...", │ │
│ │ "sha1": "223377ea..." │ │
│ │ }, │ │
│ │ "_links": { │ │
│ │ "download-http": { │ │
│ │ "href": "http://.../update.zip" │ │
│ │ } │ │
│ │ } │ │
│ │ }] │ │
│ │ }] │ │
│ │ } │ │
│ │ } │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Step B: 上报 DOWNLOAD (开始下载) │ │
│ │ │ │
│ │ POST /deploymentBase/{id}/feedback │ │
│ │ {"status": { │ │
│ │ "execution": "download", │ │
│ │ "result": {"finished": "none"}, │ │
│ │ "details": ["Starting download"] │ │
│ │ }} │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Step C: 下载 artifacts (逐个) │ │
│ │ │ │
│ │ 对每个 chunk.artifacts[]: │ │
│ │ │ │
│ │ ① 提取 download URL (优先级) │ │
│ │ download (HTTPS) > download-http (HTTP) │ │
│ │ 失败 → fallback: 遍历所有 HAL link 提取 smId │ │
│ │ │ │
│ │ ② 检查本地缓存 /tmp/hawkbit/{filename} │ │
│ │ 存在 + SHA256 匹配 → 跳过下载 │ │
│ │ 存在 + SHA256 不匹配 → 重新下载 │ │
│ │ │ │
│ │ ③ 检查 .tmp 断点文件 │ │
│ │ 存在 → 加 Range: bytes={size}- 头续传 │ │
│ │ │ │
│ │ ④ GET /softwaremodules/{id}/artifacts/{filename} │ │
│ │ 分块读取 64KB, 写入 .tmp │ │
│ │ 打印下载进度百分比 │ │
│ │ │ │
│ │ ⑤ SHA256 校验 .tmp 文件 │ │
│ │ 成功 → rename .tmp → 最终文件 │ │
│ │ 失败 → 返回 None │ │
│ │ │ │
│ │ ⑥ 上报 PROCEEDING (每 artifact) │ │
│ │ execution=proceeding, progress={cnt:1, of:1} │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Step D: 上报 DOWNLOADED (全部下载完成) │ │
│ │ │ │
│ │ POST /deploymentBase/{id}/feedback │ │
│ │ {"status": { │ │
│ │ "execution": "downloaded", │ │
│ │ "result": {"finished": "none"}, │ │
│ │ "details": ["Downloaded 1 artifact(s)"] │ │
│ │ }} │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Step E: 上报 PROCEEDING (开始安装) │ │
│ │ │ │
│ │ POST /deploymentBase/{id}/feedback │ │
│ │ {"status": { │ │
│ │ "execution": "proceeding", │ │
│ │ "result": {"finished": "none"}, │ │
│ │ "details": ["Installing update …"] │ │
│ │ }} │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Step F: 执行安装 _install(files, handling) │ │
│ │ │ │
│ │ 默认行为: 检测到 *.zip 文件 → 返回 True │ │
│ │ 生产: 覆盖此方法执行实际刷写 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ 成功 失败 │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────┐ │
│ │ Step G: 上报 CLOSED (最终) │ │
│ │ │ │
│ │ 成功: │ │
│ │ execution=closed │ │
│ │ result.finished=success │ │
│ │ code=200 │ │
│ │ │ │
│ │ 失败: │ │
│ │ execution=closed │ │
│ │ result.finished=failure │ │
│ │ code=500 │ │
│ └───────────────────────────────┘ │
│ │
│ → 返回主循环 sleep → 下次轮询 │
└────────────────────────────────────────────────────────────────┘
Hawkbit Action Status 映射:
execution=download → Status.DOWNLOAD
execution=downloaded → Status.DOWNLOADED
execution=proceeding → Status.RUNNING
execution=closed → result.success → Status.FINISHED
→ result.failure → Status.ERROR
execution=canceled → Status.CANCELED
execution=rejected → Status.WARNING
```
---
## 6. 认证流程
```
┌───────────────────────────────────────────────────────────┐
│ 认证方式选择 │
├───────────────────────────────────────────────────────────┤
│ │
│ ① GatewayToken (推荐,所有设备共享) │
│ ────────────────────────────────────── │
│ 服务端配置: │
│ curl -u admin:admin -X PUT \ │
│ .../system/configs/authentication.gatewaytoken.key \│
│ -d '{"value": "my-shared-key"}' │
│ │
│ curl -u admin:admin -X PUT \ │
│ .../system/configs/authentication.gatewaytoken.enabled \│
│ -d '{"value": true}' │
│ │
│ 客户端: │
│ 方式 A: 命令行参数 │
│ --gateway-token my-shared-key │
│ │
│ 方式 B: 从文件读取 │
│ echo "my-shared-key" > /etc/hawkbit/gateway.key │
│ chmod 600 /etc/hawkbit/gateway.key │
│ --gateway-token @/etc/hawkbit/gateway.key │
│ │
│ HTTP: Authorization: GatewayToken my-shared-key │
│ │
│ │
│ ② TargetToken (每设备独立令牌) │
│ ──────────────────────────────── │
│ 服务端配置: │
│ curl -u admin:admin -X PUT \ │
│ .../system/configs/authentication.targettoken.enabled \│
│ -d '{"value": true}' │
│ │
│ # 每个 target 的 securityToken 自动生成或手动设置 │
│ curl -u admin:admin -X PUT \ │
│ .../targets/{controllerId} \ │
│ -d '{"securityToken": "device-unique-token"}' │
│ │
│ 客户端: │
│ --target-token device-unique-token │
│ 或 --target-token @/etc/hawkbit/target.token │
│ │
│ HTTP: Authorization: TargetToken device-unique-token │
│ │
│ │
│ ③ SecurityHeader (反向代理模式, 脚本不支持) │
│ ────────────────────────────────────────── │
│ 适用于 nginx/Envoy 前置认证场景 │
│ │
│ │
│ 优先级 (客户端): --gateway-token > --target-token │
│ 无认证: 服务器返回 401 │
│ 验证失败: 服务器返回 401 │
└───────────────────────────────────────────────────────────┘
```
---
## 7. 取消任务流程
```
前置条件: 轮询返回 _links.cancelAction
┌───────────────────────────────────────────────────┐
│ 取消任务处理 │
├───────────────────────────────────────────────────┤
│ │
│ ① GET /cancelAction/{actionId} │
│ ← {"id": "11", │
│ "cancelAction": {"stopId": "11"}} │
│ │
│ ② POST /cancelAction/{actionId}/feedback │
│ Body: │
│ {"status": { │
│ "execution": "canceled", │
│ "result": {"finished": "none"}, │
│ "details": ["Cancel acknowledged"] │
│ }} │
│ │
│ → 回到主循环 sleep → 下次轮询 │
└───────────────────────────────────────────────────┘
```
---
## 8. 确认任务流程
```
前置条件: action 状态为 WAITING_FOR_CONFIRMATION
┌───────────────────────────────────────────────────┐
│ 确认任务处理 (headless) │
├───────────────────────────────────────────────────┤
│ │
│ Headless 设备自动确认,不等待人工: │
│ │
│ POST /confirmationBase/{actionId}/feedback │
│ Body: │
│ {"confirmation": "confirmed", │
│ "code": 200, │
│ "details": ["Auto-confirmed by DDI client"]} │
│ │
│ → Action 状态从 WAITING_FOR_CONFIRMATION │
│ 转为 RUNNING │
│ → 下次轮询拿到 deploymentBase 链接 │
│ → 进入正常 OTA 流程 │
│ │
│ 如果 autoConfirm.active=true
│ 下次轮询时服务端直接返回 deploymentBase │
│ 跳过确认步骤 │
└───────────────────────────────────────────────────┘
```
---
## 9. 断点续传流程
```
┌───────────────────────────────────────────────────────────┐
│ 断点续传 (Range 请求) │
├───────────────────────────────────────────────────────────┤
│ │
│ 触发条件: --no-resume 未设置 (默认启用) │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 下载 {filename} 之前 │ │
│ │ │ │
│ │ ① 检查 /tmp/hawkbit/{filename} │ │
│ │ 存在 + SHA256 匹配 │ │
│ │ → 跳过下载,直接返回本地路径 │ │
│ │ │ │
│ │ ② 检查 /tmp/hawkbit/{filename}.tmp │ │
│ │ 存在 → os.path.getsize(tmp_path) = 1048576 │ │
│ │ 添加头: Range: bytes=1048576- │ │
│ │ 打开模式: "ab" (追加) │ │
│ │ │ │
│ │ ③ 文件不存在 │ │
│ │ 无 Range 头 │ │
│ │ 打开模式: "wb" (新建) │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ GET /softwaremodules/{id}/artifacts/{filename} │ │
│ │ Range: bytes=1048576- │ │
│ │ ← 206 Partial Content │ │
│ │ Content-Range: bytes 1048576-195410/195411 │ │
│ │ (服务端从 offset 开始发送) │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 分块写入 + 进度报告 │ │
│ │ │ │
│ │ while chunk = resp.read(64KB): │ │
│ │ fh.write(chunk) │ │
│ │ downloaded += len(chunk) │ │
│ │ pct = downloaded / total * 100 │ │
│ │ │ │
│ │ 中断 (Exception / 网络断): │ │
│ │ .tmp 文件保留 │ │
│ │ 下次启动时自动续传 │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ SHA256 校验通过 │ │
│ │ → os.rename(tmp_path, local_path) 原子重命名 │ │
│ │ │ │
│ │ SHA256 校验失败 │ │
│ │ → 日志记录 mismatch │ │
│ │ → 返回 None │ │
│ │ → 调用方删除 .tmp 或手动恢复 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 禁用续传: │
│ --no-resume → 每次都从头下载 │
│ → 每次 .tmp 从 0 开始 │
└───────────────────────────────────────────────────────────┘
```
---
## 10. 错误处理流程
```
┌───────────────────────────────────────────────────────────────┐
│ 错误处理矩阵 │
├───────────────┬───────────────────────┬───────────────────────┤
│ 场景 │ 行为 │ 恢复 │
├───────────────┼───────────────────────┼───────────────────────┤
│ 轮询 HTTP 错误 │ 日志记录,退避 sleep │ 下次轮询自动重试 │
│ (network/500) │ min(interval, 30s) │ │
│ │ 守护模式继续 │ │
│ │ --once: exit(1) │ │
├───────────────┼───────────────────────┼───────────────────────┤
│ 获取部署详情 │ send_feedback(closed, │ 下次轮询后 action │
│ 失败 │ failure, "Cannot │ 可能还在 pending │
│ │ fetch deployment") │ 状态,重新处理 │
│ │ return False │ │
├───────────────┼───────────────────────┼───────────────────────┤
│ 下载失败 │ send_feedback(closed, │ .tmp 保留 │
│ (HTTP error) │ failure, "Download │ 下次轮询自动续传 │
│ │ failed for xxx") │ │
│ │ return False │ │
├───────────────┼───────────────────────┼───────────────────────┤
│ SHA256 不匹配 │ 日志 error │ .tmp 保留,可手动删除 │
│ │ "SHA256 MISMATCH" │ 下次重新下载 │
│ │ return None │ │
├───────────────┼───────────────────────┼───────────────────────┤
│ 下载中断 │ 日志 error │ .tmp 保留 │
│ (Exception) │ "Download interrupted" │ 下次自动续传 │
│ │ return None │ │
├───────────────┼───────────────────────┼───────────────────────┤
│ 安装失败 │ send_feedback(closed, │ 下次轮询后 action │
│ (_install 返 │ failure, code=500) │ 状态 → ERROR │
│ 回 False) │ return False │ 管理端可见 │
├───────────────┼───────────────────────┼───────────────────────┤
│ SSL 证书错误 │ 捕获后当作普通 HTTP 错误 │ --no-ssl-verify │
│ │ │ 跳过证书验证(不安全) │
├───────────────┼───────────────────────┼───────────────────────┤
│ 属性上报失败 │ 日志 error │ _attrs_reported=False │
│ │ 不影响轮询继续 │ 下次轮询自动重试 │
├───────────────┼───────────────────────┼───────────────────────┤
│ Ctrl+C 中断 │ 日志 "Stopped" │ return True │
│ │ sys.exit(0) │ │
└───────────────┴───────────────────────┴───────────────────────┘
日志级别:
DEBUG - HTTP 请求/响应详情, 下载进度, 属性内容 (--verbose / -v)
INFO - 状态变化, action 处理, 睡眠倒计时 (默认)
WARNING- 非致命错误 (poll 失败, 缓存失效, 无认证) (默认)
ERROR - 致命错误 (下载失败, SHA256 不匹配, HTTP 错误) (默认)
```
---
## 11. 安装钩子定制
```
┌───────────────────────────────────────────────────────────┐
│ _install() 钩子定制 │
├───────────────────────────────────────────────────────────┤
│ │
│ 默认行为: │
│ 检测下载文件列表中是否有 *.zip 文件 → 返回 True │
│ (适合测试,不做实际操作) │
│ │
│ 输入: │
│ files: List[str] → ["/tmp/hawkbit/update.zip"] │
│ handling: dict → {"download":"forced", │
│ "update":"forced", │
│ "maintenanceWindow":null} │
│ │
│ 返回: │
│ True → send_feedback(closed, success) │
│ False → send_feedback(closed, failure) │
│ │
├───────────────────────────────────────────────────────────┤
│ │
│ 方式 A: 子类继承 │
│ ───────────────── │
│ │
│ # my_client.py │
│ from ddi_client import HawkbitDDIClient │
│ │
│ class AndroidOTAClient(HawkbitDDIClient): │
│ def _install(self, files, handling): │
│ import subprocess │
│ for fp in files: │
│ if fp.endswith('.zip'): │
│ # 写 recovery 命令 │
│ subprocess.run([ │
│ 'update_engine_client', │
│ '--update', │
│ f'file://{fp}' │
│ ], check=True) │
│ # 写版本号 │
│ with open('/etc/hawkbit/current_version', │
│ 'w') as f: │
│ f.write('V2.0.0.0') │
│ return True │
│ │
│ if __name__ == '__main__': │
│ # 用自定义子类替换 CLI 入口 │
│ client = AndroidOTAClient(...) │
│ client.run() │
│ │
│ │
│ 方式 B: Monkey-Patch (快速原型) │
│ ───────────────────────────── │
│ │
│ client = HawkbitDDIClient(...) │
│ original_install = client._install │
│ def my_install(files, handling): │
│ result = subprocess.run(['unzip', '-o', files[0], │
│ '-d', '/data/ota']) │
│ with open('/etc/hawkbit/current_version','w') as f: │
│ f.write('V2.0.0.0') │
│ return result.returncode == 0 │
│ client._install = my_install │
│ client.run() │
│ │
│ │
│ 方式 C: 简单模式 (不需要 Python 继承) │
│ ─────────────────────────────────── │
│ │
│ 创建安装脚本: /etc/hawkbit/install.sh │
│ │
│ #!/bin/sh │
│ unzip -o "$1" -d /data/ota │
│ echo "V2.0.0.0" > /etc/hawkbit/current_version │
│ exit $? │
│ │
│ 修改 _install: │
│ def _install(self, files, handling): │
│ for fp in files: │
│ if fp.endswith('.zip'): │
│ import subprocess │
│ result = subprocess.run( │
│ ['/etc/hawkbit/install.sh', fp]) │
│ return result.returncode == 0 │
│ return True │
└───────────────────────────────────────────────────────────┘
```
---
## 12. 部署与运维
```
┌───────────────────────────────────────────────────────────────┐
│ 部署方案 │
├───────────────────────────────────────────────────────────────┤
│ │
│ Android 设备部署: │
│ ──────────────── │
│ │
│ # 1. 推送脚本 │
│ adb push ddi-client.py /data/local/tmp/ │
│ │
│ # 2. 创建 token 文件 │
│ adb shell "echo 'my-gateway-key' > /data/local/tmp/gateway.key" │
│ adb shell "chmod 600 /data/local/tmp/gateway.key" │
│ │
│ # 3. 创建属性文件 (可选) │
│ adb push device_attrs.json /data/local/tmp/ │
│ adb shell "mkdir -p /etc/hawkbit" │
│ adb shell "cp /data/local/tmp/device_attrs.json /etc/hawkbit/" │
│ │
│ # 4. 运行 │
│ adb shell "python3 /data/local/tmp/ddi-client.py \ │
│ -u https://hawkbit:9090 -t DEFAULT \ │
│ --gateway-token @/data/local/tmp/gateway.key \ │
│ -d /data/local/tmp/hawkbit" │
│ │
│ │
│ Linux 设备部署 (systemd): │
│ ───────────────────────── │
│ │
│ # /opt/hawkbit/ddi-client.py │
│ # /etc/hawkbit/gateway.key │
│ # /etc/hawkbit/device_attrs.json │
│ │
│ # /etc/systemd/system/hawkbit-ddi.service │
│ [Unit] │
│ Description=Hawkbit DDI Client │
│ After=network-online.target │
│ Wants=network-online.target │
│ │
│ [Service] │
│ Type=simple │
│ ExecStart=/usr/bin/python3 /opt/hawkbit/ddi-client.py \ │
│ -u https://hawkbit:9090 -t DEFAULT \ │
│ --gateway-token @/etc/hawkbit/gateway.key \ │
│ -d /var/cache/hawkbit -i 30 │
│ Restart=always │
│ RestartSec=30 │
│ User=root │
│ │
│ [Install] │
│ WantedBy=multi-user.target │
│ │
│ systemctl enable --now hawkbit-ddi │
│ │
│ │
│ 版本管理: │
│ ──────── │
│ │
│ 安装成功后写入版本号: │
│ echo "V2.0.0.0" > /etc/hawkbit/current_version │
│ │
│ 下次轮询时属性中自动包含 swVersion=V2.0.0.0 │
│ 管理员可在 Hawkbit 中按版本筛选设备 │
└───────────────────────────────────────────────────────────────┘
```
---
## 附录 A: HTTP 请求/响应速查
| 操作 | 方法 | 路径 | Content-Type |
|------|------|------|-------------|
| 轮询 | GET | `/{tenant}/controller/v1/{controllerId}` | application/hal+json |
| 部署详情 | GET | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}` | application/hal+json |
| 上报反馈 | POST | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}/feedback` | application/json |
| 下载文件 | GET | `/{tenant}/controller/v1/{controllerId}/softwaremodules/{id}/artifacts/{file}` | application/octet-stream |
| 上报属性 | PUT | `/{tenant}/controller/v1/{controllerId}/configData` | application/json |
| 取消反馈 | POST | `/{tenant}/controller/v1/{controllerId}/cancelAction/{id}/feedback` | application/json |
| 确认反馈 | POST | `/{tenant}/controller/v1/{controllerId}/confirmationBase/{id}/feedback` | application/json |
## 附录 B: 命令行参数完整列表
```
-u, --base-url Hawkbit 服务端地址 (必填)
-t, --tenant 租户名称 (必填)
--gateway-token Gateway Token 或 @文件路径
--target-token Target Token 或 @文件路径
--controller-id 控制器ID (默认自动检测SN)
-d, --download-dir 下载目录 (/tmp/hawkbit)
-i, --polling-interval 轮询间隔秒数 (60)
--no-verify 跳过 SHA256 校验
--no-resume 禁用断点续传
--no-ssl-verify 跳过 TLS 证书验证
--once 单次轮询后退出
-v, --verbose 调试日志
-h, --help 帮助
```
## 附录 C: 设备属性文件格式
```json
# /etc/hawkbit/device_attrs.json
{
"dept": "production",
"region": "east-1",
"owner": "team-a",
"rack": "A12",
"environment": "staging"
}
```
## 附录 D: 状态流转图 (Hawkbit 视角)
```
assignment
READY ──→ RETRIEVED ──→ DOWNLOAD ──→ DOWNLOADED ──→ RUNNING ──→ FINISHED
│ │ │ (成功)
│ │ │
│ │ └──→ ERROR
│ │ (失败)
│ │
└──────────┬───────────────┘
CANCELING ──→ CANCELED
CANCEL_REJECTED
WAITING_FOR_CONFIRMATION ──confirmed──→ RUNNING → ...
└──denied──→ DENIED
```

Binary file not shown.

844
hawkbit-compose/ddi-client.py Executable file
View File

@@ -0,0 +1,844 @@
#!/usr/bin/env python3
"""
Hawkbit DDI Client — lightweight pure-Python OTA update agent.
=================================================================
No dependencies beyond Python 3 standard library.
Runs on Android (adb shell) and Linux hosts.
DDI API version: v1
Ref: https://github.com/eclipse-hawkbit/hawkbit
Capabilities:
• Poll Hawkbit for deployment actions
• Resumable (Range-request) artifact download
• SHA256 file integrity verification
• Status progression: DOWNLOAD → DOWNLOADED → PROCEEDING → FINISHED / FAILURE
• Auto-detects device serial number as controllerId (Android ro.serialno, DMI, machine-id)
• Reports device attributes (osType, platform, hwRevision, swVersion, kernel…) to Hawkbit
• Authentication: GatewayToken or TargetToken (also from file: --gateway-token @/path/to/key)
Quick start:
# Gateway token
ddi-client.py --base-url https://hawkbit:8443 --tenant DEFAULT --gateway-token mykey
# Target token (per-device)
ddi-client.py --base-url http://10.0.0.1:8080 --tenant DEFAULT --target-token abc123
# One-shot poll
ddi-client.py --base-url http://localhost:8080 --tenant DEFAULT --gateway-token k --once
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import os
import subprocess
import ssl
import sys
import time
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional, Tuple
# ── logging ─────────────────────────────────────────────────────────────────
logger = logging.getLogger("hawkbit-ddi")
# ═══════════════════════════════════════════════════════════════════════════════
# HawkbitDDIClient
# ═══════════════════════════════════════════════════════════════════════════════
class HawkbitDDIClient:
"""Lightweight DDI (Direct Device Integration) client for Eclipse Hawkbit."""
# DDI execution statuses (mapped to Hawkbit action states)
EXEC_DOWNLOAD = "download"
EXEC_DOWNLOADED = "downloaded"
EXEC_PROCEEDING = "proceeding"
EXEC_CLOSED = "closed"
EXEC_CANCELED = "canceled"
EXEC_REJECTED = "rejected"
EXEC_SCHEDULED = "scheduled"
EXEC_RESUMED = "resumed"
RESULT_SUCCESS = "success"
RESULT_FAILURE = "failure"
RESULT_NONE = "none"
# ── constructor ──────────────────────────────────────────────────────
def __init__(
self,
base_url: str,
tenant: str,
controller_id: Optional[str] = None,
auth_header: Optional[str] = None,
download_dir: str = "/tmp/hawkbit",
verify_sha256: bool = True,
polling_interval: int = 60,
ssl_verify: bool = True,
resume_download: bool = True,
) -> None:
self.base_url = base_url.rstrip("/")
self.tenant = tenant
self.controller_id = controller_id or self._detect_device_sn()
self.auth_header = auth_header
self.download_dir = download_dir
self.verify_sha256 = verify_sha256
self.polling_interval = polling_interval
self.ssl_verify = ssl_verify
self.resume_download = resume_download
self._attrs_reported = False
os.makedirs(self.download_dir, exist_ok=True)
# ── device serial detection ───────────────────────────────────────────
@staticmethod
def _detect_device_sn() -> str:
"""Best-effort device serial number detection.
Priority: Android ro.serialno → DMI product_serial → /etc/machine-id → hostname
"""
# 1. Android property
try:
out = subprocess.run(
["getprop", "ro.serialno"],
capture_output=True, text=True, timeout=2,
)
sn = out.stdout.strip()
if sn:
logger.info("Detected Android serial: %s", sn)
return sn
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# 2. Linux DMI product serial
for p in ("/sys/class/dmi/id/product_serial",
"/sys/devices/virtual/dmi/id/product_serial"):
try:
with open(p) as fh:
sn = fh.read().strip()
if sn and sn not in ("0", "Not Specified", "None", ""):
logger.info("Detected DMI serial: %s", sn)
return sn
except (FileNotFoundError, PermissionError):
pass
# 3. /etc/machine-id (systemd)
try:
with open("/etc/machine-id") as fh:
mid = fh.read().strip()
if mid:
logger.info("Using machine-id: %s", mid)
return mid
except (FileNotFoundError, PermissionError):
pass
# 4. Fallback — hostname
import socket
hostname = socket.gethostname()
logger.warning("No serial found; falling back to hostname: %s", hostname)
return hostname
# ── device attributes detection ─────────────────────────────────────
@staticmethod
def _detect_device_attrs() -> Dict[str, str]:
"""Collect device metadata for Hawkbit target attributes.
These attributes are used by Hawkbit target filters to auto-assign updates.
Returns a dict of key-value pairs.
"""
attrs: Dict[str, str] = {}
# ── OS type ─────────────────────────────────────────────────
attrs["hostname"] = HawkbitDDIClient._safe_hostname()
if os.path.exists("/system/build.prop"):
attrs["osType"] = "android"
else:
attrs["osType"] = "linux"
# ── platform / architecture ─────────────────────────────────
try:
import platform
attrs["platform"] = platform.machine()
except Exception:
pass
# ── kernel version ──────────────────────────────────────────
try:
attrs["kernel"] = os.uname().release
except Exception:
pass
# ── Android: detailed attributes ────────────────────────────
if attrs.get("osType") == "android":
for prop, attr_key in (
("ro.build.version.release", "androidVersion"),
("ro.product.model", "productModel"),
("ro.product.manufacturer", "manufacturer"),
("ro.build.version.incremental", "buildNumber"),
("ro.build.fingerprint", "buildFingerprint"),
):
try:
out = subprocess.run(
["getprop", prop], capture_output=True, text=True, timeout=2,
)
val = out.stdout.strip()
if val:
attrs[attr_key] = val
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# ── Linux: DMI hardware info ────────────────────────────────
if attrs.get("osType") == "linux":
for path, attr_key in (
("/sys/class/dmi/id/product_name", "productName"),
("/sys/class/dmi/id/product_version", "productVersion"),
("/sys/class/dmi/id/product_sku", "productSku"),
("/sys/class/dmi/id/sys_vendor", "manufacturer"),
):
try:
with open(path) as fh:
val = fh.read().strip()
if val and val not in ("0", "Not Specified", "None", "", "System Product Name"):
attrs[attr_key] = val
except (FileNotFoundError, PermissionError):
pass
# hwRevision: combine product name + version
if "productName" in attrs:
rev = attrs["productName"]
if "productVersion" in attrs:
rev += " " + attrs["productVersion"]
attrs["hwRevision"] = rev
# ── current software version (if installed) ─────────────────
version_file = "/etc/hawkbit/current_version"
try:
with open(version_file) as fh:
v = fh.read().strip()
if v:
attrs["swVersion"] = v
except (FileNotFoundError, PermissionError):
pass
# ── merge user-supplied attrs from file ─────────────────────
attrs_file = "/etc/hawkbit/device_attrs.json"
try:
with open(attrs_file) as fh:
extra = json.load(fh)
if isinstance(extra, dict):
attrs.update({k: str(v) for k, v in extra.items()})
except (FileNotFoundError, PermissionError, json.JSONDecodeError):
pass
return attrs
@staticmethod
def _safe_hostname() -> str:
try:
import socket
return socket.gethostname()
except Exception:
return "unknown"
# ── report device attributes to Hawkbit ──────────────────────────────
def report_attributes(self) -> bool:
"""PUT /{tenant}/controller/v1/{controllerId}/configData
Reports device metadata so Hawkbit can use target filters to assign updates.
Uses ``mode: merge`` — only updates the keys sent, preserves existing attributes.
"""
attrs = self._detect_device_attrs()
url = self._ddi_url(f"{self.controller_id}/configData")
payload = {"mode": "merge", "data": attrs}
logger.info("Reporting %d device attributes → %s", len(attrs), url)
logger.debug("Attributes: %s", json.dumps(attrs))
resp = self._http(url, method="PUT", data=payload)
return resp is not None
def _ddi_url(self, path: str) -> str:
"""Build a DDI v1 URL: <base>/<tenant>/controller/v1/<path>"""
return f"{self.base_url}/{self.tenant}/controller/v1/{path}"
# ── low-level HTTP ────────────────────────────────────────────────────
def _http(
self,
url: str,
method: str = "GET",
data: Any = None,
headers: Optional[Dict[str, str]] = None,
stream: bool = False,
):
"""Make an HTTP request. Returns decoded JSON, or raw file handle when
``stream=True``. Returns ``None`` on HTTP errors."""
req_headers: Dict[str, str] = {"Accept": "application/hal+json, application/json"}
if self.auth_header:
req_headers["Authorization"] = self.auth_header
if data is not None:
req_headers["Content-Type"] = "application/json"
if headers:
req_headers.update(headers)
body = None if data is None else json.dumps(data).encode("utf-8")
ctx = None
if not self.ssl_verify:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, data=body, headers=req_headers, method=method)
try:
resp = urllib.request.urlopen(req, context=ctx)
except urllib.error.HTTPError as exc:
err_body = exc.read().decode("utf-8", errors="replace")[:500]
logger.error("HTTP %d %s %s%s", exc.code, method, url, err_body)
return None
if stream:
return resp
raw = resp.read()
if not raw:
return {}
return json.loads(raw.decode("utf-8"))
# ── helper: extract best download link from artifact HAL links ─────────
@staticmethod
def _pick_download_url(artifact: dict) -> Optional[str]:
"""Pick the preferred download URL from the artifact's _links.
Priority: download (HTTPS) → download-http (HTTP) → construct from filename
"""
links = artifact.get("_links", {})
if not isinstance(links, dict):
return None
# HAL links are keyed by rel; values can be a single object or a list.
for rel in ("download", "download-http"):
entry = links.get(rel)
if isinstance(entry, list):
for item in entry:
if isinstance(item, dict) and "href" in item:
return item["href"]
elif isinstance(entry, dict) and "href" in entry:
return entry["href"]
return None
# ═══════════════════════════════════════════════════════════════════════
# Public API
# ═══════════════════════════════════════════════════════════════════════
# ── poll ──────────────────────────────────────────────────────────────
def poll(self) -> Optional[dict]:
"""GET /{tenant}/controller/v1/{controllerId}
Returns parsed controller base or None on failure.
"""
url = self._ddi_url(self.controller_id)
logger.debug("Polling GET %s", url)
body = self._http(url)
if body is None:
return None
return self._parse_poll(body)
def _parse_poll(self, body: dict) -> dict:
result: Dict[str, Any] = {
"config": body.get("config", {}),
"links": {},
}
links = body.get("_links", {})
if isinstance(links, dict):
for rel in ("deploymentBase", "cancelAction",
"confirmationBase", "installedBase", "configData"):
entry = links.get(rel)
href = None
if isinstance(entry, dict):
href = entry.get("href")
elif isinstance(entry, list) and entry:
href = entry[0].get("href") if isinstance(entry[0], dict) else None
if href:
result["links"][rel] = href
# Adopt server-suggested polling interval
sleep_str = body.get("config", {}).get("polling", {}).get("sleep", "")
if sleep_str:
try:
h, m, s = (int(x) for x in sleep_str.split(":"))
seconds = h * 3600 + m * 60 + s
if seconds > 0:
self.polling_interval = seconds
except ValueError:
pass
return result
# ── get deployment base ───────────────────────────────────────────────
def get_deployment(self, deployment_url: str) -> Optional[dict]:
"""GET /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}
Returns structured deployment info or None.
"""
logger.debug("Fetching deployment: %s", deployment_url)
body = self._http(deployment_url)
if body is None:
return None
return self._parse_deployment(body)
def _parse_deployment(self, body: dict) -> dict:
info = body.get("deployment", {})
chunks = []
for ch in info.get("chunks", []):
artifacts = []
for art in ch.get("artifacts", []):
artifacts.append({
"filename": art["filename"],
"size": art.get("size", 0),
"hashes": art.get("hashes", {}),
"_links": art.get("_links", {}),
})
chunks.append({
"part": ch.get("part", ""),
"version": ch.get("version", ""),
"name": ch.get("name", ""),
"artifacts": artifacts,
})
return {
"action_id": body.get("id"),
"chunks": chunks,
"handling": {
"download": info.get("download", "attempt"),
"update": info.get("update", "attempt"),
"maintenanceWindow": info.get("maintenanceWindow"),
},
}
# ── download artifact ─────────────────────────────────────────────────
def download_artifact(self, artifact: dict, action_id: str = "") -> Optional[str]:
"""Download one artifact with Range-request resume and SHA256 verification.
Returns the local file path on success, or None on failure.
"""
filename = artifact["filename"]
expected_sha256 = artifact.get("hashes", {}).get("sha256")
expected_size = artifact.get("size", 0)
# Resolve the download URL
download_url = self._pick_download_url(artifact)
if not download_url:
# Fallback — construct from DDI path
# Try to extract smId from any artifact HAL link
logger.warning("No download link in artifact — using fallback URL")
import re
sm_id = "0"
links = artifact.get("_links", {})
for entry in links.values():
href = None
if isinstance(entry, dict) and "href" in entry:
href = entry["href"]
elif isinstance(entry, list):
for item in entry:
if isinstance(item, dict) and "href" in item:
href = item["href"]
break
if href:
m = re.search(r"/softwaremodules/(\d+)/", href)
if m:
sm_id = m.group(1)
break
download_url = self._ddi_url(
f"{self.controller_id}/softwaremodules/{sm_id}/artifacts/{filename}"
)
local_path = os.path.join(self.download_dir, filename)
tmp_path = local_path + ".tmp"
# Already downloaded and verified?
if os.path.exists(local_path):
if self._sha256_check(local_path, expected_sha256):
logger.info("Already cached & verified: %s", local_path)
return local_path
logger.warning("Cached file fails SHA256 — re-downloading")
# ── resume ────────────────────────────────────────────────────
downloaded = 0
if self.resume_download and os.path.exists(tmp_path):
downloaded = os.path.getsize(tmp_path)
logger.info("Resuming from byte %d", downloaded)
req_headers: Dict[str, str] = {}
if downloaded > 0:
req_headers["Range"] = f"bytes={downloaded}-"
logger.info("Downloading %s (%s bytes) → %s",
filename, expected_size or "?", tmp_path)
resp = self._http(download_url, headers=req_headers, stream=True)
if resp is None:
logger.error("Download request failed")
return None
# Determine total size (from Content-Range header)
total = expected_size
cr = resp.headers.get("Content-Range", "")
if cr:
try:
total = int(cr.rsplit("/", 1)[-1])
except ValueError:
pass
mode = "ab" if downloaded > 0 else "wb"
try:
with open(tmp_path, mode) as fh:
while True:
chunk = resp.read(64 * 1024)
if not chunk:
break
fh.write(chunk)
downloaded += len(chunk)
if total:
pct = downloaded / total * 100
logger.debug(" %6.1f %% (%d / %d)", pct, downloaded, total)
except Exception as exc:
logger.error("Download interrupted: %s (tmp kept for resume)", exc)
return None
logger.info("Download finished — %d bytes", downloaded)
# SHA256
if self.verify_sha256 and expected_sha256:
if not self._sha256_check(tmp_path, expected_sha256):
logger.error("SHA256 MISMATCH for %s", filename)
return None
# Atomic rename
if os.path.exists(local_path):
os.remove(local_path)
os.rename(tmp_path, local_path)
return local_path
@staticmethod
def _sha256_check(filepath: str, expected: Optional[str]) -> bool:
if not expected:
return True # nothing to compare against
h = hashlib.sha256()
try:
with open(filepath, "rb") as fh:
while True:
chunk = fh.read(64 * 1024)
if not chunk:
break
h.update(chunk)
except OSError:
return False
actual = h.hexdigest()
ok = actual.lower() == expected.lower()
if ok:
logger.info("SHA256 verified %s", actual)
else:
logger.error("SHA256 mismatch!\n expected: %s\n actual: %s", expected, actual)
return ok
# ── send feedback ─────────────────────────────────────────────────────
def send_feedback(
self,
deployment_url: str,
execution: str,
result: str = RESULT_NONE,
progress: Optional[Tuple[int, int]] = None,
details: Optional[List[str]] = None,
code: Optional[int] = None,
) -> Optional[dict]:
"""POST …/feedback — report execution status to the server.
Args:
deployment_url: full URL to the deployment base / cancel action / confirmation base.
execution: one of the EXEC_* constants.
result: ``success``, ``failure``, or ``none``.
progress: ``(cnt, of)`` e.g. ``(2, 5)``.
details: human-readable messages (shows in Hawkbit UI).
code: optional numeric status code.
"""
# strip query params — they belong to GET, not POST /feedback
base = deployment_url.split("?")[0]
url = base + "/feedback"
result_obj: Dict[str, Any] = {"finished": result}
if progress:
result_obj["progress"] = {"cnt": progress[0], "of": progress[1]}
status: Dict[str, Any] = {
"execution": execution,
"result": result_obj,
"details": details or [],
}
if code is not None:
status["code"] = code
payload = {
"timestamp": int(time.time() * 1000),
"status": status,
}
logger.info("Feedback %-12s / %-7s%s", execution, result, url)
return self._http(url, method="POST", data=payload)
# ── process deployment ────────────────────────────────────────────────
def process_deployment(self, deployment_url: str) -> bool:
"""Run the full deployment lifecycle: fetch metadata → download → verify → report."""
dep = self.get_deployment(deployment_url)
if not dep:
logger.error("Cannot fetch deployment details")
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
details=["Failed to fetch deployment base"])
return False
action_id = dep["action_id"]
handling = dep["handling"]
logger.info("══ Action %s ══ download=%s update=%s",
action_id, handling["download"], handling["update"])
# 1. DOWNLOAD phase — report start
self.send_feedback(deployment_url, self.EXEC_DOWNLOAD, self.RESULT_NONE,
details=["Starting download"])
# 2. Fetch every artifact
downloaded: List[str] = []
for chunk in dep["chunks"]:
logger.info("Chunk: %s v%s (%d artifacts)",
chunk["name"], chunk["version"], len(chunk["artifacts"]))
for idx, art in enumerate(chunk["artifacts"], 1):
# Per-artifact progress
self.send_feedback(
deployment_url, self.EXEC_PROCEEDING, self.RESULT_NONE,
progress=(idx, len(chunk["artifacts"])),
details=[f"Downloading {art['filename']}"],
)
local = self.download_artifact(art, action_id)
if local is None:
self.send_feedback(
deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
details=[f"Download failed for {art['filename']}"],
)
return False
downloaded.append(local)
# 3. DOWNLOADED — all artifacts on disk, hashes ok
self.send_feedback(deployment_url, self.EXEC_DOWNLOADED, self.RESULT_NONE,
details=[f"Downloaded {len(downloaded)} artifact(s)"])
# 4. PROCEEDING — install
self.send_feedback(deployment_url, self.EXEC_PROCEEDING, self.RESULT_NONE,
details=["Installing update …"])
success = self._install(downloaded, handling)
# 5. CLOSED — success or failure
if success:
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_SUCCESS,
details=["Installation complete"], code=200)
else:
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
details=["Installation failed"], code=500)
return success
# ── install hook — override this for your device ──────────────────────
def _install(self, files: List[str], handling: dict) -> bool:
"""Apply the downloaded update. **Override in subclass or monkey-patch.**"""
logger.info("Install hook — files: %s", files)
# Default: look for update.zip and report success.
for fp in files:
if os.path.basename(fp).lower().endswith(".zip"):
# Example: subprocess.run(["unzip", "-o", fp, "-d", "/data/ota"], check=True)
logger.info("Would install: %s", fp)
return True
return bool(files)
# ── cancel action ─────────────────────────────────────────────────────
def process_cancel(self, cancel_url: str) -> None:
"""Acknowledge a cancel action."""
self.send_feedback(cancel_url + "/feedback", self.EXEC_CANCELED,
self.RESULT_NONE, details=["Cancel acknowledged"])
# ── confirmation (auto-confirm for headless devices) ──────────────────
def process_confirmation(self, confirmation_url: str) -> None:
"""Auto-confirm a WAITING_FOR_CONFIRMATION action."""
logger.info("Auto-confirming: %s", confirmation_url)
body = {
"confirmation": "confirmed",
"code": 200,
"details": ["Auto-confirmed by DDI client"],
}
self._http(confirmation_url + "/feedback", method="POST", data=body)
# ── main loop ─────────────────────────────────────────────────────────
def run(self, *, daemon: bool = True, max_cycles: Optional[int] = None) -> bool:
"""Run the polling loop.
Args:
daemon: If False, poll once and return.
max_cycles: Maximum number of poll cycles (None = forever).
"""
logger.info("╔══════════════════════════════════════════════════════════╗")
logger.info("║ Hawkbit DDI Client v1.0 ║")
logger.info("╠══════════════════════════════════════════════════════════╣")
logger.info("║ Server: %s", self.base_url)
logger.info("║ Tenant: %s", self.tenant)
logger.info("║ Device: %s", self.controller_id)
logger.info("║ Auth: %s",
self.auth_header.split(" ", 1)[0] if self.auth_header else "none")
logger.info("╚══════════════════════════════════════════════════════════╝")
cycle = 0
while max_cycles is None or cycle < max_cycles:
cycle += 1
logger.info("── Poll cycle %d ──", cycle)
poll_result = self.poll()
if poll_result is None:
logger.warning("Poll failed; retrying in %ds", min(self.polling_interval, 30))
if not daemon:
return False
time.sleep(min(self.polling_interval, 30))
continue
# ── report device attributes after first successful poll ──
# Target is auto-created by GET /controller/v1/{SN} (findOrRegister…);
# configData requires an existing target → poll first, then report.
if not self._attrs_reported:
self.report_attributes()
self._attrs_reported = True
links = poll_result.get("links", {})
if "deploymentBase" in links:
self.process_deployment(links["deploymentBase"])
elif "cancelAction" in links:
self.process_cancel(links["cancelAction"])
elif "confirmationBase" in links:
self.process_confirmation(links["confirmationBase"])
if "deploymentBase" not in links and "cancelAction" not in links and "confirmationBase" not in links:
logger.debug("No pending action.")
if not daemon:
return True
logger.info("Sleeping %ds …", self.polling_interval)
time.sleep(self.polling_interval)
return True
# ═══════════════════════════════════════════════════════════════════════════════
# CLI entry point
# ═══════════════════════════════════════════════════════════════════════════════
def main(argv: Optional[List[str]] = None) -> None:
ap = argparse.ArgumentParser(
description="Hawkbit DDI Client — lightweight OTA agent (pure Python)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Gateway token, poll every 30s
%(prog)s -u https://hawkbit.example.com -t DEFAULT --gateway-token s3cret -i 30
# Target token, one-shot
%(prog)s -u http://10.0.0.1:8080 -t DEFAULT --target-token tok --once
# Custom controller-id, insecure SSL, custom download dir
%(prog)s -u https://hawkbit:8443 -t prod --gateway-token k \\
--controller-id edge-042 -d /data/ota --no-ssl-verify -v
""",
)
# ── required ──────────────────────────────────────────────────────
ap.add_argument("-u", "--base-url", required=True,
help="Hawkbit base URL, e.g. https://hawkbit.example.com")
ap.add_argument("-t", "--tenant", required=True,
help="Tenant name, e.g. DEFAULT")
# ── authentication (mutually-exclusive group would be nice but accepts either) ─
ap.add_argument("--gateway-token", metavar="TOKEN",
help="Gateway security token → Authorization: GatewayToken TOKEN")
ap.add_argument("--target-token", metavar="TOKEN",
help="Target security token → Authorization: TargetToken TOKEN")
# ── optional ──────────────────────────────────────────────────────
ap.add_argument("--controller-id",
help="Controller ID (default: auto-detect from device)")
ap.add_argument("-d", "--download-dir", default="/tmp/hawkbit",
help="Directory for downloaded artifacts [default: /tmp/hawkbit]")
ap.add_argument("-i", "--polling-interval", type=int, default=60,
help="Default polling interval in seconds (overridden by server) [default: 60]")
ap.add_argument("--no-verify", action="store_true",
help="Skip SHA256 verification")
ap.add_argument("--no-resume", action="store_true",
help="Disable resumable download")
ap.add_argument("--no-ssl-verify", action="store_true",
help="Disable TLS certificate verification (INSECURE)")
ap.add_argument("--once", action="store_true",
help="Poll exactly once then exit")
ap.add_argument("-v", "--verbose", action="store_true",
help="Debug-level logging")
args = ap.parse_args(argv)
# ── logging ───────────────────────────────────────────────────────
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
# ── auth header ───────────────────────────────────────────────────
auth = None
if args.gateway_token:
token = args.gateway_token
if token.startswith("@"):
with open(token[1:]) as f:
token = f.read().strip()
logger.info("Read gateway token from file: %s", args.gateway_token)
auth = f"GatewayToken {token}"
elif args.target_token:
token = args.target_token
if token.startswith("@"):
with open(token[1:]) as f:
token = f.read().strip()
logger.info("Read target token from file: %s", args.target_token)
auth = f"TargetToken {token}"
if not auth:
logger.warning("No authentication token provided — server may reject requests")
# ── run ───────────────────────────────────────────────────────────
client = HawkbitDDIClient(
base_url=args.base_url,
tenant=args.tenant,
controller_id=args.controller_id,
auth_header=auth,
download_dir=args.download_dir,
verify_sha256=not args.no_verify,
polling_interval=args.polling_interval,
ssl_verify=not args.no_ssl_verify,
resume_download=not args.no_resume,
)
try:
ok = client.run(daemon=not args.once)
sys.exit(0 if ok else 1)
except KeyboardInterrupt:
logger.info("Stopped (Ctrl+C)")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,31 @@
services:
hawkbit-backend:
image: hawkbit/hawkbit-update-server:local
container_name: hawkbit-backend
ports:
- "6060:8080"
restart: unless-stopped
command: >
--hawkbit.dmf.rabbitmq.enabled=false
--hawkbit.server.ddi.security.authentication.anonymous.enabled=true
environment:
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1024m"
networks:
hawk_default_net:
hawkbit-ui:
image: hawkbit/hawkbit-ui:local
container_name: hawkbit-ui
ports:
- "6081:8088"
restart: unless-stopped
command: -jar app.jar --hawkbit.server.url=http://hawkbit-backend:8080
environment:
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m"
networks:
hawk_default_net:
# 显式定义共享网桥,两个服务强制加入同一网络
networks:
hawk_default_net:
driver: bridge

View File

@@ -0,0 +1,25 @@
services:
hawkbit-backend:
image: hawkbit/hawkbit-update-server:local
container_name: hawkbit-backend
ports:
- "6060:8080"
restart: unless-stopped
command: >
--hawkbit.dmf.rabbitmq.enabled=false
--hawkbit.server.ddi.security.authentication.anonymous.enabled=true
environment:
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1024m"
hawkbit-ui:
image: hawkbit/hawkbit-ui:local
container_name: hawkbit-ui
ports:
- "6081:8088"
restart: unless-stopped
# 修复shell语法单条完整命令无换行拆分bug
command: /bin/sh -c "sleep 15; java $JAVA_TOOL_OPTIONS -jar app.jar --hawkbit.server.url=http://hawkbit-backend:8080"
environment:
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m"
depends_on:
- hawkbit-backend