# Agentic 中心:AI 辅助运营
URL: https://docs.dc3.site/zh/ai/agentic
# Agentic 中心:AI 辅助运营
智能中心(Agentic Center / `dc3-center-agentic`)把一个 OpenAI 兼容的大模型接到 IoT DC3
的设备、位号、数据与命令上:你用自然语言提问,模型按需调用平台内置工具去读元数据、查实时值,甚至在受控授权下触发设备读写。这页讲清这条链路怎么走、有哪些工具、会话存在哪、以及哪些动作需要人工确认。
> 你在这里:已经[接入设备](../operation/device-onboarding)、能[查数据下命令](../operation/data-commands)
> ,现在想让模型帮你运营。下一步可看 [AI Agent / MCP 集成](./mcp) 把外部 Agent 接进来。
## 为什么需要一个智能中心
设备接好、数据落库之后,日常运营往往是一连串"先查再判断"的小动作:哪台设备掉线了?这个位号最近一小时的值怎么走的?要不要把某个开关写回去?这些动作每一步都对应一个
HTTP 接口,但人工串起来既慢又容易出错。
智能中心把这层"理解意图 → 选对工具 → 取数 → 回答"交给大模型:它基于 Spring AI 的 `ChatClient`,对外暴露一个 OpenAI
兼容的聊天接口,对内挂着一组**租户隔离的内置工具**。模型在一次对话里自己决定调哪些工具、按什么顺序调,最后用自然语言把结论讲给你听。
AI 能力不是设备接入的前置条件。建议先跑通设备、位号、数据和命令链路,再启用智能中心——它消费的正是这些链路产生的数据和接口。
::: warning 工具调用默认开,但可关
内置工具调用由 `AGENTIC_TOOL_CALLING_ENABLED` 控制,默认 `true`。把它设为 `false`
,模型就退化成纯对话,不再触碰任何设备/数据接口。需要在受限环境里只放开问答时,关掉它。
:::
## OpenAI 兼容的聊天入口
智能中心对外只有一个核心入口,形态和 OpenAI 的 Chat Completions 一致,方便任何 OpenAI 客户端直接对接:
- 路径:经网关对外为 `POST /api/v3/agentic/chat/completions`(网关 `StripPrefix=2` 去掉 `/api/v3` 后转发到智能中心的
`/chat/completions`)。
- 两种返回:`stream=true` 时走 **SSE** 流式逐字返回;否则返回一次性 **JSON**。
- 权限:`@PreAuthorize("@perm.can('chat', 'list')")`,调用方需带平台鉴权头 `X-Auth-Tenant` / `X-Auth-Login` /
`X-Auth-Token`。
- 该接口自身的 AI 风险元数据标注为 `riskLevel=MEDIUM`、`destructive=false`、`idempotent=false`、`openWorld=true`。
下面是一次非流式问答的形态(示例值仅供示意,鉴权头需替换为你登录后拿到的真实 token):
::: code-group
```bash [curl]
curl -X POST http://localhost:8000/api/v3/agentic/chat/completions \
-H "Content-Type: application/json" \
-H "X-Auth-Tenant: " \
-H "X-Auth-Login: " \
-H "X-Auth-Token: " \
-d '{
"model": "gpt-4o",
"stream": false,
"messages": [
{ "role": "user", "content": "1 号锅炉温度位号最近的值是多少?" }
]
}'
```
```json [响应 JSON 形态(示例)]
{
"id": "chatcmpl-...",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "1 号锅炉温度位号当前值为 86.4 ℃,采集于 2 分钟前。"
},
"finish_reason": "stop"
}
]
}
```
:::
要拿到上面用到的鉴权 token,先 `POST /api/v3/auth/token/salt` 取盐、再 `POST /api/v3/auth/token/generate` 换 12 小时有效的
access token,详见[第一个设备:端到端](../quickstart/first-device)里的登录步骤。
## 一次 AI 辅助操作怎么走
模型不是凭空回答的——它在对话过程中调用内置工具去读真实数据。下图是一次"读 + 写"混合操作的时序:读类工具直接执行,写类(高风险)工具会先停下来等人工确认,再继续。
关键链路上的事实锚点:
- 工具是 Spring AI 的原生 `@Tool` 方法,由 `ChatClientConfig` 注册成 `ToolCallbackProvider`,并包了一层
`AgenticToolTracingCallbackProvider` 做调用追踪。
- 每个工具方法进来第一件事就是 `AgenticToolContextUtil.requireTenantId(toolContext)` 取出当前租户 ID,所有查询都带租户作用域——模型
**看不到也碰不到别的租户**的数据。
- 写类动作(命令下发)由智能中心经 `PointCommandFacade`(gRPC 实现 `PointCommandGrpcFacade` / 本地
`PointCommandLocalFacade`)下发到**数据中心**的命令平面,并非调用 HTTP `POST /point_command/write` 端点(那是数据中心面向
Web/CLI 的另一调用面,终点命令平面相同)。命令本身有 10 秒 TTL(`PointCommandDTO.expireAt` 默认 `now+10s`),过期不再执行。
## 十个内置工具
平台内置 **10** 个工具类,覆盖从租户、用户到设备、命令的完整读取面,少数涉及写入。工具方法主要用 `lookup*`(按 ID 取单条/批量)与
`search*`(分页查询)动词,辅以 `list*ByXxxId`(按归属枚举),并非 REST 层那套 `getXxx`/`listXxx`——这是工具给模型用的语义化命名,与对外
HTTP CRUD 约定相互独立。
先按域理解它们各自能干什么,再看表:模型拿到一个问题后,会把它拆成"先查模板有哪些位号 → 再查这些位号的最新值 →
最后判断要不要下命令"这样的工具序列,自动编排。
| 工具类 | 域 | 代表方法 | 典型用途 |
|------------------|-----|-----------------------------------------------------------------------------------------------|----------------------|
| `TenantTool` | 租户 | `getCurrentTenantInfo()` | 确认当前租户上下文 |
| `UserTool` | 用户 | `getCurrentUserProfile()` | 查当前用户信息 |
| `DeviceTool` | 设备 | `lookupDeviceById()` / `searchDevices()` | 查某台设备、按条件检索、查在线态与最新值 |
| `DriverTool` | 驱动 | `lookupDriverById()` / `searchDrivers()` | 查协议驱动接入情况、设备在线统计 |
| `ProfileTool` | 模板 | `lookupProfileById()` / `searchProfiles()` | 查模板及其能力 |
| `PointTool` | 位号 | `lookupPointById()` / `searchPoints()` | 查位号、读写方向、按设备/模板列位号 |
| `PointValueTool` | 位号值 | `getLatestPointValue()` / `getPointValueHistory()` / `readPointValue()` / `writePointValue()` | 读实时值、查历史曲线、下发读/写命令 |
| `SystemTool` | 系统 | `getSystemHealth()` | 看平台健康度 |
| `CommandTool` | 命令 | `lookupCommandById()` / `searchCommands()` | 查自定义命令、按设备/模板列命令 |
| `EventTool` | 事件 | `lookupEventById()` / `searchEvents()` | 查设备上报事件 |
::: info 风险元数据标在 REST 端点上,不在工具方法上
`x-dc3-ai` 风险元数据(`riskLevel` / `destructive` / `idempotent` / `openWorld`)是在 Controller 的 `@Operation` 扩展里*
*手工标注**的(如 `ChatController` 的聊天端点),供 OpenAPI / MCP 目录消费。智能中心的 10 个工具方法本身只带
`@AgenticToolMetadata(domain, title)`(仅 `domain()` 与 `title()` 两个字段),并不携带风险等级——别把端点上的风险元数据当成每个工具方法的属性。
:::
## 会话不是内存里的,而是落库的
很多 AI 服务把会话上下文放在内存里,进程一重启就丢。智能中心不是——它把每一轮对话**持久化在 `dc3_agentic` schema** 里,
`MessageChatMemoryRepository` 适配器按 `conversation_id` 从 `dc3_message` 读回历史。换句话说,会话能跨重启续上,也能被审计。
三张表的关系如下:
- 取回历史的窗口大小由 `dc3.agentic.historyWindowSize`(默认 `30`)控制:只把最近若干轮喂给模型,既省 token 又保留上下文。
- `dc3_session.session_ext` 是一段 JSON,存这次会话的模型选择、温度、`maxTokens` 等偏好,下一轮沿用。
- 附件上传后落到 `AGENTIC_ATTACHMENT_STORAGE_PATH` 指向的目录,元数据记在 `dc3_attachment`。
::: warning 记忆表结构默认不自动建
持久化会话依赖数据库表,`AGENTIC_MEMORY_SCHEMA_INIT` 经 compose / `dev.env` 注入,默认是 `never`。其语义是不自动初始化
Spring AI 的记忆表结构;如需自动建表,预期做法是首次临时设为 `always`(或 `create_if_not_exists`)建一次表,之后调回 `never`
。注意:仓库内当前未见 `application*.yml` 把该变量绑定到 Spring AI 的 `initialize-schema`,是否真正接线**以代码为准**
,必要时手工初始化记忆表更稳妥。
:::
## 高风险动作的两阶段确认
不是所有工具调用都该让模型一气呵成。读类工具(`lookup*` / `search*` / `getLatest*`)安全、可直接执行;写类一旦做错就难以回退。智能中心唯一的写工具
`PointValueTool.writePointValue` **从不直接写**,而是走两阶段确认:
1. 模型调用 `writePointValue` 时,服务调用 `ActionService.createWritePointValueAction(...)` 生成一个待确认 **Action**(
`actionId` 为 UUID),状态置为 `AgenticActionStatusEnum.PENDING`,过期时间为 `now + 10 分钟`;工具结果带
`pendingConfirmation=true` 与该 `actionId`,并不下发命令。
2. 用户看清动作内容后,携带 `action_id` 调 `POST /action/confirm` 确认(或 `POST /action/reject` 拒绝);确认通过后服务才经
`PointCommandFacade.submitWrite(...)` 真正执行。
这条机制保证"AI 提议、人来拍板",把不可逆的物理世界动作留在人工授权之内。
::: info 这是 Agentic 自己的 Action 机制,不是 MCP 网关的风险门控
确认流用的是 `actionId` + `POST /action/confirm|reject`,**不是** `CONFIRM_REQUIRED` / `confirmId`,也不是按
`riskLevel=HIGH` 的通用风险策略门控——后者属于 [MCP 网关](./mcp)的 `dc3_mcp_tool_confirmation` 子系统,与这里的聊天链路是两套实现,别混用。
:::
::: danger 命令写失败不回显伪造值
经工具下发的写命令最终走数据中心命令平面。命令带 10 秒 TTL(`PointCommandDTO.expireAt` 默认 `now+10s`),且*
*写命令失败时 `responseValue` 为 `null`、不回显任何值**——不要把"没报错"当成"写成功"
。详见[命令平面](../architecture/command-plane)。
:::
## 模型从哪来:数据库优先,env 兜底
智能中心支持多个模型提供方。`ChatClientFactory` 优先从数据库表 **`dc3_model_provider`** 读取启用的提供方配置(
`provider_type`:`0` openai-compatible / `1` anthropic、`base_url`、`api_key`、`default_flag`
等,按租户隔离);只有当表里没有可用提供方时,才回退到一组环境变量兜底配置。
也就是说:生产环境推荐在数据库里集中管理 provider;env 里的 `AGENTIC_FALLBACK_*` 只是没有 DB 配置时的最后防线。
| 变量 | 默认值 | 用途 |
|---------------------------------------|--------------------------------|---------------------------------------------------|
| `AGENTIC_FALLBACK_OPENAI_BASE_URL` | `https://api.openai.com` | 兜底的 OpenAI 兼容 API 地址 |
| `AGENTIC_FALLBACK_OPENAI_API_KEY` | *(空)* | 兜底 API key(端点需要鉴权时) |
| `AGENTIC_FALLBACK_OPENAI_MODEL` | `gpt-4o` | 兜底模型名 |
| `AGENTIC_FALLBACK_OPENAI_TEMPERATURE` | `0.7` | 采样温度(0.0–2.0) |
| `AGENTIC_FALLBACK_OPENAI_MAX_TOKENS` | `2048` | 最大输出 token |
| `AGENTIC_TOOL_CALLING_ENABLED` | `true` | 是否启用工具调用 |
| `AGENTIC_MEMORY_ENABLED` | `false` | 是否启用持久化会话记忆 |
| `AGENTIC_MEMORY_MAX_MESSAGES` | `50` | 单会话窗口最大消息数 |
| `AGENTIC_MEMORY_SCHEMA_INIT` | `never` | 记忆表结构初始化(`always`/`never`/`create_if_not_exists`) |
| `AGENTIC_ATTACHMENT_STORAGE_PATH` | `dc3/data/agentic/attachments` | 附件存储目录 |
::: tip 默认值以 compose / `dev.env` 为准
上表默认值取自 compose / `dev.env` 注入值,与 `application-agentic.yml` 里的 Spring 裸默认值**不一致**:如
`AGENTIC_MEMORY_ENABLED` 的 Spring 默认是 `true`(compose 注入 `false`)、`AGENTIC_ATTACHMENT_STORAGE_PATH` 的 Spring 默认是
`dc3/data/upload/agentic/attachment`(compose 注入 `dc3/data/agentic/attachments`)。经 compose / `make up-*` 启动时以上表为准;若在
IDE 里直接跑 Spring 而不经过 compose,则用的是 yml 裸默认值。
:::
完整环境变量说明见 [环境变量](../quickstart/environment)。
::: danger 永远不要泄露 API key
不在文档、截图、日志、issue 或提交里出现真实 `api_key` / `token` / `password`。`dc3_model_provider.api_key`
存在数据库内,访问受租户隔离约束;env 兜底的 key 也只应通过 `dc3/env/dev.env` 等本地文件注入,不入库到代码仓库。
:::
## 使用前检查
启用智能中心前,确认这些前置条件,避免"模型答得头头是道但数据是错的":
1. 智能中心已启动并可经网关 `8000` 端口访问。
2. 鉴权中心、管理中心、数据中心基础能力正常——工具最终调的是它们的接口。
3. 至少有一个设备和位号在产生数据,否则查值类工具返回空。
4. 模型提供方(DB 或 env 兜底)可访问,且 API key 未写入文档或日志。
5. 工具调用只对可信用户、明确的业务场景开放;不需要时用 `AGENTIC_TOOL_CALLING_ENABLED=false` 关闭。
## 延伸阅读
- [AI Agent / MCP 集成](./mcp) — 把外部 AI Agent 经 OAuth 2.1 + MCP 安全接入平台工具
- [核心概念](../introduction/concepts) — 驱动 / 模板 / 设备 / 位号 / 位号值的对象模型,看懂工具在查什么
- [命令平面](../architecture/command-plane) — 工具下发的读写命令如何流转、为何写失败不回显
---
# AI
URL: https://docs.dc3.site/zh/ai/
# AI
IoT DC3 把大语言模型接进了运营流程,让模型不只"看数据",还能"动设备"。AI 栏目覆盖两种让 LLM 驱动操作的方式,区别在于谁来发起、怎么约束:
- **Agentic 中心**——平台内建的对话式 AI 辅助运营。基于 Spring AI,内置 10 个 `@Tool`,通过 Tool-Calling 让 LLM
查设备、读写位号、执行命令;兼容 OpenAI API 标准,可接 GPT、Claude、DeepSeek、通义千问等主流模型。适合想要一个有界面、可多轮对话的
AI 运营助手。
- **MCP**——把平台工具安全暴露给外部 AI Agent。网关在 `POST /mcp` 提供 JSON-RPC 2.0 的 MCP Resource Server,工具目录由四个中心的
OpenAPI 自动聚合(约 330+ 个),走 OAuth 2.1 + 工具白名单 + 风险分级。适合自己搭 Agent、让模型自主决策用哪个工具。
- **为什么选 Spring AI** — DC3 AI 辅助运维背后的设计决策。详见 [为什么选 Spring AI](./spring-ai-deep-dive)
,涵盖架构深潜、工具调用机制和技术路线图。
> 你在这里:已经[跑通过一个设备](../operation/device-onboarding)
> ,想让模型帮你查询、分析、甚至下发命令。下一步选 [Agentic 中心](./agentic)、[AI Agent / MCP 集成](./mcp)
> 或 [为什么选 Spring AI](./spring-ai-deep-dive)。想用脚本而非 AI
> 自动化,见 [自动化(dc3 CLI)](../automation/cli)。
## 两种方式,同一道门
两种 AI 接入的差异不在"能做什么",而在发起方与约束方式。但无论哪种,平台对外只有一个 HTTP 入口——网关 `dc3-gateway`(`8000`
):Agentic 的对话、MCP 的工具调用最终都走这道门,再由网关注入主体上下文、下沉到 `dc3-center-auth` 做 **RBAC 权限校验**与*
*租户隔离**。
换句话说:AI 拿不到比对应账号更多的权限,跨租户的数据照样看不到(返回 404 而非数据)。README 反复强调的"数据库、缓存、API
全链路租户级隔离"与"JWT + Spring Security + RBAC",对这两条路一视同仁。
两种接入的鉴权机制不同,但终点一致——进业务服务前都要过 `@PreAuthorize` 的权限点与租户边界:
- **Agentic 中心**用登录用户的会话身份发起,Tool-Calling 调用的仍是平台业务 API,权限随当前用户走。
- **MCP** 用 OAuth 2.1 颁发的短时 JWT(默认 15 分钟),网关每次调用都重新 introspect 并校验该 MCP 连接是否启用,再用
`tools/list` 的三层过滤(RBAC ∩ 连接白名单 ∩ 风险策略)决定 Agent 到底看得见、调得动哪些工具。
## 延伸阅读
- [Agentic 中心](./agentic) — 对话式 AI 运营、10 个内置工具、会话持久化与高风险确认
- [AI Agent / MCP 集成](./mcp) — OAuth 2.1 + MCP,把工具安全接给外部 Agent
- [为什么选 Spring AI](./spring-ai-deep-dive) — DC3 选用 Spring AI 的架构考量、工具调用原理与路线图
- [自动化(dc3 CLI)](../automation/cli) — 不用 AI,用命令行脚本驱动平台
- [数据智能与 AIoT](../foundations/aiot) — 物联网数据分析与大模型结合的全景
---
# AI Agent / MCP 集成
URL: https://docs.dc3.site/zh/ai/mcp
# AI Agent / MCP 集成
IoT DC3 把整个平台的 HTTP 能力自动聚合成一份 MCP(Model Context Protocol)工具目录,让外部 AI Agent 经 OAuth 2.1 鉴权后,通过网关的
`/mcp` 入口安全地列举并调用工具——读设备、查位号值、下发命令。读完你能搞清楚 token 怎么取、`/mcp` 怎么调、为什么有的工具看不见、HIGH
风险操作为什么要确认两次。
> 你在这里:想把 IoT DC3 接给一个 AI Agent。若只是想让人在对话框里问数据,看 [Agentic 中心](./agentic)
> ;若是命令行脚本接入,看 [CLI 使用指南](../automation/cli)。
## 为什么是 MCP,而不是直接调 HTTP
一个 AI Agent 要操作平台,最朴素的办法是把每个 REST
接口手写成一个工具喂给大模型。问题是:接口有三百多个、横跨四个中心,权限和租户隔离散落各处,删除类操作和只读查询混在一起没有风险分级。MCP
把这层标准化——平台把自己的接口**自动**导出成带风险标注的工具目录,Agent 用统一的 JSON-RPC
协议发现和调用,鉴权、租户、权限、风险确认全部在网关与鉴权中心收口。
这条链路有三个角色:**鉴权中心(Auth Center / `dc3-center-auth`)** 兼做 OAuth 2.1 授权服务器,负责发 token、做内省、聚合工具目录;
**网关(Gateway / `dc3-gateway`)** 是 MCP Resource Server,承载 `POST /mcp`,每次调用都重新校验权限并签名转发到后端;后端的*
*管理中心 / 数据中心 / 智能中心**才真正执行业务。Agent 只跟前两者打交道。
## 工具目录怎么来:自动聚合,稳定 tool_id
工具目录不是手写的。鉴权中心的 `McpOpenApiAggregator` 在运行期拉取 auth / manager / data / agentic 四个中心的 OpenAPI,与
`dc3_api`(`api_code` / `api_name`)和 `dc3_resource`(`resource_code` / `permission_code`)合并,为每个接口生成一条工具记录,落库到
`dc3_mcp_tool_catalog`。规模约 **330+ 个工具**,由四个中心 **330+ 个 OpenAPI 操作**自动生成。
每个工具有一个**稳定的 `tool_id`**(等于 `dc3_api.api_code`),格式是 `{service_name}:{HTTP_METHOD}:{api_path}`,其中
`service_name` 是完整服务名 `dc3-center-`(来自 `spring.application.name`),例如:
```text
dc3-center-manager:POST:/device/add
dc3-center-data:POST:/point_command/write
dc3-center-data:POST:/point_value/latest
```
工具的**风险等级**逐条手工标注,不靠动词猜测:每个接口在 `@Extension(name = "x-dc3-ai")` 注解里显式写出 `riskLevel`(
`LOW` / `MEDIUM` / `HIGH`),resource-registrar 在扫描期强制校验该注解存在且 `riskLevel` 合法,缺失即报缺陷。聚合器原样读取注解里的
`riskLevel`,仅在注解整体缺失时才回退为保守的 `HIGH`。例如 `POST /point_command/write` 被手工标为 `HIGH`(
`destructive=true`),并不会因为它是写操作就降成 `MEDIUM`。
`read_only_hint` 由 HTTP 方法推导(`GET` → 1,`POST` → 0)。这些标注连同 `destructive_hint` / `idempotent_hint` /
`open_world_hint` 一起写进 `dc3_mcp_tool_catalog`,来源是接口上的 `x-dc3-ai` OpenAPI 扩展(见文末)。
::: info 目录如何刷新:当前只有手动端点
工具目录的唯一刷新入口是管理员手动调用的 HTTP 端点(`McpManagementController.refreshToolCatalog` →
`OAuthMcpRuntimeServiceImpl.refreshToolCatalog`)重新聚合落库。**没有定时刷新、也没有事件驱动自动刷新**:代码中不存在
`@Scheduled` 刷新任务,也不存在 `McpToolCatalogChangedEvent` 之类的提交后触发。所以新增接口后,需要管理员手动触发一次刷新,工具目录才会更新。
:::
## 访问控制:四道闸门决定一个工具可见可调
不是"在目录里 = Agent 就能用"。一个工具最终是否对某次连接**可见**、是否**可调**,要先过 OAuth 验证拿到 principal 与
scope,再经过 RBAC、连接白名单、风险策略三层求交集。
`tools/list` 返回的,是 **principal 的权限码集合 ∩ 该 MCP 连接的工具白名单 ∩ 风险策略**三者的交集;`tools/call` 还要再叠加
OAuth scope 与逐次的风险确认。也就是说:即便目录里有 `dc3-center-data:POST:/point_value/latest`,若该连接的白名单(
`dc3_mcp_connection_tool`)没放行、或 token 没有 `mcp:tools:call` scope、或 principal 没有对应的查询权限,Agent 都调不到它。
::: warning HIGH 风险默认不可见
风险等级为 HIGH 的工具(如各类 `delete`)在 `tools/list` 中默认隐藏,需要显式开启才出现,调用时还要带 `mcp:tools:call:high`
scope,并走下文的两阶段确认。这是有意的保守默认,避免 Agent 误删。
:::
## OAuth 2.1 授权服务器:怎么拿 token
鉴权中心内置了一个手写的 OAuth 2.1 授权服务器(RS256 JWT),HTTP 端点如下(均挂在鉴权中心,经网关对外):
| 端点 | 方法 | 用途 |
|-------------------------------------------|--------------|-----------------------------------|
| `/.well-known/oauth-authorization-server` | `GET` | 授权服务器元数据发现 |
| `/.well-known/oauth-protected-resource` | `GET` | 受保护资源元数据(RFC 9728,网关侧) |
| `/oauth2/authorize` | `GET` | 授权码 + PKCE(用户登录 + 同意 + 选择 MCP 连接) |
| `/oauth2/token` | `POST`(form) | 换取 access_token / refresh_token |
| `/oauth2/jwks` | `GET` | 公钥集(RS256,供验签) |
| `/oauth2/revoke` | `POST`(form) | 撤销 token(含重放检测) |
| `/oauth2/register` | `POST`(JSON) | 动态客户端注册(管理员受限) |
token 内省 `introspect` **不暴露为 HTTP 端点**——它是 gRPC 内部接口,只给网关这个 Resource Server 校验 Bearer 用。
**安全基线**(均已实现):公共客户端**强制 PKCE S256**;`redirect_uri` 精确匹配,不接受通配符;refresh-token 轮换(RFC 9700
§6.3,通过 `previous_refresh_token_hash` 做重放检测);client secret 仅存哈希,不留明文。
**token 类型与有效期**:access_token 为短时 JWT(默认 15 分钟,claims 含
`iss/aud/exp/nbf/sub=principal_id/principal_type/scope/tenant_id/mcp_connection_id`);refresh_token 轮换式,默认 30
天;authorization_code 5 分钟一次性、PKCE 绑定;client_credentials 走 SERVICE_ACCOUNT、无 refresh。
::: danger 仅 OAuth 2.1,没有长期令牌
平台的 MCP 接入**只支持 OAuth 2.1**,没有 PAT(Personal Access Token)、没有 `dc3mcp_*` 之类的长期静态令牌。所有调用都依赖短时
access_token + 轮换 refresh_token。不要试图在脚本里硬编码一个"永久 MCP key"——它不存在。
:::
## 一次完整调用:从取 token 到拿结果
下面这条时序覆盖 Agent 取 token、调用 `/mcp`、网关内省与签名转发的全过程。
注意网关到后端这一跳:网关用 `McpGatewayClient.invokeBackend()` 直接走内部 WebClient(**绕过**网关自身的路由),构造
`X-Auth-Principal` 并做 HMAC 签名;后端的 `GatewayJwtConverter` 验签、还原 principal,再交给 `@PreAuthorize` 做权限判定。HMAC
密钥 `AUTH_HMAC_SECRET` 在 `pre/pro` 环境下若为空或等于默认值会
fail-fast,详见 [鉴权 · 租户 · RBAC](../architecture/auth-rbac)。
### `/mcp` 的 JSON-RPC 方法
`POST /mcp` 是 JSON-RPC 2.0(Streamable HTTP),由网关的 `McpGatewayController` 处理,支持的方法:`initialize`、
`notifications/initialized`、`ping`、`tools/list`、`tools/call`。
一次 `tools/list` 请求:
::: code-group
```bash [curl]
curl -X POST http://localhost:8000/mcp \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}'
```
```json [响应形态(示例)]
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "data_point_value_latest",
"description": "查询设备最新位号值",
"inputSchema": { "type": "object", "properties": { "deviceId": {"type":"string"} } }
}
]
}
}
```
:::
一次 `tools/call`(调用低风险的查询工具,参数值为示例):
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "data_point_value_latest",
"arguments": { "deviceId": "1839...", "pointId": "1840...", "current": 1, "size": 10 }
}
}
```
返回时网关把后端的 `R` 包成 MCP `CallToolResult`。工具背后对应的真实接口与字段,见 [Agentic 中心](./agentic) 与各中心
OpenAPI。
## HIGH 风险:两阶段确认
对 HIGH 风险工具(如删除),平台强制两阶段确认,防止 Agent 在一次推理里直接执行不可逆操作。
第一阶段:Agent 调 `tools/call` 但没带有效确认,服务端不执行,返回 `CONFIRM_REQUIRED` 和一个 `confirmId`(UUID),默认 TTL
`PT5M`。
第二阶段:Agent 带着 `confirmId` + `idempotency_key` 重新调用。服务端校验:未过期、`parameter_digest` 与首次一致、principal /
连接 / 工具均未变、且为一次性消费(`status=PENDING` 在 SQL 层做并发护栏,重放的 `confirmId` 会输掉竞争)。
确认票据落在 `dc3_mcp_tool_confirmation`(`confirm_id`、`tool_id`、`parameter_digest`、`idempotency_key`、`status`
PENDING/CONSUMED/EXPIRED、`ttl_expires`),TTL 由 `dc3.mcp.confirm-ttl`(默认 `PT5M`)控制。每一次 HIGH 风险调用都审计进
`dc3_mcp_audit_log`(含 `confirm_id`、`idempotency_key`、`argument_digest`、`risk_level`、`duration_ms`、`remote_ip` 等)。
## 约束与边界(诚实标注)
::: info MCP resources / prompts 未实现
当前 `/mcp` 只实现了工具相关方法(`tools/list` / `tools/call` 及 `initialize` / `ping` / `notifications/initialized`)。MCP
协议中的 `resources/*` 与 `prompts/*` 能力**尚未实现**(已规划);对应的 `mcp:resources:read` scope 保留但未启用。
:::
::: info `tools/list_changed` 事件推送未实现
工具目录变化时,MCP 的 `tools/list_changed` 通知**未做事件推送**(设计已规划,RabbitMQ 推送未实现)。目录刷新走的是上文中提到的手动端点,客户端应
**按计划自行重新拉取** `tools/list`,不要假设服务端会主动推送变更。
:::
工具调用全程为 HTTP(内部 WebClient),**没有** gRPC 工具调用通道——这是有意的设计。`x-dc3-ai` 元数据来自接口上的真实注解:
```java
@Extension(name = "x-dc3-ai", properties = {
@ExtensionProperty(name = "riskLevel", value = "MEDIUM"), // LOW/MEDIUM/HIGH
@ExtensionProperty(name = "destructive", value = "false"), // 是否破坏数据/配置
@ExtensionProperty(name = "idempotent", value = "false"), // 是否可安全重试
@ExtensionProperty(name = "openWorld", value = "true") // 是否触达外部/物理世界
})
```
## 延伸阅读
- [Agentic 中心](./agentic) — 平台内置的对话与工具调用,理解 MCP 工具背后执行的是什么
- [鉴权 · 租户 · RBAC](../architecture/auth-rbac) — principal 模型、HMAC 签名与权限求解的完整链路
- [CLI 使用指南](../automation/cli) — 不接 AI,用 `dc3` CLI 直接驱动平台
---
# 为什么选择 Spring AI:DC3 如何让大模型操控你的工厂
URL: https://docs.dc3.site/zh/ai/spring-ai-deep-dive
# 为什么选择 Spring AI:DC3 如何让大模型操控你的工厂
2025 年,大语言模型(LLM)不再只是聊天机器人,它们正在成为运维操作员。GPT-4o、Claude 4、DeepSeek、Qwen —
这些模型已经能够读取传感器数据、推理设备状态,并判断是否需要开启某个阀门。唯一的缺失环节,是"模型理解"与"模型执行"
之间的桥梁。这座桥梁就是 Spring AI,也是 IoT DC3 选择它作为 Agentic Center 基座的根本原因。
## 问题所在:AI 想行动,平台说不行
传统 IoT 平台将数据和指令视为两个独立的世界。数据从设备北上流向仪表盘,指令从操作员南下流向设备。
这两个方向很少在单次 API 调用中交汇,更不用说在单句自然语言中完成。
来看一个真实场景:操作员发现 3 号锅炉温度异常。在传统平台中,工作流如下:
1. 导航到仪表盘,找到 3 号锅炉,记下温度。
2. 切换到历史页面,查询过去 2 小时的温度数据。
3. 大脑中评估:这是趋势还是脉冲?
4. 导航到指令页面,选择"风机转速",输入新值。
5. 提交,等待确认,验证温度开始下降。
五个上下文切换,跨越三个页面。每次切换耗时 15–30 秒。乘以 200 台设备,运维摩擦就变成了瓶颈。
而在 DC3 的 Agentic Center 中,操作员只需输入一句话:
> "3 号锅炉温度在上升。检查过去 2 小时的数据,如果持续上升超过 30 分钟,将排风机降到 60%。"
模型将这句话拆解为工具调用序列,执行读取和有条件写入,并在单轮对话中回报结果——
这不是 Demo,而是运行在 DC3 的 Spring AI 工具调用基础设施上的生产级能力。
## 为什么是 Spring AI,而非 LangChain 或自研
团队在选定 Spring AI 之前,评估了三种方案:
| 方案 | 优势 | 劣势 | 结论 |
|------------------------|---------------------------------------------|------------------------------|----------------|
| **LangChain (Python)** | 生态庞大,原型开发快 | Python/JVM 互操作开销、独立部署、安全边界模糊 | 对 JVM 原生平台过于沉重 |
| **自研 @Tool 框架** | 完全掌控,零依赖 | 数月工程投入,长期维护负担,无社区支撑 | 重复造轮子 |
| **Spring AI** | 原生 JVM、Spring Boot 深度集成、OpenAI 兼容、类型安全的工具定义 | 生态较新(2024+) | ✅ 最佳选择 |
Spring AI 在三个决定性维度上胜出:
### 1. 原生 JVM,无需 Python 桥接
DC3 是纯 Java 21 / Spring Boot 4 平台。每个服务都在 JVM 上运行。为引入 AI 而加入 Python 运行时,
意味着额外的容器、额外的部署、以及两个语言运行时之间脆弱的网络桥接。
Spring AI 运行在进程内——`ChatClient` 是一个 Spring Bean,工具调用是普通的 Java 方法调用,
认证上下文通过与其他任何请求相同的 Spring Security 过滤器链流转。
```java
// DC3 中的一个 @Tool 方法——纯 Java、类型安全、租户感知
@Tool(description = "获取指定测点的最新值")
public PointValueBO getLatestPointValue(
@ToolParam(description = "测点ID") Long pointId,
ToolContext toolContext) {
var tenantId = AgenticToolContextUtil.requireTenantId(toolContext);
return pointValueService.getLatestByPointId(pointId, tenantId);
}
```
无需跨语言的序列化,无需独立的认证流程,无需在 AI 和数据之间插入 gRPC 或 HTTP ——
这是直接 Java 调用到服务层,租户隔离在方法级别执行。
### 2. 天生 OpenAI 兼容
Spring AI 的 `ChatClient` 使用 OpenAI Chat Completions 协议。这意味着 DC3 可以与任何暴露 OpenAI
兼容端点的模型提供商配合:OpenAI (GPT-4o, GPT-5)、Anthropic (通过兼容代理的 Claude)、DeepSeek、
Qwen、Groq、Together AI、Ollama (本地模型)、vLLM —— 列表还在不断增长。
对运维人员而言,这意味着选择自由:先用云端模型方便上手,再切换到自托管模型保证数据主权,
或者采用混合方案,敏感查询留在本地。Agentic Center 的 `dc3_model_provider` 表支持配置多个提供商,
并可按会话切换。
### 3. 类型安全的工具定义,编译期校验
在基于 Python 的工具调用框架中,工具定义通常是 JSON Schema 或带字符串描述的装饰器。
参数名的拼写错误就是运行时错误。而在 Spring AI 中,`@Tool` 和 `@ToolParam` 注解由 Java 编译器验证。
如果你修改了方法签名中的参数名但忘了更新注解,IDE 会在编译之前就标记出来。
当你拥有 10 个工具类、30+ 个工具方法以及一个贡献者团队时,这一点至关重要。
编译器成为了 Python 工具调用框架无法提供的安全网。
## 架构全景:一条聊天消息如何变成设备指令
以下是 DC3 中一次典型 AI 辅助操作的完整路径,端到端:
四个要素使这个架构达到生产级别:
1. **每层租户隔离。** 任何工具执行之前,`requireTenantId(toolContext)` 提取调用方的租户 ID。
每次数据库查询、每个缓存键、每次 Facade 调用都携带该租户 ID。即使模型错误地猜测了另一租户
的设备 ID,查询也是返回空结果——而非返回错误数据。
2. **写入指令需要人工确认。** 模型可以*提议*写入,但无法直接执行写入。
`PointValueTool.writePointValue()` 生成一个待确认的 `Action`,10 分钟有效期。
只有人工 `POST /action/confirm`(或同等的 UI 按钮点击)才能释放它。这不是建议——
而是在服务层强制执行,不仅限于 UI 层。
3. **会话可跨重启持久化。** 每一轮——用户消息、工具调用、工具结果、助手回复——
都落入 `dc3_message` 表。即使 Agentic Center 重启,`MessageChatMemoryRepository`
会回放最近 30 轮(可配置),对话无缝恢复。这对跨小时的诊断会话至关重要。
4. **模型永远看不到其他租户的数据。** 租户 ID 由网关注入(来自 JWT),而非由模型指定。
即使提示词说"显示所有设备",工具的 SQL 查询中带有 `WHERE tenant_id = ?`,
绑定到网关注入的值。模型无法绕过——不存在跨租户查询的 API。
## 10 个内建工具:完整的运维操作面
Agentic Center 出厂自带 10 个工具类,覆盖平台每个领域对象。每个工具方法包装已有的服务层方法——
不存在重复的业务逻辑。
| 工具类 | 领域 | 关键方法 | 风险等级 |
|------------------|-----|--------------------------------------------------------------------------------------------|------------|
| `TenantTool` | 租户 | `getCurrentTenantInfo()` | 低 |
| `UserTool` | 用户 | `getCurrentUserProfile()` | 低 |
| `DeviceTool` | 设备 | `lookupDeviceById()`, `searchDevices()` | 低 |
| `DriverTool` | 驱动 | `lookupDriverById()`, `searchDrivers()` | 低 |
| `ProfileTool` | 模板 | `lookupProfileById()`, `searchProfiles()` | 低 |
| `PointTool` | 位号 | `lookupPointById()`, `searchPoints()` | 低 |
| `PointValueTool` | 位号值 | `getLatestPointValue()`, `getPointValueHistory()`, `readPointValue()`, `writePointValue()` | **高 (写入)** |
| `SystemTool` | 系统 | `getSystemHealth()` | 低 |
| `CommandTool` | 指令 | `lookupCommandById()`, `searchCommands()` | 低 |
| `EventTool` | 事件 | `lookupEventById()`, `searchEvents()` | 低 |
工具方法刻意使用与 REST/gRPC 层不同的命名规范。REST 端点遵循项目的 `getXxx`/`listXxx` 约定;
工具方法使用 `lookupXxx`/`searchXxx`。这种分离让模型能够区分"按 ID 获取一个"
(`lookupDeviceById`) 和"分页搜索"(`searchDevices`),这对模型选择正确的工具至关重要。
## 为什么这对工业 IoT 意义深远
工业场景是 AI 辅助运维的完美用武之地:
- **高认知负荷。** 一名工厂操作员需要同时监控数十个屏幕、数百个数据点,必须在数秒内响应异常。
模型可以同时观察所有这一切。
- **结构化、边界明确的动作。** 与开放式的创意任务不同,工业运维有清晰的边界:
读取传感器、检查阈值、调整执行器。这些完美映射到工具调用模式。
- **可审计性不可妥协。** DC3 中的每一次 AI 辅助操作都被记录到 `dc3_message` 和
`dc3_action` 表中。谁问了什么、模型决定了什么、调用了哪些工具、结果是什么、
谁确认了写操作——全部可审计。
- **多供应商现实。** 工厂中可能存在 Siemens、Rockwell、Mitsubishi 等十几种品牌的设备。
DC3 的 28 个协议驱动将这种异构性抽象为统一的设备模型,AI 工具查询的就是这个统一模型——
模型无需知道 Modbus 寄存器映射或 OPC UA 节点 ID。
## 路线图:下一步走向何方
今天的 Agentic Center 处理的是描述性和诊断性操作:"温度是多少""为什么在上升""给我看趋势"。
下一个前沿是*建议性*操作:
- **异常到动作管道。** 模型在数据流中检测到异常,通过工具调用诊断根因,
并在操作员打开仪表盘之前提出纠正措施。
- **定时健康报告。** 每天早上 7 点,Agentic Center 生成一份自然语言交接报告:
昨晚哪些设备离线、哪些位号趋势异常、建议哪些维护动作。
- **多模型路由。** 将简单查询路由到快速廉价的模型 (GPT-4o mini);
将复杂诊断路由到推理模型 (GPT-5 或 Claude);
将敏感的本地查询路由到本地 Ollama 实例。`dc3_model_provider` 表和按会话的模型选择
已支持这一点——路由逻辑是下一层。
- **MCP 外部 Agent 接入。** Agentic Center 面向人类操作员,而 MCP 端点 (OAuth 2.1 +
JSON-RPC 2.0) 让外部 AI Agent 访问同一工具面——即将推出的 MCP ↔ Agentic 桥接
将使对话可以从自动化 Agent 无缝升级到人类操作员。
---
> **下一步:** 查看 [Agentic Center](./agentic) 获取完整工具参考和配置指南。
> 查看 [AI Agent / MCP](./mcp) 接入外部 Agent。
> 查看 [快速开始](../quickstart/first-device) 让你的第一台设备上线。
---
# 鉴权 · 租户 · RBAC
URL: https://docs.dc3.site/zh/architecture/auth-rbac
# 鉴权 · 租户 · RBAC
平台对外只有网关一个入口,但每一次受保护的调用,背后都要回答三个问题:你是谁、你属于哪个租户、你能不能做这件事。这页讲清登录如何换取令牌、网关如何把身份签名后透传给后端、身份模型怎么组织,以及
RBAC 与租户隔离如何协同把"越权"和"跨租户"两类风险一起封死。读完你能看懂一次请求从 `X-Auth-Token` 到 `@PreAuthorize`
的完整鉴权链路,并知道生产环境哪些配置必须先到位。
> 你在这里:已理解 [核心概念](../introduction/concepts)
> 里的租户边界,想知道它在鉴权层如何落实。配套看 [服务与拓扑](./services) 与 [领域模型](./domain-model)。
## 为什么把鉴权拆成"网关签名 + 后端验签"
中心服务之间用 gRPC facade 互联,每个后端服务(管理、数据、智能)都有自己的 HTTP
端口,只是默认不对外映射。这带来一个隐患:任何能直连后端端口的调用方,只要自己伪造一个"我是租户 A 的管理员"
的请求头,后端若无条件信任,就被冒充了。
IoT DC3 的解法是把"认证身份"和"信任身份"分开:
- **认证**只在网关 `dc3-gateway` 做一次。网关拿前端传来的 `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token`
去鉴权中心核验,解析出真实的 principal(租户 ID + 身份 ID)。
- **信任**靠一段 HMAC-SHA256 签名传递。网关把解析出的身份序列化成 `X-Auth-Principal`(JSON),再用共享密钥对它签名得到
`X-Auth-Sign`,一并透传给后端。后端只信任"带正确签名"的身份头,验签不过就当匿名处理。
这样后端不必重复一遍登录校验,又不会被裸的伪造头骗到——签名是只有网关和后端共享的密钥才能产生的。
## 登录与令牌签发
登录是两步握手:先取一次性盐,再用盐把密码哈希后换令牌。盐的作用是避免密码明文或固定哈希在链路上重放。
两个端点都公开、无需鉴权:
- `POST /api/v3/auth/token/salt`:传 `tenant`、`name`,先确认租户存在,返回一个随机盐(UUID 形态),响应文案建议 **5 分钟内使用
**(服务端不存储盐、不强制过期,"5 分钟"只是客户端使用提示)。
- `POST /api/v3/auth/token/generate`:传 `tenant`、`name`、`salt`、**明文 `password`**(盐不参与密码哈希,仅用于与服务端密钥拼接给
token 签名),校验通过返回 access token,**有效期
12 小时**(`TOKEN_CACHE_TIMEOUT = 12` 小时)。
`generateToken` 内部按固定顺序逐项校验,任一不过都返回同一个"无可用认证"错误,不泄露是哪一步失败:
1. **租户**:`tenantCode` 必须解析到存在的租户。
2. **凭据**:按 `loginName` 找到 `dc3_local_credential`。
3. **成员关系**:该凭据的 `principalId` 必须是该租户的成员(查 `dc3_tenant_membership`)。
4. **盐**:盐不能为空。
5. **密码**:按存储哈希记录的算法分派校验(`ARGON2ID` 或 `BCRYPT`);默认 seed 用户 `dc3` 的口令以
BCrypt(cost=12)存储,故黄金路径登录实际走 BCrypt 校验。只有新口令 `encode()` 才优先 Argon2id(不可用时回退
bcrypt)。失败会记录一次失败登录。
6. **密码过期 / 强制改密**:`password_expire_time` 已过或 `require_password_change=1` 时,抛"需改密"而非签发令牌。
全部通过后才用 `KeyUtil.generateToken(principalId, salt, tenantId)` 签出 JWT——令牌绑定的是 **principal_id + tenant_id**
,不是用户名。注销时把 `(tenantCode:principalId)` 写入 Caffeine 注销名单(denylist),后续带旧令牌的请求即便签名合法,也会因签发时间落在注销点之前而被判失效。
```bash [curl 登录黄金路径]
# 1) 取盐
curl -s -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# 返回示例(建议 5 分钟内使用):"a1b2c3d4-...-e5f6"
# 2) 用盐哈希密码后换令牌
curl -s -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"a1b2c3d4-...-e5f6","password":""}'
# 返回示例(12 小时有效):JWT 字符串
# 3) 之后所有受保护请求都带三个头
curl -s -X POST http://localhost:8000/api/v3/manager/device/add \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: ' \
-H 'Content-Type: application/json' \
-d '{"deviceName":"...","driverId":...,"profileId":...}'
```
## 网关请求与 HMAC 签名透传
拿到令牌之后,每次受保护调用都带 `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` 三个头到网关。网关的
`AuthenticGatewayFilter` 负责把这三个头换成一段可被后端信任的、带签名的身份。
网关侧(`AuthenticGatewayFilter`):身份解析是阻塞式 gRPC 调用,整体放到 `boundedElastic` 线程池执行,避免占住 Netty 事件循环。解析出
`PrincipalHeader` 后序列化为 `X-Auth-Principal`;若 HMAC 已启用,再写入 `X-Auth-Sign`;*
*若未启用,则主动删除任何入站的 `X-Auth-Sign` 头**,防止下游被客户端自带的假签名诱骗。
后端侧(`GatewayJwtConverter`):
- 没有 `X-Auth-Principal` → 按匿名继续。
- HMAC 启用时,用同一密钥对 principal 载荷重算 HMAC,与 `X-Auth-Sign` 做**常量时间比对**,不符即拒。
- 验签通过后解析 principal,缺 `tenantId` 或 `principalId` 一律拒;随后据此加载权限集,交给 `@PreAuthorize` 判定。
签名用的共享密钥来自 `dc3.auth.hmac.secret`(或环境变量 `AUTH_HMAC_SECRET`)。这把密钥的缺省行为按环境分级——开发宽松、生产严格:
::: danger HMAC 生产 fail-fast
在 `pre` / `pro` 环境下,`AUTH_HMAC_SECRET` 为空、或仍等于默认值 `io.github.pnoker.dc3`,服务**启动即失败**(抛
`IllegalStateException`)。判定逻辑见 `HmacAuthConfig.isProtectedEnvironment()`:检查 `spring.profiles.active` 与
`spring.env`,命中 `pre`/`pro` 即进入强校验。生产部署前务必把它设成强随机值。
:::
::: warning 开发/测试环境密钥为空只告警
非保护环境下密钥为空时,`HmacAuthSigner` 不会报错,而是禁用签名并打印一条醒目的 WARNING:此时后端**无条件信任**
`X-Auth-Principal`。本地自测可以接受,但任何对外可达的部署都不应停在这个状态。
:::
## 身份模型:principal 是根
很多平台把"用户"当作鉴权的根对象,结果服务账号、系统身份只能硬塞进用户表。IoT DC3 反过来——根身份是 **`dc3_principal`**
,用户只是其中一种类型。
- **`dc3_principal`** 是统一身份表,`principal_type` 取 `USER`(人)、`SERVICE_ACCOUNT`(服务账号)、`SYSTEM`(系统身份)三种之一。
- **凭据挂在 principal 上**:`dc3_local_credential.principal_id` 指向 principal,而不是某个 `user_id`。密码哈希默认
Argon2id(也支持 BCRYPT)。这意味着同一身份模型可以承载人和机器两类调用方。
- **租户成员关系是显式的**:身份"属于哪个租户"不写死在身份上,而是由 `dc3_tenant_membership` 一行一行声明。唯一索引建在
`(tenant_id, principal_id)`,所以**一个 USER 可以属于多个租户**(多行),登录时由 `name + tenant` 一起定位是哪一段成员关系;
**SERVICE_ACCOUNT 按设计单租户**。
::: info 外部身份(identity provider)尚未实现
`dc3_identity_provider`(OIDC/SAML 等外部 IdP 配置)与 `dc3_external_identity`(外部身份与本地 principal 的绑定)两张表已在
`02-iot-dc3-auth.sql` 中建好,`principal.source_type` 也预留了 `EXTERNAL` 取值,但对应的**登录端点未实现、处于关闭状态**
。当前可用的登录路径只有上面的本地凭据(`POST /api/v3/auth/token/salt` + `/api/v3/auth/token/generate`)。
:::
## RBAC:从身份到资源码
验签拿到 principal 之后,要决定它"能做什么"。IoT DC3 用经典的"主体—角色—资源"三段绑定,但刻意把两段的作用域分开:角色归属是*
*租户内**的,资源授权是**全局**的。
链路是:`dc3_role_principal_bind`(带 `tenant_id`,决定这个 principal 在该租户内有哪些角色)→ `dc3_role_resource_bind`(无
`tenant_id`,把角色映射到资源)→ `dc3_resource`
(资源即权限码)。把角色归属做成租户内、资源做成全局,意味着同一个角色定义可在多个租户复用,而"谁在哪个租户里是这个角色"互不串台。
资源码是三段式 `{spring.application.name}:{domain}:{scope}`,由 `@perm.can` 在运行时按所在服务拼成——例如
`DeviceController` 上的 `@perm.can('device', 'add')` 实际校验的字符串是 `dc3-center-manager:device:add`,
`PointCommandController` 上的 `@perm.can('point_command', 'list')` 校验 `dc3-center-data:point_command:list`。注意 seed
数据并未为每个 API 级权限单独建行,默认管理员的资源码是通配 `*`,覆盖全部接口。
权限解析由 `AuthPermissionProvider` 完成,并带一层短缓存:
- 缓存键是 **`(tenantId:principalId)`**,TTL **5 分钟**(`CACHE_TTL_MS = 300_000`)。换言之改了授权,最长 5 分钟后才对在途会话生效。
- 解析时把该 principal 在该租户下的所有资源码收集成一个集合;`@PreAuthorize` 判断时命中具体权限码或通配权限即放行。
最关键的是失败语义——**fail-closed**:
::: danger 查不到权限 = 拒绝,不是放行
`GatewayJwtConverter` 在权限加载发生瞬时故障时,仍会创建一个"已认证但权限为空"的令牌(authorities 为空集)。这是有意的"
失败即关闭":调用方被当作已登录但无任何权限,任何 `@PreAuthorize` 守卫返回 **403**,而不是把一次后端抖动伪装成 401
或意外放行。权限查不到时默认无权,绝不默认有权。
:::
## 租户隔离:接口层校验
RBAC 决定"能不能做这类操作",租户隔离决定"能不能碰这条数据"。两者正交,缺一不可——有 `device:get` 权限不代表你能 get
别家租户的设备。隔离落在接口层(数据库查询层当前不做自动租户裁剪,`MybatisPlusConfig` 只注册了分页插件):
**控制器层 `BaseController.requireTenant()`**:按 ID 查到实体后,比对实体的 `tenantId` 与调用方租户。不一致(或实体不存在)就抛
`NotFoundException`,对外返回 **404**——刻意用"不存在"而非"无权限",避免泄露"某个跨租户资源是否存在"。批量查询走
`filterTenant()`,把不属于本租户的条目直接剔除。
::: warning 没有数据库层的自动租户兜底
不要以为忘了写租户条件也会被 SQL 层补上——当前实现**没有** MyBatis-Plus 租户行拦截器,隔离完全靠控制器层的
`requireTenant` / `filterTenant`。新增单条或批量查询时,务必主动调用这两个方法带上租户校验,否则查询不会自动按租户裁剪。
:::
```java
// 控制器层:按 ID 查到的实体若不属于本租户,返回 404 而非 403
default T requireTenant(Long tenantId, T entity) {
if (Objects.isNull(entity) || !Objects.equals(tenantId, entity.getTenantId())) {
throw new NotFoundException("Resource does not exist");
}
return entity;
}
```
::: tip 新增查询时保持租户作用域
任何新增的查询、gRPC 请求或缓存键都必须带租户上下文:查询保留 `tenantId`、缓存键纳入租户、跨服务取数前先校验归属。除非数据模型明确定义为全局记录,否则不要写
`tenant_id IS NULL` 这类绕过条件。
:::
## 约束与边界一览
把上面散落的硬约束集中一处,便于部署前核对:
| 项 | 取值 / 行为 | 来源 |
|-----------|-----------------------------------------------------------------------|----------------------------------|
| 盐有效期 | 建议客户端 5 分钟内使用(服务端不强制过期) | `POST /api/v3/auth/token/salt` |
| 令牌有效期 | 12 小时 | `TOKEN_CACHE_TIMEOUT=12` 小时 |
| JWT 绑定 | `principal_id` + `tenant_id` | `generateToken` |
| HMAC 密钥 | `AUTH_HMAC_SECRET` / `dc3.auth.hmac.secret`,默认 `io.github.pnoker.dc3` | `HmacAuthConfig` |
| HMAC 生产校验 | `pre`/`pro` 下为空或等于默认值即启动失败 | `HmacAuthConfig` |
| 权限缓存 | key=`(tenantId:principalId)`,TTL 5 分钟 | `AuthPermissionProvider` |
| 权限失败语义 | fail-closed → 空权限 → 403 | `GatewayJwtConverter` |
| 跨租户 ID 查询 | 返回 404(非 403) | `BaseController.requireTenant()` |
| 外部身份登录 | 表已建、端点未实现/关闭 | `02-iot-dc3-auth.sql` |
## 延伸阅读
- [服务与拓扑](./services) — 网关、四个中心与驱动如何分布,端口与启动顺序
- [领域模型](./domain-model) — DO/BO/VO 分层、`TenantOwned` 与租户字段如何贯穿实体
- [API 文档](../development/api-documentation) — OpenAPI、鉴权头与 CRUD 动词约定
- [物联网安全](../foundations/security) — 设备/通信/平台/数据安全的体系化视角
---
# 命令平面:读写命令的下发与回执
URL: https://docs.dc3.site/zh/architecture/command-plane
# 命令平面:读写命令的下发与回执
数据平面把设备的值采上来,命令平面做相反的事:把一次"读这个位号"或"给这个位号写值"的请求,从 HTTP
入口一路下发到驱动、到设备,再把执行结果回写。这页追踪一条命令从提交、校验、入库、经 RabbitMQ
下发、驱动执行到回执落库的完整链路与状态机,让你能看懂为什么提交命令是"立即拿号、轮询取结果",以及失败时各状态分别意味着什么。
> 你在这里:已理解 [数据平面](./data-plane) 的采集方向,现在看反向的命令下发。命令的产生方可以是 Web、CLI,也可以是
> AI(见 [数据与命令](../operation/data-commands))。
## 为什么是"异步拿号 + 轮询"
下发一条命令要跨进程、跨网络、最终落到现场设备上——这中间任何一跳都可能慢、可能失败。如果让 HTTP
请求一直阻塞等设备执行完,网关线程会被长时间占用,设备离线或协议超时还会拖垮整条调用链。
所以命令平面把"提交"和"结果"解耦:`POST /api/v3/data/point_command/read` 和 `POST /api/v3/data/point_command/write`
在数据中心做完校验、把命令以 `PENDING` 落库、发往 RabbitMQ 之后,**立即返回一个 `commandId`**(一个 36 字符的
UUID)。调用方拿这个号去轮询历史接口,就能看到命令此刻走到了哪一步、成功还是失败、设备回了什么值。
这条链路的入口在 `PointCommandController`(`dc3-common-data`),两个端点权限都是 `point_command:list`,请求体里给
`deviceId` / `pointId`(写命令再加 `value`),可选地带一个 `commandId` 让提交变成幂等——同一个 `commandId`
重复提交会直接返回已存在的那条记录,不会重复下发。
## 一条写命令的旅程
下面这张时序图是写命令的 happy path:从调用方提交,到驱动把值真正写进设备并回执成功,再到调用方轮询拿到结果。
### 提交侧:校验、落库、发布
数据中心 `PointCommandServiceImpl` 在下发前依次校验,任一步失败都直接抛异常、不入队:
- **租户范围**:`deviceId` / `pointId` 必须属于当前租户,且设备绑定的 `profileId` 与位号的 `profileId` 一致,否则按越权拒绝。
- **启用状态**:设备与位号的 `enableFlag` 必须为启用,禁用的设备或位号不接受命令。
- **可写性(仅写命令)**:位号的 `rwFlag` 必须是 `WRITE_ONLY` 或 `READ_WRITE`;写一个 `READ_ONLY` 位号会被拒绝("Point is not
writable")。这呼应了[核心概念](../introduction/concepts)里"读写由 Point 自己决定"的约定。
- **驱动在线**:从 `dc3_entity_state` 查这台设备所属驱动的状态,不是 `ONLINE` 就拒绝("Driver is offline")。
校验通过后,命令以 `PENDING` 状态写入 `dc3_point_command_history`,随即用 `rabbitTemplate.convertAndSend(...)` 发布,并把
`CorrelationData` 设为 `commandId`——这样 RabbitMQ 的 publisher-confirm 回执就能精确对应到这一条命令。发布调用返回后,记录被更新为
`SENT`、写入 `sendTime`。
### 投递的载荷:PointCommandDTO
跨 RabbitMQ 传输的不是宽泛的 JSON 字符串,而是一个强类型的 record `PointCommandDTO`,其 `payload` 字段是 `sealed`
接口;时间字段统一用 `Instant`(UTC):
```java
public record PointCommandDTO(
String commandId, // 与历史记录、回执一一对应
Long tenantId, // 租户隔离
PointCommandTypeEnum type, // READ / WRITE / ...
PointCommandPayload payload, // ReadPayload | WritePayload(多态)
PointCommandSourceEnum source,
Long sourceUserId,
Instant occurredAt,
Instant expireAt, // 默认 occurredAt + 10s
int schemaVersion
) { }
```
载荷 `PointCommandPayload` 是一个密封接口,只有 `ReadPayload(deviceId, pointId)` 与
`WritePayload(deviceId, pointId, value)` 两种实现,驱动侧用 `switch` 模式匹配分发,编译期即可穷尽所有分支。
::: warning expireAt 默认只有 10 秒
`PointCommandDTO` 的工厂方法 `ofRead()` / `ofWrite()` 把 `expireAt` 设为 `Instant.now().plusSeconds(10)`
。命令在队列里积压、或驱动消费时已经 `now > expireAt`,会被判定为 `EXPIRED` 而不执行——这是为采集类命令设计的短时效语义,不要把它当成可以慢慢排队的长任务。
:::
### 驱动侧:预检、去重、加锁、执行
驱动用 `PointCommandReceiver` 消费命令队列。拿到一条命令后,处理顺序是固定的:
1. **基本校验**:`commandId` / `tenantId` / `type` / `payload` 任一为空,或读/写载荷缺字段,直接 `reject`(进死信,不重投)。
2. **expireAt 预检**:`now > expireAt` → 回执 `EXPIRED`,不碰设备。
3. **去重**:用 Caffeine 去重缓存(5 分钟过期、上限 5 万条)`tryAcquire(commandId)`;命中说明这条命令已处理过,回执
`DUPLICATE`。
4. **每设备串行锁**:通过 `DeviceLockManager` 拿这台设备的 `ReentrantLock`(引用计数管理锁的创建与回收),保证同一设备上的多条命令不会交错执行、打乱协议时序。
5. **读/写分发**:`ReadPayload` 调 `driverReadService.read(...)`;`WritePayload` 调 `driverWriteService.write(...)`。
::: danger 写失败不回显值
写命令**只有当 `driverWriteService.write()` 返回 `Boolean.TRUE` 时才算成功**,回执 `SUCCESS` 并带上刚写入的值。一旦返回
`false`,回执是 `FAILED` 且 `responseValue=null`——**绝不回显任何值**
。这是有意为之:写失败却回显一个值,会让上层误以为命令成功、设备状态已变更,造成"假成功"。看到 `FAILED`,就要当成"
这次写入没有生效"。
:::
## 命令的生命周期
一条命令的状态由 `PointCommandStatusEnum` 定义,从提交到终态的流转如下图。提交侧负责 `PENDING → SENT`
;其余终态都由驱动消费时产生的回执、经结果队列写回。
各状态对应的枚举索引与含义(`PointCommandStatusEnum`,括号内是落库的 `status` 值):
| 状态 | index | 含义 |
|-------------|-------|-----------------------|
| `PENDING` | 0 | 已提交、待发布 |
| `SENT` | 1 | 已发布到 broker、待驱动处理 |
| `SUCCESS` | 2 | 驱动确认成功 |
| `FAILED` | 3 | 驱动报告失败(写失败 / 重投后异常) |
| `TIMEOUT` | 4 | 应用层超时(枚举已预留,当前链路尚不产生) |
| `EXPIRED` | 5 | 执行前 `expireAt` 已过 |
| `DEAD` | 6 | 被 reject 进入死信,不再处理 |
| `DUPLICATE` | 7 | 被驱动去重缓存判定为重复 |
`EXPIRED` 由驱动在消费时 `now > expireAt` 判定;`DUPLICATE` 由去重缓存命中产生。
::: info TIMEOUT 当前无生产者
`PointCommandStatusEnum` 预留了 `TIMEOUT(4)`,但当前链路里没有任何代码把命令置为此状态——`SUCCESS` / `FAILED` /
`EXPIRED` / `DUPLICATE` / `DEAD` 各有明确的产生路径,唯独 `TIMEOUT` 是为将来的应用层超时语义预留的枚举位,状态机里用注记标注(而非活跃边)即此意。
:::
命令的 `type` 由 `PointCommandTypeEnum` 区分:`READ(0)` / `READ_BATCH(1)` / `WRITE(2)` / `WRITE_BATCH(3)` / `CONFIG(4)`
——当前读写端点下发的是 `READ` 与 `WRITE`。
### 错误路径:重投一次,再失败就落账
驱动执行抛异常时的处理,专门避免"毒消息"在队列里死循环:
- **首次失败(非重投)**:释放该命令的去重占用,`nack(requeue=true)` 让消息重回队列再试一次。
- **重投后仍失败**:不再重投,直接回执 `FAILED`(`errorCode=DRIVER_ERROR`)并 `ack` 掉这条消息,让它出队。
这样每条命令最多被驱动尝试两次,既给了瞬时故障一次自愈机会,又不会让一条始终失败的命令无限循环。
## 命令的 RabbitMQ 拓扑
命令链路用两组交换机/队列:一组把命令从数据中心送到对应驱动,一组把回执从驱动送回数据中心。命令队列按驱动 `serviceName`
分队,带 30 秒 TTL 与死信交换机;结果队列带 60 秒 TTL。
命令队列 `dc3.q.point_command.{serviceName}` 是 durable、`ttl(30000)`,死信指向 `dc3.e.point_command_dead`
——两条路径会进死信:一是命令在驱动侧 30 秒内没被消费(TTL 到期),二是基本校验失败时驱动直接 `reject`(不重投)把消息打入
DLX;两者都不在原队列无限滞留。回执走 `dc3.e.point_command_result`(topic),结果队列 `dc3.q.point_command_result` 为
`ttl(60000)`,由数据中心的 `PointCommandResultReceiver` 消费:按 `commandId` 查到历史记录,写入终态 `status`、
`responseValue`、`errorCode` / `errorMessage` 与 `finishTime`。
## 提交与轮询:真实路由
下发一条写命令、再轮询结果,是两次独立的 HTTP 调用。所有路径都经网关(`http://localhost:8000`)转发,受保护端点需带
`X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` 三个鉴权头。
::: code-group
```bash [提交写命令]
# 给设备 1024 的位号 2048 写值 25.5;返回 commandId(示例 UUID)
curl -X POST http://localhost:8000/api/v3/data/point_command/write \
-H "X-Auth-Tenant: " \
-H "X-Auth-Login: " \
-H "X-Auth-Token: " \
-H "Content-Type: application/json" \
-d '{"deviceId": 1024, "pointId": 2048, "value": "25.5"}'
# → {"code":"...","data":"9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", ...}
```
```bash [轮询结果]
# 用上一步拿到的 commandId 查历史,看 status 与 responseValue
curl "http://localhost:8000/api/v3/data/point_command_history/get_by_command_id?commandId=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" \
-H "X-Auth-Tenant: " \
-H "X-Auth-Login: " \
-H "X-Auth-Token: "
```
:::
轮询返回的是 `PointCommandHistoryVO`——查询按租户隔离(`getByCommandId(tenantId, commandId)`),所以一个租户拿不到另一个租户的命令记录。
`status` 走到上面任一终态即代表本次命令结束;写命令尤其要看 `responseValue`:`SUCCESS` 时它是回显的写入值,`FAILED` 时它一定是
`null`(见上文"写失败不回显值")。
::: info 与"自定义指令"是两套命名空间
本页讲的是**位号读写**(`point_command`):交换机 `dc3.e.point_command`、DTO `PointCommandDTO`、表
`dc3_point_command_history`。平台另有一套**自定义指令**(Custom Command),走 `dc3.e.command`、DTO `CommandCallDTO`,用于在
Profile 上定义的设备级动作。两者结构相似但互不相通,不要把路由键、DTO 或历史表混用。
:::
## 延伸阅读
- [数据平面](./data-plane) — 反向的位号值采集链路:交换机、队列、TimescaleDB 落库
- [核心概念与心智模型](../introduction/concepts) — 位号的 `rwFlag` 为何决定可写性
- [驱动开发](../development/driver-authoring) — `DriverProtocol.write()` 返回 `Boolean` 的契约与命令处理管线
- [数据与命令](../operation/data-commands) — 从使用者视角下发命令、处理离线与只读位号
---
# 数据平面:位号值如何落库
URL: https://docs.dc3.site/zh/architecture/data-plane
# 数据平面:位号值如何落库
设备侧采到的原始寄存器值,要经过驱动归一、消息总线、数据中心,最终写进时序库并对外可查。这页追踪一条位号值的完整旅程:用到的交换机与队列、消费者怎么持久化、模型变换经过哪几层、以及读取最新值时缓存如何命中。读完你能看懂一条值"
从设备到 API"的每一跳,并知道做聚合查询时的硬约束。
> 你在这里:已理解 [核心概念](../introduction/concepts) 里的位号(Point)与位号值(PointValue)
> ,想看数据怎么流。反向的读写命令请看 [命令平面](./command-plane)。
## 一条值的旅程
数据流是**南向到北向**的单向链路。驱动周期性采集,把每个位号的一次采集封装成一个 `PointValue` 对象,经
`DriverSenderService.pointValueSender()` 发往 RabbitMQ 的值交换机;数据中心 `dc3-center-data` 监听队列、消费消息、写入
TimescaleDB,同时把最新值塞进本地 Caffeine 缓存供热点读取。整条链路是**异步**的——驱动发出后不等数据中心确认落库,靠消息总线的持久投递与手动
ack 保证不丢。
驱动用的路由键是 `dc3.r.value.point.` 加上自己的服务名(`driverProperties.getService()`,例如 `dc3-driver-virtual`
实例配的服务名),数据中心的队列用通配绑定 `dc3.r.value.point.*` 收下所有驱动的值。发送时 `pointValueSender()` 会从
`DriverMetadata` 注入 `driverId` 与 `tenantId`(若消息里没带),并用 `PointValueCorrelation`(携带随机 UUID + deviceId +
pointId)作为关联数据,配合 publisher confirms 跟踪投递结果。
## RabbitMQ 拓扑:值交换机、位号队列、死信
值通道是一个 **topic 交换机** `dc3.e.value`,下挂一条**持久队列** `dc3.q.value.point`。队列声明在数据中心的
`DataTopicConfig`:
```java
QueueBuilder.durable(RabbitConstant.QUEUE_POINT_VALUE) // dc3.q.value.point
.ttl(604800000) // 7 天 = 604800000 ms
.deadLetterExchange(RabbitConstant.TOPIC_EXCHANGE_POINT_VALUE_DEAD) // dc3.e.point_value_dead
.deadLetterRoutingKey("#")
.build();
```
三个要点:
- **持久 + 7 天 TTL**:队列 `durable`,消息发布时被强制设为 `PERSISTENT`(`RabbitConfig` 的 `BeforePublishPostProcessor`
统一打 `MessageDeliveryMode.PERSISTENT`)。一条消息在队列里最多停 7 天(`604800000` ms),超时未被消费即进死信。
- **死信兜底**:超时或被 `reject` 的消息流向死信交换机 `dc3.e.point_value_dead`(死信队列 `dc3.q.point_value_dead`
),不会静默丢失,可另行排查。
- **通配绑定**:队列以 `dc3.r.value.point.*` 绑定到 `dc3.e.value`,一条队列收下全部驱动实例的位号值。
::: info 交换机/队列名带环境前缀
`RabbitConstant` 里 `dc3.e.value`、`dc3.q.value.point` 等常量在装配时会拼上一个环境 `tag` 前缀。本文用的是去前缀后的稳定后缀名,便于在
RabbitMQ 管理台按名检索。
:::
## 消费者:PointValueReceiver 如何落库
数据中心的 `PointValueReceiver` 用 `@RabbitListener(queues = "#{pointValueQueue.name}")` 监听位号队列,反序列化成
`PointValueBO`(JSON 经 `JacksonJsonMessageConverter`)。它走的是**手动 ack**:
- **校验**:`pointValueBO` 为空或缺 `deviceId` → `RabbitAckUtil.reject`(`basicReject` 不重回队列)→ 进死信。
- **持久化**:根据入站速率二选一——速率低于 `POINT_BATCH_SPEED`(默认 100)时调用 `pointValueService.save(pointValueBO)` *
*即时落库**;速率超过阈值时改交 `PointValueJob` **批处理**。速率由 `speed = count / interval` 算出,
`POINT_BATCH_INTERVAL`(默认 `5`,单位**秒**,Quartz `IntervalUnit.SECOND`)是这里的除数而非刷新间隔。`PointValueJob` 由
Quartz 定时触发,每次把整个累积缓冲一次性刷出,与缓冲大小无关——没有"批量大小触发",也没有"谁先到谁先刷"。
- **确认**:成功 → `RabbitAckUtil.ack`;处理抛异常 → `RabbitAckUtil.nack(requeue=true)` 重回队列重试。
::: info 消费者并发是默认档,非高吞吐档
`PointValueReceiver` 没有显式指定 `containerFactory`,因此用默认监听容器工厂:`concurrentConsumers=2`、
`maxConcurrentConsumers=8`、`prefetchCount=10`、`AcknowledgeMode.MANUAL`。`RabbitConfig` 另提供了一个高吞吐工厂
`highThroughputRabbitListenerContainerFactory`(`concurrent=4`、`max=32`、`prefetch=100`),但当前没有监听器 opt-in。需要更高吞吐时给
`@RabbitListener` 显式加 `containerFactory="highThroughputRabbitListenerContainerFactory"`。以代码为准:
`dc3-common-rabbitmq/.../RabbitConfig.java`。
:::
`pointValueService.save()` 内部做两件事:先把值写进本地 Caffeine 最新值缓存(`PointValueLocalCache`,key =
`REAL_TIME_VALUE_KEY_PREFIX + tenantId + "." + deviceId + "." + pointId`,点号分隔且带前缀)与时序库,再立即交给告警引擎评估。
## 模型变换:六个面孔,分属不同层
同一条"位号值"在链路上换了六次外衣,每一层各有职责与序列化形态。**最容易混淆的是 `PointValue` 与 `PointValueBO` 不是同一个类
**——前者是驱动侧的发送 bean,后者是消息/业务侧的对象,分属不同层。
逐层说明:
- **`ReadPointValue`**(驱动):驱动协议层 `read()` 返回的原始读数,带 device/point 上下文。
- **`CalculatedPointValue`**(驱动):对原始值做线性换算/投影(`baseValue`/`multiple` 等)后的结果,同时算出 `finalValue`
(工程值字符串)与 `numericValue`(数值投影,可能为空)。
- **`PointValue`**(驱动发送 bean):`new PointValue(readPointValue)` 内部触发 `calculate()`,填入 `rawValue`/`calValue`/
`numValue` 与 `createTime`(采集时刻),这就是发往 RabbitMQ 的载荷。
- **`PointValueBO`**(消息/业务):数据中心从队列反序列化得到的对象,承载 `tenantId`、`createTime`、`operateTime`,是落库与告警评估的输入。
- **`PointValueDO`**(持久):写入 `dc3_point_value` 的数据库形态,`num_value` 为可空 `DOUBLE`。
- **`PointValueVO`**(API):读接口返回给客户端的形态,对外暴露 `deviceId`/`pointId`/`rawValue`/`calValue`/`numValue`/
`createTime`/`operateTime`/`hasLatestValue`/`driverId`/`tenantId`。
## 存储:dc3_point_value 是 TimescaleDB 超表
值落在 TimescaleDB **hypertable** `dc3_history.dc3_point_value`(位于 `dc3_history` schema;`search_path` 含
`dc3_history, public`,故正文与查询里常简写为 `dc3_point_value`)。它按两维分区——时间维 `create_time` 每 **1 天**一个
chunk,设备维 `device_id` **16** 个哈希桶:
```sql
SELECT create_hypertable('dc3_point_value', by_range('create_time', INTERVAL '1 day'));
SELECT add_dimension('dc3_point_value', by_hash('device_id', 16));
```
为控制存储与查询成本,这张超表还配了两条数据生命周期策略:
```sql
-- 7 天前的 chunk 自动压缩(按 tenant/device/point 分段、create_time 排序)
ALTER TABLE dc3_point_value SET (timescaledb.compress,
compress_segmentby='tenant_id,device_id,point_id', compress_orderby='create_time DESC');
SELECT add_compression_policy('dc3_point_value', INTERVAL '7 days');
-- 超过 180 天的数据自动清理
SELECT add_retention_policy('dc3_point_value', INTERVAL '180 days');
```
::: tip 压缩与保留默认开启
7 天后压缩能显著降低磁盘占用(压缩后的 chunk 仍可查询,只是写入受限);180 天保留策略会自动 drop 超期
chunk。业务需要更长留存时,在部署侧调整这两条策略的间隔。
:::
关键列与索引:
| 列 | 类型 | 说明 |
|----------------|---------------------------|---------------------------------------|
| `raw_value` | `TEXT NOT NULL` | 设备采到的原始值 |
| `cal_value` | `TEXT NOT NULL` | 换算/投影后的值 |
| `num_value` | `DOUBLE PRECISION` **可空** | `cal_value` 的数值投影;非数值/JSON 载荷为 `NULL` |
| `create_time` | `TIMESTAMPTZ NOT NULL` | **采集时刻**(驱动侧 acquisition) |
| `operate_time` | `TIMESTAMPTZ NOT NULL` | **落库时刻**(数据中心 persist) |
主时序索引 `idx_point_value_ts_lookup` 为 `(tenant_id, device_id, point_id, create_time DESC)`,租户隔离的最新值与时间窗扫描都走它;另有一个
**部分索引** `idx_point_value_num_time ... WHERE num_value IS NOT NULL`,专为数值聚合服务。
::: warning create_time 与 operate_time 是两个时刻
`create_time` 是驱动采集到该值的时间,`operate_time` 是数据中心把它写进库的时间。两者刻意分开——它们的差值就是"采集→落库"
管线延迟,仪表盘用它衡量链路时延。落库时 `save()` 总会重写 `operate_time` 为当前时间,`create_time` 缺失才补当前时间。
:::
::: danger num_value 可空:聚合查询必须 num_value IS NOT NULL
`dc3_point_value.num_value` 对非数值或 JSON 载荷为 `NULL`。任何 `AVG`/`SUM`/`MAX`/`MIN` 等聚合都**必须**加
`WHERE num_value IS NOT NULL`,否则把字符串型位号的空值混进来,结果会偏差。部分索引 `idx_point_value_num_time` 也只覆盖
`num_value IS NOT NULL` 的行——不加这个谓词还会错过索引。
:::
## 消息可靠性与落库后的告警
数据平面的不丢保证来自三处叠加,均在共享 `RabbitConfig` 里统一装配:
- **持久投递**:发布前置处理器把每条消息标 `PERSISTENT`,配合 `durable` 队列,broker 重启不丢。
- **手动 ack**:消费者处理成功才 `ack`;异常 `nack(requeue=true)` 重试,校验失败 `reject` 进死信——绝不静默吞消息。
- **publisher confirms**:`rabbitTemplate` 注册了 confirm 回调,NACK 时打错误日志;关联数据 `PointValueCorrelation`
让你能把一次确认对回具体的 device/point。
值一旦落库,`PointValueServiceImpl.save()` 紧接着调用 `alarmRuleTriggerService.processPointValue(pointValueBO)`,**同步**
对这条值做告警规则评估——不是另起一条延迟链路。规则、状态机与通知渠道见 [告警与通知](../operation/alarms)。
## 读取最新值:先查缓存,未命中回源时序库
写路径把最新值同时塞进 Caffeine;读路径就先吃这层缓存。`POST /api/v3/data/point_value/latest` 进
`PointValueServiceImpl.latest()`:先用 `pointValueLocalCacheService.selectLatestPointValue(tenantId, deviceId, pointIds)`
批量查缓存,把缓存未命中的 pointId 收集起来,再一次性回源 TimescaleDB(`repositoryService.listLatestPointValues`)补齐。
历史区间查询 `POST /api/v3/data/point_value/list` 不走缓存,直接 `repositoryService.listPagePointValue(query)` 扫时序库,按
`startTime`/`endTime` 过滤。两个读接口都受 `@PreAuthorize("@perm.can('point_value', 'list')")` 保护,返回
`分页 PointValueVO`,且查询经 `PointValueQuery` 强制带租户上下文——跨租户拿不到别人的数据。
## 怎么做:读最新值与历史
两条读接口都经网关 `dc3-gateway`(:8000)转发,受保护接口要带 `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token`
三个鉴权头(取盐+发令流程见 [快速开始](../quickstart/))。
::: code-group
```bash [最新值 curl]
curl -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H "X-Auth-Tenant: default" \
-H "X-Auth-Login: " \
-H "X-Auth-Token: " \
-H "Content-Type: application/json" \
-d '{"deviceId": 1024, "pointId": 2048, "current": 1, "size": 10}'
```
```bash [历史区间 curl]
curl -X POST http://localhost:8000/api/v3/data/point_value/list \
-H "X-Auth-Tenant: default" \
-H "X-Auth-Login: " \
-H "X-Auth-Token: " \
-H "Content-Type: application/json" \
-d '{"deviceId": 1024, "pointId": 2048, "current": 1, "size": 50,
"startTime": "2026-06-22T00:00:00", "endTime": "2026-06-22T23:59:59"}'
```
:::
响应是分页的 `PointValueVO`,每项给出位号最新(或区间内)一条值的形态:
```json
{
"code": "200",
"data": {
"current": 1,
"size": 10,
"total": 1,
"records": [
{
"deviceId": 1024,
"pointId": 2048,
"rawValue": "23.5",
"calValue": "23.5",
"numValue": 23.5,
"hasLatestValue": true,
"createTime": "2026-06-22T08:30:00",
"operateTime": "2026-06-22T08:30:01"
}
]
}
}
```
::: tip 字段名以实际响应为准
上面是示例值。`PointValueVO` 的对外字段(如 `rawValue`/`calValue`/`numValue`/`createTime`/`operateTime`)由 MapStruct
builder 从 `PointValueDO` 映射,做集成时以网关实际返回的 JSON 为准。
:::
## 约束与边界
- **聚合必带 `num_value IS NOT NULL`**:见上文 danger,这是数值统计正确性的硬前提。
- **`PointValue` ≠ `PointValueBO`**:跨层时别把驱动发送 bean 当成业务对象互用,二者字段集与序列化场景不同。
- **消费者并发是默认档**:位号队列当前跑在默认监听工厂(prefetch=10、并发 2–8);高吞吐工厂存在但未启用,需要时显式 opt-in。
- **租户隔离在接口层强制**:读接口经 `PointValueQuery` 带租户上下文,数据中心取数后由控制器层 `requireTenant` /
`filterTenant` 校验,跨租户访问取不到数据。
- **死信不等于丢失**:超时或被 reject 的值进 `dc3.e.point_value_dead`,排障时去死信队列找。
## 延伸阅读
- [命令平面](./command-plane) — 反向的读写命令如何下发、回执与查询状态
- [领域模型](./domain-model) — Point / PointValue 的 DO/BO/VO 分层与字段细节
- [告警与通知](../operation/alarms) — 落库后告警规则如何评估、通知如何投递
- [时序数据与流处理](../foundations/data-pipeline) — 时序数据库与流处理的通用原理
---
# 领域模型:DO / BO / VO 与对象关系
URL: https://docs.dc3.site/zh/architecture/domain-model
# 领域模型:DO / BO / VO 与对象关系
这页写给要在平台上写代码的人:理清 Profile / Point / Command / Event / Device / Driver
这几个对象怎么挂在一起,搞懂最容易踩坑的"三层配置"(Param / Attribute / Config),并掌握同一份数据在 DO、BO、VO 三层之间如何用
MapStruct `*Builder` 来回转换。读完你就能正确地新增字段、加枚举、读懂任意一个 `*Controller → *Service → *Manager` 调用链里值的形态。
> 你在这里:已经看过 [核心概念](../introduction/concepts)
> 的对象关系,现在往下钻一层看字段与分层。下一步可看 [数据平面](./data-plane)
> (位号值的落库链路)或 [驱动开发](../development/driver-authoring)(把这些对象落成一个真实驱动)。
## 一切始于 Profile
IoT DC3 的领域模型有一个根:**模板 Profile**。它不是一台设备,而是"一类设备的能力清单"——这类设备有哪些可读写的**位号 Point
**、支持哪些自定义**命令 Command**、会上报哪些**事件 Event**。把能力沉淀在模板上,设备只要绑定模板就自动继承这套能力,无需逐台重复定义。
**设备 Device** 是现场一台具体设备在平台里的镜像。它做两件绑定:绑一个 Profile(决定"有哪些能力"),绑一个 **驱动 Driver**
(决定"用什么协议通信")。这里有一个 Phase-1 之后定下的硬约束:`DeviceDO.profileId` 是**单一外键**(一个 `Long`),不再是早期的多对多
`ProfileBind`——每台设备**只能绑一个模板**。
::: danger 设备与模板是一对一,别再按多对多设计
`dc3_device.profile_id` 是单值外键(`DeviceDO.java`)。新增涉及"设备的能力来源"的查询时,按"设备 → 唯一 Profile"
来写,不要假设一台设备能挂多个模板。
:::
位号是数据的最小单位。它的两个关键标志位决定了它能干什么:
- `pointTypeFlag`(`PointTypeEnum`)——值的数据类型。
- `rwFlag`(`RwTypeEnum`)——读写方向。**一个位号能不能写,由它自己的 `rwFlag` 决定,而不是命令表。** 试图写一个 `READ_ONLY`
位号会在命令校验阶段被拒。
位号还带工程量信息:`unit`(单位)、`valueDecimal`(小数精度,默认 `6`)、以及线性换算 `baseValue` / `multiple`
——把驱动采到的原始值变换成工程值(`工程值 = 原始值 × multiple + baseValue` 的语义由驱动实现)。
::: info 位号类型枚举其实有 8 个,不止 4 个
`introduction/concepts` 和 Add Point 的 API 表为了好懂,只列了 `STRING / INT / FLOAT / DOUBLE`。源码里 `PointTypeEnum` 实际有
8 个值:`STRING(0) / BYTE(1) / SHORT(2) / INT(3) / LONG(4) / FLOAT(5) / DOUBLE(6) / BOOLEAN(7)`(`PointTypeEnum.java`)。
`rwFlag` 对应 `RwTypeEnum`:`READ_ONLY(0) / WRITE_ONLY(1) / READ_WRITE(2)`。以代码为准。
:::
### 领域实体关系
下图把根对象 Profile、它的三类子能力、设备与驱动的绑定,以及"三层配置"
里的属性/配置关系画在一起。它比 [核心概念](../introduction/concepts) 里的那张更全——多出了协议层 `*Attribute` 与实例层
`*AttributeConfig`。
`profileShareFlag`(`ProfileShareTypeEnum`:`TENANT / DRIVER / USER`)控制模板的共享范围;`Event` 的 `event_type_flag`(
`0=info / 1=alert / 2=fault / 3=lifecycle`)是事件**定义**上的分类,存在 `dc3_event` 表(管理域,由 `04-iot-dc3-manager.sql`
建)。这点容易和告警混淆,下文专门讲。
## 三层配置:Param、Attribute、Config 各管一摊
这是领域模型里最容易混的地方。平台把"配置"拆成**三个作用域完全不同**的层,每层回答不同的问题、由不同的人/流程产生、对应不同的
DO 类:
- **Param(业务层)** —— `CommandParamDO` / `EventParamDO`。它描述模板里一个命令/事件的输入输出参数,是**业务语义**,和具体协议无关。
- **Attribute(协议层)** —— `DriverAttributeDO` / `PointAttributeDO` / `CommandAttributeDO` / `EventAttributeDO`。它由驱动在
**启动时注册**:驱动读自己的 `application.yml`,告诉管理中心"我这个协议需要哪些配置项"。比如 Modbus 驱动会声明"
位号需要一个寄存器地址"——这是 Attribute,定义的是**有哪些项**,不含值。
- **Config(实例层)** —— `PointAttributeConfigDO`(以及 `DriverAttributeConfigDO` / `CommandAttributeConfigDO` /
`EventAttributeConfigDO`)。它存的是**这台设备**为那些属性填的**具体值**。`PointAttributeConfigDO` 的核心字段就是
`attributeId`(指向哪个属性)+ `deviceId` + `pointId` + `configValue`(填的值)。比如"3 号设备的温度位号,寄存器地址是
40001"——`40001` 就落在这里。
一句话区分:**Attribute 说"有这个坑",Config 说"这个坑填什么"。**
理解这层映射,才能看懂 [设备接入](../operation/device-onboarding) 里"配置位号属性"那一步,以及
`POST /api/v3/manager/point_attribute_config/add`(请求字段正是 `attributeId` / `deviceId` / `pointId` / `configValue`
)到底在写什么。
## 同一份数据的三种形态:DO / BO / VO
一个领域对象在系统里有三种长相,对应三个层、三种关注点。以位号为例:`PointDO` / `PointBO` / `PointVO`。
- **DO(`*DO`,如 `PointDO`)—— 数据库形态。** 它是 `dc3_point` 表的镜像:标志位是裸 `Byte`(`pointTypeFlag`、`rwFlag`、
`enableFlag` 都是 `Byte`),带 MyBatis-Plus 注解 `@TableName` / `@TableId(type = ASSIGN_ID)`(Snowflake ID)/
`@TableLogic`(逻辑删除 `deleted`)/ JSON 扩展用 `JacksonTypeHandler`。DO 只在持久层出现,**裸 `Byte` 标志位不允许泄漏到业务层或对外响应
**。
- **BO(`*BO`,如 `PointBO`)—— 业务形态。** 同样的标志位在这里是**域枚举**:`pointTypeFlag` 是 `PointTypeEnum`、`rwFlag` 是
`RwTypeEnum`、`enableFlag` 是 `EnableFlagEnum`。BO 继承 `BaseBO` 并实现 `TenantOwned`(携带 `tenantId`
,租户隔离的起点)。业务代码、Service 之间传的是 BO,不是 VO。换算字段在 BO 里用 `BigDecimal`(`baseValue` / `multiple`),到
DO 落库时是 `Double`——精度边界也由 `*Builder` 处理。
- **VO(`*VO`,如 `PointVO`)—— API 形态。** Controller 的请求/响应用 VO。它和 BO 一样用域枚举,除非为兼容旧客户端要保留原始数值。
下图是三层在调用链里的站位,以及 MapStruct `*Builder` 负责的转换方向。
`PointController` 收到 `PointVO` 后用 `PointBuilder.buildBOByVO()` 转成 `PointBO` 交给 `PointService`;Service 用
`buildDOByBO()` 转成 `PointDO` 交给 `PointManager` 落库;读取时反向走 `buildBOByDO()` → `buildVOByBO()`。`select*` 这类原始
Mapper 方法只在 `*ManagerImpl` 里出现,Service / Controller 一律用 `get*` / `list*` / `add` / `update` / `delete`
(见 [API 文档](../development/api-documentation) 的 CRUD 动词约定)。
## 枚举与 JSON 扩展:`@AfterMapping` 是关键
MapStruct 能自动映射同名同类型字段,但 `Byte ↔ 域枚举`、`JSON 字符串 ↔ 扩展对象`这两类转换它不会,得在 `*Builder` 的
`@AfterMapping` 钩子里手写。这正是 DO/BO/VO 分层不"漏"的地方。
枚举两端的契约很固定:DO 里存的 `Byte` 就是枚举上 `@EnumValue` 标注的 `index`;DO→BO 用 `XxxEnum.ofIndex(byte)`
把数值变枚举,BO→DO 用 `enum.getIndex()` 取回数值。下面这条时序就是 `PointBuilder` 里读一行位号时真实发生的事:
对应到 `PointBuilder.java` 里的真实代码:`buildBOByDO` 上把 `pointTypeFlag` / `rwFlag` / `enableFlag` 标了
`@Mapping(ignore = true)`,再在 `@AfterMapping` 里逐个 `RwTypeEnum.ofIndex(entityDO.getRwFlag())` 赋回;反方向
`buildDOByBO` 的 `@AfterMapping` 用 `Optional.ofNullable(rwFlag).ifPresent(v -> entityDO.setRwFlag(v.getIndex()))`。
`null` 安全是显式处理的——枚举为空就不写,不会抛 NPE。
JSON 扩展同理。`PointDO.pointExt` 是 `JsonExt`(`content` 存成 JSON 字符串,配 `JacksonTypeHandler` 落库),到 BO 是强类型的
`PointExt`。`@AfterMapping` 里 DO→BO 用 `JsonUtil.parseObject(content, PointExt.Content.class)` 反序列化,BO→DO 用
`JsonUtil.toJsonString(...)` 序列化。所有扩展对象都带 `BaseExt` 的三件套字段:`type`(解析时识别子类型)、`version`(乐观锁,默认
`1`)、`remark`。
::: tip 加一个带枚举或 JSON 扩展的新字段时
1. DO 加 `Byte` 字段 + `@TableField`;BO/VO 加对应的**枚举**字段。
2. 在 `*Builder` 上对该字段加 `@Mapping(target = "xxx", ignore = true)`(DO↔BO 两个方向都要)。
3. 在两个 `@AfterMapping` 里补 `ofIndex` / `getIndex` 转换;JSON 扩展则补 `parseObject` / `toJsonString`。
漏掉第 2、3 步时 MapStruct 会因类型不匹配编译失败或静默丢值——改完务必
`mvn -s .mvn/settings.xml -q -DskipTests compile` 兜底。
:::
## 枚举命名:看后缀就知道语义
平台的标志位枚举用后缀编码语义,三类各有约定:
| 后缀 | 语义 | 例子 |
|---------------|--------|-----------------------------------------------------|
| `*FlagEnum` | 0/1 开关 | `EnableFlagEnum`(`ENABLE(0)` / `DISABLE(1)`) |
| `*StatusEnum` | 状态机 | `PointCommandStatusEnum`(`PENDING → SENT → ...`) |
| `*TypeEnum` | 分类集合 | `PointTypeEnum`、`RwTypeEnum`、`ProfileShareTypeEnum` |
::: warning `EnableFlagEnum` 的 0 是"启用",不是"禁用"
`ENABLE` 的 index 是 `0`、`DISABLE` 是 `1`(`EnableFlagEnum.java`)。很多人直觉里 0 是 false/关,这里反了。读 SQL 时
`enable_flag = 0` 表示**启用**。
:::
## dc3_event 是定义,dc3_entity_alarm 是实例
最后一个易混点,也是领域建模里"模型 vs 运行实例"的典型分界:
- `dc3_event`(管理域,`04-iot-dc3-manager.sql`)—— 事件**定义**。它挂在 Profile 下,描述"这类设备会上报哪种事件",带
`event_type_flag`(`0=info / 1=alert / 2=fault / 3=lifecycle`)。这是模板能力的一部分,和 Point、Command 平级。
- `dc3_entity_alarm`(数据域,`03-iot-dc3-data.sql`)—— 运行期**告警实例**。它是规则引擎、状态超时、设备/驱动/事件上报等多个来源在运行时产生的记录,按
`alarm_source_flag` 区分来源。
换句话说:`dc3_event` 回答"这设备**能**报什么",`dc3_entity_alarm` 回答"现在**报了**什么"。两张表在不同 schema、不同 init
脚本里,新增查询时别把它们当一回事。告警与事件的完整模型(规则、通知通道、状态跟踪)见 [告警与通知](../operation/alarms)。
## 延伸阅读
- [核心概念](../introduction/concepts) — 没读过先补这张更简的对象关系图与一句话心智模型
- [数据平面](./data-plane) — `PointValue` 从 `ReadPointValue` 到 `PointValueDO` 的逐层变换与落库
- [驱动开发](../development/driver-authoring) — 驱动如何注册 Attribute、把领域对象落成一个真实协议适配
- [API 文档](../development/api-documentation) — `get`/`list`/`add`/`update`/`delete` 动词约定与 OpenAPI
- [告警与通知](../operation/alarms) — `dc3_entity_alarm` 的来源、规则与通知链路
---
# Facade 模式:grpc 与 local
URL: https://docs.dc3.site/zh/architecture/facade-modes
# Facade 模式:grpc 与 local
中心服务之间相互调用(数据中心问管理中心要设备、智能中心问数据中心要位号值)有两种装配方式:`grpc`(各服务独立进程、跨进程调用)和
`local`(所有中心合一进程、进程内直调)。这页讲清 `dc3.facade.mode` 这个开关切的是**部署拓扑**而不是传输协议,以及业务代码为什么完全不用跟着改。
> 你在这里:已读过 [系统架构总览](./) 与 [服务与拓扑](./services),想理解中心之间到底怎么互联。
## 它是部署拓扑开关,不是协议选择
容易误读的地方先说清:`dc3.facade.mode` 不是"用 gRPC 还是用 REST"的传输协议二选一,而是"中心服务**分几个进程跑**"的拓扑选择。
业务代码从不直接调对方的 gRPC stub 或 protobuf 类,而是依赖一组**协议中立的 `*Facade` 接口**——契约定义在
`dc3-common-facade-api`(如 `DeviceFacade`、`PointValueFacade`、`TenantFacade`、`PermissionFacade` 等 16 个接口)。每个接口都有两套实现,按
`dc3.facade.mode` 的值在启动时择一装配:
- `grpc` 模式装配 gRPC 实现(`dc3-common-facade-grpc`,如 `DeviceGrpcFacade`)——发起一次跨进程 gRPC 调用,去找独立运行的目标中心。
- `local` 模式装配进程内实现(`dc3-common-facade-local-*`,如 `DeviceLocalFacade`)——直接调用本进程里的目标 Service,*
*没有网络开销**。
`DeviceFacade` 接口自己的 Javadoc 就把这件事说明白了:
> `DeviceLocalFacade` — in-process call into `DeviceService`, selected when `dc3.facade.mode=local` (single deployment).
> `DeviceGrpcFacade` — gRPC call against Manager Center, selected when `dc3.facade.mode=grpc` (distributed deployment,
> default).
两套实现接同一个接口、返回同样的 BO/Page 类型,所以**切换模式不改一行业务代码**——只换被注入的那个 Bean。
## 两种模式如何装配
装配靠 Spring Boot 的 `@ConditionalOnProperty`。gRPC 自动配置在 `dc3.facade.mode=grpc` 或该属性缺省时生效(
`matchIfMissing = true`);local 自动配置只在 `dc3.facade.mode=local` 时生效。同一个 `*Facade` 接口,最终被装配成哪一套实现,完全由这个开关决定。
图里 `9400` 是管理中心的 gRPC 端口(grpc 模式下数据中心要跨进程访问它);local 模式下管理中心的 `DeviceService` 和调用方在同一个
JVM 里,`DeviceLocalFacade` 直接方法调用,连端口都不需要。
::: info 接口与实现的命名对应
`facade-api` 里的接口名是 `*Facade`(如 `DeviceFacade`);gRPC 实现统一加 `Grpc` 中缀(`DeviceGrpcFacade`),进程内实现加
`Local` 中缀(`DeviceLocalFacade`)。看到 `*GrpcFacade` 就知道是跨进程那套,看到 `*LocalFacade` 就知道是进程内那套。
:::
## 何时用哪种
选择标准很直接——你打算把中心服务跑成几个进程:
| 维度 | `grpc`(默认) | `local` |
|------|-------------------|--------------------------|
| 部署形态 | 各中心独立进程,分布式 | 所有中心合一进程,单体 |
| 调用方式 | 跨进程 gRPC | 进程内方法调用,无网络开销 |
| 适用 | 分布式部署、需要水平扩展、生产 | 本地开发、小型单机、调试 |
| 典型搭配 | 完整 compose 栈(多服务) | `dc3-center-single` 单体服务 |
分布式部署或要按服务独立扩缩容时用 `grpc`:各中心是独立的 Spring Boot 服务,可以各自伸缩、各自重启。本地开发、小型单机或调试时用
`local`:配合把四个中心合一的 `dc3-center-single` 单体服务,省去起多个进程和它们之间的网络往返,启动快、断点直达。
::: tip 怎么选
- 你在本地起整套来开发/调 bug,或只想要一个最小可跑的单机 → 用 `local`,跑 `dc3-center-single`。
- 你要做分布式部署、按中心独立扩缩容,或这是生产环境 → 用 `grpc`(即默认),各中心独立成进程。
- 拿不准就用默认 `grpc`:它是分布式 env 的既定值,单体场景才需要显式切到 `local`。
:::
## 默认值在哪里、谁覆盖谁
默认值随**部署形态**而不同,这点要看准,否则容易被某个 `application.yml` 里的字面值误导:
- **分布式中心**(如管理中心):`application.yml` 写的是 `dc3.facade.mode: ${DC3_FACADE_MODE:grpc}`,且分布式编排把环境变量
`DC3_FACADE_MODE=grpc` 显式设上(见 `.env.example` 与 `dc3/env/dev.env`)。所以分布式默认就是 `grpc`。
- **单体服务** `dc3-center-single`:`application.yml` 默认 `dc3.facade.mode: ${DC3_FACADE_MODE:local}`——单体跑在一个进程里,自然走进程内
facade。
::: warning Auth 中心的 application.yml 写着 local,别被误导
鉴权中心 `dc3-center-auth` 的 base `application.yml` 里 `dc3.facade.mode` 直接写成 `local`(一个本地覆盖项)。但**分布式部署下
**,编排注入的 `DC3_FACADE_MODE=grpc` 会覆盖它——Auth 在分布式里实际跑的是 `grpc`。判断某个服务实际用哪种模式,**以注入的环境变量为准
**,不要只看某个 yml 里的字面值。
:::
切换模式只需改这一个开关,业务代码与接口签名都不动:
::: code-group
```bash [环境变量]
# 分布式(默认):各中心独立进程,跨进程 gRPC
DC3_FACADE_MODE=grpc
# 单体:所有中心合一进程,进程内直调
DC3_FACADE_MODE=local
```
```yaml [application.yml]
dc3:
facade:
mode: ${DC3_FACADE_MODE:grpc} # 分布式中心默认 grpc
# mode: ${DC3_FACADE_MODE:local} # 单体 dc3-center-single 默认 local
```
:::
## 约束与边界
- `grpc` 与 `local` 是同一组 `*Facade` 接口的两套实现,**不是**两种传输协议;`local` 不是"facade 走 REST"这一档。环境变量页把
`DC3_FACADE_MODE` 称作"facade 协议模式",但它实际切的是装配哪套实现、即部署拓扑,以代码的 `@ConditionalOnProperty` 实现为准。
- 缺省即 `grpc`:gRPC 自动配置带 `matchIfMissing = true`,不显式配置时默认装配 gRPC 实现。
- `local` 模式要求被调用的目标 Service 必须在同一进程内——它是为 `dc3-center-single` 这类合一进程设计的;把分散的多个中心进程设成
`local` 找不到对端 Service。
- 切换模式不改业务代码,但会改变运行拓扑与故障域:`grpc` 下一个中心崩了不拖垮其它中心,`local` 下它们共享同一个 JVM 进程。
## 延伸阅读
- [服务与拓扑](./services) — 六个服务、端口、gRPC 端口与启动依赖顺序
- [系统架构总览](./) — 网关 + 四中心 + 驱动如何协作的全景
---
# 系统架构总览
URL: https://docs.dc3.site/zh/architecture/
# 系统架构总览
IoT DC3 把"采集—归一—分析—执行—反馈"
的闭环落成一套分层、多租户的微服务架构:北向只暴露一个网关入口,四个中心服务各管一段链路,南向由协议驱动接入现场设备。这页先用一张分层图建立全局心智模型,再逐一讲清四个关键设计各解决什么问题、如何协作,最后导向每条链路的深度子页。
> 你在这里:已读过 [平台定位](../introduction/) 与 [核心概念](../introduction/concepts)
> ,现在把闭环拆成可落地的分层结构。下一步可深入任一平面(数据 / 命令 / 鉴权 / 领域模型)。
## 产品架构全景
下面这张全景图把六个分层、四个中心服务的端口、消息总线交换机与可选运维栈一次铺开——先建立"一图看全"
的整体印象,再往下看每条链路的逻辑细化。图会随站点明暗主题自适应。
## 四层参考架构映射
IoT 业界标准的四层参考架构——感知层、网络层、平台层、应用层——外加贯穿四层的安全——DC3 的每一个组件都能在这张
图上对号入座。这张图帮助你快速理解 DC3 站在 "从传感器到 AI 运营" 完整地图的哪个位置。
图例颜色:紫色=应用层 · 绿色=平台层 · 橙色=网络层 · 青色=感知层 · 琥珀色=安全。
| 层级 | IoT 参考职责 | DC3 落地 |
|----------|---------------------------|----------------------------------------------------------------------------------|
| **应用层** | 运营、告警、数据分析、AIoT、第三方系统集成 | Web 控制台、开放 API、dc3 CLI、Agentic Center、MCP 工具与告警分析 |
| **平台层** | 设备管理、数据存储、规则计算、身份认证、业务编排 | Gateway、Auth / Manager / Data / Agentic 四个中心服务、PostgreSQL、TimescaleDB、领域模型与命令状态机 |
| **网络层** | 现场总线、IoT 协议、无线 / WAN、消息传输 | 28 个协议驱动、RabbitMQ 异步消息总线、gRPC facade、南向读写命令通道 |
| **感知层** | 传感测量、自动识别、执行器、现场设备与数据源 | Profile / Device / Point 将物理设备、测点和原始信号归一为平台可理解的语义数据 |
| **横切安全** | 身份、授权、租户隔离、传输与调用可信 | JWT、RBAC、tenantId 全链路传播、HMAC 网关签名、TLS / 密钥配置与审计日志 |
读这张图时要注意:四层参考架构是**职责视角**,不是进程部署图。比如 Gateway 同时承担北向入口和平台治理职责;RabbitMQ
既服务网络层的协议解耦,也支撑平台层的数据削峰;Agentic Center 属于应用层能力,但它通过平台层的鉴权、命令和数据平面安全落地。更多
IoT 四层参考架构的体系化讲解,见 [物联网技术总览](../foundations/)。
## 三层结构:接入、平台、存储与消息
平台不是一个大单体,而是按职责切开的一组服务。从调用方视角看进去,请求只有一个入口——网关 `dc3-gateway`(HTTP `8000`),它是唯一对外的
HTTP 端口;其余中心服务的 HTTP/gRPC 端口都只在内部网络可达。网关把请求路由到四个中心服务,它们彼此之间不走 HTTP,而是通过
gRPC facade 跨进程协作。
南向是另一套节奏:现场设备由协议驱动(`dc3-driver-*`,共 28 个)接入,驱动与数据中心之间**不直接调用**,而是经 RabbitMQ
异步收发——位号值往北上行、命令往南下行。所有持久化最终落到 PostgreSQL,其中时序数据(位号值历史)由 TimescaleDB 超表承载。
这张图的"虚线"是 gRPC facade 调用、"双向实线"是 RabbitMQ
异步收发——两种连接方式的差别,正是下面四个设计要解释的重点。各服务的端口、启动顺序与健康检查见 [服务与拓扑](./services)。
::: info 单体与分布式两种部署形态
上图是默认的分布式形态(四个中心独立进程)。平台也支持把中心合并为单一进程(`dc3-center-single`
)跑在一台机器上——这只是部署拓扑的选择,不改变业务链路。切换由 `DC3_FACADE_MODE` 决定,详见 [Facade 模式](./facade-modes)。
:::
## gRPC facade:中心之间如何互相调用
四个中心服务需要频繁互相取数——比如数据中心下发命令前要向管理中心确认设备、位号是否存在且启用。如果让业务代码直接拼 HTTP
URL 去调对方,服务边界会被传输细节污染,单体/分布式两种部署也无法共用同一套代码。
IoT DC3 的解法是 **facade 接口**:跨服务调用统一面向 `dc3-common-facade-api` 里的契约接口编程,业务代码只认接口、不认传输。运行时由
`DC3_FACADE_MODE` 决定接口背后的实现:
- `grpc`(分布式默认)—— 实现来自 `dc3-common-facade-grpc`,调用走 gRPC 跨进程到目标中心;
- `local`(单进程)—— 实现来自 `dc3-common-facade-local-*`,调用在进程内直接方法调用,不过网络。
也就是说,"分布式还是单体"是一个部署开关,而非两套代码。同一份业务逻辑,换 `DC3_FACADE_MODE` 即可在两种形态间切换。
::: warning facade 模式的默认值要看清
分布式各中心默认 `grpc`:管理中心 `application.yml` 声明 `dc3.facade.mode: ${DC3_FACADE_MODE:grpc}`,`dc3/env/dev.env` 也设
`DC3_FACADE_MODE=grpc`。鉴权中心基础 `application.yml` 里有一行 `dc3.facade.mode: local`,那是单进程场景的本地覆盖,不代表分布式默认是
local——以环境变量与 Manager 的声明为准。完整辨析见 [Facade 模式](./facade-modes)。
:::
## RabbitMQ 异步解耦:驱动与数据中心为什么不直连
位号值是高频、突发的——一个 Modbus 驱动一轮采集可能瞬间产出成百上千条值。如果驱动同步调用数据中心写库,任一环节变慢都会反压到采集线程,导致驱动掉线、数据丢点。命令下行同理:HTTP
请求不该一直阻塞等设备把寄存器写完。
所以驱动与数据中心之间隔着一层 RabbitMQ,把"产生"和"消费"在时间上解开:
- **上行(数据)**:驱动把采集结果发到 topic 交换机 `dc3.e.value`,路由键 `dc3.r.value.point.{驱动服务名}`,落到持久队列
`dc3.q.value.point`(7 天 TTL,配死信交换机 `dc3.e.point_value_dead`)。数据中心的 `PointValueReceiver` 异步消费、批量或即时落库。
- **下行(命令)**:命令经交换机 `dc3.e.point_command` 投递到对应驱动队列(30 秒 TTL + 死信),驱动执行后把结果回发到
`dc3.e.point_command_result`(60 秒 TTL),由数据中心的结果接收器回收。
这样位号写入永不阻塞采集,命令下发也立即返回 `commandId` 供轮询。消息采用持久投递 + 手动 ack + publisher
confirm,失败按重投/死信处理。完整的交换机、队列与回执链路见 [数据平面](./data-plane) 与 [命令平面](./command-plane)。
::: danger 写命令失败不回显伪造值
写命令只有当驱动的 `write()` 返回 `Boolean.TRUE` 才算成功;失败时结果 `responseValue=null`,**不会**回填任何"看起来成功"
的值。这是有意为之,避免假成功误导上层。命令 `PointCommandDTO.expireAt` 默认 `now + 10s`,超时由驱动在消费时判定为
`EXPIRED`。
:::
## 多租户隔离:tenantId 如何贯穿每一层
平台从设计上就是多租户的——隔离在接口层强制,`tenantId` 沿着"网关 → 中心服务 → gRPC 调用 → 缓存键"传递;单条按
ID、批量查询都经控制器层校验,跨租户访问被挡下。
落地上有几个强制点:
- **接口层(单条按 ID)**:`BaseController.requireTenant()` 查到实体后比对 `tenantId`,跨租户访问返回 404(而非泄露数据)。
- **接口层(批量)**:`BaseController.filterTenant()` 在批量结果里剔除别家租户记录。数据库查询层当前不做自动租户裁剪(
`MybatisPlusConfig` 只注册了分页插件),隔离施加在控制器层。
- **跨服务**:gRPC facade 调用在契约支持时携带租户 ID,缓存键也带租户上下文。
这意味着写新查询、新 gRPC 调用、新缓存键时,都必须保留租户作用域——这是硬要求,不是可选优化。隔离怎么一层层落实、与 RBAC
如何配合,见 [鉴权 · 租户 · RBAC](./auth-rbac)。
## HMAC 网关签名:后端如何信任"调用方是谁"
网关在鉴权后,会把解析出的身份(租户、登录名、principal)打成 `X-Auth-Principal` JSON
头,转发给后端中心服务。后端据此做权限判定。问题是:后端凭什么相信这个头不是伪造的?毕竟绕过网关直接打内网端口也能构造一个假
principal 头。
解法是 **HMAC-SHA256 签名**。网关用密钥 `AUTH_HMAC_SECRET`(配置键 `dc3.auth.hmac.secret`)对 principal 内容签名,把签名放进
`X-Auth-Sign` 头;后端用同一密钥验签,验不过就拒绝。只有持有密钥的网关能签出有效请求,伪造的 principal 头会在后端被挡下。
::: danger 生产环境密钥必须改,否则启动即失败
`AUTH_HMAC_SECRET` 出厂默认值是 `io.github.pnoker.dc3`,仅供开发。当 Spring profile 含 `pre` 或 `pro`
时,若密钥为空或仍等于该默认值,服务会在启动时抛 `IllegalStateException` **fail-fast**,拒绝带着弱密钥上生产。
`DC3_SECURITY_KEY`(登录 Token 签名)同理必须换成环境专属的强随机值。
:::
## 一致性与可扩展性
四个中心服务本身**无状态**——会话、令牌denylist、最新值等热数据放在 Caffeine
缓存或数据库里,请求不黏在某个实例上。因此每个中心都可以水平扩展:在网关后面多挂几个同类实例即可分担负载,无需共享内存。
数据中心的吞吐瓶颈在消费侧,而消费并发是可调的:`PointValueReceiver` 用高吞吐监听容器消费 `dc3.q.value.point`
,按入站速率在"即时落库"与"`PointValueJob` 批量落库"之间切换;批量阈值由 `POINT_BATCH_SPEED`(默认 100 条)/
`POINT_BATCH_INTERVAL`(默认 5 秒)控制,谁先满足谁先刷盘。面对采集洪峰,先由 RabbitMQ 削峰,再靠并发消费与批量写入消化。
::: info 强一致与最终一致并存
租户隔离、权限判定、命令状态机这些在请求路径上的环节是强一致的(同步校验、即时拒绝);而位号值的上行落库是经 MQ
的最终一致——值进了队列即视为可靠交付,落库与告警评估异步完成。理解这条边界,有助于排查"命令已回执但历史查询还差一拍"
之类的时序问题。
:::
## 延伸阅读
- [服务与拓扑](./services) — 六个可部署单元、端口分配、启动依赖与健康检查
- [Facade 模式](./facade-modes) — `grpc` 与 `local` 的取舍,单体/分布式切换
- [数据平面](./data-plane) — 位号值从设备到 TimescaleDB 的每一跳与 MQ 拓扑
- [命令平面](./command-plane) — 读写命令的下发、生命周期状态机与回执
- [鉴权 · 租户 · RBAC](./auth-rbac) — 网关签名、令牌签发、权限解析与租户穿透
- [领域模型](./domain-model) — Profile / Point / Device 的字段与 DO/BO/VO 分层
- [模块地图](./modules) — Maven 模块结构、28 个驱动与依赖关系
---
# 模块地图
URL: https://docs.dc3.site/zh/architecture/modules
# 模块地图
IoT DC3 的代码按"部署单元 + 共享契约 + 协议驱动"三类组织。这页从架构视角讲清楚:哪些模块会被打包成独立服务跑起来、它们靠哪些公共库与契约相互协作、28
个驱动如何按协议归类,以及驱动 SDK 暴露的 SPI 长什么样。读完你能定位任意一段功能落在哪个模块、它依赖谁。
> 你在这里:已读过 [系统架构总览](./) 与 [服务与拓扑](./services)
> ,想从模块/依赖角度看清边界。逐个模块的清单见 [模块清单](../modules/)。
## 三类模块,三种生命周期
不必把仓库里几十个 Maven 模块平铺记忆。它们只分三类,各有不同的存在理由:
- **部署单元**(`dc3-gateway`、`dc3-center-*`、`dc3-driver-*`)——会被打成可运行的 Spring Boot 进程、出现在 compose
文件里、占一个端口。这是运维和拓扑关心的粒度。
- **公共与契约库**(`dc3-api-*`、`dc3-common-*`)——不单独运行,被部署单元依赖。它们承载"服务之间怎么说话"(gRPC 契约、facade
接口)和"大家共用什么"(实体、枚举、DAL、消息配置)。
- **协议驱动**(`dc3-driver-*`)——一种特殊的部署单元:每个驱动是一个独立进程,但都站在同一个 SDK(`dc3-common-driver`
)之上,只填协议适配那一小块。
这三类的依赖方向是单向的:驱动与中心依赖公共库,公共库依赖契约库,契约层不反向依赖任何业务。下面先看这张依赖图,再逐类展开。
## 模块如何相互依赖
下图省略了基础设施(PostgreSQL / RabbitMQ)和逐个 common 子模块,只画"谁依赖谁"的骨架:网关与四个中心都建立在各自的
`dc3-common-*` 领域库上,跨服务调用统一走 facade 契约,驱动则通过 facade 把元数据请求打到管理中心、通过 RabbitMQ
与数据中心交换值和命令。
注意 facade 被画成一个被多方依赖的中间层——业务代码只编译期依赖 `dc3-common-facade-api` 里的接口,运行时由 `grpc` 或
`local` 实现注入,调用方对传输方式无感。这个"三态"是 IoT DC3
既能拆成分布式、又能合并成单体的关键,详见 [Facade 模式](./facade-modes)。
## 部署单元:网关、四中心与驱动
会被打包成进程跑起来的模块如下。端口与对外暴露策略以 compose 为准:只有网关的 HTTP `8000` 对外,其余中心的 HTTP/gRPC
端口都是集群内部端口。
| 部署单元 | 角色 | HTTP | gRPC | 对外 |
|----------------------|----------------------------------------|--------|--------|-----|
| `dc3-gateway` | 唯一对外 HTTP 入口、认证透传、MCP 资源服务器 | `8000` | — | 是 |
| `dc3-center-auth` | 认证 / 租户 / RBAC / OAuth 2.1 | `8300` | `9300` | 否 |
| `dc3-center-manager` | 驱动 / 模板 / 设备 / 位号等元数据管理 | `8400` | `9400` | 否 |
| `dc3-center-data` | 位号值落库、命令分发与回执、告警 | `8500` | `9500` | 否 |
| `dc3-center-agentic` | LLM 会话、工具调用、记忆 | `8600` | — | 否 |
| `dc3-center-single` | auth + manager + data 合并的单体(本地 facade) | `8100` | `9100` | 视部署 |
| `dc3-driver-*` | 协议适配(28 个独立进程) | 各自 | — | 仅个别 |
`dc3-center-single` 把三个中心合进一个进程,用 `local` facade 在进程内直连,适合本地开发或资源受限的小规模部署;它和四中心分布式版本共享同一套
`dc3-common-*` 领域库,区别只在 facade 实现和打包方式。
::: info 智能中心没有 gRPC 端口
`dc3-center-agentic` 只暴露 HTTP(`8600`),不开 gRPC 服务端口——它作为 facade 的调用方去访问其他中心,自身不被其他中心通过
gRPC 反向调用。
:::
::: tip 驱动里只有少数对外暴露端口
绝大多数驱动是"主动出击"型:定时轮询设备、把值推到 RabbitMQ,不需要监听入站端口。例外是 `dc3-driver-listening-virtual`
这类反向接入驱动,它监听 TCP `6270` / UDP `6271`,让外部系统主动把数据推进来——这两个端口因此被映射到宿主机。
:::
## 公共与契约:服务之间怎么说话
部署单元之所以能各管一摊又协同工作,靠的是下面这层不单独运行的库。它们回答两个问题:**跨进程怎么传**(契约层)和**大家共用什么
**(公共层)。
**契约层 `dc3-api-*`** 是 protobuf / gRPC 的合约定义——`dc3-api-auth`、`dc3-api-data`、`dc3-api-driver`、`dc3-api-manager`
各自描述对应中心对外暴露的 RPC。改 proto 等于改服务间合约,需重新生成桩并跑契约测试。
**facade 三态** 是这页最该理解的一组模块,它把"调用哪个服务"和"用什么传输"解耦成三个职责清晰的模块:
| 模块 | 职责 | 何时生效 |
|-----------------------------------------------|-------------------------------|-------------------------------|
| `dc3-common-facade-api` | 定义跨服务调用的 Java 接口(业务代码只依赖它) | 始终 |
| `dc3-common-facade-grpc` | 接口的 gRPC 实现,底层走 `dc3-api-*` 桩 | `dc3.facade.mode=grpc`(分布式默认) |
| `dc3-common-facade-local-{auth,manager,data}` | 接口的进程内直连实现 | `dc3.facade.mode=local`(单体) |
控制器和 service 永远只 `@Autowired` `dc3-common-facade-api` 里的接口,永不直接绑定 gRPC
桩或某个具体服务——这正是 [Facade 模式](./facade-modes) 能在不改业务代码的前提下切换部署拓扑的原因。
**公共层 `dc3-common-*`** 是跨服务复用的基础设施与领域库,按职责分四组:
- 基础:`dc3-common-constant`(枚举、常量,如 `PointCommandTypeEnum`)、`dc3-common-model`(BO / VO / DTO,如
`PointCommandDTO`)、`dc3-common-exception`、`dc3-common-public`(`R` 响应封装)、`dc3-common-web`、`dc3-common-log`、
`dc3-common-thread`。
- 数据访问:`dc3-common-dal`(MyBatis-Plus 基础能力,数据访问与查询封装)、`dc3-common-postgres`(多 schema 数据源)、
`dc3-common-repository`(仓储抽象与位号值领域对象,如 `PointValueBO`)、`dc3-common-sql`。
- 通信:`dc3-common-rabbitmq`(交换机 / 队列配置,如 `dc3.e.value`)、`dc3-common-mqtt`。
- 领域:`dc3-common-{auth,manager,data,driver,gateway,agentic}`,每个对应一个部署单元的业务逻辑。例如 `dc3-center-manager`
这个进程几乎只是 `dc3-common-manager` 的运行外壳。
::: info 运行时缓存用 Caffeine,不是 Redis
最新值缓存、Token 拒绝名单、权限缓存等都用进程内 Caffeine(如 `PointValueLocalCache`),不依赖独立的 Redis 基础设施。
:::
## 驱动按协议归类
28 个驱动是平台"协议广度"的载体。把它们按协议家族分组,比平铺一长串更容易找到你需要的那个。每个驱动是一个
`dc3-driver-` 模块,都继承同一个 SDK,差异只在协议适配实现。
| 类别 | 代表驱动 | 说明 |
|--------------|------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
| 工业现场总线 / PLC | `dc3-driver-modbus-tcp`、`dc3-driver-opc-ua`、`dc3-driver-plcs7`、`dc3-driver-iec104` | 工厂与电力 SCADA 现场最常见的一组;另含 modbus-rtu、opc-da、ethernet-ip、fins、melsec、bacnet-ip、sl651 |
| IoT 无线 | `dc3-driver-mqtt`、`dc3-driver-coap`、`dc3-driver-lwm2m`、`dc3-driver-http` | 轻量级 / 受限设备;另含 ble、zigbee |
| 基础通信 | `dc3-driver-tcp-udp`、`dc3-driver-serial`、`dc3-driver-snmp`、`dc3-driver-can` | 裸 socket、串口、网络管理与车载总线 |
| 数据库桥接 | `dc3-driver-mysql`、`dc3-driver-postgresql` | 把外部数据库当数据源接入;另含 oracle、sqlserver |
| 计量 | `dc3-driver-dlms` | DLMS/COSEM 智能电表 |
| 仿真 | `dc3-driver-virtual`、`dc3-driver-listening-virtual` | 见下方说明 |
仿真这一类有两个角色完全不同的成员,别混淆:
- **`dc3-driver-virtual`** 是**驱动开发模板**。新写一个协议驱动就从它复制改名,它演示了 SDK
的完整用法(注册、调度、读写、健康)。[快速开始](../quickstart/) 跑通"第一个设备"用的就是它产生的合成值。
- **`dc3-driver-listening-virtual`** 是**反向监听接入**。它不主动轮询,而是监听 TCP/UDP
端口,等外部系统把数据推进来——适合那些"设备/系统主动上报"而非"平台主动采集"的场景。
::: danger `dc3.driver.code` 是稳定注册标识,不可随意改
每个驱动启动时用 `dc3.driver.code` 向管理中心注册;值/命令的 RabbitMQ routing key 则由驱动服务名 `dc3.driver.service`(
`driverProperties.getService()`)拼接派生——code 与 service 是 `DriverProperties` 上两个独立字段。改 code 等于换了注册身份、改
service 等于换了路由身份,都会让在途消息或注册信息找不到归属——除非配套迁移,否则不要改动已上线驱动的这两个值。
:::
## Driver SDK 的 SPI:一个聚合接口,七项契约
所有驱动共享的 SDK 在 `dc3-common-driver`。它面向驱动作者暴露的扩展点是 `DriverCustomService`——这个接口**自身不声明任何方法
**,只是把驱动通常要实现的 7 个能力接口聚合在一起。SDK 在需要"全部驱动钩子的并集"时注入它;而新驱动若只用到子集,也可以单独实现其中较小的几个接口。
七项契约各管一段驱动生命:`DriverLifecycle` 管启动初始化与调度注册;`DriverMetadataListener.event(...)` 收元数据变更刷新本地缓存;
`DriverHealth` 与 `DeviceHealth` 分别上报驱动级和设备级健康;`DriverProtocol` 是核心读写——`read(...)` 返回
`ReadPointValue`、`write(...)` 返回 `Boolean`;`DriverCommand` 处理自定义命令;`DriverValidator` 负责校验,其
`simulate(...)` 是一个**确定性**合成值生成器(输出稳定,区别于 virtual 驱动 `read()` 内用 `ThreadLocalRandom` 现场生成的随机值)。
::: danger 写命令失败不回显值
`DriverProtocol.write(...)` 只有返回 `Boolean.TRUE` 才算成功;失败时命令结果的 `responseValue` 为 `null`,**不回显任何写入值
**——这是故意设计,防止把失败误判为成功。
:::
::: tip 健康状态 TTL 必须大于采集周期
驱动上报健康状态带 TTL,这个 TTL 必须大于读调度周期(例如 30s cron 配 TTL ≥ 25s),否则设备会在两次心跳间被判离线而反复抖动。
:::
SDK 还内置了注册(`DriverRegisterService`,指数退避重试)、调度(`DriverScheduleService`,Quartz 驱动)、发送(
`DriverSenderService`,含 `pointValueSender` / `deviceStatusSender`
等)等运行时服务,驱动作者一般无需触碰。完整开发流程见 [驱动开发](../development/driver-authoring)。
## 与模块清单页的分工
这页讲的是**架构与依赖**:模块分几类、谁依赖谁、facade 三态怎么解耦、驱动怎么归类、SDK 暴露什么。如果你要的是**逐个模块的用途速查
**(每个 `dc3-common-*` / `dc3-api-*` 子模块一行说明),去 [模块清单](../modules/)——那页是参考资料,本页是心智模型。
## 延伸阅读
- [模块清单](../modules/) — 逐个子模块的用途速查表
- [服务与拓扑](./services) — 部署单元的端口、启动顺序与健康检查
- [Facade 模式](./facade-modes) — `grpc` 与 `local` 两态如何切换部署拓扑
- [驱动开发](../development/driver-authoring) — 从 virtual 模板派生新协议驱动的完整流程
---
# 服务与拓扑
URL: https://docs.dc3.site/zh/architecture/services
# 服务与拓扑
IoT DC3 不是一个单体进程,而是一组可独立部署、靠 gRPC 与 RabbitMQ 协作的服务。这页讲清平台由哪些可部署单元构成、它们之间怎么连线、以及为什么必须按某个固定顺序启动——读完你能看懂
`docker-compose.yml` 里的每一条 `depends_on`,也能自己排查"为什么网关起不来"。
> 你在这里:已读 [系统架构总览](./),想把"五个中心 + 驱动"
> 落到具体进程、端口和启动次序。下一步可看 [Facade 模式](./facade-modes)
> 理解服务间怎么调用,或直接 [快速开始](../quickstart/) 把这套栈跑起来。
## 为什么拆成这么多单元
把平台拆成网关、四个中心和一组驱动,不是为了"微服务而微服务"
,而是因为这几类职责的伸缩与故障边界天然不同:南向的协议驱动数量多、按现场扩,和北向的元数据管理完全两回事;鉴权是所有请求的必经关卡,应当独立且先就绪;时序数据写入吞吐高,需要单独的数据中心承接
RabbitMQ 消息洪峰。拆开之后,每一类单元可以单独扩容、单独重启、单独定位问题。
平台共有**六类可部署单元**,外加一个把全部中心打包进单进程的 single 单体:
- **网关 Gateway(`dc3-gateway`)**——唯一的对外 API 聚合入口,做鉴权头解析、HMAC 签名注入、路由转发,并承载 MCP 资源服务器
`/mcp`(app 栈下经 Web 前端反代触达,见下文端口小节)。
- **鉴权中心 Auth Center(`dc3-center-auth`)**——认证、租户、RBAC、OAuth 2.1 授权服务器。无业务依赖,最先就绪。
- **管理中心 Manager Center(`dc3-center-manager`)**——驱动、模板、设备、位号等元数据管理。
- **数据中心 Data Center(`dc3-center-data`)**——位号值落库、命令分发与回执、告警引擎。
- **智能中心 Agentic Center(`dc3-center-agentic`)**——Spring AI 会话、工具调用、对话持久化。
- **协议驱动 Drivers(`dc3-driver-*`)**——驱动目录共 28 个协议适配实现,`docker-compose.yml` 默认内置其中 22 个驱动容器(未内置
`ble`/`iec104`/`lwm2m`/`sl651`/`zigbee`/`can` 这 6 个,需要时自行启动对应容器);南向接设备、北向经 RabbitMQ
与数据中心解耦。
- **single 单体(`dc3-center-single`)**——把四个中心的能力合并进一个进程,用 `dc3.facade.mode: local`
在进程内直连,适合本地开发与轻量部署(见 [Facade 模式](./facade-modes))。
::: info 中心服务首次出现给"中文名 + 标识"
后文沿用术语表口径,"数据中心"即 `dc3-center-data`,"网关"即 `dc3-gateway`,不再重复全称。
:::
## 谁监听哪个端口
每个单元同时可能暴露一个 HTTP 端口(对外或对内的 REST 入口)和一个 gRPC 端口(中心之间 facade 调用)。**关键约束:网关是唯一的对外
API 聚合入口,但它的 HTTP `8000` 在 app 栈里并不发布到宿主机**——`docker-compose.yml` 只把 Web 前端 `8080/8443` 与
listening-virtual 驱动的设备入站口映射到宿主机,外部请求经 Web 前端的 nginx 反代到容器内的 `dc3-gateway:8000`。网关 `8000`
与其余中心的 HTTP 端口一样只在容器网络内可达;只有在 dev 栈(`docker-compose-dev.yml`)里网关 `8000`
才发布到宿主机(同时各中心端口也一并发布)。生产环境不应把后端端口映射到宿主机。
下图按"谁依赖谁先就绪"画出服务拓扑:实线箭头是 `depends_on` 健康依赖,标注的是各服务的 HTTP / gRPC 端口。
端口分配是有规律的:HTTP 端口 `83/84/85/86xx` 与 gRPC 端口 `93/94/95xx` 一一对应到 auth/manager/data。智能中心目前只暴露
HTTP `8600`。single 单体则独占 HTTP `8100` / gRPC `9100`(`DC3_SINGLE_PORT` / `DC3_SINGLE_GRPC_PORT`),与分布式栈端口不冲突,可在同机并存。
下表把上图的端口与对宿主机的发布情况固化为参考;写代码或配 Nginx 反向代理时以此为准。"对宿主机发布"一列指 app 栈(
`docker-compose.yml`)的实际 `ports:` 映射:
| 单元 | HTTP | gRPC | app 栈对宿主机发布 | 环境变量(发布端口) |
|-------------------------------|-----------------|--------|-------------------------------------------|----------------------------------------------|
| Web 前端 `dc3-web` | `8080` / `8443` | — | **是(app 栈唯一 HTTP 入口,nginx 反代到网关)** | `DC3_WEB_HTTP_PORT` / `DC3_WEB_HTTPS_PORT` |
| 网关 `dc3-gateway` | `8000` | — | 否(仅容器网络内可达;dev 栈才用 `DC3_GATEWAY_PORT` 发布) | `DC3_GATEWAY_PORT` |
| 鉴权中心 `dc3-center-auth` | `8300` | `9300` | 否 | `DC3_AUTH_PORT` / `DC3_AUTH_GRPC_PORT` |
| 管理中心 `dc3-center-manager` | `8400` | `9400` | 否 | `DC3_MANAGER_PORT` / `DC3_MANAGER_GRPC_PORT` |
| 数据中心 `dc3-center-data` | `8500` | `9500` | 否 | `DC3_DATA_PORT` / `DC3_DATA_GRPC_PORT` |
| 智能中心 `dc3-center-agentic` | `8600` | — | 否 | `DC3_AGENTIC_PORT` |
| single 单体 `dc3-center-single` | `8100` | `9100` | 视部署 | `DC3_SINGLE_PORT` / `DC3_SINGLE_GRPC_PORT` |
::: warning 后端 HTTP 端口在 app 栈里都不发布到宿主机
在 `docker-compose.yml`(app 栈)里,网关与 auth/manager/data/agentic 都**没有** `ports:` 映射——它们只在 `dc3net`
容器网络内可达。被映射到宿主机的只有 Web 前端 `8080/8443`(`DC3_WEB_HTTP_PORT` / `DC3_WEB_HTTPS_PORT`)和 listening-virtual
驱动的设备入站口 TCP `6270` / UDP `6271`(`DC3_LISTENING_VIRTUAL_TCP_PORT` / `..._UDP_PORT`)。因此在 app 栈下,从外部访问业务
API 要走 Web 前端 `8080`,由其 nginx 反代到容器内的 `dc3-gateway:8000`;宿主机无法直连 `8000`。只有 dev 栈(
`docker-compose-dev.yml`)才把网关 `8000`(及各中心端口)发布到宿主机,可直连。
:::
## 以什么顺序启动
服务之间有硬性的就绪顺序:网关要给请求注入鉴权信息,就得 auth 先在;data 落库和分发命令要先有 manager 的元数据;agentic
要读数据、调命令,就得 auth/manager/data 都在。这套顺序不是写在文档里靠人记,而是用 Compose 的
`depends_on: condition: service_healthy` 强制——**被依赖方健康检查通过,依赖方才启动**。
健康判定统一用各服务的 `/actuator/health/readiness`(readiness 探针)。注意中心服务的 readiness 路径带 base-path 前缀,网关不带:
- 网关:`http://127.0.0.1:8000/actuator/health/readiness`
- 鉴权中心:`http://127.0.0.1:8300/auth/actuator/health/readiness`
- 管理中心:`http://127.0.0.1:8400/manager/actuator/health/readiness`
- 数据中心:`http://127.0.0.1:8500/data/actuator/health/readiness`
- 智能中心:`http://127.0.0.1:8600/agentic/actuator/health/readiness`
下图按依赖链画出从基础依赖到驱动的整条就绪时序——每一跳都等上游 readiness 通过后才开始。
这张图也解释了一个常见现象:**驱动只依赖 manager**,不依赖网关或数据中心。驱动起来后向管理中心 gRPC
注册自己(带协议属性定义),再开始按计划采集、经 RabbitMQ 把位号值推给数据中心——所以驱动可以和网关并行启动,无需等网关就绪。
::: danger 基础依赖必须先于全部应用就绪
`docker-compose.yml`(应用栈)里的中心服务**不包含** PostgreSQL 与 RabbitMQ——它们在独立的 `docker-compose-db.yml`(db
栈)里,分别以 `pg_isready` 和 `rabbitmq-diagnostics ping` 做健康检查。应用栈假定这两者已经健康。因此正确的启动序列是**先起
db 栈、等其健康,再起应用栈**;跳过这一步,auth 会因连不上库反复重启。对应命令见下。
:::
## 把这套栈跑起来
实际操作分两步:先拉起 db 栈(PostgreSQL + RabbitMQ),再拉起应用栈。Makefile 把 Compose 细节包好了,命令在 `iot-dc3/` 目录下执行:
::: code-group
```bash [make(推荐)]
# 1. 先起基础依赖,等健康
make up-db
# 2. 起应用栈(构建镜像 + 按 depends_on 顺序启动)
make up STACK=app
# 跟随日志,确认各服务 readiness 依次通过
make logs
```
```bash [podman compose(底层)]
# db 栈:postgres + rabbitmq
podman compose -f dc3/docker-compose-db.yml up -d
# 应用栈:gateway + 四中心 + 驱动
podman compose -f dc3/docker-compose.yml up -d
# 校验 compose 语法
podman compose -f dc3/docker-compose.yml config --quiet
```
:::
起栈后确认整链就绪。注意 app 栈下网关 `8000` 不发布到宿主机,宿主机直连 `127.0.0.1:8000` 会连接失败——要么进网关容器内探(与容器
healthcheck 同址),要么从宿主机走 Web 前端 `8080`:
```bash
# app 栈:在网关容器内探 readiness(期望返回 {"status":"UP"})
podman exec dc3-gateway curl -fsS http://127.0.0.1:8000/actuator/health/readiness
# 宿主机入口是 Web 前端 8080(nginx 反代到 dc3-gateway:8000)
curl -fsS http://127.0.0.1:8080/
```
::: info dev 栈才能从宿主机直连网关 8000
若用 `make up-dev`(dev 栈,`docker-compose-dev.yml`)启动,网关 `8000` 会发布到宿主机,此时可直接 `curl
-fsS http://127.0.0.1:8000/actuator/health/readiness`。
:::
::: tip 本地开发可用 single 单体免去多进程编排
若只想在本机快速验证业务逻辑,不必拉起六个容器:`dc3-center-single` 以 `dc3.facade.mode: local` 在进程内直连各中心能力,监听
HTTP `8100` / gRPC `9100`。分布式与单体之间只是部署拓扑差异,不改变业务语义——详见 [Facade 模式](./facade-modes)。
:::
## 约束与边界
- **网关是唯一的对外 API 聚合入口,但宿主机入口因栈而异**。在 app 栈(`docker-compose.yml`)里只有 Web 前端 `8080/8443` 与
listening-virtual 的设备入站口 TCP `6270`/UDP `6271` 映射到宿主机,网关 `8000` 不发布、外部请求经 Web 前端 nginx 反代到
`dc3-gateway:8000`;dev 栈(`docker-compose-dev.yml`)才把网关 `8000` 与各中心端口一并发布到宿主机。无论哪种栈,其余后端端口都只在容器网络内可达,生产环境不要映射到宿主机。
- **启动顺序由健康检查保证,不靠人工 sleep**。`depends_on: condition: service_healthy` 让依赖方等到被依赖方 readiness
通过才启动;但这只覆盖应用栈内部,db 栈必须由你先行拉起。
- **readiness 路径带 base-path**。中心服务用 `webflux.base-path`(如 auth 的 `/auth`),探针路径相应带前缀;网关不带。写监控/探活脚本时别漏掉前缀。
- **分布式默认走 gRPC facade**。manager 等中心的 `dc3.facade.mode` 默认 `${DC3_FACADE_MODE:grpc}`,`dc3/env/dev.env` 也设为
`grpc`;single 单体的基础 `application.yml` 才声明 `local`
。这是部署拓扑选择,不是协议选择,细节见 [Facade 模式](./facade-modes)。
## 延伸阅读
- [系统架构总览](./) — 闭环的整体视角与各角色定位
- [Facade 模式](./facade-modes) — `grpc`(分布式)与 `local`(单体)如何切换、为何这是拓扑而非协议选择
- [快速开始](../quickstart/) — 本地从零起栈、跑通第一个设备
- [鉴权 · 租户 · RBAC](./auth-rbac) — 网关如何注入鉴权头与 HMAC 签名
---
# CLI 使用指南
URL: https://docs.dc3.site/zh/automation/cli
# CLI 使用指南
`dc3-cli` 是 IoT DC3 的命令行客户端:一个独立的 TypeScript 包(Node ≥ 20),把平台的全部能力封装成 `dc3` 命令,全程经网关
`/api/v3/*` 通信。读完这页你能装好它、配好网关、登录拿到 token,并用真实命令读设备、读位号值、下发命令。
> 你在这里:已经能用前端或 curl 跑通 [第一个设备](../quickstart/first-device),现在想用命令行或在 AI Agent 里驱动平台。需要让
> AI 工具直连平台时,转向 [AI Agent / MCP 集成](../ai/mcp)。
## 它是什么、给谁用
`dc3-cli` 不是另一套后端,它只是一个 HTTP 客户端:所有请求都打到你配置的网关地址,路径前缀统一为 `/api/v3/*`
(网关再聚合到鉴权、管理、数据、智能各中心)。它没有任何 Java 或构建上的耦合,装一个 Node 包即可独立运行。
它面向三类人:在终端里快速查设备、读值、下发命令的**运维/接入工程师**;把平台操作写进脚本与流水线的**自动化作者**;以及让 AI
编码工具(Claude Code、Codex、Gemini CLI 等)通过 shell 直接调用平台的 **Agent 集成方**——每个命令都支持 `--format json`
,输出可被程序稳定解析。
```bash
npm install -g dc3-cli
```
三步即可开始:配网关、登录、然后用。
```bash
dc3 config set gateway http://localhost:8000 # 网关地址(示例:本地默认端口 8000)
dc3 auth login # 交互式登录
dc3 device list # 列出设备
```
## 鉴权:三段式 token 如何拿到与保鲜
`dc3 auth login` 背后是一条三段式的 token 链路,与平台 [黄金路径](../quickstart/first-device) 里 curl 登录用的是同一对端点,只是
CLI 帮你串好了。先 `POST /api/v3/auth/token/salt` 用租户名 + 用户名换一个**盐(salt)**;再把**明文密码**连同盐
`POST /api/v3/auth/token/generate`,换回一个 JWT。拿到 JWT 后,CLI 解出其中的 `iat` / `exp`,把
`{ token, salt, tenant, username, issuedAt, expiresAt }` 写入 `~/.dc3/tokens.json`(文件权限 `0600`,每个 profile 一条)。
后续每次调用 API 前,CLI 会做两件事保证你几乎不会撞到 401:
- **主动续期**:若当前 token 在续期阈值内即将过期,调用前先静默重登换新 token。阈值由 profile 的 `renewal_threshold_hours`
控制,默认 **1 小时**(即剩余有效期不足 1h 就提前续)。
- **401 兜底**:万一仍收到 401(时钟漂移、服务重启等),续期后**自动重试一次**该请求。
发往受保护端点的请求头沿用平台约定的三件套——`X-Auth-Tenant`、`X-Auth-Login`、`X-Auth-Token`,其中 `X-Auth-Token` 携带
`{ salt, token }`。
::: warning 续期依赖密码可取
主动续期和 401 重试都需要 CLI 能重新拿到密码去走一遍 salt→generate。如果你用 `--no-save` 或 `--store prompt`(不落盘),token
过期后没有可用密码,CLI 无法静默续期,需要你手动 `dc3 auth login` 重新登录。
:::
::: danger 不要打印真实密码或 token
本页所有密码、token 均为示例占位。请勿在脚本、日志、issue 中明文粘贴真实密码或 JWT;`dc3 auth token`
仅用于本机排障,输出的令牌等同于一次有效登录凭证。
:::
## 凭据存哪儿:四级解析链
密码本身不进 `tokens.json`,而是交给**凭据存储后端**保管。CLI 在续期时按固定优先级解析密码:先问 OS
钥匙串,再问加密文件,再读环境变量,最后回落到交互式 prompt 兜底。每一级"可用且命中"就采用,否则继续往下。用
`dc3 config set auth.store ` 选择当前 profile 使用哪种后端。
四种后端各自的定位:
| 存储 | 落点 | 适用场景 |
|-------------|----------------------------------------------------------------------------|--------------|
| `keychain` | OS 钥匙串(macOS Keychain / Linux Secret Service / Windows Credential Manager) | 日常使用(默认) |
| `encrypted` | `~/.dc3/credentials.enc`,AES-256-GCM 加密 | 钥匙串不可用时的回落 |
| `env` | 读取 `DC3_PASSWORD` 环境变量 | CI/CD、脚本 |
| `prompt` | 不落盘,每次用到都交互输入 | 安全性最高,无法自动续期 |
加密文件后端用 `aes-256-gcm`,密钥由机器标识经 `scrypt` 派生,密码以 `identifier → password`(`username@tenant`)形式存储,不写明文。
```bash
# 登录时一并选择凭据后端
dc3 auth login --store keychain # 存入 OS 钥匙串(适合日常)
dc3 auth login --store env # 从 DC3_PASSWORD 读取(适合 CI)
dc3 auth login --no-save # 不保存密码,过期需手动重登
# 非交互登录(示例值,请勿用真实密码明文)
dc3 auth login --tenant default --username dc3 --password '<示例密码>'
dc3 auth status # 查看登录态与剩余有效期
dc3 auth token --header # 以 JSON 打印完整鉴权头(排障用)
```
## 命令模块概览
CLI 共 14 个命令模块,按对象与场景划分。配置与鉴权是入口,元数据类(device/driver/point/profile/group/label)对应管理中心的增删改查,事件/命令/告警/仪表盘对应数据与运行态,
`chat` 则把请求转发到智能中心。
| 模块 | 命令前缀 | 用途 |
|-----|-----------------|-------------------------|
| 配置 | `dc3 config` | 网关地址、租户、凭据后端、profile 切换 |
| 鉴权 | `dc3 auth` | 登录/登出、查看登录态与 token |
| 设备 | `dc3 device` | 设备增删改查、计数、在线状态 |
| 驱动 | `dc3 driver` | 驱动列表、详情、运行状态 |
| 位号 | `dc3 point` | 位号增删改查、读最新值、历史、写值 |
| 模板 | `dc3 profile` | 模板增删改查 |
| 分组 | `dc3 group` | 设备分组管理 |
| 标签 | `dc3 label` | 标签管理 |
| 事件 | `dc3 event` | 事件定义增删查、事件历史 |
| 命令 | `dc3 command` | 命令列表、调用、命令历史 |
| 告警 | `dc3 alert` | 告警概览、列表、确认、趋势、Top 来源 |
| 仪表盘 | `dc3 dashboard` | 统计、时序、拓扑、健康、实时流 |
| 主题 | `dc3 topic` | 主题列表 |
| 智能 | `dc3 chat` | 与智能中心对话(可选流式、指定模型) |
结构上,`dc3` 入口把命令行解析到 14 个命令模块,所有模块再共用同一组核心组件:HTTP 客户端、配置管理、token
管理与凭据存储。命令模块只描述"做什么",真正的网关请求、profile 解析、续期与密码读取都收敛在核心层。
全局选项对所有模块通用:`--profile ` 切换配置档;`--format json|table|yaml` 选输出格式(TTY 默认 table,管道默认
json);`--verbose` 打印请求/响应细节;`--ci` 进入 CI 模式(无颜色、json 输出、严格退出码)。
::: details 多 profile 并存(开发 / 生产切换)
每个 profile 各自保存网关、租户、凭据后端与 token,互不干扰:
```bash
dc3 config profile use prod
dc3 config set gateway https://iot.example.com # 示例生产地址
dc3 auth login
dc3 config profile use default # 切回本地
dc3 device list
```
:::
## 实操:读值、读历史、下发命令
下面用真实命令演示常见操作,所有 ID、值均为示例占位。读最新值对应数据中心 `POST /api/v3/data/point_value/latest`,写位号对应
`POST /api/v3/data/point_command/write`,命令回执对应 `GET /api/v3/data/point_command_history/get_by_command_id`。
::: code-group
```bash [dc3 CLI]
# 读位号最新值
dc3 point read 456789 --format json
# 读位号历史
dc3 point history 456789 --device-id 123456 --count 100 --format json
# 给可写位号下发写命令(位号须为 WRITE_ONLY 或 READ_WRITE)
dc3 point write 456789 --device-id 123456 --value 25.5
# 调用设备命令
dc3 command call --device-id 123456 --command-id 789 --params '{"speed":1500}'
# 查命令执行回执(用 call 返回的 recordId,示例值)
dc3 command history 9a1f2c3d-0000-0000-0000-000000000000
# 设备与系统健康
dc3 device status 123456 --format json
dc3 dashboard health --format json
```
```bash [对应 curl]
# 等价的写命令直连网关(示例值)
curl -X POST http://localhost:8000/api/v3/data/point_command/write \
-H 'Content-Type: application/json' \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: {"salt":"<示例盐>","token":"<示例JWT>"}' \
-d '{"deviceId":123456,"pointId":456789,"value":"25.5"}'
```
:::
`dc3 point write` 与 `dc3 command call` 的最终落点是命令链路。写命令是异步下发:网关/数据中心受理后立即返回一个命令
ID,真正的执行结果要用该 ID 去查命令历史。
::: danger 写失败不回显、命令有 TTL
位号能否写取决于它的 `rwFlag`,对 `READ_ONLY` 位号写会被拒绝。写命令若执行失败,回执的 `responseValue` 为 `null`
,不会把失败值回显成成功;命令本身有有效期,`PointCommandDTO.expireAt` 默认 `now + 10s`,超时未被驱动消费即作废。这些语义在
CLI 与直连 curl 下完全一致。
:::
## 退出码:脚本里如何判定结果
`dc3` 用退出码区分成功与失败:成功退出 `0`,任何错误(参数非法、网关不可达、鉴权被拒、API 报错等)都统一退出 `1`。CLI 顶层捕获所有异常后
`process.exit(1)`,并不按错误类别细分退出码——要分辨具体原因,读 stderr 的错误信息或加 `--verbose`。
| 退出码 | 含义 |
|-----|-------------------------------|
| `0` | 成功 |
| `1` | 任何错误(参数非法、网络不可达、鉴权被拒、API 报错等) |
```bash
# CI 中按退出码判定:非 0 即失败,从 stderr 区分原因
if ! dc3 device list --ci 2>err.log; then
if grep -qE 'Authentication failed|Forbidden' err.log; then
echo "需要登录"
else
echo "其他错误"; cat err.log
fi
exit 1
fi
```
::: tip 给 AI Agent 用时优先 `--format json`
让 AI 编码工具通过 shell 调用平台时,统一加 `--format json`(或 `--ci`),输出字段稳定、可解析;退出码 `0`/`1` 让 Agent
先判断成功与否,再读 stderr 的错误信息决定是否重新登录或重试。若希望 AI 工具直接发现并调用平台全部
API,参见 [AI Agent / MCP 集成](../ai/mcp) 的网关 MCP 端点接法。
:::
## 延伸阅读
- [自动化](./) — CLI、脚本与 MCP 在整体自动化中的位置
- [AI Agent / MCP 集成](../ai/mcp) — 让 AI 工具经网关 `/mcp` 自动发现并调用平台工具
- [第一个设备](../quickstart/first-device) — 黄金路径:从建驱动到读值的端到端流程
---
# 自动化
URL: https://docs.dc3.site/zh/automation/
# 自动化
确定性的、可重复的程序化操作,用 `dc3` CLI 完成——不涉及大模型,结果可预测、可脚本化。AI 栏目(Agentic 中心、MCP)解决"
让模型决策",自动化栏目解决"让人或脚本执行"。
## dc3 CLI
`dc3` CLI 是一个独立的 TypeScript 命令行客户端(Node ≥ 20),通过 HTTP 网关与运行中的后端通信,自身不耦合 Java
构建。它封装了三段式登录、Token 自动续期与凭据存储,让你直接面对按结果基数命名的子命令:`dc3 device list`、
`dc3 point history`、`dc3 driver add`。
适合三类场景:
- **本地调试**——终端里快速读一个位号值、看某台设备状态、下发一次读命令。
- **脚本与 CI**——批量建设备、定时拉历史、把平台操作编进部署流水线。
- **AI 编码工具**——让 Claude Code、Codex、Gemini CLI 等经 shell 调用平台;每条命令都支持 `--format json`,输出可供程序可靠解析。
CLI 的鉴权用登录令牌:先取盐、再换 12 小时有效的 access token,每个请求带 `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token`
三个鉴权头。和 AI 栏目一样,CLI 拿不到比登录账号更多的权限,跨租户数据看不到(返回 404 而非数据)。
> 想让大模型而非脚本来驱动操作?见 [AI 栏目](../ai/)(Agentic 中心对话式运营 + MCP 接外部 Agent)。
## 延伸阅读
- [CLI 使用指南](./cli) — 完整命令面、三段式登录、凭据存储后端(keychain / 加密文件 / 环境变量)
- [AI](../ai/) — 对话式 Agentic 与 MCP 接外部 Agent
- [第一个设备:端到端](../quickstart/first-device) — 用 CLI 跑通第一个设备的示例
---
# 行为准则
URL: https://docs.dc3.site/zh/community/code-of-conduct
这页写给 IoT DC3 社区的每一位参与者——无论你是提 Issue、评论 PR、参与讨论还是维护仓库,读完你会知道这里提倡什么、禁止什么,以及遇到越线行为时该怎么举报。
> 你在这里:准备参与社区互动。动手前也看一眼[贡献指南](./contributing),了解代码和文档的提交流程。
## 我们的承诺
我们作为贡献者和维护者承诺,让 IoT DC3 社区的参与对所有人都是零骚扰的体验,不论年龄、体型、残疾、民族、性别认同与表达、经验水平、教育程度、国籍、个人外貌、种族、宗教、性认同与取向,或其他任何个人特质。
## 期望的行为
以下行为让社区变得更好,也是维护者在评审贡献时除了代码之外会看的:
- **使用友好、包容的语言** — 尤其在 Code Review 和 Issue 讨论中,对事不对人。
- **尊重不同的观点和经验** — 同一问题可能有多种合理方案,有分歧时聚焦技术事实。
- **虚心接受建设性反馈** — 你的 PR 被要求修改不代表你做得不好;Review 的目的是让项目更好。
- **聚焦于对项目和用户最有利的事情** — 架构决策、API 取舍以平台用户的长期利益为准,而非个人偏好。
- **对其他社区成员展现同理心** — 新人提问、非英语母语者的表达都值得耐心回应。
## 不可接受的行为
以下行为在任何社区渠道(GitHub Issue / PR / Discussion、Gitee、邮件列表、即时通讯等)均不被容忍:
- **骚扰与恐吓** — 包括但不限于人身攻击、网络暴力、跟踪、持续的无端挑衅。
- **歧视性言论** — 针对前述任何个人特质的攻击性语言或绰号。
- **带有性暗示的语言或图像** — 社区是专业场合,不接受任何形式的性骚扰。
- **未经许可发布他人隐私信息** — 包括真实姓名、联系方式、工作单位等非公开信息,即便是公开渠道获得的也需当事人同意。
- **持续干扰** — 反复在无关话题下推销、刷屏、或无视维护者指引一意孤行。
- **维护者合理认为不适合专业社区的任何其他行为** — 维护者保留最终判断权,这不是模糊条款,而是对未穷举但同样有害行为的兜底。
## 执行
项目维护者负责澄清和执行本准则,有权:
- **删除、编辑或拒绝**不符合准则的评论、提交、代码、Issue、PR、Wiki 编辑或其他贡献。
- **临时或永久封禁**对社区有危害行为的参与者,封禁决定会附带具体理由(引用被违反的条款)。
执行遵循以下原则:
| 情况 | 处置 |
|--------------------------|-------------------|
| 初犯、非恶意(如用词不当但不自知) | 私下提醒 + 要求修正,不公开点名 |
| 再犯或明显恶意(如人身攻击、歧视性言论) | 公开警告或临时封禁,视严重程度而定 |
| 屡教不改或严重违规(如发布他人隐私、系统性骚扰) | 永久封禁,不另行通知 |
::: warning 行为准则同样约束维护者
维护者违反准则一样会受到处罚,且标准只会更严。如果你认为某位维护者行为不当,同样可以通过下方渠道举报,不会遭到报复。
:::
## 举报
辱骂、骚扰或其他不可接受的行为可以通过以下任一渠道举报:
1. **创建私有 Issue**(如平台支持)并 @ 项目维护者。
2. **邮件上报** — 直接联系项目维护者,邮件主题建议包含 `Code of Conduct` 字样便于优先分流。
所有举报将被**及时、公正**地审查和调查。处理结果会反馈给举报人(如举报人愿意提供联系方式),涉及隐私的部分不会公开。
::: tip 举报不会让你吃亏
我们不会因为举报而对你采取不利行动。报复举报人本身即属于不可接受的行为,会直接触发封禁。
:::
## 延伸阅读
- [贡献指南](./contributing) — 代码/文档提交流程与验证步骤
- [安全策略](./security) — 如何负责任地报告安全漏洞,以及生产安全基线
- [开源与许可](../introduction/license) — IoT DC3 社区版使用的 AGPL v3 许可
---
本行为准则改编自 [Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/)。
---
# 贡献指南
URL: https://docs.dc3.site/zh/community/contributing
这页写给准备给 IoT DC3 提交代码、文档或反馈的贡献者:读完你会知道怎么搭好本地环境、一条改动从分支到 PR
该怎么走、提交信息为什么必须可读,以及合并前要跑哪些验证。
> 你在这里:想动手参与。写后端代码前先通读[开发概览与规范](../development/)(权威工程约定以仓库根的 `AGENTS.md`
> 为准);想跑通验证看[测试](../development/testing)。
## 怎么参与
参与不止"写代码"。下面四类贡献都欢迎,价值同样真实:
- **报告可复现的缺陷**——附上日志、版本、配置和复现步骤,让维护者不用猜。
- **提议新功能**——说清目标场景、期望行为、以及对现有兼容性的影响。
- **改进文档**——补充示例、翻译、排错笔记;文档错一个字也值得提 PR。
- **提交代码**——聚焦的提交 + 测试或验证说明,每行改动都能追溯到一个需求。
::: tip 先开 Issue 再动手
较大的功能或会改变行为的改动,建议先开 Issue 对齐方案再写代码,避免做完才发现方向不符。小的修复可以直接提 PR。
:::
## 搭好本地开发环境
平台是 Java 21 / Spring Boot 4 的分布式服务,本地至少要起依赖栈(PostgreSQL + RabbitMQ)才能跑通。先准备工具链,再起依赖,最后让
Java 进程读到正确的运行时变量。
支持的工具链:
- JDK 21
- Maven 3.9+
- Podman 或 Docker
- Make(可选,但推荐)
从仓库根目录起本地依赖栈:
::: code-group
```bash [启动依赖栈]
make up-db # PostgreSQL + RabbitMQ
make up-optional # 可选栈:EMQX / ELK / Prometheus / Grafana
```
```bash [校验 compose]
podman compose -f dc3/docker-compose-db.yml config --quiet
```
:::
源码方式运行 Java 进程时,要把运行时变量注入到进程里——根目录的 `.env` 只服务 Docker Compose,**不会**自动注入本地 Java 进程:
::: code-group
```bash [Shell 运行 Java]
source dc3/env/dev.env.sh
```
```bash [准备 Compose 插值]
cp .env.example .env
```
:::
::: warning `.env` 和 `dev.env` 不是一回事
根目录 `.env`(由 `.env.example` 复制)只用于 Docker Compose 的变量插值;本地 IDE/CLI 跑 Java 必须用 `dc3/env/dev.env`(IDE
EnvFile 插件读)或 `dc3/env/dev.env.sh`(`source` 进 shell)。四个文件的区别、以及 JetBrains IDEA
的用法见[环境变量详解](../quickstart/environment)。
:::
## 分支与 Pull Request
一次贡献从一个聚焦的分支开始,到一个聚焦的 PR 结束。把无关的重构、格式整理和行为改动分开,评审才跑得快。
- 除非维护者另有要求,从最新的 `main` 切出功能/修复分支。
- 分支名带语义,如 `feature//` 或 `fix//`。
- PR 提交到 `develop` 分支。
- 保持 PR 聚焦:不要把重构、格式整理和行为改动混进一个 PR,除非它们是同一个修复必需的。
- 在 PR 描述里引用相关 Issue。
## 提交信息:Conventional Commits
提交信息会被直接生成进发布说明(`dc3/doc/CHANGE.md` 由 git 历史生成),所以 subject 必须具体、可读。格式固定:
```text
():
```
- subject 用**英文、小写、祈使句**,足够具体以便写进发布说明。
- 允许的 type:`feat`、`fix`、`perf`、`refactor`、`docs`、`build`、`ci`、`test`、`chore`、`style`、`security`、`revert`。
- 非根级的微小改动尽量带 scope。
- 不要用 `update`、`fix`、`misc`、`wip`、`.` 这类弱 subject——它们会让发布说明无法读。
真实示例:
```text
fix(manager): validate tenant scope for device queries
docs(env): explain JetBrains IDEA environment variables
refactor(container): deduplicate compose registry overrides
```
::: warning subject 直接进发布说明
`dc3/doc/CHANGE.md` 由提交信息生成,弱 subject 会让发布说明无法读。提交前请对照上面的格式与真实示例自查 subject 是否具体、可读。
:::
## 合并前的验证
提 PR 前,按改动触及的范围跑对应检查——验证范围与改动成正比,不必每次全量。
::: code-group
```bash [Java / 共享行为]
mvn -s .mvn/settings.xml clean package
```
```bash [容器 / compose]
podman compose -f dc3/docker-compose-db.yml config
make config STACK=db # 或 app/dev/optional,按触及的栈
```
:::
- **纯文档改动**:至少手动核对链接、命令和排版是否正确。
- **容器改动**:对每个改到的 compose 文件跑 `make config STACK=` 或 `podman compose config`。
- **更多测试约定**(单元、集成、E2E、覆盖率门槛)见[测试](../development/testing)。
## 编码约定(要点)
完整规范以 `AGENTS.md` 为准,这里只列贡献时最常踩的几条。它们都不是风格偏好,而是平台正确性的硬约束。
- 沿用既有的包结构、命名、校验、异常、日志与 facade 模式,不引入新模式。
- **租户隔离是硬要求**:新增的查询、gRPC 调用、缓存键和数据变更都必须保留 `tenantId` 作用域。
- 对成组的配置,优先用带校验的类型化配置属性,而非散落的 `@Value`。
- 行为改动要带测试或聚焦的验证说明,尤其是共享 common 模块和跨服务契约。
- 不要提交密钥、本地生成文件、IDE 元数据或机器相关配置。
::: tip CRUD 动词随结果基数走
平台没有自由命名空间——方法名/HTTP 路径/gRPC RPC 的动词必须反映结果基数(查单条 `get`,查集合 `list`
)。详见[开发概览与规范](../development/)。
:::
## 文档与翻译
改动根 README 内容时,保持 `README.md`、`README.zh.md`、`README.ja.md`、`README.vi.md` 结构对齐。若同一个 PR 内无法完成翻译同步,在
PR 描述里明确标注。
## 发布说明
打 tag 发布前,从 git 历史生成分类变更日志:
```bash
make changelog
```
默认它读取 `pom.xml` 里的当前版本,对比 `HEAD` 与最近可达的 `dc3.release.*` tag,更新 `dc3/doc/CHANGE.md`。需要时可覆盖范围或版本:
```bash
make changelog FROM=dc3.release.20251005.00 TO=HEAD VERSION=2026.5.22
```
::: info 变更日志专用提交
默认跳过"生成 changelog"这类发布提交,便于在提交 `CHANGE.md` 后重复运行保持稳定。只有当这些提交需要出现在发布说明里时,才设
`INCLUDE_CHANGELOG_COMMITS=true`。
:::
## 许可
IoT DC3 社区版基于 GNU Affero General Public License v3.0 or later 授权。授权声明见仓库根的 `LICENSE-AGPL.txt` 与
`LICENSE.txt`。
## 延伸阅读
- [开发概览与规范](../development/) — 工程权威约定:CRUD 动词、分层调用、facade 边界
- [测试](../development/testing) — 单元、集成、E2E 与覆盖率约定
- [环境变量详解](../quickstart/environment) — `.env` / `dev.env` / `dev.env.sh` 的区别与 IDE 用法
- [行为准则](./code-of-conduct) — 参与社区前请先读
- [安全策略](./security) — 如何负责任地报告安全漏洞
---
# 常见问题
URL: https://docs.dc3.site/zh/community/faq
## 许可证与授权
### IoT DC3 使用什么开源协议?
IoT DC3 基于 [AGPL-3.0](https://github.com/pnoker/iot-dc3/blob/release/LICENSE-AGPL.txt) 协议发布。
AGPL-3.0 的核心要求:如果你修改了平台代码并**通过网络提供服务**(包括
SaaS、内部系统),你必须将修改后的完整源代码开源。如果只是内部使用、未分发、未通过网络提供服务,则无需开源。
### AGPL-3.0 对我们公司意味着什么?
| 场景 | 是否需要开源 |
|---------------------------|----------------|
| 内部部署、不改代码、仅自己用 | 否 |
| 内部部署、改了代码、仅自己用(未对外提供服务) | 否(但建议贡献回来) |
| 基于 DC3 做 SaaS 产品对外售卖 | **是**,必须开源全部修改 |
| 基于 DC3 做了二次开发并分发给客户部署 | **是**,必须开源全部修改 |
| 只是调用 DC3 的 API,未修改 DC3 本身 | 否 |
### 可以闭源二次开发吗?
如果你只是通过 API 调用 DC3、没有修改 DC3 源码本身,你的调用方代码可以闭源。一旦你修改了 DC3 源码并通过网络对外提供服务,AGPL-3.0
要求你将修改开源。
### 有商业授权吗?
目前没有独立的商业授权。如果你的使用场景与 AGPL-3.0 兼容,可以直接使用。如有特殊需求,可通过社区渠道联系维护者讨论。
---
## 收费与商业模式
### IoT DC3 本身收费吗?
**不收费。** IoT DC3 是完全开源免费的,你可以自由下载、使用、修改和分发(遵守 AGPL-3.0 条款)。
### 项目方如何盈利?
目前 IoT DC3 是维护者的个人开源项目,以社区驱动方式运作。未来可能的商业化方向包括:技术支持服务、企业定制开发、SaaS
托管服务等。核心平台本身将始终保持开源。
### 使用 IoT DC3 需要付费给谁吗?
不需要。你不需要向任何人付费即可使用 IoT DC3。但你需要自行承担部署所需的服务器、数据库等基础设施费用。
---
## 技术选型
### 为什么用 Java 而不是 Go/Node.js/Python?
IoT DC3 选择 Java + Spring 生态的核心原因:
1. **工业物联网场景**:工业领域大量现存系统是 Java 生态(SCADA、MES、ERP),Java 在工业集成中有天然优势
2. **Spring 生态成熟度**:Spring Boot/Cloud/Security/Data 提供开箱即用的分布式、安全、数据访问能力
3. **JVM 稳定性**:长时间运行的设备接入服务对 GC、内存管理要求高,JVM 经过数十年的生产验证
4. **AI 集成**:Spring AI 让平台能以统一范式接入多家大模型(OpenAI、Claude、本地模型等)
5. **团队技能**:维护者在 Java/Spring 生态有深厚积累
### 为什么用 PostgreSQL 而不是 MySQL?
1. **TimescaleDB 扩展**:IoT 时序数据场景,PostgreSQL 的 TimescaleDB 扩展提供原生的超表自动分区、压缩、数据保留策略
2. **Apache AGE**:图数据库扩展,用于设备关系、拓扑路径查询
3. **pgvector**:向量扩展,为 AI 语义检索提供基础设施
4. **更丰富的数据类型**:JSONB、数组、范围类型等
5. **更严格的 SQL 标准**:在复杂查询和事务场景下更可靠
IoT DC3 对 PostgreSQL 的依赖很深,这三个扩展(TimescaleDB + AGE + pgvector)是平台数据架构的核心。
### 支持哪些设备协议?应该怎么选择?
平台内置 **28 个驱动模块**,覆盖:
- **工业总线/PLC**:Modbus TCP/RTU、OPC UA/DA、S7 (Siemens)、MELSEC、FINS (Omron)、EtherNet/IP
- **SCADA/电力/计量**:BACnet/IP、IEC 104、DLMS、SL651、SNMP
- **IoT/无线**:MQTT、CoAP、LwM2M、HTTP、BLE、Zigbee、CAN
- **串口/通用网络**:Serial、TCP/UDP
- **数据库**:MySQL、PostgreSQL、Oracle、SQL Server
选择建议:先确定现场设备支持的协议,再看驱动能力矩阵([驱动能力矩阵](../drivers/matrix))确认所需读写/订阅能力是否满足。
---
## 部署与运维
### 最低硬件要求?
**开发环境**(仅依赖栈 PostgreSQL + RabbitMQ):
- CPU: 2 核
- 内存: 4 GB
- 磁盘: 20 GB
**生产环境**(全栈:网关 + 4 个中心 + N 个驱动 + 依赖栈):
- CPU: 8 核及以上
- 内存: 16 GB 及以上
- 磁盘: 100 GB SSD 及以上(时序数据持续增长,需规划扩容)
### 如何从开发环境迁移到生产?
1. **安全加固**:修改默认密钥/密码、启用 TLS、配置防火墙规则、关闭调试端点
2. **数据持久化**:确保 PostgreSQL 和 RabbitMQ 数据卷正确挂载和备份
3. **高可用**:根据需求配置 PostgreSQL 主从、RabbitMQ 集群
4. **监控告警**:部署 Prometheus + Grafana(docker-compose-optional.yml 已包含)
5. **日志收集**:接入 ELK(docker-compose-optional.yml 已包含)
6. **环境变量**:参考 [环境变量配置](../quickstart/environment),将开发变量替换为生产值
详见 [安全策略](./security) 的生产基线清单。
### 数据怎么备份?
PostgreSQL 数据备份:
```bash
# 全量备份
podman exec dc3-postgres pg_dumpall -U dc3 > backup.sql
# 仅备份平台数据(不含 TimescaleDB 时序数据)
podman exec dc3-postgres pg_dump -U dc3 \
--schema=dc3_auth --schema=dc3_manager --schema=dc3_data > backup_platform.sql
```
生产环境建议配置 pgBackRest 或 pg_dump 定时任务 + 异地存储。
---
## 驱动开发
### 怎么开发一个新驱动?
1. 阅读 [驱动开发指南](../development/driver-authoring)
2. 在 `dc3-driver/` 下复制最接近的驱动模块作为模板
3. 实现 Driver SDK 要求的 `read()`、`write()` 和(可选的)`subscribe()` 方法
4. 在 `dc3/docker-compose.yml` 中添加驱动服务配置
5. 写文档(参考已有驱动文档页的格式)
### 驱动一定要用 Java 吗?
Driver SDK 本身是 Java 的,但你也可以通过 **MQTT 桥接** 或 **HTTP 代理** 的方式用任意语言实现设备接入:非 Java 程序将数据发到
MQTT Topic → MQTT 驱动订阅 → 进入平台数据管道。不过这种方式会丢失 SDK 内置的状态管理、自动重连、健康上报等能力。
---
## AI 能力
### AI 能做什么?
IoT DC3 的 Agentic 中心(基于 Spring AI)让大模型具备以下能力:
- **设备查询**:自然语言查询设备状态、位号值、历史数据
- **命令下发**:通过对话让 AI 向设备写入参数
- **告警分析**:AI 分析告警历史,给出根因推断
- **数据洞察**:对时序数据做趋势分析和异常检测
AI 能力通过 MCP(Model Context Protocol)协议暴露,可被 Claude Desktop、VS Code、Cursor 等 AI
工具直接调用。详见 [AI 概览](../ai/)。
### 支持哪些大模型?
通过 Spring AI,理论上支持所有主流模型提供商:OpenAI、Anthropic Claude、Google Gemini、阿里通义千问、百度文心一言、本地 Ollama
模型等。具体配置见 [Agentic 中心](../ai/agentic)。
---
## 社区与贡献
### 遇到问题怎么求助?
1. 先查 [故障排查指南](../guide/troubleshooting)
2. 搜索 [GitHub Issues](https://github.com/pnoker/iot-dc3/issues) 看是否有人遇到过
3. 没找到?提新 Issue,附上:版本号、日志、复现步骤、环境信息
### 如何参与贡献?
见 [贡献指南](./contributing)。任何形式的贡献都欢迎:报告 bug、改进文档、提交代码、参与讨论。
### 有商业支持服务吗?
目前项目以社区形式运作,暂无官方商业支持。如有企业级支持需求,可通过社区渠道联系维护者沟通。
---
# 安全策略
URL: https://docs.dc3.site/zh/community/security
IoT DC3 是连接现场设备的工业物联网平台,一处安全缺口可能同时影响数据与控制。这页写给两类人:想知道**哪些版本仍在维护、发现漏洞该怎么私下上报
**的使用者,以及准备把平台投入生产、需要一份**最小安全基线清单**的运维与部署人员。
> 你在这里:已了解平台并准备落地。生产前请同时过一遍 [环境变量详解](../quickstart/environment)
> 与 [部署模式与镜像源](../guide/usage)。
## 受支持的版本
我们只为当前活跃维护的主线版本提供安全补丁与更新。版本号采用 `YYYY.M.x` 的年月方案(如 `2026.5.x` 表示 2026 年 5
月线),同一主线内的补丁号 `x` 持续向前滚动。当前发行线为 `2026.5.x`(最新 `2026.5.22`,镜像 tag `2026.6`)。
下表列出当前接受安全更新的版本线;不在表内的旧版本不再回补,请升级到受支持的主线后再上报。
| 版本线 | 是否受支持 |
|----------------|--------|
| `2026.5.x` | ✅ 受支持 |
| `2026.4.x` | ✅ 受支持 |
| `2025.x.x` 及更早 | ❌ 不再维护 |
::: tip 升级优先
报告漏洞前,先确认问题在受支持版本上仍能复现。许多安全问题已在新主线修复,升级往往是最快的处置路径。
:::
## 漏洞披露流程
我们对安全问题非常重视:一旦漏洞被确认,会尽快修复,并在发布说明(release notes)中披露修复信息。
::: danger 请勿公开披露
**不要**在 GitHub / Gitee 的 Issues 或讨论区公开潜在漏洞。公开的 PoC 会让尚未修复的实例直接暴露在攻击面下。请走下面的私有渠道。
:::
发现潜在安全漏洞时,请通过以下任一私有渠道上报:
1. **邮件上报**:向项目维护团队发送邮件,并在邮件主题中包含关键词 `Security Vulnerability`,便于优先识别与分流。
2. **私信上报**:通过 Gitee 或 GitHub 的私信功能直接联系项目维护者。
为便于复现与定位,建议在上报中包含:受影响的版本线、复现步骤或最小复现用例、影响面(数据泄露 / 越权 /
命令注入等)、以及你认为合理的修复建议。漏洞验证后我们会着手修复,并在对应版本的发布说明中同步修复信息。
## 生产安全基线
平台的默认配置面向本地开发,**怎么开发方便就怎么来**
:弱口令、明文端口、开发用密钥一应俱全。投产前必须逐项收紧。下面三条是最关键的硬约束,其余配置项见 [环境变量详解](../quickstart/environment)。
### 一、密钥必须随机,且 pre/pro 会强制校验
平台有两把对外不可泄露的密钥,默认值仅供开发:
- `AUTH_HMAC_SECRET` — 网关(Gateway)向后端服务签名 `X-Auth-Principal` 的 HMAC-SHA256 密钥,默认 `io.github.pnoker.dc3`。
- `DC3_SECURITY_KEY` — 鉴权中心(Auth Center / `dc3-center-auth`)生成与校验登录 Token 的签名密钥,默认
`dc3.security.key.2026.io.github.pnoker`。
::: danger HMAC 密钥在 pre/pro 环境 fail-fast
当激活的 Spring profile(或 `spring.env` 属性)命中 `pre` 或 `pro` 时,`AUTH_HMAC_SECRET` 若为空、或仍等于默认值
`io.github.pnoker.dc3`,启动阶段会直接抛出 `IllegalStateException` 让服务**起不来**
。这是有意为之——宁可启动失败,也不让生产实例带着开发密钥对外服务。生产请用强随机值,例如 `openssl rand -base64 48`
生成,并通过环境变量注入,切勿硬编码或写入日志。
:::
```bash
# 为两把密钥各生成一段强随机值(示例输出,请勿照抄)
openssl rand -base64 48 # → 用作 AUTH_HMAC_SECRET
openssl rand -base64 48 # → 用作 DC3_SECURITY_KEY
```
`DC3_SECURITY_KEY` 不像 HMAC 那样有启动期 fail-fast,但同样必须改成强随机值——它一旦泄露,攻击者可伪造登录 Token。
### 二、启用 TLS,不要让消息总线与 Broker 明文跑
平台依赖 RabbitMQ 与(可选的)EMQX MQTT Broker。两者默认均**关闭** TLS,仅适合本地。投向生产或跨网络部署时启用加密:
- RabbitMQ:置 `RABBITMQ_SSL_ENABLED=true`,连接走 TLS 端口(`5671`,对外发布端口 `DC3_RABBITMQ_TLS_PORT`,默认 `35671`
),并视情况开启 `RABBITMQ_SSL_VALIDATE_SERVER_CERTIFICATE` 与 `RABBITMQ_SSL_VERIFY_HOSTNAME`(默认均为 `false`)。
- EMQX:使用 MQTT-over-TLS 端口(`DC3_EMQX_MQTTS_PORT`,默认 `38883`)与安全 WebSocket(`DC3_EMQX_WSS_PORT`,默认 `38084`
),而非明文的 `31883` / `38083`。
对外的 HTTP 入口(网关 `dc3-gateway`,默认 `8000`)应置于反向代理 / 负载均衡之后,由其终结 HTTPS。所有外部接口启用 HTTPS /
SSL,并对外部调用做访问审计。
### 三、最小暴露端口,别把现场协议端口暴露到公网
`DC3_BIND_HOST` 默认 `127.0.0.1`,即所有发布端口只绑定到本机;只有显式改为 `0.0.0.0` 才会对网络开放。生产请只对外暴露**必须
**对外的端口,其余一律收在内网或安全组之后。
::: danger 现场协议端口禁止直连公网
Modbus、TCP/UDP、各类 PLC 网关等现场协议端口(如监听型驱动 `dc3-driver-listening-virtual` 的
`DC3_LISTENING_VIRTUAL_TCP_PORT=6270` / `UDP=6271`)多无内建认证,**绝不可**直接暴露到公网。设备侧通过
VPN、专网或网关白名单接入;唯一应对公网开放的业务入口是经反代加固的网关 HTTP 端口。
:::
理想的暴露面只有一个:网关。鉴权中心(`8300`)、管理中心(`8400`)、数据中心(`8500`)、Agentic 中心(`8600`)以及各 gRPC 端口(
`9300/9400/9500`)都属内部链路,不对外发布。端口清单与默认值见 [环境变量详解](../quickstart/environment)
的「网关与服务端口」与「gRPC / facade」小节。
### 其余通用实践
- ✅ 始终运行受支持的版本,定期更新系统依赖与容器镜像。
- 🔑 改掉所有默认口令:PostgreSQL(`POSTGRES_PASSWORD` 默认 `dc3dc3dc3`)、RabbitMQ(`RABBITMQ_PASSWORD`)、MQTT(`MQTT_PASSWORD`
)等默认凭据全部替换为强随机值。
- 🧩 只授权受信任的设备与用户接入;对外部接口遵循最小权限原则并做访问审计。租户隔离与 RBAC
的实现见 [鉴权·租户·RBAC](../architecture/auth-rbac)。
## 延伸阅读
- [环境变量详解](../quickstart/environment) — 全部安全相关变量的默认值、作用域与生产取值指引
- [部署模式与镜像源](../guide/usage) — 容器化部署、端口发布与镜像源选择
- [鉴权·租户·RBAC](../architecture/auth-rbac) — 登录、租户隔离与权限模型如何保障多租户安全
---
# API 文档
URL: https://docs.dc3.site/zh/development/api-documentation
# API 文档
IoT DC3 的 REST 接口文档由代码注解自动生成,经网关聚合成一个统一的 Swagger
UI。读完这页,你能在开发环境打开各中心的在线文档、用默认凭据走完"取盐 → 取 token → 带鉴权头调用"的登录流、看懂 CRUD
路径约定,并理解每个接口上的 `x-dc3-ai` 风险元数据是怎么喂给 AI/MCP 工具的。
> 你在这里:准备调用或调试后端接口。要先把环境跑起来,看 [第一个设备](../quickstart/first-device)
> ;要理解鉴权头背后的租户与权限,看 [鉴权·租户·RBAC](../architecture/auth-rbac)。
## 文档从哪来:注解生成,网关聚合
平台不维护任何手写的 API 规格文件。每个接口的标题、参数、请求/响应模型,全部来自 Controller 上的 `springdoc-openapi` 注解(
`@Tag`、`@Operation`、`@Parameter`、`@Schema`),运行时由各中心服务在 WebFlux 栈上生成自己的 OpenAPI JSON。
四个业务中心——鉴权中心(Auth Center / `dc3-center-auth`)、管理中心(Manager Center / `dc3-center-manager`)、数据中心(Data
Center / `dc3-center-data`)、智能中心(Agentic Center / `dc3-center-agentic`)——各自暴露一份文档。网关(Gateway /
`dc3-gateway`)本身没有业务 Controller,它通过 `springdoc.swagger-ui.urls` 把这四份文档聚合到一个带服务下拉选择器的
Swagger UI 里,对外只暴露一个入口。
分组靠两层配置完成:`dc3-common-web` 的 `SpringDocConfig` 提供全局元信息(标题、版本、联系人、许可证、安全方案),各业务模块在自己已扫描的包下声明
`GroupedOpenApi` Bean,只扫描本模块 Controller。各中心服务的 `spring.webflux.base-path`(如 `/auth`、`/manager`)会加到文档路径前,例如
`/auth/v3/api-docs`;网关聚合路径 `/v3/api-docs/{svc}` 抹平了这层差异,统一访问。
::: info dc3-center-single 模式
`dc3-center-single` 把多个业务模块打进一个进程,所以它的 Swagger UI 里会同时出现多个分组——这是预期行为,不是配置重复。
:::
## 访问入口
开发环境下,优先用网关聚合入口;调试单个中心时也可以直连它的 base-path 文档。
| 目标 | URL |
|------------------|-------------------------------------------------|
| 网关聚合 UI(推荐) | `http://:8000/swagger-ui.html` |
| 鉴权中心直连 | `http://:8300/auth/swagger-ui.html` |
| 管理中心直连 | `http://:8400/manager/swagger-ui.html` |
| 数据中心直连 | `http://:8500/data/swagger-ui.html` |
| 智能中心直连 | `http://:8600/agentic/swagger-ui.html` |
| 单中心 OpenAPI JSON | `http://://v3/api-docs` |
## 登录与鉴权:取盐 → 取 token → 带 X-Auth-* 头
除 `/api/v3/auth/token/**`(取盐、生成 token、改密)这类公开端点外,网关后的所有业务接口都要求携带三个鉴权头:`X-Auth-Tenant`、
`X-Auth-Login`、`X-Auth-Token`。登录本身是一个两步握手:先用用户名+租户向服务端要一枚随机盐(建议 5 分钟内使用,服务端不强制过期),再把
**明文密码**连同这枚盐一起提交换取 token(12 小时有效)。盐不参与密码哈希——它与服务端密钥
`DC3_SECURITY_KEY` 拼接后,作为 JWT 的 HMAC-SHA256 签名密钥;密码本身以明文提交(依赖 HTTPS 保护传输),由后端
`PasswordUtil.verify` 用 Argon2id(不可用时回退 BCrypt)校验。
实际调用形如下面这样(示例值仅作演示,`default`/`dc3` 是种子数据自带的默认租户与用户):
::: code-group
```bash [curl]
# 1. 取盐
curl -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# → R:data 即 salt(示例:"f3a9c1..."),建议 5 分钟内使用
# 2. 连同 salt 一起提交明文密码(依赖 HTTPS 保护传输)换 token
curl -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"f3a9c1...","password":"<明文密码>"}'
# → R:data 即 access token(示例:"eyJ..."),12 小时有效
# 3. 带鉴权头调用业务接口
curl -X POST http://localhost:8000/api/v3/manager/device/list \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: {"salt":"f3a9c1...","token":"eyJ..."}' \
-H 'Content-Type: application/json' \
-d '{"current":1,"size":10}'
```
```bash [dc3 CLI]
# CLI 封装了取盐→换 token→保存凭据的全过程
dc3 config set gateway http://localhost:8000
dc3 auth login --tenant default --username dc3
```
:::
在 Swagger UI 里调试受保护接口时,点右上角 **Authorize**,按下表填入鉴权头即可:
| Header | 示例值 |
|-----------------|--------------------------------|
| `X-Auth-Tenant` | `default` |
| `X-Auth-Login` | `dc3` |
| `X-Auth-Token` | `{"salt":"...","token":"..."}` |
::: danger 不要把真实凭据写进文档/日志/issue
token、password、salt、api key 一律不入库到文档、提交记录或工单。示例里的哈希值、token 都用占位符代替。
:::
## CRUD 路径约定:动词反映结果基数
所有业务接口的命名遵循同一条规则——HTTP 路径、Java 方法、gRPC RPC、前端函数上的动词,必须反映**返回结果的基数**。读单条用
`getXxx`,读集合用 `listXxx`,写入三件套是 `add`/`update`/`delete`。这让你看到路径就能判断它返回一条还是一批、是读还是写。
| 动作 | Java 方法 | HTTP 路径 | gRPC RPC | 前端函数 |
|------|----------------|-------------|-----------|------------------|
| 单条记录 | `getXxx(...)` | `/get_xxx` | `GetXxx` | `getXxx(...)` |
| 集合 | `listXxx(...)` | `/list_xxx` | `ListXxx` | `listXxx(...)` |
| 新增 | `add(BO)` | `/add` | n/a | `addXxx(...)` |
| 更新 | `update(BO)` | `/update` | n/a | `updateXxx(...)` |
| 删除 | `delete(Long)` | `/delete` | n/a | `deleteXxx(...)` |
::: tip 保留动词的专用语义
`select*` 只用于 `*ManagerImpl` 里的原生 MyBatis Mapper 调用;`remove*` 只用于 MyBatis-Plus 继承来的 Manager 方法。业务删除一律用
`delete*`。`find*`/`query*`/`fetch*` 不作为主 CRUD 动词。
:::
举一个走管理中心的真实例子(黄金路径中"新增设备"一步):接口是 `POST /api/v3/manager/device/add`,请求体是 `DeviceVO`,关键字段
`deviceName`、`driverId`、`profileId`、`enableFlag`,成功返回成功码 `SuccessCode.ADD`("Added successfully")——`add` 不回传新建实体
ID,后续若需要这个 id,得用 `device/list` 按名称查回。需要 `device:add` 权限。接口统一用 `R` 响应封装,含 `ok`、`code`、
`message`、`data` 四字段。
## x-dc3-ai:给 AI/MCP 工具的风险标注
每个接口的 `@Operation` 上可以挂一段 `x-dc3-ai` OpenAPI 扩展,用四个布尔/枚举属性描述这次调用对 AI Agent
意味着什么风险。这段元数据不是写给人看的注释——它会被 MCP 工具目录聚合器读取,落进 `dc3_mcp_tool_catalog` 表,进而决定一个工具在
`tools/list` 里是否对某个 AI 连接可见、调用时是否需要二次确认。
```java
@Extension(name = "x-dc3-ai", properties = {
@ExtensionProperty(name = "riskLevel", value = "MEDIUM"), // LOW / MEDIUM / HIGH
@ExtensionProperty(name = "destructive", value = "false"), // 是否破坏数据/配置
@ExtensionProperty(name = "idempotent", value = "false"), // 是否可安全重试
@ExtensionProperty(name = "openWorld", value = "true") // 是否触达外部/物理世界
})
```
各属性的含义:
- `riskLevel`:`LOW`/`MEDIUM`/`HIGH`,**按动词语义约定人工标注**(不是由代码从 HTTP 方法自动推导)——惯例上 `delete` 标
`HIGH`、`add`/`update` 标 `MEDIUM`、`get`/`list` 标 `LOW`,但最终取值来自每个 `@Operation` 上手写的注解,聚合器只在缺失或非法时兜底为
`HIGH`。`HIGH` 风险工具默认对 AI 隐藏,需显式启用,且调用走两阶段确认。
- `destructive`:调用是否会破坏既有数据或设置(如改密、取消 token)。
- `idempotent`:同样参数重复调用是否安全(决定失败后能否自动重试)。
- `openWorld`:是否会触达平台之外的外部系统或物理设备(如下发写命令)。
以鉴权中心 `TokenController` 为例,取盐接口标注 `riskLevel=LOW, destructive=false, idempotent=false, openWorld=false`,而生成
token 接口标注 `riskLevel=HIGH`——两者都属公开端点、对 AI 工具目录隐藏(`hidden=true`),但风险等级如实区分。智能中心的
`POST /api/v3/agentic/chat/completions` 则标注
`riskLevel=MEDIUM, destructive=false, idempotent=false, openWorld=true`。
聚合器还会从 HTTP 方法补出 `read_only_hint`(`GET` → 1,`POST` → 0),把全部提示位(`destructive_hint`/`idempotent_hint`/
`open_world_hint`/`read_only_hint`,取值 0/1)连同 `risk_level` 一起持久化。AI Agent 通过 MCP 看到的工具风险,就是这套标注的最终呈现。完整的
MCP 工具暴露、过滤与确认机制见 [AI Agent / MCP 集成](../ai/mcp)。
## 导出 OpenAPI JSON
需要离线契约快照、或喂给客户端代码生成器时,从运行中的开发/测试栈一键导出各中心的 OpenAPI JSON:
```bash
make openapi
```
可通过变量覆盖导出入口与输出目录:
```bash
make openapi OPENAPI_BASE=http://localhost:8000 OPENAPI_OUT=build/openapi
```
## 新增接口时的文档要求
给后端加接口时,文档不是事后补的——注解就是文档源:
1. Controller 类加 `@Tag(name = "...", description = "...")`。
2. 方法加 `@Operation(summary = "...", description = "...")`,摘要遵循 CRUD 动词约定(`add`/`delete`/`update`/`getXxx`/
`listXxx`)。
3. 路径、查询、请求体参数加 `@Parameter`。
4. 请求/响应 DTO 字段加 `@Schema(description = ...)`,必要时补 `example` 与 `requiredMode = REQUIRED`。
5. 涉及 AI/MCP 可调用的接口,按真实风险补 `x-dc3-ai` 扩展。
6. 新增业务模块时,补 `GroupedOpenApi` Bean、网关聚合配置和 Swagger UI 分组。
::: warning 注解文字一律用英文
注解里的 `summary`/`description` 属于用户可见代码文本,按工程规则用英文书写;同时不要在 `@Schema` 的 `example` 里放
`apiKey`、`password`、`secret`、`token` 等敏感值。
:::
::: danger 生产环境关闭 Swagger / OpenAPI 暴露
API 文档仅在 `dev`、`test`、`pre` 环境可用,生产环境(`pro` profile)由每个中心服务各自的 `application-pro.yml`
(auth/manager/data/agentic/single 各一份)关闭:
```yaml
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
```
共享的 `application-web.yml` 只设 springdoc 的基线路径,不负责禁用——它的注释也写明禁用动作落在各服务的
`application-pro.yml`。生产环境中 springdoc 端点根本不存在,因此不会暴露任何文档内容。
:::
## 延伸阅读
- [鉴权·租户·RBAC](../architecture/auth-rbac) — 鉴权头背后的取盐/token/HMAC 与租户隔离、权限模型
- [第一个设备](../quickstart/first-device) — 用 `dc3` CLI 走完黄金路径,把这些接口实际跑一遍
- [AI Agent / MCP 集成](../ai/mcp) — `x-dc3-ai` 元数据如何变成 MCP 工具风险策略与两阶段确认
- [测试](./testing) — 接口契约与集成测试如何验证这些路径
---
# 变更日志
URL: https://docs.dc3.site/zh/development/changelog
# 变更日志
下面这份变更日志不是手写的——它由 `make changelog` 从 git 提交历史按 Conventional Commits
规则自动归类生成。这页先讲清它是怎么来的、怎么按版本读、以及版本号和 tag 的规则,然后内联完整清单。
> 你在这里:想了解某个版本改了什么,或想知道这份清单如何维护。写代码请先看 [开发概览与规范](./)
> ,提交规范见 [贡献指南](../community/contributing)。
## 这份清单从哪来
平台不维护手写的 `CHANGELOG`。所有改动都通过规范化的提交信息留痕,发布时由脚本扫描 git
历史、解析每条提交的类型与作用域,再聚合成下面这份按版本分组的清单。换句话说,**提交信息就是变更日志的原始数据**——一条含糊的
`update` 或 `fix bug` 会变成一行没有价值的发布说明,所以提交规范本身就是这份文档质量的前提。
生成器是 `dc3/bin/changelog.py`(Python,无第三方依赖),由 Makefile 目标 `make changelog` 驱动,产出文件为
`dc3/doc/CHANGE.md`——也就是本页底部内联的那份。整条生成链路如下:
链路是单向的:提交历史是唯一原始数据,脚本聚合产物落在 `CHANGE.md`,本页只是把它内联展示——所以**不要在本页手工编辑条目**
,改动会在下次 `make changelog` 时被覆盖。
::: code-group
```bash [默认(上一个发布 tag → HEAD)]
# 在 iot-dc3/ 目录下执行
make changelog
```
```bash [指定范围与版本号]
# FROM/TO 接受任意 git ref(tag、分支、commit);VERSION 写入分组标题
make changelog FROM=dc3.release.20251005.00 TO=HEAD VERSION=2026.5.17
```
:::
不传参数时,生成器会自动找到上一个匹配 `dc3.release.*` 的 tag 作为起点、`HEAD` 作为终点,并从 `pom.xml` 的 `dc3.version`
读取版本号。生成结果会覆盖写回 `dc3/doc/CHANGE.md`,再由本页的 include 指令内联展示。
::: info 改了它要单独提交
变更日志是从历史生成的产物。当 `CHANGE.md` 本身被重新生成需要提交时,使用约定的提交信息
`docs(release): update generated changelog`——这是仓库为"仅变更日志"保留的固定 subject。生成器会识别并跳过这类提交(
`docs(release):` 与 `chore(release):` 两种前缀均匹配),避免变更日志里出现"更新变更日志"的噪声条目。
:::
## 怎么读:按版本,再按类别
清单的最外层按**版本**分组,每个版本一个 `### <版本号>` 标题,下面一行 `_Generated on <日期>._` 标明该段生成时间,再往下是一段
Summary(覆盖的提交数、各类别计数、最活跃的作用域、若干 Highlights),最后才是按类别展开的逐条提交。
每个版本段内的类别顺序是固定的,从最该被注意的到最琐碎的:
| 顺序 | 类别 | 来源提交类型 |
|----|------------------|------------------------------|
| 1 | Breaking Changes | 任意类型带 `!`(如 `feat!:`) |
| 2 | Security | `security` |
| 3 | Features | `feat` / `feature` |
| 4 | Bug Fixes | `fix` |
| 5 | Performance | `perf` |
| 6 | Refactoring | `refactor` |
| 7 | Documentation | `docs` / `doc` |
| 8 | Build | `build` |
| 9 | CI | `ci` |
| 10 | Tests | `test` / `tests` |
| 11 | Chores | `chore` / `style` / `revert` |
| 12 | Other Changes | 不符合 Conventional Commits 的提交 |
::: info Security 类别还会按关键词提升
上表是 type → 类别的映射。除此之外,生成器对**任意**提交,只要其类型、作用域或摘要里出现 `security` / `vulnerability` /
`cve` / `auth bypass` 关键词(不区分大小写),也会把它提升到 Security 类别——即便它的提交类型并非 `security`。这样安全相关改动不会因为被提成
`fix`/`refactor` 而埋没在普通类别里。
:::
每条目形如 `****: ()`,scope 来自提交信息里的 `()`,括号里的短哈希指向具体
commit。想读最新一版改了什么,跳到清单顶部的第一个 `###` 段即可;想对比两版之间的差异,看两个版本段的 Summary 行的提交计数与
Highlights 最快。
::: tip 提交规范决定输出质量
解析规则是 `(): <英文祈使句摘要>`。type 必须是约定集合之一(`feat`/`fix`/`perf`/`refactor`/`docs`/`build`/
`ci`/`test`/`chore`/`style`/`security`/`revert`),否则该提交会落入 Other Changes
而不带类别。完整规范见 [贡献指南](../community/contributing)。
:::
## 版本号与 tag 规则
清单里的版本号对应 git tag,由 `make tag`(`dc3/bin/tag.sh`)生成,格式是 `dc3...`:
- `` 由当前分支推断——`develop` 分支打 `develop` tag,`release` / `main` 分支打 `release` tag;其它分支不允许打 tag。
- `` 是当天日期。
- `` 是当天该类型已有 tag 数量的两位补零序号,从 `00` 起。所以同一天第一个发布 tag 是 `dc3.release.20260622.00`,第二个是
`dc3.release.20260622.01`(示例值)。
```bash
# 在 iot-dc3/ 目录下,release 分支上
make tag
# → 生成形如 dc3.release.20260622.00 的 tag 并 push 到 origin
```
`make changelog` 默认就是以上一个 `dc3.release.*` tag 为起点扫描到 `HEAD`,所以正常发布流程是:先 `make tag` 打出新 tag,再
`make changelog` 生成这一段的变更,提交回 `CHANGE.md`。
::: warning tag 会推送到远端
`make tag` 末尾会执行 `git push origin --tags`——这是对外操作,确认在正确分支、当天序号无误后再执行。
:::
## 完整变更清单
以下内容由 `dc3/doc/CHANGE.md` 内联,每次 `make changelog` 后随之更新;不要在本页手工编辑条目,改动会在下次生成时被覆盖。
## 延伸阅读
- [开发概览与规范](./) — 二次开发的整体地图与编码约定
- [贡献指南](../community/contributing) — 提交信息规范、commit-msg 钩子与贡献流程
---
# 驱动开发
URL: https://docs.dc3.site/zh/development/driver-authoring
# 驱动开发
驱动是 IoT DC3 的南向 I/O 层:它把 Modbus、OPC UA、MQTT、S7、BACnet 等异构协议设备,统一接入到平台的数据平面和命令平面。本页带你从
`dc3-driver-virtual` 模板派生一个新协议驱动,并讲清驱动的生命周期、读/写调度与那条"不能随手改"
的路由约束——读完你能写出一个能注册、能采集、能接受命令的驱动。
> 你在这里:想为一种现成驱动还不支持的协议接入设备。只想使用已有驱动,请先看 [操作手册](../operation/)
> 和 [快速开始](../quickstart/)。下一步可看 [命令平面](../architecture/command-plane) 理解读写命令如何流回设备。
除非特别说明,命令都在 `iot-dc3` 仓库根目录执行。
## 驱动是什么:一个聚合了 7 个 SPI 的 Spring Boot 服务
一个驱动本质上是一个独立的 Spring Boot 服务(`dc3-driver-`)。它不直接和管理中心、数据中心打交道,而是继承
`dc3-common-driver` 这个 SDK——SDK 负责注册、调度、RabbitMQ 收发、gRPC 调用和租户上下文,**你只需要实现协议逻辑**。
协议逻辑通过一个入口接口暴露:`DriverCustomService`。它本身不声明方法,而是聚合了 7 个职责单一的 SPI 子接口,一个驱动实现这一个接口,就等于把这
7 件事都接管了:
| SPI 子接口 | 你要回答的问题 |
|--------------------------|---------------------------------------------------|
| `DriverLifecycle` | 进程启动时初始化什么(`initial()`)?每个自定义周期做什么(`schedule()`)? |
| `DriverProtocol` | 怎么从设备读一个位号(`read(...)`)?怎么写一个位号(`write(...)`)? |
| `DriverCommand` | 怎么执行模板里定义的自定义命令(`execute(...)`)? |
| `DriverMetadataListener` | 设备/位号元数据变更时(`event(...)`)如何刷新本地缓存? |
| `DriverHealth` | 驱动整体在线态是 ONLINE / OFFLINE / FAULT / MAINTAIN? |
| `DeviceHealth` | 单台设备的在线态如何判断? |
| `DriverValidator` | 驱动/位号配置是否合法(`validate*`)?能否生成仿真值? |
源码入口:`dc3-common/dc3-common-driver/.../service/DriverCustomService.java`(一行 `extends` 把 7 个接口拼起来)。
`dc3-driver-virtual` 模板把这 7 个方法都给了可运行的示例实现,是新驱动最好的起点。
::: tip 术语对齐
**属性(Attribute)** 来自驱动 `application.yml` 的 `dc3.driver.*-attribute`,定义"这个驱动有哪些配置项";**配置(Config)**
是某台设备为这些属性填的**具体值**,存在管理中心。驱动启动时注册属性,运行时通过 `Map` 拿到某台设备的配置值。
:::
## 生命周期:注册(带重试)→ initial → schedule
驱动进程启动后,`DriverInitRunner`(`ApplicationRunner`)执行一段固定的引导序列:先向管理中心注册自己和全部属性定义,注册成功后调用你的
`initial()` 做一次性初始化,最后由 SDK 装配定时任务(读调度、自定义调度、设备健康检查)。
注册走 gRPC,而管理中心在驱动启动时未必就绪(滚动重启、Pod 重新调度)。所以注册不是"一锤子买卖":
`DriverInitRunner.registerWithRetry()` 用**带上限的指数退避**重试——初始 2 秒,每次翻倍,封顶 30 秒,最多 30
次;全部失败才抛异常退出。没有它,管理中心的一次短暂抖动就会把驱动拖进 CrashLoopBackOff。
源码:`dc3-common/dc3-common-driver/.../init/DriverInitRunner.java`(`REGISTER_MAX_ATTEMPTS=30`、
`REGISTER_INITIAL_BACKOFF=2s`、`REGISTER_MAX_BACKOFF=30s`)。`initial()` 只在启动时跑一次,适合建连接池、订阅关系;
`schedule()` 由 `dc3.driver.schedule.custom` 的 cron 周期触发。
## 从模板到新驱动:四步
新驱动的工作量集中在四处:拷贝模板、改 `pom.xml`、改 `application.yml`、实现 `DriverCustomService`。下图是整体路径,随后逐步展开。
### 第 1 步:拷贝模板并重命名
驱动模块命名用 `dc3-driver-`,协议名用 kebab-case:
```bash
cp -r dc3-driver/dc3-driver-virtual dc3-driver/dc3-driver-knx
```
然后重命名 Java 包、启动类和自定义服务实现类。模板里两个关键类是:
| 类 | 说明 |
|----------------------------------|--------------------------------------------|
| `VirtualDriverApplication` | Spring Boot 启动类 |
| `VirtualDriverCustomServiceImpl` | 协议逻辑实现入口(`implements DriverCustomService`) |
新驱动应使用协议专用命名,例如 `KnxDriverApplication`、`KnxDriverCustomServiceImpl`,避免多个驱动出现重复类名。启动类与实现类放在同一父包下,确保组件扫描能找到带
`@Service` 的 `DriverCustomService` 实现:
```java
@SpringBootApplication
public class KnxDriverApplication {
public static void main(String[] args) {
SpringApplication.run(KnxDriverApplication.class, args);
}
}
```
### 第 2 步:接入父 POM
在 `dc3-driver/pom.xml` 的 `` 中登记新模块:
```xml
dc3-driver-knx
```
新模块自己的 `pom.xml` 通常只继承驱动父模块,并添加协议库依赖:
```xml
io.github.pnoker
dc3-driver
2026.5.22
dc3-driver-knx
jar
```
`dc3-driver` 父模块已引入 `dc3-common-driver`(SDK)和 Spring Boot Maven Plugin,你无需重复声明。
### 第 3 步:配置 `application.yml`
`dc3.driver` 是驱动最重要的用户可见配置。SDK 启动时读取它并注册到管理中心,管理侧据此渲染设备和位号的配置表单。下面以
`dc3-driver-virtual` 的真实结构为蓝本(把 virtual 的值换成 KNX 语义):
```yaml
dc3:
driver:
tenant: default
name: KNX Driver
code: KnxDriver # 稳定路由标识,详见下文约束
type: DRIVER_CLIENT
remark: @project.description@
schedule:
read: # 读调度:周期采集位号值
enabled: true
cron: '0/30 * * * * ?' # 每 30 秒一轮
custom: # 自定义调度:驱动 schedule() 回调
enabled: true
cron: '0/5 * * * * ?'
health:
device: # 设备健康上报
enabled: true
cron: '0/15 * * * * ?'
timeout: 45 # 设备状态租约 TTL(秒)
timeout-unit: SECONDS
driver-attribute: # 驱动级属性:每个设备实例填一份
- attribute-name: Host
attribute-code: host
attribute-type-flag: STRING
default-value: localhost
remark: KNX/IP gateway host
- attribute-name: Port
attribute-code: port
attribute-type-flag: INT
default-value: 3671
remark: KNX/IP gateway port
point-attribute: # 位号级属性:每个位号填一份
- attribute-name: Group Address
attribute-code: groupAddress
attribute-type-flag: STRING
default-value: 1/0/1
remark: KNX group address
spring:
application:
name: @project.artifactId@
profiles:
active:
- ${NODE_ENV:dev}
logging:
file:
name: dc3/logs/driver/knx/${spring.application.name}.log
```
属性字段的含义(前面散文已建立心智模型,下表作速查):
| 字段 | 说明 |
|-----------------------|--------------------------------------------------------------------------------------------------------------|
| `attribute-name` | UI 显示名,驱动元数据约定用英文 |
| `attribute-code` | 协议实现读取的稳定 key,例如 `host`、`port`、`objectType` |
| `attribute-type-flag` | 属性类型,`AttributeTypeEnum` 共 8 值:`STRING` / `BYTE` / `SHORT` / `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN` |
| `default-value` | 默认值 |
| `remark` | 说明文字,建议英文 |
::: warning 开关字段名是 enabled
调度开关字段名为 `enabled`:`DriverScheduleServiceImpl` 读取 `getRead().getEnabled()` / `getCustom().getEnabled()` /
`device.getEnabled()`,绑定到 `DriverProperties` 内的 `private Boolean enabled`。Spring 宽松绑定不会把 `enable` 映射到
`enabled`(属于不同属性名)。`dc3-driver-virtual` 模板里写的是 `enable`,该字段实际不会生效——新驱动请用 `enabled`。注意
device health 的 `enabled` 默认为 `false`,需显式置 `true` 才启用。
:::
属性注册的链路是:`application.yml` 的 `dc3.driver` → SDK 解析为 `RegisterBO` → 经 gRPC 提交到管理中心。下图给出这条注册流的实体关系:
### 第 4 步:实现 `DriverCustomService`
核心协议逻辑放在 `DriverCustomService` 实现里。`read(...)` 返回一条 `ReadPointValue`,`write(...)` 返回 `Boolean`
——这是协议契约的全部对外约定(源码 `DriverProtocol.java`):
```java
@Slf4j
@Service
public class KnxDriverCustomServiceImpl implements DriverCustomService {
@Resource
private DriverMetadata driverMetadata;
@Resource
private DriverSenderService driverSenderService;
@Override
public void initial() {
// 一次性初始化:建立协议栈、连接池、订阅关系
}
@Override
public void schedule() {
// 自定义周期任务,例如周期上报设备状态(带 TTL)
driverMetadata.getDeviceIds().forEach(deviceId ->
driverSenderService.deviceStatusSender(
deviceId, EntityStatusEnum.ONLINE, 45, TimeUnit.SECONDS));
}
@Override
public void event(MetadataEventDTO metadataEvent) {
// 响应设备/位号元数据变更(ADD/UPDATE/DELETE),刷新本地缓存或订阅
}
@Override
public ReadPointValue read(Map driverConfig,
Map pointConfig,
DeviceBO device,
PointBO point) {
String host = driverConfig.get("host").getValue(String.class);
Integer port = driverConfig.get("port").getValue(Integer.class);
String groupAddress = pointConfig.get("groupAddress").getValue(String.class);
// 执行协议读取,返回原始字符串值(示例值 "0")
return new ReadPointValue(device, point, "0");
}
@Override
public Boolean write(Map driverConfig,
Map pointConfig,
DeviceBO device,
PointBO point,
WritePointValue writePointValue) {
// 执行协议写入;仅当设备确认写成功才返回 true
return true;
}
}
```
::: danger 读/写失败不要静默吞异常
`read()` / `write()` 抛异常是 SDK 约定的失败信号——SDK 会记录日志并对 RabbitMQ 上的命令做 ack/nack。写命令失败时结果不会回显写入值(
`responseValue=null`),这是为了避免"假成功"。单个位号读取失败也不应拖垮整轮采集。
:::
## 读/写调度:数据怎么出去、命令怎么进来
驱动有两条方向相反的数据流,都由 SDK 编排,你只填协议实现。
**读(出站)**:Quartz 的 `DriverReadScheduleJob` 按 `dc3.driver.schedule.read` 的 cron 触发,从 `DriverMetadata`
缓存遍历本驱动的设备,为每台设备提交读任务(线程池),调用你的 `read()` 拿到 `ReadPointValue`,再由 SDK 经 RabbitMQ 发往数据中心。你
**不需要**自己写 RabbitMQ 或 gRPC 管道。
**写(入站)**:数据中心把读/写命令经 RabbitMQ 下发到本驱动的命令队列;`PointCommandReceiver` 做去重、按设备加锁后,反向调用你的
`read()` 或 `write()`,结果再发回数据中心。
入站写命令在驱动侧的处理不是裸调用 `write()`,而是一条带校验、去重、加锁的流水线。下图展开 `PointCommandReceiver`
的处理管线(含错误路径):
发送侧统一走 `DriverSenderService`(源码 `DriverSenderService.java`),常用方法:
| 方法 | 用途 |
|-----------------------------------------------------------------------|----------------|
| `pointValueSender(PointValue)` / `pointValueSender(List)` | 发送单条 / 批量位号值 |
| `deviceStatusSender(deviceId, status)` | 上报设备状态(默认 TTL) |
| `deviceStatusSender(deviceId, status, timeout, unit)` | 上报带 TTL 的设备状态 |
| `driverAlarmSender(String)` | 上报驱动级告警 |
| `deviceAlarmSender(deviceId, String)` | 上报设备级告警 |
| `eventReportSender(EventReportDTO)` | 上报设备事件 |
| `pointCommandResultSender(...)` / `commandResultSender(...)` | 回执命令结果 |
其中 `status` 取值见 `EntityStatusEnum`:`ONLINE(0)` / `OFFLINE(1)` / `MAINTAIN(2)` / `FAULT(3)`。
::: warning 设备状态上报 TTL 必须大于读周期
设备状态以"租约"形式上报:到期未续约就判离线。TTL 必须**大于**状态上报/读取周期,否则设备会在两次心跳之间被判离线、反复掉线(flap)。例如读
cron 为 `0/30 * * * * ?`(每 30 秒),TTL 应 ≥ 25 秒;模板默认设备健康 `timeout: 45` 秒,留足了余量。
:::
## 命名与路由:哪个标识不能改
驱动路由涉及三个标识,区分清楚能避免投产后改不动的坑:
| 标识 | 来源 | 用途 |
|---------------------------|-------------------|----------------------------------------|
| `dc3.driver.code` | `application.yml` | 驱动类型唯一编码,管理中心据此识别驱动类型 |
| `dc3.driver.service` | 自动派生或显式覆盖 | 驱动实例路由标识,用于 RabbitMQ 命令队列和 routing key |
| `spring.application.name` | Maven artifactId | 日志文件名、Actuator 元数据等 |
::: danger dc3.driver.code 是稳定标识,变更需迁移
`dc3.driver.code` 一旦投产就不能随手改。它作为 driverCode
注册到管理中心、绑定该驱动类型的全部元数据——改了等于换了一个驱动类型,已接入的设备会全部失联,必须配套数据迁移方案。(RabbitMQ
命令队列与 routing key 由 `dc3.driver.service` 构建,不是 `code`,见下表。)
:::
## 构建、运行与冒烟验证
本地运行先加载环境变量(让本地 Java 进程指向 Compose 发布到 localhost 的依赖端口):
```bash
source dc3/env/dev.env.sh
```
构建新驱动及其依赖,然后运行:
::: code-group
```bash [构建]
mvn -s .mvn/settings.xml clean package -pl dc3-driver/dc3-driver-knx -am
```
```bash [运行]
java -jar dc3-driver/dc3-driver-knx/target/dc3-driver-knx.jar
```
:::
开发环境下驱动会自动向管理中心注册。查看驱动日志确认出现类似 `Driver register succeeded` 的事件即表示注册成功(注册重试时会打印
`Driver register failed on attempt n/30, retrying...`)。
走通黄金路径做一次端到端冒烟(HTTP 路径与字段来自网关合约,示例值标注为示例):
1. 管理侧建驱动、模板、位号、设备,并为驱动属性 `host`/`port`、位号属性 `groupAddress` 填配置值。
2. 等待一个读周期(默认 30 秒)。
3. 取最新位号值,确认 `read()` 的采集已落库:
```bash
# 示例:deviceId/pointId 为示例值
curl -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: ' \
-H 'Content-Type: application/json' \
-d '{"deviceId": 1, "pointId": 1, "page": {"current": 1, "size": 10}}'
```
4. 对可写位号下发写命令,确认 `write()` 被调用并回执:
```bash
curl -X POST http://localhost:8000/api/v3/data/point_command/write \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: ' \
-H 'Content-Type: application/json' \
-d '{"deviceId": 1, "pointId": 1, "value": "42"}'
```
接口返回 `commandId`,再用它查询命令历史看执行状态(`PointCommandHistoryVO` 的 `status` 取 `SUCCESS`/`FAILED` 等,写成功时
`responseValue` 回显写入值):
```bash
curl -X GET 'http://localhost:8000/api/v3/data/point_command_history/get_by_command_id?commandId=' \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: '
```
完整的命令生命周期与回执语义见 [命令平面](../architecture/command-plane)。
::: info 鉴权头怎么来
所有受保护接口都需要 `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token`。token 通过 `POST /api/v3/auth/token/salt` 取盐、
`POST /api/v3/auth/token/generate` 换取(有效期 12 小时)。详见 [API 文档](./api-documentation)。
:::
## 常见问题
| 问题 | 根因与处理 |
|---------------------------|---------------------------------------------------------------------------------------------------|
| 驱动编码冲突 | `dc3.driver.code` 重复——保持全局唯一且稳定,不要改已投产的 code |
| `DriverCustomService` 未加载 | 实现类缺 `@Service`,或不在启动类组件扫描范围内 |
| 注册一直重试不成功 | 管理中心未就绪或 gRPC 不通——看 `Driver register failed on attempt n/30` 日志,确认 `CENTER_MANAGER_HOST` 和管理中心健康态 |
| `read` 返回空或异常 | 不要静默吞异常,让日志暴露协议错误;单点失败不应拖垮整轮采集 |
| 设备频繁离线(flap) | 状态 TTL 小于读/上报周期——增大 TTL 或缩短调度周期 |
| 有读取但数据页无值 | 检查 RabbitMQ 连通性、数据中心日志和租户上下文 |
| 元数据变更不生效 | 在 `event(...)` 中更新本地协议客户端、订阅或缓存 |
| 协议依赖很重 | 只放具体驱动模块的 `pom.xml`,不要放进 `dc3-common-driver` |
## 延伸阅读
- [命令平面](../architecture/command-plane) — 读/写命令如何下发、去重、加锁、回执,与本页的 `read()`/`write()` 对接
- [模块地图](../architecture/modules) — 28 个驱动模块的全貌与 `dc3-common-driver` SDK 在依赖树中的位置
- [领域模型](../architecture/domain-model) — Profile / Point / Device 与 Param/Attribute/Config 三层的字段与边界
- [API 文档](./api-documentation) — 鉴权流程、网关合约与 OpenAPI
- [故障排查](../guide/troubleshooting) — 启动依赖、端口与环境变量类问题
---
# 开发概览与规范
URL: https://docs.dc3.site/zh/development/
# 开发概览与规范
这页写给准备给 IoT DC3
写后端代码的开发者:读完你会知道工程的权威规范在哪、命名与分层必须遵守哪些硬约定,以及提交一条改动该走的"第一条路径"。
> 你在这里:想动手扩展平台。下一步按目标分流——写新协议驱动看[驱动开发](./driver-authoring)
> ,调通接口看 [API 文档](./api-documentation),跑测试看[测试](./testing)。
## 权威规范在 `AGENTS.md`
本页是入口与速览,**真正的工程规范以仓库根的 `iot-dc3/AGENTS.md` 为准**。它是一份跨 AI 工具共享的单一事实源,覆盖模块分层、Maven
命令、验证流程、提交与变更日志规则、以及下文要点的完整版本。`iot-dc3/.claude/CLAUDE.md` 只是把规范委托给它,不重复内容。动手前先通读
`AGENTS.md`;本页与它冲突时以 `AGENTS.md` 为准。
平台是 Java 21 / Spring Boot 4 / Spring Cloud 2025 的分布式服务,跨服务协调走 gRPC,元数据落 PostgreSQL,异步消息走
RabbitMQ。这套技术栈决定了下面三条不可绕过的约定:CRUD 动词随结果基数走、跨服务调用必须经 facade、领域对象按 DO/BO/VO 分层。
## CRUD 动词随"结果基数"走
平台没有自由命名空间:每个 CRUD 形态的方法、HTTP 路径、gRPC RPC、以及前端 API 函数,**动词必须反映返回结果的基数**——查单条用
`get`,查集合用 `list`。这条约定横跨 Service 接口、ServiceImpl、Controller、Local/gRPC Facade、gRPC server 与 `.proto` 里的
RPC 名,前后端两个仓库一致执行。它的价值是:看到一个方法名或一条路径,不用读实现就知道它返回一条还是一批。
| 动作 | Java 方法 | HTTP 路径 | gRPC RPC | 前端函数 |
|-----|----------------|-------------|-----------|------------------|
| 查单条 | `getXxx(...)` | `/get_xxx` | `GetXxx` | `getXxx(...)` |
| 查集合 | `listXxx(...)` | `/list_xxx` | `ListXxx` | `listXxx(...)` |
| 新增 | `add(BO)` | `/add` | n/a | `addXxx(...)` |
| 更新 | `update(BO)` | `/update` | n/a | `updateXxx(...)` |
| 删除 | `delete(Long)` | `/delete` | n/a | `deleteXxx(...)` |
`add`/`delete`/`update`/`getById`/`list(Q)` 这五个基础方法由 `BaseService` 继承而来;子接口只在需要按维度查询时追加
`getByXxx`/`listByXxx`,且动词仍要匹配基数。`DeviceController` 是一个现成范本——它的端点恰好是 `/add`、`/delete`、`/update`、
`/get_by_id`(查单条)、`/list_by_ids`、`/list_by_profile_id`、`/list`(查集合),动词与基数严格对齐。
::: warning 三个保留动词不要混用
- `select*` 只用于 `*ManagerImpl` 里对 MyBatis Mapper 的原始调用,**不出现**在 Service/Controller/Facade 上。
- `remove*` 只用于 MyBatis-Plus 继承来的 Manager 方法(`removeById`、`remove(wrapper)`);业务删除一律用 `delete*`。
- `find*`、`query*`、`fetch*` 不作为主 CRUD 动词。
:::
## 分层调用:Controller(VO) → Service(BO) → Manager(DO) / Facade(跨服务)
请求进来要穿过三层,每层只认一种数据表示。Controller 接收和返回 **VO**(API 形态);Service 接口继承 `BaseService`,**只在
BO 类型上工作**(业务语义,用领域枚举如 `EnableFlagEnum`);Manager/Mapper 操作 **DO**(数据库形态,flag 用 `Byte`)。三种表示之间由
MapStruct 的 `*Builder` 转换,DO 的 flag 绝不直接泄漏到业务或响应模型。
这里有一条硬边界:**当业务代码需要别的服务的数据时,不能直连传输细节,必须经过 facade 接口**。Controller 和 Service 类不绑定
gRPC 或 REST 的任何细节——它们只调用 `dc3-common-facade-api` 里的契约接口,由部署形态决定背后是 gRPC 实现(
`dc3-common-facade-grpc`)还是同进程实现(`dc3-common-facade-local-*`)。分布式部署默认走 `grpc`(`DC3_FACADE_MODE=grpc`)。
图中那条标注"必须经 facade"的虚线就是边界本身:左半边是本服务内的 VO→BO→DO 直落,右半边是任何跨服务读写都要先抽象成 facade
契约、再由配置选择传输实现。这让 `grpc`(分布式)与 `local`(单体)成为纯部署拓扑选择,业务代码一行不改。各层对象的字段、枚举转换与
`*Builder` 的细节见[领域模型](../architecture/domain-model)。
## 第一条路径:从改一个端点到提交
把上面三条约定串起来,一次典型的后端改动是这样走的。假设你要给设备管理加一个"按 driverId 查设备数量"的接口:
::: code-group
```text [分层落点]
1. VO/BO/DO 在 dc3-common-model 或对应模块补字段(若需要),MapStruct *Builder 同步
2. Manager *ManagerImpl 里用 select* 调 Mapper(仅此处可用 select*)
3. Service 在 Service 接口加 getCountByDriverId(...),ServiceImpl 实现,只碰 BO
4. 跨服务? 若要拿别的中心的数据,走 *Facade 接口,不直连 gRPC
5. Controller GET /get_count_by_driver_id —— 查单值用 get 动词
```
```bash [验证]
# 快速编译校验(改 Java/共享行为后必跑)
mvn -s .mvn/settings.xml -q -DskipTests compile
# 完整打包(提交前按改动比例选择)
mvn -s .mvn/settings.xml clean package
# 改了 DAL/SQL:需要容器运行时的集成测试
make test-it
```
:::
写完代码、跑过验证,再提交。
## 提交规范:Conventional Commits
提交信息直接变成发布说明(`CHANGE.md` 由 git 历史生成),所以 subject 必须具体、可读。格式固定为:
```text
(optional-scope):
```
- subject 用**英文、小写、祈使句**,足够具体以便写进 `CHANGE.md`。
- 允许的 type:`feat`、`fix`、`perf`、`refactor`、`docs`、`build`、`ci`、`test`、`chore`、`style`、`security`、`revert`。
- 非根级的微小改动尽量带 scope;破坏性变更用 `!` 并在 body 说明影响。
- 不要用 `update`、`fix bug`、`change code`、`misc`、`wip`、`.` 这类弱 subject。
真实示例:
```text
feat(agentic): add session cleanup policy
fix(manager): validate tenant scope for device queries
docs(env): explain JetBrains IDEA environment variables
```
::: warning 提交前的硬约定
- AI 协作代理**未经明确确认不得创建提交**;提交前需展示拟用的 commit message 与纳入的文件,等待批准。
- 不要把无关改动塞进一个提交——按意图拆分(功能、修复、重构各自成提交)。
- 发布说明专用提交固定为 `docs(release): update generated changelog`,且 `CHANGE.md` 单独提交。
- 提交前对照上面的格式与真实示例自查 commit message;不合规格式会被 CI 拦在合并前。
:::
## 常用 Maven 命令速查
| 场景 | 命令 | 说明 |
|--------|----------------------------------------------------------------------|-------------|
| 全量编译 | `mvn -s .mvn/settings.xml compile` | 只编译,不跑测试 |
| 快速编译检查 | `mvn -s .mvn/settings.xml -q -DskipTests compile` | 安静模式,改完快速验证 |
| 全量打包 | `mvn -s .mvn/settings.xml clean package` | 编译+测试+打包 |
| 跳测试打包 | `mvn -s .mvn/settings.xml -DskipTests clean package` | 不跑测试 |
| 单模块打包 | `mvn -s .mvn/settings.xml -pl dc3-driver/dc3-driver-virtual package` | -pl 指定模块 |
| 查看依赖树 | `mvn -s .mvn/settings.xml dependency:tree -pl <模块>` | 排查传递冲突 |
::: tip 并行构建
`.mvn/maven.config` 已配 `-T 1C`,不需要手动加。
:::
## 调试技巧
### IDEA 远程调试
VM options 添加 `-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005`,然后 Run → Attach to Process。
建议调试端口:Gateway 5005、Auth 5006、Manager 5007、Data 5008、Agentic 5009。
### 模块导航速查
| 我要... | 去这里 |
|---------------|-----------------------------------------|
| 改设备/驱动/位号业务逻辑 | `dc3-center/dc3-center-manager` |
| 改认证/租户/RBAC | `dc3-center/dc3-center-auth` |
| 改位号值存储/命令分发 | `dc3-center/dc3-center-data` |
| 改 AI 对话/工具调用 | `dc3-center/dc3-center-agentic` |
| 改网关路由/过滤器 | `dc3-gateway` |
| 新加协议驱动 | `dc3-driver/`,从 `dc3-driver-virtual` 复制 |
| 改 gRPC proto | `dc3-api/`,改完重新 `mvn compile` |
| 改前端页面 | `dc3-web/`(独立 pnpm 项目) |
## 延伸阅读
- [领域模型](../architecture/domain-model) — DO/BO/VO 各层字段、枚举转换与 MapStruct `*Builder` 细节
- [驱动开发](./driver-authoring) — 复制 `dc3-driver-virtual` 模板扩展新协议驱动,实现 `DriverCustomService`
- [API 文档](./api-documentation) — OpenAPI/Swagger 暴露方式、鉴权头与导出流程
- [测试](./testing) — 单元、集成、E2E 与覆盖率约定
---
# 技术栈
URL: https://docs.dc3.site/zh/development/technology-stack
这页汇总 IoT DC3 当前推荐和运行中的主要技术选型。精确版本以仓库内的 `pom.xml`、`dc3-web/package.json` 和
`docs/package.json` 为准;README 只保留面向新读者的摘要。
## 后端与中心服务
| 范围 | 技术 | 用途 |
|-----------|------------------------------------------------------|---------------------------------------------------------------------|
| 语言与框架 | Java 21 · Spring Boot 4 · Spring Cloud 2025 | 网关、四个中心服务与驱动进程的运行基础 |
| AI 集成 | Spring AI 2.0 | Agentic Center 接入 OpenAI-compatible provider、Tool Calling 与 MCP 工作流 |
| Web 与 API | Spring WebFlux · Spring Security · springdoc-openapi | HTTP API、认证鉴权、聚合 API 文档 |
| 服务协作 | gRPC · Protobuf · Facade 接口 | 中心服务之间的强类型调用契约 |
| 构建 | Maven 3.9+ | 多模块构建、测试、打包和依赖版本管理 |
## 数据、消息与调度
| 范围 | 技术 | 用途 |
|------------|------------------------------|-------------------------|
| 主存储 | PostgreSQL | 业务数据、租户、资源、设备模型与运行数据 |
| 时序与扩展 | TimescaleDB · AGE · pgvector | 位号历史、图能力和向量能力扩展 |
| ORM / 数据访问 | MyBatis-Plus | DO 层持久化访问与分页查询 |
| 消息总线 | RabbitMQ | 驱动与数据中心之间的异步值上报、命令下发与削峰 |
| 缓存与调度 | Caffeine · Quartz | 进程内缓存、定时任务与调度 |
## 前端、文档与自动化
| 范围 | 技术 | 用途 |
|---------|----------------------------------------------|------------------------------------------|
| Web 前端 | Vue 3 · TypeScript 6 · Vite 8 · Element Plus | `dc3-web/` 下的管理控制台 |
| 可视化 | AntV G2/G6 | 仪表盘图表与关系可视化 |
| 文档站 | VitePress · Mermaid | 当前 `docs/` 文档站、架构图和流程图 |
| CLI 自动化 | TypeScript · pnpm · Vitest | sibling `dc3-cli/` 项目,面向 Gateway 的命令行客户端 |
| 容器部署 | Podman · Docker Compose | 本地依赖、开发栈、应用栈和可选观测栈 |
## 继续阅读
- [从源码本地开发](../quickstart/) — 启动依赖、加载环境变量、构建与验证
- [前端开发](../frontend/) — `dc3-web/` 的运行、目录和测试命令
- [系统架构总览](../architecture/) — 网关、中心服务、驱动、消息总线和存储如何协作
- [模块地图](../architecture/modules) — Maven 模块、部署单元和依赖关系
---
# 测试
URL: https://docs.dc3.site/zh/development/testing
# 测试
这页讲清 IoT DC3 后端的测试怎么分层、什么时候必须补测试、用哪条命令跑哪一层。读完你能选对测试类型、在本地把单元/集成/E2E
跑起来,并知道改一处代码该补哪种测试才算"完成"。
> 你在这里:已读过[开发概览与规范](./)、想把改动验证到可合并。写驱动的测试要点另见[驱动开发](./driver-authoring)。
## 为什么要分层
测试不是越重越好。一个断言能在毫秒级捕获的逻辑错误,没必要拉起 PostgreSQL 容器去验;而跨服务的消息契约、时序库的落库行为,靠纯
Mock 又测不出真实问题。IoT DC3 据此把测试分成三层,越往下越快、越多、越孤立,越往上越慢、越少、越接近真实链路:
- **单元测试**最多,跑得最快,验证孤立的业务逻辑,不启动 Spring 上下文。
- **集成测试**用 Testcontainers 拉起真实 PostgreSQL/TimescaleDB、RabbitMQ、MQTT,验证 DAL、gRPC、消息这些跨组件协作。
- **E2E 测试**最少,验证端到端业务链路(命令下发、事件路由、时序表操作),默认关闭,靠环境变量显式启用。
选层的判断很直接:能不碰外部依赖就用单元测试;非要真容器才能复现的(SQL、消息、gRPC 契约)才上集成;只有验证整条链路时才动 E2E。
## 三层金字塔与对应命令
下图把三层、各自的范围与启动方式、以及触发命令对齐在一起。底层执行器(Surefire/Failsafe)和门禁(`DC3_E2E`)决定了同一次 `mvn`
会跑到哪一层。
::: warning 集成与 E2E 需要容器运行时
`make test-it` 和 `make test-e2e` 都通过 Testcontainers 在运行时拉起 PostgreSQL/TimescaleDB、RabbitMQ
等容器,因此本地必须有可用的容器运行时(podman 或 Docker)。E2E 在此基础上还会在共享 Docker
网络上引导一整套真实依赖。没有容器运行时时,这两条命令会失败而非跳过——`make test`(纯单元)不受影响。
:::
各层的目标、执行器与技术栈对照如下(作参考,细节以上文为准):
| 层级 | 目标 | 执行器 / 门禁 | 技术方式 |
|--------|----------------|----------------------------------------------------|--------------------------------------------------------|
| 单元测试 | 快速验证孤立业务逻辑 | Surefire | JUnit 5、Mockito 5、AssertJ;Reactor `StepVerifier` 验证响应式 |
| 集成测试 | 验证真实基础设施与跨模块协作 | Failsafe;`*IT.java` | Testcontainers、gRPC InProcess、RabbitMQ harness |
| E2E 测试 | 验证端到端业务链路 | `@EnabledIfEnvironmentVariable(named = "DC3_E2E")` | `dc3-e2e`、Testcontainers(共享 Docker 网络) |
## 本地怎么跑
后端命令统一走 `iot-dc3/` 下的 Makefile。最常用的四条:
```bash
make test # 单元测试(Surefire)
make test-it # 集成测试(Failsafe + Testcontainers,需容器运行时)
make test-e2e # E2E:等价于 DC3_E2E=true mvn -s .mvn/settings.xml -pl dc3-e2e -am -Pe2e verify
make coverage # 聚合 JaCoCo 报告(dc3-coverage -am verify)
```
`make test-e2e` 本身已设置 `DC3_E2E=true` 并只在 `dc3-e2e` 模块上跑 `-Pe2e`,无需手动导出环境变量。只想跑单个模块或单个用例时,直接用
Maven:
```bash
# 指定模块的单元测试
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-manager
# 单个测试类 / 单个方法
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-manager -Dtest=DeviceControllerTest
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-public -Dtest="RTest#testOkWithData"
```
`make coverage` 完成后,聚合报告落在:
```text
dc3-coverage/target/site/jacoco-aggregate/index.html
```
::: info Failsafe 为何指向 outputDirectory
父 POM 给 Failsafe 配置了 `classesDirectory=${project.build.outputDirectory}`。原因是 Spring Boot 会把模块产物 repackage
成可执行 fat jar,那个 jar 在 Failsafe classpath 上不可直接加载;让集成测试针对未重打包的普通编译类运行,才能正确加载被测类。这是改动驱动等可执行模块的集成测试时需要留意的坑。
:::
## 前端测试命令
前端(`dc3-web/`)用 pnpm + Vitest(单元/接口/组件/视图)与 Playwright(E2E),与后端独立:
::: code-group
```bash [Vitest 分套]
pnpm test # 全部 Vitest 套件
pnpm test:unit # tests/unit
pnpm test:api # tests/api
pnpm test:component # tests/component
pnpm test:views # tests/views
pnpm test:guard # tests/guardrails(AI 编码护栏)
pnpm test:ci # vitest run --coverage(CI 门禁)
```
```bash [Playwright E2E]
pnpm test:e2e # headless chromium
pnpm test:e2e:headed # 可见浏览器(E2E_HEADLESS=false)
```
:::
## 什么时候必须补测试
核心约定:**改 bug 先写能复现的回归测试,再修。** 把"这个 bug 不再出现"变成一条可执行、会失败的测试,修完它变绿,才算闭环。其余变更按风险补测:
| 变更类型 | 要求 |
|---------------|---------------------------------------|
| Bug 修复 | 先补能复现问题的回归测试,再实现修复 |
| 新功能 / 行为变化 | 补单元测试,按风险追加集成测试 |
| 重构 | 保持现有覆盖;隐式契约补 contract 测试 |
| DAL / SQL 变更 | 补 Testcontainers 测试并运行 `make test-it` |
| gRPC proto 变更 | 同步更新服务端、客户端与契约测试 |
| 纯文档 / 格式变更 | 不需要 Java 测试,做文档构建或格式检查即可 |
## 可复用的测试基础设施
`dc3-common-test` 模块沉淀了跨模块共享的容器与基类,避免每个模块各搭一套。集成测试直接复用这些单例容器和 harness:
| 工具 | 用途 |
|--------------------------|-------------------------------------------------------|
| `PgTimescaleContainer` | 单例 `timescale/timescaledb-ha:pg18` 容器,数据库与时序表测试 |
| `RabbitContainer` | RabbitMQ 容器,消息发布、确认、消费测试 |
| `MqttContainer` | MQTT 容器,MQTT 驱动测试 |
| `GrpcInProcessExtension` | JUnit 5 扩展:每个测试一组 in-process gRPC server + 托管 channel |
| `RabbitTestHarness` | 测试内收发 RabbitMQ,`awaitTrue()` 基于 Awaitility |
| `FixedClockConfig` | `@TestConfiguration`,把 `Clock` bean 固定到确定时刻 |
两个契约测试基类用于守住横切约定:
- `EnumContractTest`:通过 `@TestFactory` 验证枚举 `getIndex()` 唯一、`ofIndex()` 往返、常量名稳定。
- `SecretFieldContractTest`:验证 `apiKey`、`password`、`secret`、`token` 等敏感字段不从 `@ToString` 与序列化中泄漏。
::: tip 时间、随机数与等待
注入 `java.time.Clock`,不要直接 `LocalDateTime.now()`;需要固定时间用 `FixedClockConfig`。异步等待用 Awaitility,禁止裸用
`Thread.sleep`。WebFlux 用 `WebTestClient`,不用 `MockMvc`;gRPC 用 in-process channel,不打开真实 socket。
:::
## 覆盖率门禁
`make coverage` 聚合各模块的 JaCoCo 数据,由 `dc3-coverage/pom.xml` 的门槛把关:
| 指标 | 当前门槛 |
|-------|---------------------------------------|
| 行覆盖率 | `coverage.line.minimum = 0.20`(20%) |
| 分支覆盖率 | `coverage.branch.minimum = 0.15`(15%) |
门槛当前相对克制,便于测试体系扩展期持续推进;判定只看静态最低阈值——任一指标低于上表门槛即阻断改动,不做与历史基线的回退比较。提高门槛时,应同时提交能支撑新门槛的测试,而非只调数字。
## CI 工作流
PR 与推送在 GitHub Actions 上分三个工作流跑测试,与本地命令一一对应:
| Workflow | 触发 | 主要任务 |
|------------|-----------------------------------|-----------|
| `ci.yml` | push / PR 到 develop、release、main | 快速编译 |
| `test.yml` | push / PR 到 develop、release、main | 单元、集成、覆盖率 |
| `e2e.yml` | push 到 develop、release、main 或手动触发 | E2E |
合并前应确认:单元与集成 job 通过;覆盖率不低于 `dc3-coverage/pom.xml` 门槛(低于即阻断);行为变更在说明里写清已验证内容与未验证风险。
## 延伸阅读
- [开发概览与规范](./) — 二次开发的整体约定与命令入口
- [驱动开发](./driver-authoring) — 派生新驱动时如何补协议层与集成测试
- [环境变量](../quickstart/environment) — 本地 Java 运行所需的依赖主机与端口
---
# BACnet/IP 驱动
URL: https://docs.dc3.site/zh/drivers/bacnet-ip
# BACnet/IP 驱动
`dc3-driver-bacnet-ip` 把 BACnet/IP 设备接入 IoT DC3。它作为本地 BACnet
设备加入网络,通过广播发现远端设备,周期性读取对象属性值,并支持向可写对象属性写值。读完你能在[设备](../introduction/concepts/device)
上配好本地组网参数、在[位号](../introduction/concepts/point)上用"对象类型 + 实例号 + 属性"定位远端数据点,并定位常见的"
发现不到设备 / 读到错对象 / 写不下去"问题。
> 你在这里:网络层"楼宇自控侧"的一个落地驱动。BACnet
> 的对象-属性寻址模型与它在四层架构中的位置,见[工业总线与协议](../foundations/fieldbus)。
## 协议背景
BACnet(Building Automation and Control network)是楼宇自控领域的国际标准协议(ASHRAE 135 / ISO 16484-5),自 1995
年起广泛用于空调机组、新风、照明、电梯、冷热源、温控器等机电设备。它的设计目标是让不同厂商的楼宇设备能互操作,因此把"设备能力"
抽象成一套统一的对象模型,而不绑定具体硬件。
**BACnet/IP** 是 BACnet 跑在以太网上的变体:把 BACnet 应用层报文封装进 UDP,默认监听 **47808** 端口(即 `0xBAC0`)。相比早期跑在
MS/TP 串口或以太网 ISO 8802-3 上的形态,BACnet/IP 直接复用现有 IP 网络,靠 UDP
广播在子网内发现设备,这也决定了它的一个硬约束——广播默认不跨三层路由(见下方故障排查)。
在[物联网四层架构](../foundations/fieldbus)里,BACnet/IP 属于**网络层**
的楼宇自控侧:它定义机电设备如何在网络上被寻址与读写,把感知层采集的温度、状态、计量等物理量搬运到平台。它的通信模型是*
*主从 / 请求-响应**——本驱动作为发起方(client)主动发现并访问远端设备,按 cron 周期轮询读取,远端设备不主动上报。
BACnet 的寻址是一套三层结构,理解它就理解了位号怎么配:
一台物理设备由唯一的**设备实例号**标识;设备内含若干**对象**(如 `ANALOG_INPUT`、`BINARY_OUTPUT`),每个对象由"对象类型 +
对象实例号"定位;对象又有若干**属性**,最常用的是 `PRESENT_VALUE`(当前值)。读写一个测点,本质就是定位"远端设备 → 对象 →
属性"这条路径。
- **驱动名 / code**:`BACnet IP Driver` / `BacnetIpDriver`
- **类型**:`DRIVER_CLIENT`(本驱动主动发现并访问远端设备)
## 属性配置
接入一台 BACnet/IP 设备,需要在三个层面填[属性](../introduction/concepts/attribute-config):设备级的本地组网参数(
`driver-attribute`)、每个采集位号的寻址参数(`point-attribute`)、每个可写位号的写命令参数(`command-attribute`
)。下面各属性、类型、默认值均取自驱动的 `application.yml`(`dc3-driver-bacnet-ip` 模块)。
### 驱动属性(设备级 `driver-attribute`)
驱动属性回答"本驱动用什么身份、绑哪块网卡、怎么发广播"——注意这里配的是**本地驱动**接入网络的参数,**不是**
远端设备地址;远端设备靠下面位号里的 `remoteDeviceId` 定位。在[设备](../introduction/concepts/device)上为每台设备填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------------|--------------------|--------|-------------------|----------------------------------|
| Local Device ID | `localDeviceId` | INT | `1001` | 本地 BACnet 设备实例号 |
| Bind Address | `bindAddress` | STRING | `0.0.0.0` | 本地绑定地址 |
| Port | `port` | INT | `47808` | BACnet UDP 端口(默认 47808 = 0xBAC0) |
| Broadcast Address | `broadcastAddress` | STRING | `255.255.255.255` | 设备发现用的广播地址 |
| Timeout | `timeout` | INT | `6000` | 请求超时时间(毫秒) |
`localDeviceId` 是本驱动作为 `LocalDevice` 加入网络时占用的实例号,需与网络上现有 BACnet 设备的实例号都不冲突。
`bindAddress` 默认 `0.0.0.0` 让系统自选网卡;本机有多块网卡、或广播打不到目标网段时才需指定具体网卡 IP。`broadcastAddress`
是发现远端设备时发广播用的地址,默认 `255.255.255.255` 是受限广播,跨网段接入时要据实改成目标子网的定向广播地址。驱动按设备
ID 缓存连接(一台设备一个 `LocalDevice`),底层传输超时取 `timeout`。配置校验(`validate`)会要求 `localDeviceId`、
`bindAddress`、`port` 三项非空。
### 位号属性(`point-attribute`)
位号属性回答"读哪台远端设备的哪个对象的哪个属性"。每个采集[位号](../introduction/concepts/point)填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|------------------|------------------|--------|-----------------|--------------------|
| Remote Device ID | `remoteDeviceId` | INT | `0` | 远端 BACnet 设备实例号 |
| Object Type | `objectType` | STRING | `ANALOG_INPUT` | BACnet 对象类型(见下方枚举) |
| Object Instance | `objectInstance` | INT | `0` | 对象实例号 |
| Property ID | `propertyId` | STRING | `PRESENT_VALUE` | 属性标识 |
`remoteDeviceId` 是要读的那台远端设备的实例号,驱动先按它广播发现设备,再按 `objectType` + `objectInstance` 定位对象、按
`propertyId` 取属性。`objectType` 与 `propertyId` 都是按**精确的大写枚举名**
匹配的,取值来源于代码里固定的映射表(详见下方易错点与"在 IoT DC3 中如何落地")。配置校验(`validatePoint`)会要求这四项全部非空。
### 写命令属性(`command-attribute`)
可写位号还要在写命令上填一组,结构与位号属性一致,但默认指向可写的输出对象:
| 属性 | code | 类型 | 默认值 | 说明 |
|------------------|------------------|--------|-----------------|-------------------|
| Remote Device ID | `remoteDeviceId` | INT | `0` | 远端 BACnet 设备实例号 |
| Object Type | `objectType` | STRING | `ANALOG_OUTPUT` | 写入目标的 BACnet 对象类型 |
| Object Instance | `objectInstance` | INT | `0` | 对象实例号 |
| Property ID | `propertyId` | STRING | `PRESENT_VALUE` | 属性标识 |
写值会按目标对象类型自动编码(见下方"写值编码"提示)。
::: warning Object Type / Property ID 必须用精确的大写枚举名,否则静默回退
`objectType` 和 `propertyId` 按精确的大写名匹配。填错或拼错**不会报错**——驱动的 `resolveObjectType()` /
`resolvePropertyIdentifier()` 会**静默回退**到 `ANALOG_INPUT` / `PRESENT_VALUE`,于是你可能读到的是另一个对象的值却毫无察觉。
- `objectType` 代码支持 **10** 种:`ANALOG_INPUT`、`ANALOG_OUTPUT`、`ANALOG_VALUE`、`BINARY_INPUT`、`BINARY_OUTPUT`、
`BINARY_VALUE`、`MULTI_STATE_INPUT`、`MULTI_STATE_OUTPUT`、`MULTI_STATE_VALUE`、`DEVICE`(yml 的 remark 只列了前 9 种,
`DEVICE` 也可用——以代码为准)。
- `propertyId` 支持 7 种:`PRESENT_VALUE`、`DESCRIPTION`、`STATUS_FLAGS`、`EVENT_STATE`、`RELIABILITY`、`UNITS`、
`OUT_OF_SERVICE`。
:::
## 故障排查
BACnet/IP 接入失败大多集中在广播发现、对象寻址、写值编码三类。按下面顺序排查:
1. **设备一直 offline / 创建连接失败**。驱动以 `localDeviceId` 加入网络时若该实例号与网络上已有设备冲突、或绑定端口/网卡失败,
`LocalDevice.initialize()` 会抛 `ConnectorException`,该设备始终 offline。先确认 `localDeviceId` 在网络内唯一、`47808`
端口未被同机其他 BACnet 程序占用、`bindAddress` 指向真实可用网卡。
2. **连上了但读取一直超时(卡到 timeout)**。驱动靠 `getRemoteDeviceBlocking(remoteDeviceId)` 阻塞等待广播发现远端设备——
`remoteDeviceId` 在网络上找不到时会**一直阻塞到超时**再抛 `ReadPointException`。先核对 `remoteDeviceId`
是否就是目标设备的实例号;再确认驱动与目标设备处在同一广播域(见下方易错点)。
3. **读到的值不对 / 像是别的点**。多半是 `objectType` 或 `propertyId` 拼错触发了静默回退(回退到 `ANALOG_INPUT` /
`PRESENT_VALUE`)。逐字符核对大写枚举名,确认在上方支持列表内。
4. **写命令失败**。先确认目标对象类型可写(`*_INPUT` 类对象通常物理只读,写不进去),再确认传入值与编码规则匹配(见下方"
写值编码")。写失败会抛 `WritePointException`。
5. **跨网段 / 多网卡环境读不到设备**。BACnet/IP 走 UDP 广播,默认不跨三层路由。详见下方易错点容器。
6. **设备在线状态抖动**。健康检查默认每 15 秒一次(cron `0/15 * * * * ?`)、租约超时 45 秒;驱动按
`LocalDevice.isInitialized()` 判定在线。若频繁在 online/offline
间跳变,多半是网络丢包或本地设备初始化不稳定——在线状态机制见[设备](../introduction/concepts/device)。
::: tip 写值编码由对象类型决定
驱动 `createEncodable()` 按对象类型前缀决定怎么编码写入值:`ANALOG_*` 当浮点数(`Real`)写;`BINARY_*` 把 `true` / `1` /
`active`(不分大小写)当"激活"、其余当"未激活";`MULTI_STATE_*` 与 `DEVICE` 当整数(`UnsignedInteger`)写;非数值且落到模拟量分支时退化为字符串(
`CharacterString`)。所以给 `BINARY_OUTPUT` 写开关,传 `1` 或 `true` 而不是 `ON`。
:::
::: warning 远端设备必须能被广播发现
驱动靠广播在网络上发现远端设备后才能读写。BACnet/IP 走 UDP 广播,通常无法跨三层路由——请确保驱动与目标设备在同一广播域;跨网段时需在网络上部署
**BBMD**(BACnet/IP Broadcast Management Device),并把 `broadcastAddress` 据实改成目标子网的定向广播地址。找不到
`remoteDeviceId` 会阻塞直至超时(见上方排查第 2 条)。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`BacnetIpDriver`(类型 `DRIVER_CLIENT`,主动发现并访问远端设备)。这是稳定的路由标识,不要随意改。
- **读能力**:✓ 已实现。按 `remoteDeviceId` 广播发现设备,按 `objectType` + `objectInstance` + `propertyId`
读对象属性,结果以字符串形态回传。
- **写能力**:✓ 已实现。按对象类型自动编码写值(见上方"写值编码"提示)。
- **订阅/上报**:— 不支持。BACnet 是主从轮询模型,本驱动只主动读写、不被动接收推送(未实现 COV
订阅)。这与[驱动能力矩阵](./matrix)中 BACnet/IP 的「✓ / ✓ / —」一致。
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮),在驱动 `application.yml` 的 `schedule.read` 配置。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`,按 `LocalDevice.isInitialized()` 判定。
::: info 实现状态:可用
本驱动是**完整实现**(非骨架),底层基于 BACnet4J。`read()` / `write()` 走真实的 BACnet 读写请求,`health()` 按本地设备初始化状态判在线,
`validate()` / `validatePoint()` 做必填校验,并按设备 ID 缓存 `LocalDevice` 连接。需注意两处与直觉不同的行为:①
`objectType` / `propertyId` 拼错会**静默回退**而非报错;② 找不到 `remoteDeviceId` 会**阻塞至超时**——均见上文。
:::
::: info schedule.custom 已开启但为空实现
驱动 `application.yml` 标注了 `schedule.custom`(cron `0/5 * * * * ?`、`enable: true`),但 `schedule()`
方法是空实现(无自定义周期逻辑)。也就是说该自定义调度当前不产生任何采集行为,实际采集只由 `schedule.read` 驱动——以代码为准。
:::
### 最小接入示例
把网络上一台设备实例号为 `9001` 的 BACnet/IP 设备的温度对象接进来:
1. 选 `BACnet IP Driver` 创建[设备](../introduction/concepts/device),driver 属性可全用默认(`localDeviceId=1001`、
`bindAddress=0.0.0.0`、`port=47808`、`broadcastAddress=255.255.255.255`、`timeout=6000`);只有当本机有多块网卡、或广播打不到目标网段时才需调整
`bindAddress` / `broadcastAddress`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(`READ_ONLY`
),point 属性填 `remoteDeviceId=9001`、`objectType=ANALOG_INPUT`、`objectInstance=1`、`propertyId=PRESENT_VALUE`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到该对象的 `PRESENT_VALUE`。
4. 若该位号需可写,给它配写[命令](../introduction/concepts/command),把 `objectType` 显式设为可写的输出对象(如
`ANALOG_OUTPUT`),写值按对象类型规则传(模拟量传数字、开关量传 `1`/`true`)。
::: tip 一个驱动实例可接多台设备
同一个 BACnet/IP 驱动进程可服务多台设备,每台按设备 ID 各持一个缓存的 `LocalDevice`,由位号里的 `remoteDeviceId` 区分目标远端设备。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动入口与分类
- [驱动能力矩阵](./matrix) — 读/写/订阅能力一览,含 BACnet/IP 行
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — BACnet 的对象-属性寻址模型与楼宇自控侧的定位
- [SNMP 驱动](./snmp) — 另一种主动读写的网络管理类协议
---
# BLE 驱动
URL: https://docs.dc3.site/zh/drivers/ble
`dc3-driver-ble` 把蓝牙低功耗(BLE)设备接入 IoT DC3。它作为 BLE 主机(central),通过主机上的蓝牙适配器连到外设,周期性读取
GATT 特征的字节、按配置格式解析成[位号值](../introduction/concepts/point-value)
,并支持把命令值写回特征。读完你能在[设备](../introduction/concepts/device)上配好 `adapterName`/`deviceAddress`
、在[位号](../introduction/concepts/point)上配好服务/特征 UUID 与解析格式,并定位常见的"设备一直离线/读不到值"问题。
## 协议背景
BLE(Bluetooth Low Energy,蓝牙低功耗)是可穿戴、环境传感器、信标、便携仪表上最常见的近场无线协议——典型十米级覆盖、低速率、极省电,靠纽扣电池可跑数月到数年。在物联网四层参考架构里,BLE
属于[网络层](../foundations/iot-protocols)中的**无线接入**一侧:它只负责"信号怎么在空中传",本身不规定上层消息怎么组织,因此
BLE 设备通常需要一个主机或网关做中继才能把数据送上互联网——本驱动就扮演这个 central 主机角色。
BLE 设备把数据组织成 **GATT**(Generic Attribute Profile)树状结构:一个外设(peripheral)包含若干 **Service(服务)**,每个服务下挂若干
**Characteristic(特征)**,每个特征用一个 **UUID** 唯一标识,特征值就是一段原始字节。主机要读一个数据点,就向"服务 UUID + 特征
UUID"定位到的特征发起 read;要写,就把字节 write 进目标特征。本驱动据此把每个[位号](../introduction/concepts/point)
映射到外设上的一个特征,按位号配置的 UUID 读字节、写字节,再按配置的格式把字节解析成位号值。
::: info GATT 决定"怎么寻址一个数据点"
Modbus 用"功能码 + 寄存器地址"寻址,BLE 用"服务 UUID + 特征 UUID"寻址——两者都是"先连到设备、再定位到设备内部的一个数据点"
。理解这层映射,配置时就不会把 Service 和 Characteristic 搞混。
:::
底层传输上,本驱动用 Sputnikdev Bluetooth Manager 框架搭配 TinyB 传输,由它在运行主机的物理蓝牙适配器(默认 `hci0`
)之上完成扫描、连接与 GATT 读写。
## 属性配置
接入一台 BLE 设备,需要在三个层面填[属性](../introduction/concepts/attribute-config):设备级的连接参数(`driver-attribute`
)、每个采集位号的寻址与解析参数(`point-attribute`)、每个可写位号的写命令参数(`command-attribute`)。下面各属性、类型、默认值均取自驱动的
`application.yml`(`dc3-driver-ble` 模块)。
### 驱动属性(`driver-attribute`)
驱动属性回答"用哪个适配器连到哪台外设"。`adapterName` 指定运行主机上的蓝牙适配器名(Linux 下通常是 `hci0`、`hci1`);
`deviceAddress` 是外设的 MAC 地址,作为外设的唯一标识。`connectionTimeout` 虽在 `application.yml`
里声明,但当前驱动代码并未读取它(见下方提示)。在[设备](../introduction/concepts/device)上为每台 BLE 设备填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------------|---------------------|--------|---------|--------------------------------------|
| Adapter Name | `adapterName` | STRING | `hci0` | 主机蓝牙适配器名 |
| Device Address | `deviceAddress` | STRING | (空) | BLE 设备 MAC 地址(如 `AA:BB:CC:DD:EE:FF`) |
| Connection Timeout | `connectionTimeout` | INT | `10000` | 连接超时(毫秒);**当前实现未读取**,见下方提示 |
::: info `connectionTimeout` 当前未生效
`connectionTimeout` 在 `application.yml` 中声明、可在设备上填写,但 `dc3-driver-ble` 代码从未读取该值——建链由
`bluetoothManager.getCharacteristicGovernor(charUrl, true)` 等待 governor ready 完成,不传任何超时参数。改这个值不会影响连接行为,它是预留属性。
:::
::: tip 一个外设 = 一个设备
`deviceAddress` 是外设的唯一标识,一台 BLE 外设对应平台里一个[设备](../introduction/concepts/device)。同一适配器(`hci0`
)可同时连多台外设,由各设备的 `deviceAddress` 区分;驱动按 `deviceId` 缓存每台外设的连接控制器(governor),首次读写时建链。
:::
### 位号属性(`point-attribute`)
位号属性回答"读这台外设的哪一个特征、读回来的字节怎么解析"。每个采集[位号](../introduction/concepts/point)填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------------|----------------------|--------|--------|-----------------------------------|
| Service UUID | `serviceUuid` | STRING | (空) | GATT Service UUID |
| Characteristic UUID | `characteristicUuid` | STRING | (空) | GATT Characteristic UUID |
| Read Format | `readFormat` | STRING | `UTF8` | 数据格式(UTF8、HEX、INT16、UINT16、FLOAT) |
| Byte Order | `byteOrder` | STRING | `BIG` | 字节序(BIG、LITTLE) |
`serviceUuid` + `characteristicUuid` 共同定位到外设上的一个特征。特征读回来的是一段原始字节,驱动按 `readFormat` 解析:
`UTF8` 当字符串(默认)、`HEX` 转十六进制串、`INT16`/`UINT16`/`FLOAT` 按数值解析。`INT16`/`UINT16`/`FLOAT` 这三种数值格式还受
`byteOrder` 影响(`BIG` 大端、`LITTLE` 小端);`UTF8`、`HEX` 与字节序无关。
::: tip 解析格式按外设手册定
读哪种 `readFormat`、用哪种 `byteOrder`,取决于外设固件在特征里实际放的是什么——例如某温度计把温度以小端 4 字节浮点写在某特征里,就配
`readFormat=FLOAT`、`byteOrder=LITTLE`。配错格式不会报错,只会把字节解析成无意义的值,所以先查外设的 GATT 规格再填。
:::
### 写命令属性(`command-attribute`)
`application.yml` 在 `command-attribute` 下声明了 `serviceUuid`/`characteristicUuid`,但**写入路径并不读取它们**:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------------|----------------------|--------|-----|---------------------------|
| Service UUID | `serviceUuid` | STRING | (空) | GATT Service UUID(当前未被消费) |
| Characteristic UUID | `characteristicUuid` | STRING | (空) | 写入目标特征的 UUID(当前未被消费) |
::: warning 写入复用位号上的 UUID,命令属性当前不生效
BLE 的 `write()` 与 `read()` 一样从位号属性(`point-attribute`)读取 `serviceUuid`/`characteristicUuid`,写哪个特征由位号决定。驱动并未覆写
`execute()`,而 `command-attribute` 只在 `execute()` 路径才会被消费,因此上表声明的写命令属性是占位配置、当前写路径不读取。*
*可写位号无需在写命令上重复填 UUID**,把它配在位号上即可。
:::
::: warning 写入按 UTF-8 字节下发,不做格式转换
读路径有 `readFormat`/`byteOrder` 把字节解析成值,但写路径没有对称的逆变换——驱动把命令值按 UTF-8 编码成字节直接 write
进特征。要写数值或十六进制,必须在上层把它表达成目标特征接受的字符串形式(驱动不会替你把 `25.5` 转成小端浮点字节)。
:::
### 采集与健康
- **采集周期**:默认 cron `0/30 * * * * ?`,每 30 秒读一轮全部位号。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`。设备在线 = BLE 链路已连接且可达(驱动判定
`governor.isOnline() && governor.isConnected()`);驱动整体在线 =
蓝牙管理器已初始化。在线状态机制见[设备](../introduction/concepts/device)。
::: info `application.yml` 里的 `custom` schedule 当前为空实现
yml 中虽配了一条 `custom` cron(`0/5 * * * * ?`),但驱动的 `schedule()` 方法体为空,不执行任何自定义逻辑。实际只有 `read`(
`0/30`)和 `health`(`0/15`)两条调度生效。
:::
## 故障排查
1. **设备一直离线(最常见)**。运行主机没有可用的蓝牙适配器、或 `adapterName` 填错(默认 `hci0`),`governor.isOnline()`
永远为假,设备一直显示离线。先在主机上用 `hciconfig`/`bluetoothctl` 确认适配器存在且 up,再核对 `adapterName`。
2. **容器里连不上蓝牙**。本驱动依赖运行主机的**物理**蓝牙适配器和 TinyB 本地库。容器化部署时若没把宿主蓝牙能力透传进容器(如未挂载
D-Bus/适配器、未给特权),驱动初始化虽不报错(`withIgnoreTransportInitErrors(true)` 会吞掉传输初始化错误),但设备始终连不上。
3. **特征找不到、读写失败**。`serviceUuid`/`characteristicUuid` 要和外设实际暴露的 GATT 逐字一致(含短/长格式、大小写)。UUID
写错时定位不到特征,读会抛 `ReadPointException`(被驱动捕获为读取失败)、写抛 `WritePointException`。先用 BLE 扫描工具(
`bluetoothctl`、nRF Connect 等)确认外设的 service/characteristic UUID 再填。
4. **读到的值像乱码或数值离谱**。多半是 `readFormat`/`byteOrder` 与外设实际编码不符——例如外设用小端浮点而你配了 `UTF8`
,或字节序配反。对照外设 GATT 规格调整这两项;读空字节(`data.length == 0`)时驱动返回 `null`,不会落库。
5. **连接超时/连不上**。当前驱动不使用 `connectionTimeout`(见上文提示),调大该值无效;连接失败只与外设信号弱、距离远、正被别的主机占用连接有关。BLE
同一时刻通常只允许一个 central 连一个外设,确认没有手机 App 或其他网关抢占连接。
6. **写命令"没生效"**。命令值不是目标特征接受的字符串形式(见上文写入语义),或该特征不可写。先确认特征的 GATT 属性含
Write,再确认上层下发的字符串与设备约定一致。
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`BleDriver`(驱动名 `Bluetooth LE Driver`,类型 `DRIVER_CLIENT`,由驱动主动连外设)。这是稳定的路由标识,不应随意更改。
- **能力**:读 ✓、写 ✓、订阅 —。与[驱动能力矩阵](./matrix)一致——BLE 为请求-响应式主动读写,驱动周期性 read 特征、命令式 write
特征,不订阅 GATT notify/indication 被动上报。
- **实现状态**:可用。`read()`/`write()`/`initial()`/`health()` 均为完整实现,基于 Sputnikdev Bluetooth Manager + TinyB
完成连接与 GATT 读写。
::: warning 落地前提:主机有可用蓝牙适配器 + TinyB 本地库
代码已就绪,但能否跑通取决于部署环境:运行主机必须有物理蓝牙适配器(默认 `hci0`)和 TinyB
本地库。这是纯软件之外的硬件/环境前提,缺一不可——详见上文故障排查第 1、2 条。
:::
### 最小接入示例
把 MAC `AA:BB:CC:DD:EE:FF` 的一台 BLE 温度计接进来:
1. 选 `Bluetooth LE Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `adapterName=hci0`、
`deviceAddress=AA:BB:CC:DD:EE:FF`(`connectionTimeout` 当前未被读取,留默认即可)。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(`READ_ONLY`
),point 属性填 `serviceUuid`、`characteristicUuid` 为该温度特征的 UUID,`readFormat=FLOAT`、`byteOrder=LITTLE`(按外设手册定)。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到采集值。
4. 若该位号需可写,给它配写[命令](../introduction/concepts/command)即可——写入复用该位号上已配的 `serviceUuid`/
`characteristicUuid`,无需在命令上重复配置;只需在上层把命令值表达成特征接受的字符串。
## 延伸阅读
- [驱动总览](./index) — 全部 28 个驱动的分组与选型
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [IoT 协议与无线网络](../foundations/iot-protocols) — BLE 在网络层无线接入侧的定位与权衡
---
# CAN 总线驱动
URL: https://docs.dc3.site/zh/drivers/can
`dc3-driver-can` 把 CAN 总线设备接入 IoT DC3:作为总线上的一个节点,监听 SocketCAN 接口上匹配指定 CAN ID
的帧并按[位号](../introduction/concepts/point)配置解析为采集值,必要时先发"请求帧"再读"应答帧",并通过命令帧写值。读完本页,你能在一台
CAN 设备上配好驱动属性与位号属性,并判断哪些行为已就位、哪些仍是骨架。
## 协议背景
CAN(Controller Area
Network,控制器局域网)是汽车与工业自动化里使用极广的现场总线,在[物联网四层架构](../foundations/fieldbus)里属于**网络层**
——它定义了设备之间怎么在一根总线上可靠地交换帧。
CAN 与 Modbus 这类主从协议最大的不同在于它是**广播 + 按 ID 过滤**的发布-订阅模型:
- 节点不靠地址点对点通信,而是把带 **CAN ID**(标识符)的帧广播到总线上,所有节点都能收到,由接收方按自己关心的 CAN ID 过滤。
- 一帧最多携带 **8 字节**载荷;要表达的物理量按字节偏移、长度、字节序从这 8 字节里切分出来。
- 标准帧用 **11 位** CAN ID,扩展帧用 **29 位** CAN ID,二者由帧格式标志区分。
- 没有中心轮询器,天然适合多接收方、事件驱动的场景:值变就发,谁关心谁收。
典型用途包括车载 ECU、电池管理系统(BMS)、伺服驱动器、各类传感器节点,以及越来越多的工业嵌入式控制器。在 Linux 上,CAN 设备通常以
**SocketCAN** 网络接口(如 `can0`)的形式呈现,应用通过它收发帧——本驱动正是接到这层接口上工作。
::: info CAN 在物联网网络层的位置
CAN 解决的是"同一根总线上的节点如何交换帧",属于现场总线(网络层)范畴。它和 Modbus、Profibus、BACnet
等并列;和这些协议如何在四层架构里取舍,见[工业总线与协议](../foundations/fieldbus)。
:::
## 属性配置
CAN 设备的接入配置分三层填写:设备级 `driver-attribute`(接口、波特率、帧格式),位号级 `point-attribute`(CAN
ID、字节切分、可选请求帧),以及可写位号的 `command-attribute`(写目标与帧数据模板)。下表均来自驱动 `application.yml`
,先读散文理解每项作用,再照表填值。
### 驱动属性(设备级 `driver-attribute`)
接入一台 CAN 设备时,在[设备](../introduction/concepts/device)上填这些[属性](../introduction/concepts/attribute-config)。
`interfaceName` 指向驱动进程所在 Linux 主机上的 SocketCAN 接口名,是必填项(其它属性都建立在它之上);`bitrate` 与
`frameFormat` 描述总线本身的物理与帧格式特征,必须与设备实际一致。
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------|-----------------|--------|------------|-------------------------------------------|
| Interface | `interfaceName` | STRING | `can0` | SocketCAN 接口名,驱动据此收发帧 |
| Bitrate | `bitrate` | INT | `500000` | CAN 总线波特率(bps),需与总线一致 |
| Frame Format | `frameFormat` | STRING | `STANDARD` | 帧格式:`STANDARD`(11bit) 或 `EXTENDED`(29bit) |
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填以下属性。前五项决定"
匹配哪一帧、从载荷哪几个字节、按什么格式和字节序解析";后两项 `requestCanId`/`requestData` 是可选的"先请求后应答"
机制,留空则纯被动监听。
| 属性 | code | 类型 | 默认值 | 说明 |
|----------------|----------------|--------|----------|----------------------------|
| CAN ID | `canId` | STRING | (空) | 要匹配的 CAN 标识符(十六进制,不带 `0x`) |
| Data Offset | `dataOffset` | INT | `0` | 帧载荷内的起始字节偏移 |
| Data Length | `dataLength` | INT | `1` | 从偏移处读取的字节数 |
| Data Format | `dataFormat` | STRING | `INT` | 解析格式:`INT`/`UINT`/`HEX` |
| Byte Order | `byteOrder` | STRING | `LITTLE` | 多字节的字节序(如 `LITTLE`) |
| Request CAN ID | `requestCanId` | STRING | (空) | 可选请求帧的 CAN ID |
| Request Data | `requestData` | STRING | (空) | 可选请求帧的载荷(十六进制) |
::: tip 主动请求型读取
不少 CAN 设备需要先收到一帧"请求"才会应答数据。源码中,只有 `requestCanId` 与 `requestData` **同时非空**时,驱动才会在采集前用
`cansend` 发一帧请求(形如 `cansend can0 #`),再监听 `canId` 匹配的应答帧;两者留空则纯被动监听总线上周期广播的帧。
:::
::: warning dataOffset / dataLength / byteOrder 当前未参与解析
按源码,`read()` 实际只用到 `interfaceName`、`canId`、`requestCanId`、`requestData`,把 `candump` 抓到的那一帧载荷字段原样返回为采集值;
`dataOffset`、`dataLength`、`dataFormat`、`byteOrder` 这几项尚未在读取路径里用于切分/转换字节(属骨架待补部分)。配置项已就位,行为以后续实现为准。
:::
### 写命令属性(`command-attribute`)
`application.yml` 在 `command-attribute` 下声明了 `canId` 与 `data`(`data` 默认 `${value}`),原意是让写命令携带帧数据模板。
::: warning `data` 模板当前不会生效(骨架待补)
按源码,`write()` 只从**位号属性**(`pointConfig`)读取 `canId` 与 `data`:
`canId = getConfigValue(pointConfig, "canId", "")`、`data = getConfigValue(pointConfig, "data", "")`。但 `point-attribute`
里**没有** `data`(`data` 只声明在 `command-attribute` 下),而 `command-attribute` 仅经
`DriverCommand.execute(commandConfig, …)` 这条路传入——CAN 驱动并未覆写 `execute()`(用默认空实现),所以写路径根本读不到
`command-attribute`。结果是 `data` 恒取默认空串、`frameData` 恒为空,`${value}` 模板不会被渲染,`cansend` 发出的载荷为空(形如
`cansend can0 #`)。这与 `dataOffset`/`dataLength` 同属"骨架待补"部分,下发的写值当前不会真正落到帧载荷里。
:::
| 属性 | code | 类型 | 默认值 | 说明 |
|--------|---------|--------|------------|------------------------------------------|
| CAN ID | `canId` | STRING | (空) | 写入目标的 CAN 标识符(十六进制) |
| Data | `data` | STRING | `${value}` | 帧数据模板(设计上用命令参数渲染 `${value}`,当前未接通,见上方告警) |
### 采集与健康
- **采集周期**:`read` 调度默认 cron `0/30 * * * * ?`(每 30 秒抓一轮帧);驱动另有 `custom` 自定义调度默认 cron
`0/5 * * * * ?`,但 CAN 驱动的 `schedule()` 为空实现,未挂自定义任务。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`——驱动用 `ip link show `
判断接口是否存在(退出码 0 即在线),在线状态机制见[设备](../introduction/concepts/device)。
::: details 最小接入示例
把 `can0` 上一个周期广播温度、CAN ID 为 `123` 的节点接进来:
1. 选 `CAN Bus Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `interfaceName=can0`、`bitrate=500000`、
`frameFormat=STANDARD`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(`READ_ONLY`
),point 属性填 `canId=123`、`dataOffset=0`、`dataLength=2`、`dataFormat=INT`、`byteOrder=LITTLE`,`requestCanId`/
`requestData` 留空(被动监听)。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到采集值。
:::
## 故障排查
- **驱动必须跑在装了 `can-utils` 的 Linux 上**。底层读写依赖 `candump`/`cansend`,健康检查依赖 `ip link show`,且要求一个可用的
SocketCAN 接口。命令通过 `sh -c` 执行:在 macOS/Windows 或缺少 `can-utils` 时,`candump` 无输出,`read()` 会因
`output.isEmpty()` 抛 `No CAN frame received` 读异常(`ReadPointException`);写侧 `cansend` 缺失则因 `executeCommand`
的退出码/超时进入 `WritePointException`,设备一直离线。
- **设备一直离线**。健康检查实质是 `ip link show ` 的退出码:接口名写错、接口未 `up`、或进程无权限访问该接口,都会让退出码非
0 而判离线。先在主机上手动跑 `ip link show can0` 确认接口存在且 UP。
- **采到 `No CAN frame received`**。`candump` 用 `timeout 3` 抓单帧,3 秒内没等到匹配 `canId` 的帧就抛读异常。排查方向:
`canId` 写错(大小写/进制)、`frameFormat` 与设备实际帧格式(11/29 位)不一致、设备本就需要先收到请求帧——后者要配上
`requestCanId`/`requestData`。
- **canId 写法要对**。`canId`/`requestCanId` 按 `can-utils` 的写法填十六进制、**不带 `0x` 前缀**(标准帧如 `123`,扩展帧按其
29 位十六进制原文填)。带前缀或写成十进制会匹配不到帧。
- **波特率/帧格式不匹配**。`bitrate` 与 `frameFormat` 必须与总线和设备一致;总线波特率配错会导致整条总线收不到任何帧,表现为持续
`No CAN frame received`。
- **写值没生效**。当前写路径未真正接通:`write()` 从位号属性读取 `data`,而 `data` 仅声明在 `command-attribute`、且
`execute()` 未实现,因此 `data` 恒为空、`${value}` 不被渲染,`cansend` 发出的是空载荷帧(`cansend can0 #`
),设备收不到预期数据——这属于"骨架待补",不是配置问题。即便如此仍可先确认目标 `canId` 是否正确、该位号是否为可写(
`rwFlag`)。
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`CanDriver`(驱动名 `CAN Bus Driver`,类型 `DRIVER_CLIENT`,主动在总线上收发帧)。这是稳定的路由标识,不可随意更改。
- **读能力**:✓ 支持。`read()` 通过 `candump` 抓取匹配 `canId` 的单帧并返回载荷字段,可选先用 `cansend` 发请求帧。
- **写能力**:桩/部分实现。`write()` 已能调 `cansend` 把帧发上总线,但 `data` 帧数据模板当前未接通(`data` 取自
`pointConfig` 却只声明于 `command-attribute`、`execute()` 未实现),故 `${value}` 不被渲染、当前发出的是空载荷帧(详见上方"
写命令属性"告警)。
- **订阅能力**:— 不支持。CAN 在本驱动里是请求-响应式的定时主动读,并非把订阅推送接进 DC3。以上与[驱动能力矩阵](./matrix)中
CAN 行(✓ 读 / — 写 / — 订阅)一致。
::: warning 实现状态:骨架(WIP),底层走 can-utils
该驱动是一个起步模板。`read()`/`write()` 通过 `ProcessBuilder` 调用 Linux `can-utils`(`candump`/`cansend`)完成收发,
`health()` 用 `ip link show` 检查接口——这些调用路径在装了 can-utils 的 Linux + 真实 SocketCAN 接口上能真正执行,而非抛"
未实现"。但源码自身标注为 WIP 骨架,仍有未接通处:
- **读路径**:位号属性里的 `dataOffset`/`dataLength`/`dataFormat`/`byteOrder` 尚未参与字节切分与类型转换,`read()`
把抓到的载荷字段原样返回。
- **写路径**:`write()` 的 `data` 帧数据模板渲染当前未真正接通——`data` 取自 `pointConfig` 但仅声明于 `command-attribute`
,且 `execute()` 未实现,故 `${value}` 不被渲染、当前发出的是空载荷帧。与 `dataOffset` 等同属待补骨架,**不要把 write
当作已可写值**。
- `TODO` 标注计划用原生 SocketCAN JNI 替换每次起进程的 `ProcessBuilder` 方案以降延迟。生产前需补齐字节解析、写值模板渲染与原生
I/O 集成。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动的导航与分类
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — CAN 所在的网络层与现场总线选型
---
# CoAP 驱动
URL: https://docs.dc3.site/zh/drivers/coap
# CoAP 驱动
`dc3-driver-coap` 把 CoAP 设备接入 IoT DC3。它基于 Eclipse Californium,既能作为 **CoAP 客户端**主动连设备(读发 GET、写发
PUT),也能作为 **CoAP 服务端**监听设备主动 POST 上报的遥测。读完你能在[设备](../introduction/concepts/device)上配好
`deviceHost`/`devicePort`、在[位号](../introduction/concepts/point)上配好读写资源路径,并定位常见的"采不到值 / UDP 连不通"
问题。
> 你在这里:网络层"轻协议侧"的一个落地驱动。CoAP 在协议层的请求/响应模型、UDP/DTLS 端口、Observe
> 概念见[IoT 协议与无线网络](../foundations/iot-protocols)。
## 协议背景
CoAP(Constrained Application Protocol,受限应用协议)是 IETF 为低功耗、低带宽的物联网终端设计的轻量协议(RFC 7252)。它保留了
HTTP 熟悉的**请求/响应 + 方法(GET/PUT/POST/DELETE)+ 资源路径**模型,但把报文压到几十字节,跑在 **UDP** 上、默认端口 `5683`
(加密用 DTLS 的 CoAPS 走 `5684`)。无连接的 UDP 省去了 TCP 握手与保活开销,对电池供电、偶尔醒来上报一次的终端极友好;代价是可靠性要靠
CoAP 自己的 CON/NON 确认机制补回来。常见于电池供电的传感器、嵌入式网关、NB-IoT/6LoWPAN 终端等"省电省流量"的场景。
在[物联网四层架构](../foundations/iot-protocols)里,CoAP 属于**网络层**的应用层消息协议:它定义"
一条消息长什么样、怎么投递、可靠到什么程度",与底层用什么无线无关——同一个 CoAP 报文,可以跑在 Wi-Fi 上,也可以跑在 NB-IoT
蜂窝链路上。CoAP 的通信模型既支持客户端**主动请求**资源,也支持服务端在客户端 POST 时**被动接收**,本驱动两侧都实现了:
客户端模式是默认形态,由 IoT DC3 的[采集调度](../introduction/concepts/driver)按 cron 周期对每个位号的 `readPath` 发
GET,下发写命令时对 `writePath` 发 PUT。服务端模式则反过来:驱动监听一个 CoAP 端口,设备主动把遥测 POST 到 `/data`
资源,驱动解析后转发上报。两种模式由 `dc3.driver.coap.mode` 决定(见下文属性配置)。
## 属性配置
接入一台 CoAP 设备,主要在两个层面填[属性](../introduction/concepts/attribute-config):设备级的连接参数(`driver-attribute`
)和每个位号的资源路径(`point-attribute`)。此外驱动还暴露一组进程级的 `dc3.driver.coap.*` Spring
配置项(控制客户端/服务端模式、超时、DTLS),它们不在设备配置里,而是运维通过环境/配置文件调。下面各属性、类型、默认值均取自驱动的
`application.yml` 与 `CoapProperties`(`dc3-driver-coap` 模块)。
### 驱动属性(设备级 `driver-attribute`)
驱动属性回答"连到哪台设备"。在[设备](../introduction/concepts/device)上为每台 CoAP 设备填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|--------|-------------|----------------------|
| Device Host | `deviceHost` | STRING | `localhost` | CoAP 设备主机地址(IP 或主机名) |
| Device Port | `devicePort` | INT | `5683` | CoAP 设备端口(标准 `5683`) |
驱动用这两个属性拼出设备根地址 `coap://:`,再接上位号的资源路径访问具体资源。驱动按设备根地址(URI)缓存
`CoapClient`(一个 URI 一个客户端),设备被删除或更新时释放对应客户端。配置校验(`validate`)会要求 `deviceHost`、`devicePort`
两项都非空,缺一即报错、设备无法启动。
### 位号属性(`point-attribute`)
位号属性回答"读写这台设备的哪个资源路径"。每个[位号](../introduction/concepts/point)填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|----------------|-----------------|--------|--------------|--------------------------------------------------|
| Read Path | `readPath` | STRING | `/sensors` | 采集时 GET 的 CoAP 资源路径 |
| Write Path | `writePath` | STRING | `/actuators` | 下发写值时 PUT 的 CoAP 资源路径 |
| Content Format | `contentFormat` | STRING | `json` | 内容格式声明:`json` / `text` / `cbor` / `octet-stream` |
::: tip 读写各走各的资源路径
采集时驱动对 `coap://:` 发 GET,返回的响应体(payload)就是这个[位号](../introduction/concepts/point)
的[位号值](../introduction/concepts/point-value);下发写命令时对 `` 发 PUT,请求体是要写的值。`readPath` 和
`writePath` 互不影响,只读位号只配 `readPath` 即可,`writePath` 空着不会被用到。位号配置校验(`validatePoint`)只强制要求
`readPath` 非空。
:::
CoAP 没有独立的 `command-attribute` 配置表——可写位号的写入目标由位号自身的 `writePath` 决定,下发写命令时驱动直接对该路径发
PUT,无需额外的命令属性。
### 进程级配置(`dc3.driver.coap.*`)
这组配置控制驱动进程整体行为(客户端超时、是否起服务端、DTLS 加密),通过配置文件或环境变量设置,对所有设备生效。
`application.yml` 里未显式列出,因此默认全部取 `CoapProperties` 的内置默认值:
| 配置项 | 默认值 | 说明 |
|-----------------------|-----------|-------------------------------------------------------------------------------------------------------------------|
| `mode` | `CLIENT` | 工作模式:`CLIENT`(仅客户端,主动读写)/ `SERVER`(仅服务端,监听上报)/ `BOTH` |
| `serverHost` | `0.0.0.0` | 服务端绑定地址(`SERVER`/`BOTH` 模式生效) |
| `serverPort` | `5683` | 服务端监听端口(`SERVER`/`BOTH` 模式生效) |
| `secureEnabled` | `false` | 是否启用 DTLS 加密 |
| `clientTimeout` | `5000` | 客户端 GET 的报文交换生命周期(毫秒,最小 100) |
| `clientAckTimeout` | `2000` | 客户端 CON 确认超时(毫秒,最小 100) |
| `clientMaxRetransmit` | `4` | 客户端最大重传次数(最小 1) |
| `dtls.*` | 空 | DTLS 凭据:`pskIdentity` / `pskSecret` 或证书路径 `trustStorePath` / `identityCertificatePath` / `identityPrivateKeyPath` |
::: info 默认即客户端模式,服务端模式需显式开
默认 `mode=CLIENT`,驱动作为客户端按 cron 周期主动 GET/PUT,不监听任何端口。要让设备主动 POST 上报,需把 `mode` 设为
`SERVER` 或 `BOTH`——此时驱动起一个 CoAP 服务端、在 `serverPort` 上注册 `/data` 资源接收上报。`secureEnabled`/`dtls.*` 是为
DTLS 预留的配置项,当前 `CoapClientManager` 与 `CoapServerManager` 建连时尚未据它装配 DTLS 端点(明文 UDP),公网加密能力以代码为准。
:::
## 故障排查
CoAP 接入失败大多集中在 UDP 链路、资源路径、上报格式三类。按下面顺序排查:
1. **UDP 连不通(采不到值 / `statusCode=timeout`)**。CoAP 默认走 **UDP 5683**(不是 TCP)。设备无响应时,客户端 GET 超时返回
`null`,`read` 按失败处理、跳过本轮并打 `CoAP read failed ... statusCode=timeout`。先用 CoAP 客户端(如
`coap-client -m get coap://:5683`)手动验链路:确认设备在线、防火墙放行 **UDP** 5683、`deviceHost`/
`devicePort` 填对,而不是先怀疑路径配错。
2. **能连上但路径取不到(4.04 Not Found)**。`readPath` 写错会让设备返回 `4.04`,此时 `response.isSuccess()` 为 false、`read`
同样返回 `null`。核对资源路径大小写与前导 `/`,必要时先 GET 设备的 `/.well-known/core` 看它实际暴露了哪些资源。
3. **响应慢于超时被误判**。客户端默认 `clientTimeout=5000ms`、`clientAckTimeout=2000ms`、最大重传 `4` 次。链路 RTT
高(如蜂窝/NB-IoT)的设备可能在默认窗口内来不及应答而被判超时——调大 `dc3.driver.coap.clientTimeout`/`clientAckTimeout`
,而不是缩短采集周期。
4. **写命令返回失败**。写走 PUT 到 `writePath`,请求体为命令传入的值。失败(PUT 超时或设备返回非 2.xx)时 `write` 返回
`false`、写命令不回显。先确认 `writePath` 是设备上可写的资源、且设备接受 PUT 方法。注意:驱动 PUT 固定以
`application/json` 媒体类型发送,与位号的 `contentFormat` 声明无关(见下方易错点)。
5. **服务端模式收不到上报**。`mode` 必须为 `SERVER` 或 `BOTH`,设备要 POST 到 `coap://<驱动host>:/data`
。上报体必须是能反序列化成 `PointValue` 的 JSON(至少含 `deviceId`、`pointId`),否则驱动打 `missingIdentity` /
`parse failed` 并丢弃。空 body 会被回 `4.00 Bad Request`。
6. **设备在线状态抖动**。健康检查默认每 15 秒一次、租约超时 45 秒。频繁 online/offline 跳变多半是 UDP
丢包或设备响应慢于超时——在线状态机制见[设备](../introduction/concepts/device)。
::: warning contentFormat 只是声明,当前不参与解析
`contentFormat` 声明资源的内容格式(`json` / `text` / `cbor` / `octet-stream`),但当前驱动按原始 payload(
`response.getResponseText()`)直接返回[位号值](../introduction/concepts/point-value)、**未据它做格式解析**;写方向也固定用
`application/json` 媒体类型 PUT,不读这个属性。拿不准设备实际返回格式时,先用 CoAP 客户端手动 GET 一次看返回内容再填。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`CoapDriver`(类型 `DRIVER_CLIENT`)。这是稳定的路由标识,不要随意改。
- **读能力**:✓ 已实现。客户端模式按 cron 对每个位号的 `readPath` 发
GET,响应体作为[位号值](../introduction/concepts/point-value)上报。
- **写能力**:✓ 已实现。对位号的 `writePath` 发 PUT(`application/json`),下发命令时触发。
- **订阅/上报**:— 不计入[驱动能力矩阵](./matrix)的"订阅"列。矩阵中 CoAP 为「✓ / ✓ / —」,指的是 SDK 位号模型下的**主动读 /
主动写 / 无订阅**。CoAP 的 **Observe**(RFC 7641,资源变化推送)在本驱动里**尚未接通**:仅有 `CoapObserveHandler`
接口定义,无任何实现或调用方。
- **服务端上报(额外能力)**:`SERVER`/`BOTH` 模式下驱动起 CoAP 服务端、在 `/data` 接收设备 POST 的 `PointValue` JSON
并转发上报。这是矩阵之外的一条独立链路,需显式开 `mode`。
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒采一轮),在驱动 `application.yml` 的 `schedule.read` 配置。
`schedule.custom`(默认 cron `0/5 * * * * ?`)虽启用,但 `schedule()` 实现为空操作,不做位号采集之外的周期逻辑。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`。
::: info 实现状态:可用(客户端读写 + 服务端上报),Observe 未实现
本驱动的**客户端读写**与**服务端上报接收**均为完整实现(非骨架),底层基于 Eclipse Californium。唯一未接通的是 CoAP *
*Observe** 订阅推送:`CoapObserveHandler` 仅是接口、无实现,因此驱动当前不能"订阅"资源由设备变更推送——需要事件驱动上报的场景请改用服务端
POST 模式。DTLS 加密(`secureEnabled`/`dtls.*`)为配置预留、尚未在建连时装配,以代码为准。
:::
### 最小接入示例
把地址 `192.168.1.20:5683`、温度资源在 `/temp` 的一台 CoAP 传感器接进来:
1. 选 `CoAP Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `deviceHost=192.168.1.20`、
`devicePort=5683`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(`READ_ONLY`
),point 属性填 `readPath=/temp`、`contentFormat=json`(`writePath` 留空)。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到对 `coap://192.168.1.20:5683/temp` GET
回来的值。
4. 若该位号需可写,给它配写[命令](../introduction/concepts/command)、并填 `writePath`,下发时驱动对该路径 PUT。
::: tip 设备主动上报选服务端模式
若设备只会偶尔醒来主动上报、不接受被轮询,把 `dc3.driver.coap.mode` 设为 `SERVER`,让设备 POST 到驱动的 `/data` 资源(上报体为含
`deviceId`/`pointId` 的 `PointValue` JSON)。这避免了对休眠设备做无谓的周期 GET。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动入口与分类
- [驱动能力矩阵](./matrix) — 读/写/订阅能力一览,含 CoAP 行
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [IoT 协议与无线网络](../foundations/iot-protocols) — CoAP/LwM2M 等轻协议的请求/响应模型、UDP/DTLS 与 Observe
- [LwM2M 驱动](./lwm2m) — 架在 CoAP 之上、带设备管理对象模型的驱动
---
# DLMS/COSEM 驱动
URL: https://docs.dc3.site/zh/drivers/dlms
# DLMS/COSEM 驱动
`dc3-driver-dlms` 把 DLMS/COSEM 计量设备(电表、水表、气表、热表)接入 IoT DC3:它以 **OBIS 编码**为目标,作为 DLMS
客户端连接表计,周期性读取 COSEM 对象的属性值。读完本页你能看懂 DLMS/COSEM 的对象寻址方式、给设备和位号填对协议属性,并清楚这个驱动当前的实现边界。
> 你在这里:现场设备的"计量表"接入侧。要理解计量协议为什么自成一套、OBIS
> 编码在网络层处于什么位置,先读 [工业总线与协议](../foundations/fieldbus)。
## 协议背景
DLMS/COSEM(Device Language Message Specification / Companion Specification for Energy
Metering)是电力、水、气、热等公用事业计量领域的国际标准协议,对应 IEC 62056 / EN 13757-1 系列标准,由 DLMS User Association
维护。它是抄表系统、能源管理平台与智能电表/水表之间事实上的通用语言:欧洲与中国的大量智能电表、集中器、采集终端都说这套协议。
DLMS/COSEM 与 Modbus、CIP 这类协议最大的不同在**对象化的寻址模型**:
- **Modbus 按寄存器地址寻址**——你要知道某个量挂在第几号保持寄存器(如 `40001`),地址是裸数字。
- **DLMS/COSEM 按对象 + OBIS 编码寻址**——表里每个可读量(有功电能、电压、时钟……)被建模为一个 **COSEM 对象**,用一段 6 字段的
**OBIS 编码**(形如 `1.0.1.8.0.255`)唯一标识;每个对象又有若干带编号的**属性**(attribute),其中属性 `2` 通常是"当前值"
。读一个量,就是"按 OBIS 编码定位对象、按属性编号取值"。
这套"对象 + 编码"的模型让计量语义高度标准化——`1.0.1.8.0.255` 在任何符合规范的电表里都表示"总有功电能"
,跨厂商可移植;代价是接入前必须查表确认每个量的 OBIS 编码与对象类型。
在物联网四层架构里,DLMS/COSEM 属于**网络层**的计量协议侧:它解决"一只表计如何把计量数据按标准语义送出去"
的问题,位于感知层(计量芯片/传感)之上、平台层(IoT DC3 数据汇聚)之下。下面这张图给出 OBIS 按编码寻址在一次采集里的位置:
驱动用 **Gurux DLMS 库**(`GXDLMSClient`)构建并解析 DLMS 帧,作为客户端(client)通过 TCP 或串口连到表计,按位号配置的 OBIS
编码取对应属性值,统一为 [位号值](../introduction/concepts/point-value) 上送平台。
## 属性配置
DLMS/COSEM 的接入参数分两层:**驱动属性(driver-attribute)** 描述"连哪只表、用什么传输方式与认证"
,填在 [设备](../introduction/concepts/device) 上;**位号属性(point-attribute)** 描述"读哪个对象、取哪个属性"
,填在每个 [位号](../introduction/concepts/point) 上。这些属性的默认值都来自驱动的 `application.yml`
,三层来历见 [属性与配置](../introduction/concepts/attribute-config)。DLMS/COSEM 是只读抄表语义,本驱动不提供写命令,因此没有命令属性(
`command-attribute` 为空)。
### 驱动属性(设备级 `driver-attribute`)
接入一只表计时,先在设备上指明它的传输方式与网络/串口位置,再填 DLMS 会话的客户端/服务端地址与认证。`transportType` 决定走
TCP 还是串口,对应两组互斥的连接参数;`clientAddress` / `serverAddress` 标识 DLMS 会话的两端;`authentication` / `password`
决定以什么权限建立关联。
| 属性 | code | 类型 | 默认值 | 说明 |
|----------------|------------------|--------|----------------|-----------------------------|
| Transport Type | `transportType` | STRING | `TCP` | 传输方式(TCP, SERIAL) |
| Host | `host` | STRING | `localhost` | 远端设备地址(TCP 模式) |
| Port | `port` | INT | `4059` | 远端设备端口(TCP 模式,DLMS 标准 4059) |
| Serial Port | `serialPort` | STRING | `/dev/ttyUSB0` | 串口路径(SERIAL 模式) |
| Baud Rate | `baudRate` | INT | `9600` | 波特率(SERIAL 模式) |
| Client Address | `clientAddress` | INT | `16` | DLMS 客户端地址(公共客户端=16) |
| Server Address | `serverAddress` | INT | `1` | DLMS 服务端地址 |
| Authentication | `authentication` | STRING | `NONE` | 认证方式(NONE, LOW, HIGH) |
| Password | `password` | STRING | (空) | 认证密码(LOW/HIGH 认证时使用) |
::: tip TCP 与 SERIAL 二选一
`transportType=TCP` 时,只看 `host` / `port`;`transportType=SERIAL` 时,只看 `serialPort` / `baudRate`
。另一组属性按当前传输方式被忽略,不必删。`clientAddress` / `serverAddress` / `authentication` / `password` 两种方式都用。
:::
::: tip `clientAddress=16` 是公共客户端
`clientAddress=16` 对应 DLMS 的"公共客户端"(public client),多数表计允许其以 `NONE`
认证读取基础计量量。要读受保护的对象(如负荷曲线、参数配置),需改用更高权限的客户端地址,并把 `authentication` 调到 `LOW` /
`HIGH` 并配上 `password`。
:::
::: info `validate()` 只校验五项必填
驱动的 `validate()` 把 `transportType` / `host` / `port` / `clientAddress` / `serverAddress` 列为必填项;`serialPort` /
`baudRate` / `authentication` / `password` 不在必填校验内(按需填)。校验只检查"有没有填",不检查传输方式与所填参数是否自洽——见下文故障排查。
:::
### 位号属性(`point-attribute`)
每个采集位号都要指明读哪个 COSEM 对象、以及取该对象的哪个属性。OBIS 编码定位"读哪个量",属性编号定位"取这个量的哪一面"。
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------|---------------|--------|------------|------------------------------------|
| Object Type | `objectType` | STRING | `REGISTER` | DLMS 对象类型(REGISTER, CLOCK, DATA 等) |
| Logical Name | `logicalName` | STRING | (空) | 对象逻辑名 / OBIS 编码(例 `1.0.1.8.0.255`) |
| Attribute ID | `attributeId` | INT | `2` | 属性编号(2=当前值) |
::: tip OBIS 编码定位"读哪个量"
`logicalName` 是 6 段 OBIS 编码,唯一标识表里的一个计量量——例如 `1.0.1.8.0.255` 是"总有功电能",`1.0.32.7.0.255` 是"A
相电压"。`objectType` 告诉驱动这个对象是哪一类 COSEM 接口类(`REGISTER` 计量寄存器、`CLOCK` 时钟、`DATA`
通用数据等),不同接口类的属性含义不同。`attributeId=2` 取该对象的"当前值"
属性。位号自身的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)要与对象属性的实际类型对得上。
:::
::: info `validatePoint()` 只校验 `objectType`
位号校验只把 `objectType` 列为必填;`logicalName` 默认值为空,但若不填则定位不到对象。接入时务必为每个采集位号填写实际的
OBIS 编码。
:::
### 采集与健康
- **采集周期**:默认 read cron `0/30 * * * * ?`(每 30 秒读一轮)。
- **自定义调度**:`schedule.custom` 在 yml 中启用(cron `0/5 * * * * ?`),但当前 `schedule()` 方法体为空,不执行任何自定义逻辑。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`
——在线状态机制见 [设备](../introduction/concepts/device)。
## 故障排查
DLMS/COSEM 接入失败大多落在"传输方式没配对"和"读不到对象"两类。下面按由表及里的顺序排查。
::: warning 传输方式与连接参数对不上
`host` / `port` 只在 `transportType=TCP` 下生效,`serialPort` / `baudRate` 只在 `SERIAL` 下生效。`validate()` 不会拦下"
传输方式与参数不自洽"的组合——若把 `transportType` 设成 `SERIAL` 却只填了 `host`,驱动会走串口分支去找 `serialPort`
,连不上表计。改传输方式时,记得同步填对应那一组属性。
:::
::: warning TCP 端口或防火墙:4059 不通
DLMS over TCP 的标准端口是 `4059`,与 Modbus 的 `502`、IEC 104 的 `2404` 都不同,别沿用。先在驱动主机上确认 `host:4059` 可达(
`telnet 4059` 或 `nc -vz 4059`)。常见原因:表计/集中器侧未开放 DLMS 端口、网段不通、防火墙拦了 4059。
:::
::: warning 串口路径或波特率不符
串口模式下,`serialPort`(如 `/dev/ttyUSB0`)必须是驱动主机上真实存在的设备节点,且当前用户有读写权限(Linux 上常需把用户加入
`dialout` 组)。`baudRate` 要与表计设定一致(计量表常见 `300` / `9600` / `19200`),波特率不符会导致帧无法正确解析。
:::
::: warning OBIS 编码或对象类型不符,定位不到量
`logicalName` 必须与表计里实际存在的对象逐字一致,`objectType` 要与该对象的真实 COSEM 接口类匹配。把一个 `CLOCK`(时钟)对象配成
`REGISTER`,或填了表里不存在的 OBIS 编码,都会导致取数失败。接入前先用厂商的对象列表(OBIS 表)核对每个量的编码与对象类型。
:::
::: warning 认证权限不足,读受保护对象被拒
`clientAddress=16` 的公共客户端通常只能读基础计量量。读负荷曲线、事件日志、参数等受保护对象时,若仍用公共客户端或
`authentication=NONE`,表计会拒绝关联或拒绝读取。需改用更高权限的客户端地址,并把 `authentication` 调到 `LOW` / `HIGH`
配上正确的 `password`。
:::
::: info 设备在线态不可作为"采到数据"的依据
`health()` 只检查驱动内部是否缓存了该设备的 `GXDLMSClient` 对象,**不做真实连通探测**
——而当前实现下连接缓存从未被填充(传输层待补,见下文)。判断是否真正采到数据,应以 [位号值](../introduction/concepts/point-value)
是否更新为准,而非看设备在线态。
:::
## 在 IoT DC3 中如何落地
- **dc3.driver.code**:`DlmsDriver`(稳定路由标识,注册与消息路由都以它为准,不要随意改)。驱动名 `DLMS/COSEM Driver`,类型
`DRIVER_CLIENT`(驱动主动连表计)。
- **读能力**:抄表语义,本应按 OBIS 编码周期性读 COSEM 属性——但当前 `read()` 直接抛 `ReadPointException`
,取数主干尚未接通(见下方骨架说明)。
- **写能力**:不提供。DLMS/COSEM 在本驱动中是只读抄表,`command-attribute` 为空,`write()` 直接抛 `WritePointException`。
- **订阅/上报**:不支持。本驱动按采集周期主动轮询,不监听表计主动上报。
与 [驱动能力矩阵](./matrix) 对齐:DLMS 在矩阵中读/写/订阅均标记为 `—`,备注"智能电表,传输层待补"。
::: warning 当前为骨架实现(Work in progress)
本驱动是协议骨架,比"上层流程已接通、仅差组帧"的驱动更早期:Gurux 客户端(`GXDLMSClient`)能生成 DLMS 帧,但**传输层收发与
HDLC 握手尚未实现**,取数/写值主干都还没接通:
- `read()` / `write()` 直接抛"未实现"异常(`ReadPointException` / `WritePointException`)快速失败,让 SDK
记录失败并对连接退避,而非回显缓存值或伪造成功;
- `health()` 仅检查内部连接缓存 `clientMap` 是否含该设备,并非真实协议探测;而该缓存在当前实现下从未被填充,故设备实际不会显示在线;
- `schedule()` 方法体为空,yml 中启用的 `custom` 周期任务暂不执行任何逻辑。
请将它作为接入新表计的起点模板,而非生产可用驱动。下文属性表与采集周期均逐字取自真实 `application.yml`,可照填;但实际取数行为以
`DlmsDriverCustomServiceImpl` 的 `read()` / `write()` / `initial()` 源码为准。
:::
把一只 DLMS/COSEM 电表接进来的最小路径(用于验证配置流程,非生产采集):
1. 选 `DLMS/COSEM Driver` 创建 [设备](../introduction/concepts/device),driver 属性填 `transportType=TCP`、
`host=192.168.1.20`、`port=4059`、`clientAddress=16`、`serverAddress=1`、`authentication=NONE`。
2. 给设备绑定的 [模板](../introduction/concepts/profile) 加一个电能 [位号](../introduction/concepts/point)(
`pointTypeFlag=DOUBLE`、`READ_ONLY`),point 属性填 `objectType=REGISTER`、`logicalName=1.0.1.8.0.255`、`attributeId=2`。
3. 启动驱动观察日志;当前 `read()` 直接抛 `ReadPointException`,SDK
记录读失败并退避,[位号值](../introduction/concepts/point-value) 里暂时不会有采集值——待传输层补全后才能真正采到值。
完整的接入操作流程见 [设备接入](../operation/device-onboarding)。
## 延伸阅读
- [驱动总览](./index) — 全部驱动的分类与选型入口
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力与实现状态一览
- [设备接入](../operation/device-onboarding) — 一次完整的设备接入流程
- [工业总线与协议](../foundations/fieldbus) — 网络层的计量协议侧,OBIS 对象寻址与其它协议的定位对比
---
# DL/T645 驱动
URL: https://docs.dc3.site/zh/drivers/dlt645
`dc3-driver-dlt645` 通过 RS485 读取符合国标 DL/T645-2007 的电能表,按数据标识(DI0-DI3)解析数值。
## 协议背景
DL/T645-2007 是多功能电能表国标,帧以 68H 定界、携带 6 字节 BCD 表地址、以累加和结尾。
驱动 code:Dlt645Driver,类型:DRIVER_CLIENT,底层库:jSerialComm
## 属性配置
### 驱动属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Serial Port | port | STRING | /dev/ttyUSB0 | 串口设备路径 |
| Baud Rate | baudRate | INT | 2400 | 波特率 |
| Parity | parity | INT | 2 | 校验位(2=偶校验) |
| Timeout | timeout | INT | 1000 | 读超时(毫秒) |
| Meter Address | meterAddress | STRING | 000000000000 | 12 位 BCD 表地址 |
### 位号属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Data Identifier | di | STRING | (空) | 4 字节数据标识(DI0-DI3) |
| Data Format | dataFormat | STRING | FLOAT | FLOAT, HEX, ASCII, BINARY |
## 能力矩阵
| 能力 | 支持 |
|------|------|
| 读 | ✓ |
| 写 | ✓ |
| 订阅 | — |
::: info 实现状态:可用 :::
帧编解码(Dlt645Frame)与串口连接(Dlt645SerialPortConnection)已完整实现。
## 最小接入示例
1. 选 DL/T645 驱动创建设备,填 port、baudRate=2400、parity=2、meterAddress。
2. 加位号(READ_ONLY),填 di=02010100、dataFormat=FLOAT。
3. 启动驱动,30 秒内即可看到采集值。
## 延伸阅读
- [驱动总览](./index)
- [驱动能力矩阵](./matrix)
- [设备接入](../operation/device-onboarding)
---
# DNP3 驱动
URL: https://docs.dc3.site/zh/drivers/dnp3
`dc3-driver-dnp3` 面向电力自动化的 DNP3(IEEE 1815)主站。协议栈由 io.stepfunc:dnp3 原生 FFI 绑定提供(Rust dnp3 运行时,jar 内置各平台原生库)。
## 协议背景
DNP3(IEEE 1815)是北美电力 SCADA 主流协议,主站连接从站、轮询事件类并同步本地数据库。
驱动 code:Dnp3Driver,类型:DRIVER_CLIENT,底层库:Step Function I/O io.stepfunc:dnp3
## 属性配置
### 驱动属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Host | host | STRING | (空) | DNP3 从站地址 |
| Port | port | INT | 20000 | DNP3 TCP 端口 |
| Master Address | masterAddress | INT | 1 | 主站链路地址 |
| Outstation Address | outstationAddress | INT | 1 | 从站链路地址 |
### 位号属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Point Index | pointIndex | INT | 0 | DNP3 点索引 |
| Point Type | pointType | STRING | BINARY_INPUT | BINARY_INPUT, ANALOG_INPUT, COUNTER, DOUBLE_BIT_BINARY_INPUT, BINARY_OUTPUT, ANALOG_OUTPUT |
### 命令属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Point Index | pointIndex | INT | 0 | 命令点索引 |
| Point Type | pointType | STRING | BINARY_OUTPUT | BINARY_OUTPUT 或 ANALOG_OUTPUT |
## 能力矩阵
| 能力 | 支持 |
|------|------|
| 读 | ✓ |
| 写 | ✓ |
| 订阅 | — |
::: info 实现状态:可用 :::
::: warning 原生栈需针对真实从站联调
io.stepfunc:dnp3 原生栈(Rust dnp3 运行时,jar 内置各平台原生库)可正常加载,read()/write() 已实现,但上线前仍需在目标环境针对真实从站联调验证报文行为。 :::
## 最小接入示例
1. 选 DNP3 驱动创建设备,填 host、port=20000。
2. 加位号,填 pointIndex=0、pointType=BINARY_INPUT。
3. 上线前针对真实从站联调验证。
## 延伸阅读
- [驱动总览](./index)
- [驱动能力矩阵](./matrix)
- [设备接入](../operation/device-onboarding)
---
# EtherNet/IP 驱动
URL: https://docs.dc3.site/zh/drivers/ethernet-ip
# EtherNet/IP 驱动
`dc3-driver-ethernet-ip` 把基于 EtherNet/IP(CIP)的罗克韦尔 Allen-Bradley PLC 接入 IoT DC3:它以**标签名(Tag Name)**
为目标,周期性读取 PLC 标签值,并支持向标签写值的命令。读完本页你能看懂 EtherNet/IP 的寻址方式、给设备和位号填对协议属性,并清楚这个驱动当前的实现边界。
> 你在这里:现场设备的"罗克韦尔 PLC"接入侧。要理解工业协议为什么各家私有、CIP
> 在网络层处于什么位置,先读 [工业总线与协议](../foundations/fieldbus)。
## 协议背景
EtherNet/IP(Ethernet Industrial Protocol)是一种工业以太网协议,把 **CIP(Common Industrial Protocol,通用工业协议)** 承载在标准
TCP/IP 之上。它由 ODVA 维护,主要用于罗克韦尔 Allen-Bradley 系列 PLC(如 ControlLogix / CompactLogix)以及兼容这套生态的伺服、变频器、I/O
模块。在工厂自动化里,它和 Siemens 的 S7、Mitsubishi 的 MELSEC、Omron 的 FINS 一样,属于"厂商主导、彼此不通"的私有协议阵营——选了哪家
PLC,往往就被绑定到哪套协议。
EtherNet/IP 与 Modbus 这类协议最大的不同在**寻址模型**:
- **Modbus 按寄存器地址寻址**——你要知道某个量挂在第几号保持寄存器(如 `40001`),地址是裸数字。
- **CIP 按标签名寻址**——PLC 工程里的变量有名字(如 `Motor_Speed`),驱动通过 CIP 的 **Data Table Read/Write**
服务直接按名字读写,不必关心它在控制器内存的物理地址。
这套"按名字访问"的模型让 PLC 程序改动后地址不再漂移,但也意味着标签名必须与 PLC 工程逐字一致。
在物联网四层架构里,EtherNet/IP 属于**网络层**的工业有线侧:它解决"现场设备如何在以太网上把一个数据点送出去"
的问题,位于感知层(传感器/变送器)之上、平台层(IoT DC3 数据汇聚)之下。下面这张图给出 CIP 按名寻址在一次采集里的位置:
CIP 不依赖物理地址,因此位号上配的是 `tagName` 而非偏移量;驱动按位号配置的 `tagType` 把 PLC
返回的字节解码成具体数值后,统一为 [位号值](../introduction/concepts/point-value) 上送平台。
## 属性配置
EtherNet/IP 的接入参数分两层:**驱动属性(driver-attribute)** 描述"连哪台 PLC、用什么端口和超时"
,填在 [设备](../introduction/concepts/device) 上;**位号属性(point-attribute)** 描述"读哪个标签、按什么类型解码"
,填在每个 [位号](../introduction/concepts/point) 上。可写位号再加一项**命令属性(command-attribute)**。这些属性的默认值都来自驱动的
`application.yml`,三层来历见 [属性与配置](../introduction/concepts/attribute-config)。
### 驱动属性(设备级 `driver-attribute`)
接入一台 EtherNet/IP PLC 时,先在设备上指明它的网络位置。`host` / `port` 决定 TCP 连到哪里,`slot` 标识 PLC
在背板上的槽位(多模块机架需要据此定位 CPU),`timeout` 限制单次请求的等待时长。
| 属性 | code | 类型 | 默认值 | 说明 |
|---------|-----------|--------|-------------|------------------------------|
| Host | `host` | STRING | `localhost` | PLC 主机地址(IP 或主机名) |
| Port | `port` | INT | `44818` | EtherNet/IP TCP 端口(标准 44818) |
| Slot | `slot` | INT | `0` | PLC 背板槽位号,定位机架中的 CPU 模块 |
| Timeout | `timeout` | INT | `5000` | 请求超时(毫秒),设到套接字 `SoTimeout` |
::: info `slot` 当前仅参与校验,未参与组帧
`validate()` 会把 `slot` 列为必填项校验,但当前连接与读写代码尚未把 `slot` 编入 CIP 路由路径(`ForwardOpen` 的连接路径仍是占位)。单
CPU、CPU 在 0 号槽的场景不受影响;多槽机架的精确寻址需待协议组帧补全。
:::
### 位号属性(`point-attribute`)
每个采集位号都要指明读哪个标签、以及该标签在 PLC 里是什么数据类型——驱动不会向 PLC 探测类型,完全按你填的 `tagType` 解码字节。
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|--------|-------------------------------------------------------------|
| Tag Name | `tagName` | STRING | (空) | CIP 标签名,如 `Motor_Speed`,必须与 PLC 工程逐字一致 |
| Tag Type | `tagType` | STRING | `DINT` | 标签数据类型:`BOOL` / `SINT` / `INT` / `DINT` / `REAL` / `STRING` |
| Element Count | `elementCount` | INT | `1` | 读取的元素个数(数组标签用) |
::: tip `tagType` 决定字节怎么解码
驱动按 `tagType` 把 PLC 返回的原始字节(小端序)解析成对应类型:`BOOL` 1 字节、`SINT` 1 字节、`INT` 2 字节整数、`DINT` 4 字节整数、
`REAL` 4 字节浮点、`STRING` 取 ASCII 文本。`tagType` 要与 PLC
里该标签的实际类型一致,否则解析出无意义的值。位号自身的数据类型([Point](../introduction/concepts/point) 的
`pointTypeFlag`)应与之匹配。
:::
::: warning `elementCount` 当前未被消费
`buildReadTagRequest()` 把读请求里的元素个数硬编码为 `1`,配置的 `elementCount` 暂不生效。数组标签的整段读取需待实现补全;当前只能按单元素读取。
:::
### 命令属性(`command-attribute`)
可写位号在写命令上额外配置写值模板。
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------|---------------|--------|------------|-------------------------------|
| Send Command | `sendCommand` | STRING | `${value}` | 写值模板,约定用命令参数渲染后按 `tagType` 编码 |
::: warning `sendCommand` 模板当前未被消费
`write()` 直接取命令传入的值并按 `tagType` 编码(`encodeTagValue()`),未经过 `sendCommand` 模板渲染。该属性目前是预留约定,模板替换逻辑待补全。
:::
### 采集与健康
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮)。
- **自定义调度**:`schedule.custom` 在 yml 中启用(cron `0/5 * * * * ?`),但当前 `schedule()` 方法体为空,不执行任何自定义逻辑。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`
——在线状态机制见 [设备](../introduction/concepts/device)。
## 故障排查
EtherNet/IP 接入失败大多落在"连不上"和"读到的值不对"两类。下面按由表及里的顺序排查。
::: warning 端口或防火墙:44818 不通
EtherNet/IP 显式报文走 TCP `44818`(隐式 I/O 另用 UDP `2222`,本驱动不涉及)。先在驱动主机上确认 `host:44818` 可达(
`telnet 44818` 或 `nc -vz 44818`)。常见原因:PLC 侧未启用 EtherNet/IP 服务、网段不通、防火墙拦了 44818。
`getConnector()` 建连失败会抛 `ConnectorException`,日志含 `EtherNet/IP connection failed`。
:::
::: warning tagName 不存在或大小写不符
CIP 按名字寻址,`tagName` 必须与 PLC 程序里的变量名**逐字一致且区分大小写**。这点和 Modbus 的 `offset` 不同:Modbus
填错偏移会静默读到别的寄存器,而 CIP 标签名不存在会直接读取失败、抛 `ReadPointException`。排查时优先回到 PLC
工程核对标签拼写与作用域(控制器级标签 vs 程序级标签)。
:::
::: warning tagType 与 PLC 真实类型不符,读到乱值
驱动完全按你填的 `tagType` 解码字节、不向 PLC 探测真实类型。把一个 `REAL`(浮点)标签配成 `DINT`,会把浮点的 4
字节当整数解析,得到一个无意义的大数。接入前在 PLC 工程里逐个确认标签的实际类型,并让位号的 `pointTypeFlag` 与之匹配。
:::
::: warning 超时偏短导致间歇性读失败
`timeout` 设到套接字 `SoTimeout`,默认 `5000` 毫秒。网络抖动或 PLC 负载高时,过短的超时会让 `readFully()` 抛
`SocketTimeoutException`,进而触发 `invalidateConnector()` 断开重连。表现为周期性读失败、设备在线态抖动。可适当调大
`timeout`,并优先排查网络质量。
:::
::: info 设备显示在线但读不到值
`health()` 只检查缓存的套接字是否 `isConnected() && !isClosed()`,**不做真实协议探测**。也就是说,TCP 连着但 CIP
会话未真正建立时,设备仍可能显示"在线"。判断是否真正采到数据,应以 [位号值](../introduction/concepts/point-value)
是否更新为准,而非仅看设备在线态。
:::
::: info 多槽机架定位不到 CPU
当前 `slot` 未编入 CIP 路由路径。若 PLC 在非 0 号槽、或机架含多个 CPU,精确寻址需待协议组帧补全;在此之前建议用单 CPU、CPU 置于
0 号槽的场景做接入验证。
:::
## 在 IoT DC3 中如何落地
- **dc3.driver.code**:`EthernetIpDriver`(稳定路由标识,注册与消息路由都以它为准,不要随意改)。驱动名 `EtherNet/IP Driver`
,类型 `DRIVER_CLIENT`(驱动主动连 PLC)。
- **读能力**:`read()` 已接通"取标签名 → 组 Read Tag 请求 → 解码字节"的主干流程。
- **写能力**:`write()` 已接通"取标签名/类型 → 编码值 → 组 Write Tag 请求"的主干流程。
- **订阅/上报**:不支持。EtherNet/IP 显式报文是请求-应答模型,本驱动按采集周期主动轮询,不监听设备主动上报。
与 [驱动能力矩阵](./matrix) 对齐:EtherNet/IP 在矩阵中读/写/订阅均标记为 `—`,备注"罗克韦尔 / CIP,骨架待补"。
::: warning 当前为骨架实现(Work in progress)
本驱动是协议骨架。读写的上层流程(按 `tagName` 取数、按 `tagType` 编解码、套接字连接与失效重建)已就位,但 **CIP 协议组帧尚未补全
**:
- 会话建立 `RegisterSession`、连接打开 `ForwardOpen` 仍是 `TODO` 占位;
- `buildEncapsulationHeader()` 只写了一个 24 字节长度头,并非完整的 EtherNet/IP 封装帧;
- `health()` 仅检查套接字状态,非真实协议探测;
- `elementCount`、`sendCommand` 两个属性当前未被消费。
请将它作为接入起点模板,而非生产可用驱动。最终行为以 `EthernetIpDriverCustomServiceImpl` 的 `read()` / `write()` /
`initial()` 源码为准。
:::
把一台 Allen-Bradley PLC 接进来的最小路径(用于验证流程,非生产采集):
1. 选 `EtherNet/IP Driver` 创建 [设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.20`、`port=44818`、
`slot=0`、`timeout=5000`。
2. 给设备绑定的 [模板](../introduction/concepts/profile) 加一个转速 [位号](../introduction/concepts/point)(
`pointTypeFlag=INT`、`READ_ONLY`),point 属性填 `tagName=Motor_Speed`、`tagType=DINT`、`elementCount=1`。
3. 启动驱动观察连接与采集日志;CIP 组帧补全后,30 秒内即可在 [位号值](../introduction/concepts/point-value) 看到采集值。
完整的接入操作流程见 [设备接入](../operation/device-onboarding)。
## 延伸阅读
- [驱动总览](./index) — 全部驱动的分类与选型入口
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力与实现状态一览
- [设备接入](../operation/device-onboarding) — 一次完整的设备接入流程
- [工业总线与协议](../foundations/fieldbus) — 网络层工业有线侧,CIP 与其它私有协议的定位与寻址模型
---
# FINS 驱动
URL: https://docs.dc3.site/zh/drivers/fins
# FINS 驱动
`dc3-driver-fins` 把欧姆龙(Omron)PLC 通过 FINS 协议接入 IoT DC3:作为 FINS 客户端主动 TCP 连接
PLC,按[位号](../introduction/concepts/point)上配置的内存区与字地址周期性采数,并支持向内存区写值的命令。读完本页你能完成一台
Omron PLC 的接入,并清楚当前实现支持到哪一步。
- **驱动名 / code**:`Omron FINS Driver` / `FinsDriver`
- **类型**:`DRIVER_CLIENT`(主动连 PLC)
## 协议背景
FINS(Factory Interface Network Service)是欧姆龙 PLC 的原生通信协议,CP/CJ/CS 等系列广泛在用。它把 PLC 的内存按用途划分为若干
**内存区(Memory Area)**,每个区以"字"(16 位)为寻址单位;上位机通过发送内存读/写命令帧,按内存区代码 + 字地址访问数据。FINS
可承载在 UDP、TCP、以太网或欧姆龙专用总线上,本驱动使用 **FINS/TCP**——在每个 FINS 帧前加 4 字节长度前缀。
在[物联网四层架构](../foundations/fieldbus)中,FINS 属于**网络层**:它定义了车间内设备如何被寻址、命令如何编码、字节如何在链路上传输。IoT
DC3 在它之上做归一化,把"读 `D100` 这个字"翻译成统一的[位号值](../introduction/concepts/point-value)。
::: tip 先认识几个 FINS 概念
**内存区(Memory Area)**:PLC 里按用途划分的数据区——`D`(数据存储区,最常用)、`W`(工作区)、`H`(保持区)、`C`(计数器区)。本驱动把它们映射到
FINS 区代码:`D`=0x82、`W`=0xB1、`H`=0xB0、`C`=0x83。
**字地址(Word Address)**:内存区内以"字"(16 位)为单位的偏移,如 `D100` 就是 D 区第 100 个字。
**节点/单元号(Node/Unit)**:FINS 网络里寻址 PLC 用的源/目的地址,单台直连场景一般保持默认。
:::
### 一帧 FINS 读命令如何拼装
驱动不依赖第三方协议库,而是手工按字节拼装 FINS 帧。读 1 个字的请求帧由 4 字节 TCP 长度前缀 + 10 字节 FINS 头 + 2
字节命令码 + 4 字节读参数组成:
响应帧在 FINS 头与命令码之后带 2 字节**结束码(end code)**:非 0 表示 PLC 拒绝或出错,驱动据此抛出 `ReadPointException`;为
0 时数据从第 14 字节开始,按 `dataType` 解码(见下文的实现状态说明)。
## 属性配置
FINS 的接入参数分两类:连到哪台 PLC 由设备级的 **driver 属性**决定;每个位号读/写哪个字由 **point/command 属性**
决定。下面三张表的字段都来自驱动的 `application.yml`,表格前的散文说明每个属性的作用与取值来源。
### 驱动配置(设备级 `driver-attribute`)
接入一台 FINS PLC 时,在[设备](../introduction/concepts/device)上填这些[属性](../introduction/concepts/attribute-config)。
`host`/`port` 指向 PLC;`sourceNode`/`destNode`/`sourceUnit`/`destUnit` 是 FINS 帧头里的源/目的寻址字节,单台直连按默认即可;
`timeout` 同时用作 TCP 连接超时与读超时(`setSoTimeout`)。
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|--------|-------------|----------------------|
| Host | `host` | STRING | `127.0.0.1` | PLC 主机地址 |
| Port | `port` | INT | `9600` | FINS 端口(标准 9600) |
| Protocol | `protocol` | STRING | `TCP` | 传输协议(驱动固定走 FINS/TCP) |
| Source Node | `sourceNode` | INT | `1` | FINS 源节点号 |
| Dest Node | `destNode` | INT | `2` | FINS 目的节点号 |
| Source Unit | `sourceUnit` | INT | `0` | FINS 源单元号 |
| Dest Unit | `destUnit` | INT | `0` | FINS 目的单元号 |
| Timeout | `timeout` | INT | `5000` | 连接 / 请求超时(毫秒) |
### 位号配置(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填读取目标。`memoryArea` + `address` 共同定位到一个字(如 `memoryArea=D`、
`address=100` 对应欧姆龙习惯写法的 `D100`);`dataType` 声明解码方式;`bitPosition` 是字内位偏移。
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------|---------------|--------|----------|----------------------------------------------------------|
| Memory Area | `memoryArea` | STRING | `D` | 内存区,`D`/`W`/`H`/`C` |
| Address | `address` | INT | `0` | 内存区内的字地址 |
| Data Type | `dataType` | STRING | `UINT16` | `INT16`/`UINT16`/`INT32`/`UINT32`/`FLOAT`/`STRING`/`BCD` |
| Bit Position | `bitPosition` | INT | `0` | 字内位偏移(当前读路径未使用,固定按 `0` 处理) |
::: tip 读取字长由 `dataType` 决定
读取时按 `dataType` 请求对应字长:`INT32`/`UINT32`/`FLOAT` 读 **2 个字(4 字节)**,其余类型读 **1 个字(2 字节)**(见
`wordCount()`)。解码支持 `INT16`/`UINT16`/`INT32`/`UINT32`/`FLOAT`/`STRING`/`BCD`
,均按大端序(Big-Endian)解码;位号的数据类型([Point](../introduction/concepts/point)
的 `pointTypeFlag`)应与这里的 `dataType` 对得上。
:::
### 写命令配置(`command-attribute`)
可写位号还需在写命令上填目标位置与写入类型,字段含义同位号配置。
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|--------|----------|---------------------|
| Memory Area | `memoryArea` | STRING | `D` | 内存区,`D`/`W`/`H`/`C` |
| Address | `address` | INT | `0` | 内存区内的字地址 |
| Data Type | `dataType` | STRING | `UINT16` | 写值数据类型 |
### 采集与健康调度
这些节奏由 `application.yml` 的 `schedule`/`health` 段固定,接入时无需在设备上重填:
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮)。
- **自定义任务**:默认 cron `0/5 * * * * ?`,但 FINS 驱动的 `schedule()` 为空实现——该调度位保留、当前不做任何事。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`。驱动以 TCP 连接是否存活判定在线(
`socket.isConnected() && !socket.isClosed()`
),连接断了会尝试重连,重连失败即判离线;在线状态机制见[设备](../introduction/concepts/device)。
## 故障排查
::: warning address 是字地址,不是带区前缀的写法
`address` 只填内存区内的数字偏移。要读欧姆龙习惯写法的 `D100`,应填 `memoryArea=D`、`address=100`,**不要**把 `D100` 整体填进
`address`。区由 `memoryArea` 单独指定。`memoryArea` 只识别 `D`/`W`/`H`/`C`,其它值会被静默当作 `D`(0x82)处理。
:::
- **连不上 / 一直离线**:先确认 PLC 已启用 FINS/TCP 且端口为 `9600`(驱动只走 TCP,不会回退 UDP)。连接失败时驱动记
`Driver FINS connection failed` 日志并把该设备置为离线,下个健康检查周期会重试。检查 `host`、网络可达性、PLC 侧是否限制了客户端连接数。
- **读到的值不对**:确认 `dataType` 与 PLC 侧寄存器的实际类型/字长一致(`INT32`/`UINT32`/`FLOAT` 会读 2
个字),且字节序为大端;类型不匹配会解出错误数值。
- **endCode 非 0**:响应帧第 12–13 字节是 FINS 结束码,非 0 表示 PLC 拒绝(如地址越界、内存区不存在、权限不足)。驱动会抛
`FINS command failed, endCode=0x...`,按 FINS 手册查该码含义并核对 `memoryArea`/`address` 是否落在 PLC 实际内存范围内。
- **写浮点**:写命令对 `INT32`/`UINT32` 按整数解析(`Integer.parseInt`)写 4 字节大端整数,对 `FLOAT` 按
`Float.parseFloat` 编码为 4 字节 IEEE 754 大端浮点。请确保下发的字符串与 `dataType` 匹配(给 `FLOAT` 下发 `12.5` 即可)。
- **超时频繁**:`timeout` 同时管连接与读(默认 5000ms)。链路抖动或 PLC 响应慢时适当调大;注意每次读写异常都会主动关闭并移除该设备的缓存连接,下次访问重建。
- **节点号寻址失败**:跨网关/路由的 FINS 场景需要正确的 `sourceNode`/`destNode`。注意当前实现:驱动在 GCT 之后直接写出
`destNode`/`destUnit` 与 `srcNode`/`srcUnit`,并未单独写出置 0 的 DNA/SNA 网络地址字节——帧头未严格区分网络地址与节点号(节点号占据了规范里
DNA/SNA 的槽位)。单台直连保持默认 `1`/`2` 即可;跨网关多级路由场景下该简化帧头可能寻址不正确。
## 在 IoT DC3 中如何落地
- **dc3.driver.code**:`FinsDriver`(稳定路由标识,注册、命令分发都靠它,不要随意改)。
- **读能力**:✓ 已落地——周期轮询,读取字长随 `dataType`,解码支持
`INT16`/`UINT16`/`INT32`/`UINT32`/`FLOAT`/`STRING`/`BCD`。
- **写能力**:✓ 已落地——`INT16`/`UINT16`/`INT32`/`UINT32`/`STRING` 与 `FLOAT`(IEEE 754)均可正确写入。
- **订阅/上报能力**:— 不提供。FINS 是主动轮询型,驱动不监听设备推送,与[驱动能力矩阵](./matrix)中标注一致。
::: tip 一个驱动实例可接多台 PLC
同一个 FINS 驱动进程可服务多台设备,每台设备各自维护一条 TCP 连接(`clientMap` 按设备 ID 缓存)。多台 PLC 用各自的 `host`、
`destNode` 区分;设备被删除或更新时,驱动通过元数据事件销毁对应连接。
:::
### 最小接入示例
把 IP `192.168.1.20:9600` 的一台欧姆龙 PLC 接进来,采集 `D100` 的一个 16 位整数:
1. 选 `Omron FINS Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.20`、`port=9600`,其余(
`protocol`、节点/单元号、`timeout`)保持默认。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个[位号](../introduction/concepts/point)(
`pointTypeFlag=INT`、`READ_ONLY`),point 属性填 `memoryArea=D`、`address=100`、`dataType=INT16`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到 `D100` 的采集值。
## 延伸阅读
- [驱动总览](./index) — 按类别挑选协议,进入各驱动页
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — FINS 所属的网络层:寻址、字节序、轮询模型
---
# HTTP 驱动
URL: https://docs.dc3.site/zh/drivers/http
`dc3-driver-http` 把任意 HTTP/REST 接口当作数据源接入 IoT DC3——周期性调用 REST 端点、从 JSON
响应里抽一个字段作为[位号值](../introduction/concepts/point-value),并支持用请求体模板向接口写值。读完你能判断哪些设备/平台适合用它接入、每个属性该填什么、接不通时从哪里查。
## 协议背景
HTTP(HyperText Transfer Protocol)与建立在它之上的 REST 风格接口,是互联网最通用的请求/响应协议:客户端用一个**方法**(
`GET`/`POST`/`PUT`/`DELETE`)对一个**资源路径**发起请求,服务端返回状态码与一段报文(在物联网场景里通常是
JSON)。它无连接语义简单、几乎所有语言和工具都原生支持、调试方便,因此成为系统间集成的"最大公约数"。
在物联网四层参考架构里,HTTP 属于**网络层**中的**应用层消息协议**一类(与 MQTT、CoAP、LwM2M 并列)——它定义"
消息长什么样、怎么投递",而不关心底层走的是 Wi-Fi 还是蜂窝。但要诚实地说:HTTP 报文头臃肿、保活成本高、不为受限设备设计,**并不适合
**电池供电终端的高频上报。它在 IoT 里的真正位置是**对接现成接口**——第三方平台开放的 REST API、设备自带的 RESTful
接口、把现场数据聚合成 HTTP 端点的数据网关。这类"上游已经讲 REST、只需周期取数"的场景,正是本驱动的用武之地。关于 HTTP 与
MQTT/CoAP/LwM2M 的取舍,见[物联网网络层章节](../foundations/iot-protocols)。
本驱动作为 HTTP 客户端([驱动](../introduction/concepts/driver)类型 `DRIVER_CLIENT`),用 Spring WebFlux 的 `WebClient`
按[位号](../introduction/concepts/point)上配置的路径与方法去调接口,从 JSON 响应中按路径抽出一个值。两个本驱动特有的概念后面会反复出现:
- **响应路径(Response Path)**:从响应 JSON 里定位某字段的简单点号路径,如 `$.data.temperature` 表示取 `data` 对象下的
`temperature`。留空则把整段响应原文作为值。
- **请求体模板(Body Template)**:写值时用的请求体模板,里面的 `${value}` 占位符会被命令参数替换成实际写入值。
## 属性配置
属性分两层填写:**驱动属性**(`driver-attribute`,设备级,决定连到哪个服务)、**位号属性**(`point-attribute`
,决定每个位号调哪个路径、用什么方法、取哪个字段——读和写都用这一份)。`application.yml` 里还声明了**命令属性**(
`command-attribute`),但当前 `write()`
不消费它(见下文命令属性小节)。三类属性的来历与覆盖关系见[属性与配置](../introduction/concepts/attribute-config)
。所有默认值均取自驱动的 `application.yml`。
### 驱动属性(设备级 `driver-attribute`)
接入一个 HTTP 数据源时,在[设备](../introduction/concepts/device)上填这些属性。它们决定连到哪个服务、带什么头、超时多久——同一台设备下所有位号共享这套连接参数:
| 属性 | code | 类型 | 默认值 | 说明 |
|----------|-----------|--------|--------|----------------------------------------------------|
| Base URL | `baseUrl` | STRING | (空) | API 请求的基础地址(如 `https://api.example.com`) |
| Method | `method` | STRING | `GET` | 声明的默认 HTTP 方法,但当前实现未读取该属性(见下方告警);实际方法只由位号属性决定 |
| Headers | `headers` | STRING | (空) | 自定义请求头,JSON 形式(如 `{"Authorization":"Bearer xxx"}`) |
| Timeout | `timeout` | INT | `5000` | 请求超时(毫秒),作用于响应超时 `responseTimeout` |
`baseUrl` 是必填项——驱动用它构造 `WebClient` 的 base URL,所有位号路径都拼在它之后;缺了它驱动的 `validate()` 会直接判定校验不通过。
`timeout` 默认 `5000` 毫秒,落到 Reactor Netty `HttpClient` 的 `responseTimeout` 上。
::: warning Headers 属性目前不生效
`headers` 在 `application.yml` 里已声明,但当前实现的 `getConnector()` 只为 `WebClient` 设置了固定的
`Content-Type: application/json`,**没有**读取并应用 `headers` 属性。需要带 `Authorization` 等自定义头鉴权的接口,暂时无法仅靠该属性接入。
:::
::: warning 驱动级 Method 属性目前不生效
`method` 在 `application.yml` 里声明为驱动级属性,但 `getConnector()` 只读取 `baseUrl` 与 `timeout`,从不读取驱动级
`method`;`read()`/`write()` 的方法只取自位号属性 `method`,缺省回退到硬编码的 `GET`。因此在设备上设 `method=POST` 不会生效——它与
`headers` 同属"已声明但未应用"的属性。HTTP 方法实际只由位号属性 `method` 决定。
:::
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填:调哪个路径、用什么方法、(写时)发什么请求体、从响应里取哪个字段。读/写实际使用的
HTTP 方法只来自位号的 `method`,缺省时回退到硬编码的 `GET`,与驱动级 `method` 属性无关(驱动级 `method` 不被实现读取,见上):
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|-------|----------------------------------------|
| Path | `path` | STRING | (空) | API 路径(如 `/api/v1/sensor/{id}`) |
| Method | `method` | STRING | `GET` | 本位号的 HTTP 方法,缺省回退到硬编码 `GET` |
| Body Template | `bodyTemplate` | STRING | (空) | 带 `${value}` 占位符的请求体模板 |
| Response Path | `responsePath` | STRING | (空) | 从 JSON 响应抽值的路径(如 `$.data.temperature`) |
::: tip path 拼在 baseUrl 之后
实际请求 URL 是 `baseUrl + path`。例如 `baseUrl=https://api.example.com`、`path=/api/v1/sensor/1`
,驱动就请求 `https://api.example.com/api/v1/sensor/1`。
:::
::: warning Response Path 是简单点号路径,不是完整 JSONPath
驱动把 `$.` 前缀去掉后按 `.` 逐段从根对象往下钻,只支持 `$.a.b.c` 这样的逐层取字段写法。**不支持**数组下标(`[0]`
)、过滤器、通配符等完整 JSONPath 语法。取数组元素、或路径取不到对应字段时,会退回返回整段响应原文——这往往就是位号值"
看起来不对"的根因。响应本身就是裸值(纯数字/字符串)时,`responsePath` 留空即可,驱动把整段响应当作位号值。
:::
::: warning 写值要靠 Body Template 渲染
写命令不会自动把值塞进请求体。必须在位号的 `bodyTemplate` 里写好带 `${value}` 的模板(如 `{"value":${value}}`
),驱动才会把命令参数替换进去发出;模板为空时发的是空请求体。
:::
### 命令属性(`command-attribute`)
::: warning 命令属性目前不被写入路径消费
`application.yml` 在 `command-attribute` 下声明了 `path` 与 `method`(`default-value: POST`),但驱动的 SPI `write()` 签名只接收
`driverConfig` 与 `pointConfig`,不传入命令属性映射;`write()` 实际从位号属性(`pointConfig`)读取 `path`/`method`/
`bodyTemplate`,且 `method` 缺省回退到硬编码的 `GET`(不是 `POST`)。因此下表列出的 `command-attribute`
当前是声明但未生效的死配置,写操作的路径与方法请填在位号属性上。
:::
下表为 `application.yml` 中的声明值(当前 `write()` 未读取):
| 属性 | code | 类型 | 默认值 | 说明 |
|--------|----------|--------|--------|--------------------------------|
| Path | `path` | STRING | (空) | 命令的 API 路径(当前未被 `write()` 读取) |
| Method | `method` | STRING | `POST` | 命令的 HTTP 方法(当前未被 `write()` 读取) |
### 采集与健康检查
这些参数定在 `application.yml` 的 `dc3.driver.schedule` 与 `health` 下,无需在设备上填:
- **采集周期**:`application.yml` 基线定时读 cron `0/30 * * * * ?`(每 30 秒读一轮);默认激活的 `dev` profile 在
`application-dev.yml` 把 read cron 覆盖为 `0/5 * * * * ?`(每 5 秒),故开箱即用的实际采集间隔是 5 秒。
- **健康检查**:设备健康检查 cron `0/15 * * * * ?`,租约超时 `45 秒`
——在线状态机制见[设备](../introduction/concepts/device)。
- **在线判定**:`health()` 以"`clientMap` 里是否为该设备建过 `WebClient`"判断在线——首次成功读/写后建立连接即视为在线;读/写抛异常时驱动会
`clientMap.remove(deviceId)` 摘除连接,下一轮重建。
## 故障排查
::: warning 连不上 / 一直 offline
先确认 `baseUrl` 可达:用 `curl ` 在驱动所在主机直接验证网络与端口是否通。驱动以"是否建过 `WebClient`"
判在线,首次读/写失败会立即摘除连接,因此 `baseUrl` 错、DNS 解析不了、或目标端口被防火墙挡,都会表现为设备始终 offline。
:::
::: warning 请求超时
默认 `timeout=5000` 毫秒落在响应超时上。目标接口本身慢、或网络抖动时会触发超时并把本轮读/写判为失败、摘除连接。先用
`curl -w '%{time_total}'` 量一下真实耗时,确认是接口慢还是链路慢,再据此调大 `timeout`。
:::
::: warning 位号值看起来不对 / 总是整段 JSON
多半是 `responsePath` 没匹配上。该驱动只认 `$.a.b.c` 的逐层点号路径,路径写错、字段名大小写不符、或想取数组元素(
`$.list[0].v`),都会因取不到字段而**退回返回整段响应原文**。对照接口真实响应逐层核对字段名;要取数组里的元素,当前实现做不到。
:::
::: warning 鉴权接口 401/403
当前实现未应用 `headers` 属性(见上文驱动属性表的告警)。需要 `Authorization`、`X-Api-Key` 等请求头的接口,暂时无法仅凭设备属性接入,会被服务端拒为
401/403。
:::
::: warning 写命令报错或没生效
写值经由位号的 `bodyTemplate` 渲染:模板为空时发的是**空请求体**,接口若要求 JSON body 会拒绝。确认 `bodyTemplate` 里含
`${value}` 占位符、且渲染后是接口接受的合法 JSON;写失败时驱动抛 `WritePointException` 并摘除连接。
:::
## 在 IoT DC3 中如何落地
- **dc3.driver.code**:`HttpDriver`(驱动名 `HTTP REST Client Driver`,类型 `DRIVER_CLIENT`)。该 code 是稳定路由标识,不可随意改。
- **读**:✓ 已实现。定时按位号 `path`/`method` 调接口,`responsePath` 从 JSON 抽值。
- **写**:✓ 已实现。把位号 `bodyTemplate` 中 `${value}` 替换为命令参数后发请求。
- **订阅/上报**:— 不支持。HTTP 是请求/响应模型,驱动主动发起,无被动推送通道。
以上读/写能力与[驱动能力矩阵](./matrix)的 `HTTP (HttpDriver)` 行一致。
::: info 实现状态:可用
`HttpDriverCustomServiceImpl` 的 `initial()`/`read()`/`write()`/`health()`/`validate()` 均已完整实现,是可投入采集的成熟驱动。两处实现边界须知:①
`responsePath` 仅支持简单点号路径,不支持数组/过滤器(见上);② `headers` 属性已声明但尚未在连接中应用,自定义请求头当前无法生效。
:::
### 最小接入示例
把一个返回 `{"data":{"temperature":25.6}}` 的接口接进来:
1. 选 `HTTP REST Client Driver` 创建[设备](../introduction/concepts/device),驱动属性填
`baseUrl=https://api.example.com`、`method=GET`、`timeout=5000`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),位号属性填 `path=/api/v1/sensor/1`、`method=GET`、`responsePath=$.data.temperature`。
3. 启动驱动,数秒内就能在[位号值](../introduction/concepts/point-value)里看到抽取出的 `25.6`(默认 `dev` profile 每 5
秒采一轮)。
## 延伸阅读
- [驱动总览](./index) — 全部驱动分组与选型入口
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [物联网网络层章节](../foundations/iot-protocols) — HTTP 与 MQTT/CoAP/LwM2M 在网络层的定位与取舍
---
# IEC 104 驱动
URL: https://docs.dc3.site/zh/drivers/iec104
# IEC 104 驱动
`dc3-driver-iec104` 把 IEC 60870-5-104 远动设备接入 IoT DC3:它作为 104 客户端连到变电站/调度自动化设备,按**
信息对象地址(IOA)**采集遥测遥信,并支持下发遥控命令。读完本页你能看懂 104 的寻址方式、给设备和位号填对协议属性,并清楚这个驱动当前的实现边界。
> 你在这里:现场设备的"电力远动 / SCADA"接入侧。要理解工业协议为什么各家私有、IEC 104
> 在网络层处于什么位置,先读 [工业总线与协议](../foundations/fieldbus)。
## 协议背景
IEC 60870-5-104(简称 IEC 104)是电力系统调度自动化领域的国际标准远动协议,把 IEC 60870-5-101 的应用层架在标准 TCP/IP
之上。它广泛用于变电站综合自动化、配电终端(DTU/FTU)、远动机(RTU)与主站之间的**"四遥"通信**
——遥测(telemetry)、遥信(telesignaling)、遥控(telecontrol)、遥调(telesetpoint)。在电力调度领域,它和楼宇自控的 BACnet、公用事业计量的
DLMS/COSEM 一样,属于"按行业需求立标准"的协议阵营。
IEC 104 与 Modbus 这类协议的寻址模型不同,它用两个概念定位与解释一个数据点:
- **信息对象地址 IOA(Information Object Address)** 唯一定位远动设备里的一个数据点(一个遥测量、一个遥信状态)。
- **ASDU 类型** 描述这条报文的数据语义,例如 `M_ME_NC_1`(短浮点遥测)、`M_SP_NA_1`(单点遥信)——同一个 IOA 配不同的 ASDU
类型,解出来的数据含义就不同。
104 报文里没有字段分隔符,公共地址、传送原因、信息对象地址各占几个字节,全靠主站与远动设备在工程组态时约定(典型 `2/2/3`
)。这套"靠字节宽度切分 + 靠 IOA 定位"的模型,使得字节长度配置必须与对端一字不差。
在物联网四层架构里,IEC 104 属于**网络层**的工业有线侧:它解决"现场远动设备如何在 TCP/IP
上把一个遥测遥信送出去、把一个遥控接进来"的问题,位于感知层(互感器/变送器)之上、平台层(IoT DC3 数据汇聚)之下。下面这张图给出
104 客户端在一次采集里的位置:
驱动作为客户端(client)主动连一个 104 服务端,按位号配置的 `ioa` 定位读哪个点、按 `asduType`
解释字节,统一为 [位号值](../introduction/concepts/point-value) 上送平台。
## 属性配置
IEC 104 的接入参数分两层:**驱动属性(driver-attribute)** 描述"连哪台远动设备、用什么端口、各字段几个字节"
,填在 [设备](../introduction/concepts/device) 上;**位号属性(point-attribute)** 描述"读哪个 IOA、按什么 ASDU 类型解释"
,填在每个 [位号](../introduction/concepts/point) 上。可写位号再加一项**命令属性(command-attribute)**。这些属性的默认值都来自驱动的
`application.yml`,三层来历见 [属性与配置](../introduction/concepts/attribute-config)。
### 驱动属性(设备级 `driver-attribute`)
接入一台 IEC 104 设备时,先在设备上指明它的网络位置与报文字段约定。`host` / `port` 决定 TCP 连到哪里;`asduAddress`
(公共地址,又称站地址)区分同一连接下的多个逻辑站;`cotLength` / `caLength` / `ioaLength` 是 104
报文中传送原因、公共地址、信息对象地址三个字段各占的字节宽度;`connectTimeout` 限制建连等待时长。
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------------|------------------|--------|-------------|---------------------|
| Host | `host` | STRING | `localhost` | 104 服务端 IP(远动设备地址) |
| Port | `port` | INT | `2404` | 104 TCP 端口(标准 2404) |
| ASDU Address | `asduAddress` | INT | `1` | 公共地址(站地址),区分逻辑站 |
| COT Length | `cotLength` | INT | `2` | 传送原因字段字节数 |
| CA Length | `caLength` | INT | `2` | 公共地址字段字节数 |
| IOA Length | `ioaLength` | INT | `3` | 信息对象地址字段字节数 |
| Connect Timeout | `connectTimeout` | INT | `10000` | 连接超时(毫秒) |
::: tip COT/CA/IOA 长度是站内约定,必须与对端一致
`cotLength` / `caLength` / `ioaLength` 是 104 报文里各字段的字节宽度,由主站与远动设备在工程组态时约定(典型 `2/2/3`
)。这三项必须与对端配置完全一致,否则报文按错误的字节边界切分,地址会读到错位的字节上。`asduAddress`(公共地址)用于在同一
`host:port` 连接下区分多个逻辑站。
:::
::: info `host` / `port` / `asduAddress` 为必填校验项
`validate()` 把 `host`、`port`、`asduAddress` 列为必填项;缺任一项会在校验时报 `ERROR`,设备配置不通过。其余字节长度项有默认值,缺省即用上表默认。
:::
### 位号属性(`point-attribute`)
每个采集位号都要指明读哪个信息对象、以及该点用什么 ASDU 类型解释——驱动不会向设备探测类型,完全按你填的 `asduType` 理解字节。
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------|------------|--------|-------------|------------------|
| IOA | `ioa` | INT | `0` | 信息对象地址,唯一定位一个数据点 |
| ASDU Type | `asduType` | STRING | `M_ME_NC_1` | ASDU 类型标识,决定数据语义 |
::: tip IOA 定位"读哪个点",ASDU 类型决定数据语义
`ioa` 是信息对象地址,唯一标识远动设备里的一个数据点。`asduType` 标明该点的数据类型,默认 `M_ME_NC_1`(短浮点遥测);遥信常用
`M_SP_NA_1`(单点遥信)。位号自身的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)应与 ASDU
类型携带的实际数据对得上。`validatePoint()` 把 `ioa` 列为必填项。
:::
### 命令属性(`command-attribute`)
可写位号(下发遥控)在写命令上额外配置下发模板。
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------|---------------|--------|------------|----------------|
| Send Command | `sendCommand` | STRING | `${value}` | 下发命令模板,用命令参数渲染 |
::: tip sendCommand 是模板,用命令参数占位
`sendCommand` 用 `${参数名}` 占位,驱动 `execute()` 时把命令参数(如 `${value}`)逐个替换进去,默认 `${value}`
即把命令值直接作为遥控值。注意:`execute()` 当前只完成**模板渲染并返回**,把渲染结果放进返回 Map 的 `sendCommand`
键,并未真正把遥控帧下发到 104 服务端(见下方实现状态)。
:::
### 采集与健康
- **采集周期**:默认 read cron `0/30 * * * * ?`(每 30 秒读一轮)。
- **自定义调度**:`schedule.custom` 在 yml 中启用(cron `0/5 * * * * ?`),但当前 `schedule()` 方法体为空,不执行任何自定义逻辑。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`
——在线状态机制见 [设备](../introduction/concepts/device)。
## 故障排查
IEC 104 接入失败大多落在"连不上"和"报文解析错位"
两类。下面按由表及里的顺序排查。需要注意的是:本驱动的协议读写当前是骨架(见下一节),下列排查项面向接入参数与网络可达性,而非已能采到值的运行态。
::: warning 端口或防火墙:2404 不通
104 标准走 TCP `2404`,与 Modbus 的 `502`、EtherNet/IP 的 `44818`、DLMS 的 `4059` 都不同,别张冠李戴。先在驱动主机上确认
`host:2404` 可达(`telnet 2404` 或 `nc -vz 2404`)。常见原因:远动设备未启用 104 服务、网段不通、防火墙拦了
2404。建连超时由 `connectTimeout`(默认 `10000` 毫秒)控制。
:::
::: warning COT/CA/IOA 长度对不上 = 整条报文解析错位
104 报文没有字段分隔符,全靠约定的字节宽度切分。若 `cotLength` / `caLength` / `ioaLength`
与对端不一致,地址会被读到错误的字节上,导致采到的不是目标点甚至解析失败。接入前务必向运维确认对端的 `2/2/3`
(或其他)组态,三项一字不差地照填。
:::
::: warning 公共地址(asduAddress)选错连到错的逻辑站
一台远动机下可能挂多个逻辑站,用 `asduAddress`(公共地址)区分。当多个设备共用相同 `host:port` 时,由各自的 `asduAddress`
决定连/读哪个站。若 `asduAddress` 填错,会对端返回的总召唤里取不到预期的点。接入前向运维确认每个逻辑站的公共地址。
:::
::: warning ASDU 类型与点的实际类型不符
驱动完全按位号填的 `asduType` 理解字节。把一个短浮点遥测点(`M_ME_NC_1`)配成单点遥信(`M_SP_NA_1`),解出来的值会失去意义。接入前逐个确认每个
IOA 对应的 ASDU 类型,并让位号的 `pointTypeFlag` 与之匹配(遥测多为 `FLOAT`,遥信多为 `BOOLEAN`)。
:::
::: info 设备在线态不等于已采到值
设备在线/离线由租约机制(默认 `45 秒` 超时)维护,反映的是驱动与平台之间的心跳,而非 104
链路上真正读到了数据。判断是否采到值,应以 [位号值](../introduction/concepts/point-value)
是否更新为准。当前驱动协议读写尚未实现(见下一节),不会有位号值更新。
:::
## 在 IoT DC3 中如何落地
- **dc3.driver.code**:`Iec104Driver`(稳定路由标识,注册与消息路由都以它为准,不要随意改)。驱动名 `IEC 104 Driver`,类型
`DRIVER_CLIENT`(驱动主动连远动设备)。
- **读能力**:`read()` **未实现**——直接抛 `ReadPointException` 快速失败(让 SDK 记录失败并退避,而非回显缓存值或伪造成功)。
- **写能力**:`write()` **未实现**——直接抛 `WritePointException` 快速失败。
- **遥控命令**:`execute()` **部分实现**——只完成 `sendCommand` 模板的参数渲染并返回渲染结果,尚未把遥控帧真正下发到 104
服务端。
- **订阅/上报**:不涉及。本驱动按客户端模型设计,由采集周期主动读,不监听设备主动上报。
与 [驱动能力矩阵](./matrix) 对齐:IEC 104 在矩阵中读/写/订阅均标记为 `—`,备注"电力 SCADA,骨架待补"。
::: warning 当前为骨架实现(Work in progress)
本驱动是协议模板骨架:属性表、采集周期、IOA/ASDU 寻址语义已就位且可照填,但 **104 协议层 I/O 尚未实现**:
- `read()` / `write()` 直接抛"未实现"异常快速失败,不做任何 IOA 读取或遥控下发;
- `execute()` 仅渲染 `sendCommand` 模板并返回,不真正发帧;
- `initial()`、`schedule()`、`event()` 方法体为空,无自定义初始化/调度/元数据事件逻辑;
- 已实现的只有配置校验(`validate()` / `validatePoint()`)与命令模板渲染。
请将它作为接入 104 设备的**起点模板**,而非生产可用驱动。最终行为以 `Iec104DriverCustomServiceImpl` 的 `read()` /
`write()` / `initial()` 源码为准。
:::
把一台远动设备接进来的最小路径(用于验证配置流程,非生产采集):
1. 选 `IEC 104 Driver` 创建 [设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.30`、`port=2404`、
`asduAddress=1`(`cotLength` / `caLength` / `ioaLength` 用默认 `2/2/3`,与对端约定一致即可)。
2. 给设备绑定的 [模板](../introduction/concepts/profile) 加一个遥测 [位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `ioa=16385`、`asduType=M_ME_NC_1`。
3. 启动驱动观察连接与校验日志。协议层补全前,30
秒一轮的读会快速失败(异常退避);补全后即可在 [位号值](../introduction/concepts/point-value) 看到采集值。
完整的接入操作流程见 [设备接入](../operation/device-onboarding)。
## 延伸阅读
- [驱动总览](./index) — 全部驱动的分类与选型入口
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力与实现状态一览
- [设备接入](../operation/device-onboarding) — 一次完整的设备接入流程
- [工业总线与协议](../foundations/fieldbus) — 网络层工业有线侧,IEC 104 与其它行业标准协议的定位与寻址模型
---
# IEC 61850 驱动
URL: https://docs.dc3.site/zh/drivers/iec61850
`dc3-driver-iec61850` 作为 IEC 61850 MMS 客户端,按 IED 设备维护一条 MMS 关联,读/写以对象引用与功能约束寻址的数据属性。
## 协议背景
IEC 61850 是变电站自动化标准,数据对象按逻辑设备/逻辑节点/数据对象组织,MMS 协议取值。
驱动 code:Iec61850Driver,类型:DRIVER_CLIENT,底层库:OpenMUC openiec61850
## 属性配置
### 驱动属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Host | host | STRING | (空) | IED 服务器地址 |
| Port | port | INT | 102 | MMS 端口 |
### 位号属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Object Reference | objectReference | STRING | (空) | 数据对象引用,如 S1MMXU1.TotW.actVal |
| Functional Constraint | functionalConstraint | STRING | MX | MX, ST, CO, SP, SE |
### 命令属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Object Reference | objectReference | STRING | (空) | 命令对象引用 |
## 能力矩阵
| 能力 | 支持 |
|------|------|
| 读 | ✓ |
| 写 | ✓ |
| 订阅 | — |
::: info 实现状态:可用 :::
服务器模型每个关联检索一次并缓存;读调用 getDataValues 并取第一个 BasicDataAttribute 的 getValueString()。
## 最小接入示例
1. 选 IEC 61850 驱动创建设备,填 host、port=102。
2. 加位号(READ_ONLY),填 objectReference=S1MMXU1.TotW.actVal、functionalConstraint=MX。
3. 启动驱动,按采集周期读取测量值。
## 延伸阅读
- [驱动总览](./index)
- [驱动能力矩阵](./matrix)
- [设备接入](../operation/device-onboarding)
---
# 驱动总览
URL: https://docs.dc3.site/zh/drivers/
> IoT DC3 内置 **36 个协议驱动**,覆盖工业总线、PLC/SCADA、物联网、数据库与虚拟测试。每个驱动是一个独立服务(`dc3-driver-*`
> ),启动时把自己和可接受的[配置属性](../introduction/concepts/attribute-config)
> 注册到管理中心,按[位号](../introduction/concepts/point)采数、按[指令](../introduction/concepts/command)写值。
接入一台设备的通用流程见[设备接入](../operation/device-onboarding)
;驱动的通用模型见[驱动](../introduction/concepts/driver)概念。下面按类别选你的协议:
## 协议适配层
设备世界的协议异构且割裂:Modbus 主从、OPC UA 地址空间、MQTT 发布订阅、PLC 专有帧、SQL 结果集——彼此既无统一寻址也无统一报文。这
28 个驱动在网络层各自扮演**感知数据汇聚点**的角色(见《基于物联网的四网融合技术研究及其应用》温喆、范亚斌著,吉林人民出版社·2016,第一章
第三节 物联网体系结构,p13),把上述异构协议的读写收敛为统一的[位号](../introduction/concepts/point)值;这正对应 IoT
三层架构中应用层的"**统一数据建模**、通信通道管理"(同上,p13)——上层应用只消费归一化后的位号,不再感知底层协议差异。
## 工业总线 / PLC / SCADA
| 驱动 | 协议 | 说明 |
|------------------------------|-------------------|---------------|
| [Modbus TCP](./modbus-tcp) | Modbus TCP | 以太网 Modbus 主站 |
| [Modbus RTU](./modbus-rtu) | Modbus RTU | 串口 Modbus 主站 |
| [OPC UA](./opc-ua) | OPC UA | OPC 统一架构客户端 |
| [OPC DA](./opc-da) | OPC DA | 经典 OPC 数据访问 |
| [S7](./plcs7) | Siemens S7 | 西门子 PLC |
| [MELSEC](./melsec) | Mitsubishi MELSEC | 三菱 PLC |
| [FINS](./fins) | Omron FINS | 欧姆龙 PLC |
| [EtherNet/IP](./ethernet-ip) | EtherNet/IP (CIP) | 罗克韦尔 / CIP |
| [BACnet/IP](./bacnet-ip) | BACnet/IP | 楼宇自控 |
| [IEC 104](./iec104) | IEC 60870-5-104 | 电力 SCADA |
| [DLMS](./dlms) | DLMS / COSEM | 智能电表 |
| [SL651](./sl651) | SL651 | 水文监测 |
| [SNMP](./snmp) | SNMP | 网络设备监控 |
| [DL/T645](./dlt645) | DL/T645-2007 | 电能表协议 |
| [DNP3](./dnp3) | DNP3 (IEEE 1815) | 电力自动化 |
| [IEC 61850](./iec61850) | IEC 61850 (MMS) | 变电站自动化 |
| [KNX](./knx) | KNX | 楼宇自动化总线 |
| [M-Bus](./mbus) | M-Bus (EN 13757) | 仪表总线 |
## 物联网 / 无线
| 驱动 | 协议 | 说明 |
|--------------------|--------------|--------------|
| [MQTT](./mqtt) | MQTT | 物联网消息总线 |
| [CoAP](./coap) | CoAP | 受限设备 RESTful |
| [LwM2M](./lwm2m) | LwM2M | 轻量级设备管理 |
| [HTTP](./http) | HTTP | 通用 HTTP 采集 |
| [BLE](./ble) | Bluetooth LE | 低功耗蓝牙 |
| [Zigbee](./zigbee) | Zigbee | 短距无线 |
| [CAN](./can) | CAN | 控制器局域网 |
| [LoRaWAN](./lorawan) | LoRaWAN | ChirpStack MQTT 上行接入 |
| [Kafka](./kafka) | Apache Kafka | 流式数据源 |
## 串口 / 通用网络
| 驱动 | 协议 | 说明 |
|-----------------------|-----------|--------------|
| [串口 Serial](./serial) | Serial | 通用串口 |
| [TCP/UDP](./tcp-udp) | TCP / UDP | 通用 socket 接入 |
## 数据库
| 驱动 | 数据源 | 说明 |
|----------------------------|------------|---------|
| [MySQL](./mysql) | MySQL | 从库表采集点位 |
| [PostgreSQL](./postgresql) | PostgreSQL | 从库表采集点位 |
| [Oracle](./oracle) | Oracle | 从库表采集点位 |
| [SQL Server](./sqlserver) | SQL Server | 从库表采集点位 |
| [Redis](./redis) | Redis | 从键采集点位 |
## 虚拟 / 测试
| 驱动 | 说明 |
|-----------------------------------------------|-----------------------|
| [虚拟 Virtual](./virtual) | 生成模拟数据,无需真实设备,用于体验与压测 |
| [监听虚拟 Listening Virtual](./listening-virtual) | 监听端口接收设备推送,用于联调 |
## 参考文献
温喆, 范亚斌. 基于物联网的四网融合技术研究及其应用[M]. 长春: 吉林人民出版社, 2016. ISBN 978-7-206-12410-5. (第一章 第三节
物联网体系结构, p13)
## 延伸阅读
- [驱动 Driver](../introduction/concepts/driver) — 驱动的通用模型与注册机制
- [属性与配置](../introduction/concepts/attribute-config) — driver / point / command 属性的三层
- [设备接入](../operation/device-onboarding) — 一次完整接入流程
- [模块地图](../architecture/modules) — 驱动在整体架构里的位置
- [工业总线与协议](../foundations/fieldbus) · [IoT 协议与无线网络](../foundations/iot-protocols) — 协议背后的体系化知识
---
# Kafka 驱动
URL: https://docs.dc3.site/zh/drivers/kafka
`dc3-driver-kafka` 把 Apache Kafka 当作流式数据源:入站消息异步消费并缓存,读返回最新缓存值,写生产消息。
## 协议背景
Kafka 是分布式发布订阅流,值异步到达,按消息键(无键按主题)缓存最新消息。
驱动 code:KafkaDriver,类型:DRIVER_SERVER,底层库:Spring Kafka
## 属性配置
### 驱动属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Topic | topic | STRING | dc3-driver-kafka | 默认主题 |
### 位号属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Topic | topic | STRING | (空) | 覆盖本位号主题 |
| Key | key | STRING | (空) | 消息键 |
### 命令属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Topic | topic | STRING | (空) | 覆盖本命令主题 |
## 能力矩阵
| 能力 | 支持 |
|------|------|
| 读 | ✓ |
| 写 | ✓ |
| 订阅 | ✓ |
::: info 实现状态:可用 :::
连接通过 spring.kafka.*(KAFKA_BOOTSTRAP_SERVERS)配置。
## 最小接入示例
1. 选 Kafka 驱动创建设备。
2. 加位号(READ_ONLY),填 key=sensor-1。
3. 启动驱动,消息从主题被消费并缓存。
## 延伸阅读
- [驱动总览](./index)
- [驱动能力矩阵](./matrix)
- [设备接入](../operation/device-onboarding)
---
# KNX 驱动
URL: https://docs.dc3.site/zh/drivers/knx
`dc3-driver-knx` 通过 KNX IP 网关连接 KNX(ISO/IEC 14543-3)安装,按设备维护一条隧道链路,读/写布尔、无符号、浮点或控制组地址。
## 协议背景
KNX 是家居与楼宇自动化标准,组地址连接传感器与执行器,IP 网关通过 KNXnet/IP 隧道暴露总线。
驱动 code:KnxDriver,类型:DRIVER_CLIENT,底层库:Calimero(calimero-core)
## 属性配置
### 驱动属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Remote Host | remoteHost | STRING | (空) | KNX IP 网关地址 |
| Remote Port | remotePort | INT | 3671 | 网关端口 |
| Local Host | localHost | STRING | (空) | 本地绑定地址 |
| Use NAT | useNat | BOOLEAN | false | 启用 NAT |
| Device Address | deviceAddress | STRING | 0.0.0 | 本地 KNX 个体地址 |
### 位号属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Group Address | groupAddress | STRING | (空) | KNX 组地址,如 1/2/3 |
| Data Type | dataType | STRING | BOOL | BOOL, UINT, FLOAT, CONTROL |
| DPT | dpt | STRING | (空) | UINT 数据点类型,如 5.001 |
### 命令属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Group Address | groupAddress | STRING | (空) | 命令组地址 |
## 能力矩阵
| 能力 | 支持 |
|------|------|
| 读 | ✓ |
| 写 | ✓ |
| 订阅 | — |
::: info 实现状态:可用 :::
KnxDriverCustomServiceImpl 按设备缓存 KNXNetworkLink + ProcessCommunicator,设备 UPDATE/DELETE 事件关闭连接。
## 最小接入示例
1. 选 KNX 驱动创建设备,填 remoteHost、deviceAddress=1.1.0。
2. 加位号(READ_ONLY),填 groupAddress=1/2/3、dataType=BOOL。
3. 启动驱动,按采集周期读取组值。
## 延伸阅读
- [驱动总览](./index)
- [驱动能力矩阵](./matrix)
- [设备接入](../operation/device-onboarding)
---
# Listening Virtual 驱动
URL: https://docs.dc3.site/zh/drivers/listening-virtual
# Listening Virtual 驱动
> `dc3-driver-listening-virtual` 把"自己往平台推数据"的 TCP/UDP 设备接入 IoT
> DC3。驱动开一个监听端口等设备来连,从设备推上来的字节流里按[位号](../introduction/concepts/point)
> 配置截取并解析出值。读完本页,你能配出第一个监听位号、让 GPS/北斗一类终端把二进制帧推进平台。
工业现场有一类设备不等人来问,而是自己周期性地把数据"推"出去:GPS 定位器、环境监测盒子、各种走私有二进制报文的传感终端。常见做法就是连到一个固定的
IP:端口,把一段字节流发过来。本驱动正是为这类场景准备的——它是一个**监听型(被动)驱动**,自启 TCP 与 UDP
两个监听端口,设备主动连上来推数据,驱动把字节流解析成[位号值](../introduction/concepts/point-value)。它不会主动去"读"设备。
## 协议背景
这是一个**虚拟/测试驱动**,没有真实的标准协议层——报文格式是本驱动自定的、最小够用的二进制约定,专门用来演示和打通"
设备主动上报"这条链路。在物联网四层架构里,它落在**网络层**:靠 TCP/UDP 承载设备到平台的字节流,由平台被动接收。真实项目里你可以拿它当模板,把私有上报协议的解析逻辑替换进去。
适用场景:
- 自带上报逻辑的 GPS/北斗终端、推送二进制帧的传感网关;
- 任何"客户端连服务端、服务端被动收"的私有 TCP/UDP 协议;
- 想在没有真实硬件时,验证"推送 → 解析 → 落库"全链路。
在动手前,先记住两个本驱动特有的概念,后面配置表会反复用到:
- **报文关键字(Keyword)**:设备推上来的报文里,紧跟设备名之后的 1 个字节,用十六进制表示(如 `62`
)。同一台设备可以用不同关键字区分不同类型的报文,驱动据此决定这一帧该解析给哪个位号。
- **字节区间(Start / End)**:在报文里截取数据的字节偏移。`start` 是起始偏移(含),`end` 是结束偏移(不含);定长数值位号只看
`start` 起的固定字节数,`coordinate` 字符串位号才用 `start..end` 这段区间。
报文结构固定为:设备名 22 字节 + 关键字 1 字节 + 数据载荷变长。
- 前 22 字节是设备名,驱动用 `Long.parseLong` 把它解析成[设备](../introduction/concepts/device) ID(必须与平台上的设备一一对应)。
- 第 23 字节(偏移 22)是关键字,与位号上配的 `key` 逐字比对。
- 之后是数据载荷,每个位号按自己的 `start`/`end` 从报文里取一段,**解析类型由位号名(pointName)决定**,而不是 `type` 属性。
## 属性配置
本驱动**不声明任何设备级 `driver-attribute`**
——监听端口是驱动进程级配置,所有接入细节都落在[位号](../introduction/concepts/point)上。
**驱动名 / code / 类型**(来自 `application.yml`):
- 驱动名 / code:`Listening Virtual TCP/UDP Driver` / `ListeningVirtualDriver`
- 类型:`DRIVER_SERVER`(驱动作监听端,被动收设备推上来的数据)
**监听端口**为进程级配置,不在位号上填:TCP 默认 `6270`、可用环境变量 `TCP_PORT` 覆盖;UDP 默认 `6271`、可用 `UDP_PORT`
覆盖。两个端口由 `initial()` 在驱动启动时各起一个线程监听,收到的报文走同一套解析逻辑。
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填这四个[属性](../introduction/concepts/attribute-config)
,告诉驱动认哪个关键字、从报文哪一段取(解析类型则由位号名决定,见下方说明):
| 属性 | code | 类型 | 默认值 | 说明 |
|------------|---------|--------|----------|------------------------|
| Keyword | `key` | STRING | `62` | 报文识别关键字,十六进制 |
| Start Byte | `start` | INT | `0` | 起始字节偏移(含) |
| End Byte | `end` | INT | `8` | 结束字节偏移(不含) |
| Type | `type` | STRING | `string` | 必填属性;仅做存在性校验,不用于选择解析类型 |
::: tip key 用十六进制,与报文关键字逐字比对
`key` 填的是报文第 23 字节的十六进制值(如默认 `62`)。一帧报文进来,驱动只把关键字相同的位号拿来解析;关键字对不上的位号这一帧不出值。同一台设备的不同位号可以用同一个
`key`(一帧里同时取多个字段),也可以用不同 `key`(不同帧各管各的)。
:::
::: warning 解析类型由位号名决定,不是 `type` 属性
驱动按 **位号名(pointName)** 选解析方式(见 `NettyServerHandler.readConfiguredValue`),受支持的位号名只有这 6 个:
`altitude`→float(4 字节)、`speed`→double(8 字节)、`level`→long(8 字节)、`direction`→int(4 字节)、`locked`→boolean(1 字节)、
`coordinate`→string(按 `start..end`)。位号名必须是上述之一,否则该位号这一帧解析为空字符串、采不到数据。`type` 属性只在
`validatePoint` 做存在性检查(必填四项之一),解析时从不读取它。
:::
## 故障排查
接入这个驱动时,绝大多数"采不到值"都能归到下面几条。报文被丢弃时驱动只在日志里 `warn`,不会向设备回错,所以排查时优先看驱动日志。
::: warning 报文前 22 字节必须是平台上的数字设备 ID
驱动用 `Long.parseLong` 把报文前 22 字节解析成设备 ID 去匹配平台上的[设备](../introduction/concepts/device)。设备推数据时
**必须把对应的数字设备 ID 填进这 22 字节**——解析不出数字(`deviceIdInvalid`)、或匹配不到设备(`deviceMissing`
),这一帧会被直接丢弃且不向设备报错。先在平台建好设备拿到 ID,再配进设备端固件。
:::
::: warning start/end 的偏移是相对整帧报文,不是相对载荷
位号的 `start`/`end` 是在**整帧报文**上的字节偏移。前 23 字节被设备名(22)和关键字(1)占用,所以载荷第一个字节的偏移是 `23`
,而不是 `0`。要取载荷开头的数据,`start` 应从 `23` 起算;按默认 `start=0` 会落在设备名里取到错误的值。
:::
- **关键字对不上**:位号的 `key` 与报文第 23 字节的十六进制必须完全相等(如 `62`)。`key` 不匹配的位号这一帧静默跳过、不出值;先确认设备实际发的是哪个字节。
- **字节序问题**:定长数值用 Netty `ByteBuf` 的 `getFloat/getDouble/getLong/getInt` 读取,均为**大端(big-endian)**
。设备端若按小端打包,解析出的数值会错乱,需在设备侧改成大端或自行调整解析逻辑。
- **报文太短 / 偏移越界**:整帧不足 23 字节(`payloadTooShort`),或某位号 `start+长度` 超出报文实际长度(
`payloadOutOfBounds`),该位号这一帧不出值。注意 UDP 单包不可分片、TCP 可能粘包/拆包——本驱动按收到的 `ByteBuf` 原样解析,不做组帧。
- **设备一直在线、与是否推数据无关**:本驱动**未实现协议级健康判定**(没有覆写 `health()`),SDK 用默认实现每
`0/15 * * * * ?` 无条件把设备上报为在线,并续上 `45` 秒租约 TTL。也就是说设备**不会**因为"超时未推数据"
而离线——在线状态与数据推送完全无关。若你需要"无推送即离线",得在驱动里覆写 `health()`、按最近一次推送时间返回
OFFLINE。在线状态机制见[设备](../introduction/concepts/device)。
## 在 IoT DC3 中如何落地
- **dc3.driver.code**:`ListeningVirtualDriver`(路由标识,稳定不可随意改)。
- **读 / 写 / 订阅能力**(与[驱动能力矩阵](./matrix)对齐):
- **读**:`—`,不做主动轮询。`schedule.read.enable=false`,`read()` 直接返回 `null`,数据完全由设备推送触发。配置上启用了
`0/5 * * * * ?` 的 `schedule.custom` 定时回调,但驱动的 `schedule()` 是空实现、不执行任何动作(无周期性自维护逻辑)。
- **写**:`✓`,`write()` 已实现。任何(TCP 或 UDP)成功解析的报文都会把该设备最近一次的 `Channel` 登记进
`DEVICE_CHANNEL_MAP`(登记发生在 TCP/UDP 共用的 `NettyServerHandler.read()` 里);下发写命令时按 `deviceId`
取出活跃通道、把值字节写回设备(5 秒 flush 超时)。通道不存在或不活跃则写失败返回 `false`。注意:若该设备最近一次登记的是无连接的
UDP 通道,回写通常会失败——回写依赖的是面向连接的 TCP 通道。
- **订阅 / 上报**:`✓`,这是本驱动的主能力——设备主动连入、被动收推送。
::: info 这是虚拟/测试驱动,报文格式自定
本驱动整体可用(监听、解析、回写均已实现),但它没有标准协议层:报文格式(22+1+载荷)、6 个固定位号名、大端数值解析都是本驱动自定的演示约定。真实项目接入私有协议时,请把
`NettyServerHandler` 的解析逻辑替换为你的协议规则,把它当作"被动监听型驱动"的实现模板。
:::
### 最小接入示例
把一台通过 TCP 推送数据的 GPS 终端接进来:
1. 用 `Listening Virtual TCP/UDP Driver` 创建[设备](../introduction/concepts/device)(本驱动无 driver
属性,设备本身不需填连接参数),记下平台分配的数字设备 ID。
2. 给设备绑定的[模板 Profile](../introduction/concepts/profile) 加一个字符串[位号](../introduction/concepts/point),*
*位号名必须取支持的名字之一**(这里用 `coordinate`,解析为字符串),point 属性填 `key=62`、`start=23`、`end=31`、
`type=string`——即认关键字 `62` 的报文,从载荷起始处取 8 个字节当字符串。位号名若不在
`altitude/speed/level/direction/locked/coordinate` 之列,驱动采不到值。
3. 启动驱动;让设备把"22 字节数字设备 ID + 1 字节 `0x62` + 载荷"推到驱动的 TCP `6270`
端口,几秒内就能在[位号值](../introduction/concepts/point-value)里看到解析结果。
## 延伸阅读
- [驱动总览](./index) — 驱动是什么、注册与生命周期、配置三层来历
- [驱动能力矩阵](./matrix) — 28 个驱动的读/写/订阅一览,确认本驱动定位
- [设备接入](../operation/device-onboarding) — 一次完整的设备接入流程
---
# LoRaWAN 驱动
URL: https://docs.dc3.site/zh/drivers/lorawan
`dc3-driver-lorawan` 订阅 ChirpStack MQTT 上行,解码 JSON,按 DevEUI 缓存最新载荷与 Cayenne LPP 字段,并发布下行命令。
## 协议背景
LoRaWAN 设备经网关上行到网络服务器(ChirpStack),ChirpStack 通过 MQTT 暴露上行;本驱动订阅这些主题,按 DevEUI 匹配位号。
驱动 code:LorawanDriver,类型:DRIVER_SERVER,底层库:Eclipse Paho MQTT + Jackson
## 属性配置
### 驱动属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Application ID | applicationId | STRING | (空) | ChirpStack 应用 ID |
| Broker URI | brokerUri | STRING | tcp://dc3-mqtt:1883 | MQTT broker |
| Subscribe Topic | topic | STRING | application/+/device/+/event/up | 上行主题过滤 |
### 位号属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| DevEUI | devEui | STRING | (空) | 设备 EUI(16 位十六进制) |
| Field | field | STRING | (空) | Cayenne LPP 字段,空返回原始 base64 |
### 命令属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| DevEUI | devEui | STRING | (空) | 下行设备 EUI |
## 能力矩阵
| 能力 | 支持 |
|------|------|
| 读 | ✓ |
| 写 | ✓ |
| 订阅 | ✓ |
::: info 实现状态:可用 :::
MQTT 连接首次读写时惰性建立;messageArrived 解析 deviceInfo.devEui、data 与 Cayenne LPP object。
## 最小接入示例
1. 选 LoRaWAN 驱动创建设备,填 applicationId、brokerUri。
2. 加位号(READ_ONLY),填 devEui、field=temperature。
3. 启动驱动,下一次上行即被缓存并作为位号值。
## 延伸阅读
- [驱动总览](./index)
- [驱动能力矩阵](./matrix)
- [设备接入](../operation/device-onboarding)
---
# LwM2M 驱动
URL: https://docs.dc3.site/zh/drivers/lwm2m
# LwM2M 驱动
`dc3-driver-lwm2m` 内嵌一个基于 Eclipse Leshan 的 LwM2M 服务端:设备作为客户端用自己的 `endpoint` 名注册上来,驱动再按位号配置的
`Object / Object Instance / Resource` 三段路径,对该 endpoint 读写资源。这页讲清它接什么协议、要填哪些属性、接不通时怎么排查,以及它在
IoT DC3 里的真实落地状态。
## 协议背景
LwM2M(Lightweight M2M,轻量级 M2M)是 OMA 制定的物联网**设备管理 + 数据采集**协议。它不另起炉灶,而是**架在 CoAP 之上**——跑在
UDP(默认 `5683`,加密用 DTLS/CoAPS `5684`)上,补齐了 CoAP 缺的"设备管理"
那一层。在[物联网四层参考架构](../foundations/iot-protocols)里,它和 CoAP、MQTT 一样属于**网络层的应用层消息协议**:定义"
一条消息长什么样、怎么投递、可靠到什么程度",与底层用 Wi-Fi 还是 NB-IoT 无关。
LwM2M 的核心是把设备能力抽象成一棵**对象树**:
- **Object**(对象,如 `3303`=温度)——一类能力;
- **Object Instance**(对象实例)——同一类能力的多个实例(如一台设备上有多个温度传感器);
- **Resource**(资源,如 `5700`=传感器读数)——实例里的一个具体可读/可写项。
访问一个具体的值,就是给出 `///` 这条路径。固件升级、远程配置、订阅上报都被标准化进这套对象模型,因此
LwM2M 在**电信级、需要远程运维**的终端里很常见:NB-IoT 模组、智能表计、远端环境传感器等"既要远程管理、又要省电"的场景。
与 Modbus、CoAP 这类驱动**主动去连设备**不同,本驱动反过来——它内嵌一个 **LwM2M 服务端**:
设备先用自己的 endpoint 名注册到这个服务端,注册成功后驱动才能按位号路径对它发起读/写。设备的在线与否,就取决于其 endpoint
是否仍在注册表里。
## 属性配置
LwM2M 的属性分两层:**driver 属性**配在[设备](../introduction/concepts/device)上,描述"服务端监听在哪、用哪个
endpoint、是否加密";**point 属性**配在[位号](../introduction/concepts/point)上,描述"这个位号对应对象树里的哪条资源路径"
。两者都来自驱动 `application.yml` 的 `driver-attribute` / `point-attribute`
声明,接入时在设备实例上为每个属性[填具体值](../introduction/concepts/attribute-config)。
### 驱动属性(设备级 `driver-attribute`)
`endpoint` 是把这台 DC3 设备和已注册的 LwM2M 客户端对应起来的关键——它必须与设备注册时上报的 endpoint 名**一字不差**
,否则匹配不上、设备一直离线。`serverHost` / `serverPort` / `securePort` 声明服务端监听的地址与端口;`securityMode` 决定走明文还是
PSK 加密,启用 PSK 时再补 `pskIdentity` 与 `pskKey`。
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|-----------|--------------------------------------|
| Endpoint | `endpoint` | STRING | (空) | LwM2M 设备 endpoint 名 |
| Server Host | `serverHost` | STRING | `0.0.0.0` | 服务端绑定地址 |
| Server Port | `serverPort` | INT | `5683` | CoAP 端口 |
| Secure Port | `securePort` | INT | `5684` | CoAPS/DTLS 端口 |
| Security Mode | `securityMode` | STRING | `NOSEC` | 安全模式:NOSEC、PSK |
| PSK Identity | `pskIdentity` | STRING | (空) | PSK 身份(`securityMode=PSK` 时) |
| PSK Key | `pskKey` | STRING | (空) | HEX 编码的 PSK 密钥(`securityMode=PSK` 时) |
::: warning serverHost / serverPort / securePort 当前未真正生效
驱动启动内嵌服务端时用的是 `new LeshanServerBuilder().build()` 的**默认绑定**(恰好就是 `5683`/`5684`),并没有把上表里
`serverHost`/`serverPort`/`securePort` 或 PSK 这些值喂给 Leshan。也就是说这几项目前是**声明在册、尚未连线**
:填了也只会落到默认端口、明文链路。打通链路请按默认 `5683` 明文端口来,改端口/启 PSK 的能力还需在驱动里补齐(见下文实现状态)。
:::
### 位号属性(`point-attribute`)
每个位号填一条 LwM2M 资源路径。驱动把 `objectId` / `objectInstanceId` / `resourceId` 拼成
`///`,对设备 endpoint
发起读取,返回值即为该位号的[位号值](../introduction/concepts/point-value)。
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------------|--------------------|--------|---------|-----------------------------------|
| Object ID | `objectId` | INT | `0` | LwM2M Object ID(如 `3303`=温度) |
| Object Instance ID | `objectInstanceId` | INT | `0` | LwM2M Object Instance ID |
| Resource ID | `resourceId` | INT | `0` | LwM2M Resource ID(如 `5700`=传感器读数) |
| Observe | `observe` | STRING | `false` | 是否启用 LwM2M Observe:true、false |
::: tip 三段路径决定读哪个资源
位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)要和该 Resource 实际的数据类型对得上。LwM2M
没有独立的 `command-attribute` 配置表(yml 中 `command-attribute: [ ]` 为空)——可写位号的写入目标就是它自己的那条三段路径:下发写命令时,驱动直接对
`///` 发 `WriteRequest`,无需额外的命令属性。
:::
::: warning observe 属性当前不生效
`observe=true` 在协议层意为"对该资源开启 LwM2M Observe(订阅式上报)、由设备在值变化时主动推送"。但本驱动**尚未注册任何
Observe,也不消费它**——`read()`/`write()` 都没有读取 `observe` 这个属性。位号值目前**只能靠默认 30 秒一轮的主动读取**拿到,填
`observe=true` 不会触发订阅推送(详见下文实现状态)。
:::
### 采集与健康节律
下列周期来自 `application.yml` 的 `dc3.driver.schedule` / `health`:
- **采集周期**:默认 cron `0/30 * * * * ?`,每 30 秒对每个位号的资源路径发起一次读取。
- **自定义任务**:内置一个 custom 调度,默认 cron `0/5 * * * * ?`,每 5 秒一次,留给驱动自有周期逻辑(当前 `schedule()`
实现为空)。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45` 秒。设备在线与否取决于其 endpoint 是否仍注册在内嵌服务端上。
## 故障排查
| 现象 | 可能原因 | 排查方向 |
|---------------------------|----------------------------|-------------------|
| 设备一直离线、读取无值 | `endpoint` 名与设备注册时上报的不一致 | 二者必须一字不差,见下方第一条 |
| 设备连不上服务端 | UDP `5683` 被防火墙挡或 NAT 映射失效 | 先验 UDP 链路再查应用配置 |
| 改了 `serverPort` 仍只监听 5683 | 端口配置当前未喂给 Leshan | 改端口能力未实现,按默认端口接入 |
| 启 PSK 后注册失败 | PSK 配置当前未生效,或设备强制要求 DTLS | 先用 `NOSEC` 明文打通链路 |
| `observe=true` 收不到推送 | Observe 自动转发未实现 | 依赖默认 30 秒主动读取 |
::: warning endpoint 名必须和设备注册时一字不差
设备在线判定靠 `endpoint` 名在内嵌服务端的注册表里匹配(`isDeviceRegistered(endpoint)`)。设备实际注册用的 endpoint(常见形如
`urn:imei:` 或厂商自定义串)和设备上填的 `endpoint` 只要差一个字符,就匹配不上:设备会一直显示离线、读取也拿不到值。接入前先确认设备固件里注册用的
endpoint 名到底是什么。
:::
::: tip UDP 协议先查链路
LwM2M 走 CoAP over UDP,连不通常是 UDP 端口(`5683`/`5684`)被防火墙挡、或 NAT 映射失效,而非应用配置错——排错先验链路,再查
endpoint 与位号路径。设备在公网/蜂窝上时,记得放行对应的 UDP 端口。
:::
::: warning 改端口与启用 PSK 暂不可用
当前驱动不消费 `serverPort`/`securePort`/`securityMode`/PSK 配置,内嵌服务端固定走 Leshan 默认(明文 `5683` / DTLS `5684`
)。因此:想换监听端口、或想用 PSK 加密握手,目前都做不到——请用默认 `NOSEC` 明文 `5683` 端口先把链路跑通。需要加密时,要先在
`Lwm2mServerManager` 里把这些配置接进 `LeshanServerBuilder`。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`Lwm2mDriver`(稳定路由标识,与[驱动能力矩阵](./matrix)一致,不要随意改)。
- **驱动名 / 类型**:`LwM2M Driver` / `DRIVER_CLIENT`。
- **读能力(已实现)**:`read()` 经 `Lwm2mServerManager.read()` 向已注册设备发
`ReadRequest(objectId, objectInstanceId, resourceId)`,把返回内容作为位号值——这是真实的协议 I/O,不是桩。
- **写能力(已实现)**:`write()` 经 `Lwm2mServerManager.write()` 对同一三段路径发 `WriteRequest`,写成功返回 `true`、失败/超时返回
`false`。
- **订阅能力(未实现)**:[驱动能力矩阵](./matrix)把 LwM2M 标为读/写/订阅俱全,但 **Observe 订阅上报当前未落地**——
`Lwm2mObservationHandler.onObservation()` 仅打印日志、带 `TODO`,驱动既不注册 Observe、也没有 endpoint→deviceId /
资源路径→pointId 的映射来转发观测值。
::: warning 实现状态:读/写可用,订阅与服务端配置未完成
`Lwm2mDriverCustomServiceImpl` 的类注释仍标注 "work-in-progress skeleton",但**读和写已经接好真实的 Leshan I/O,对已注册设备可用
**。尚未完成的是三块:① Observe 订阅值的自动转发;② 把 `serverHost`/`serverPort`/`securePort` 配置喂给 Leshan;③ PSK
加密链路。请把它当作"读写可跑、订阅/加密待补"的接入起点,按下面示例先用明文链路验证读写。
:::
::: details 最小接入示例:读回一个温度位号
把一台 endpoint 名为 `urn:imei:860000000000001`、温度资源在 `/3303/0/5700` 的 LwM2M 传感器接进来:
1. 选 `LwM2M Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `endpoint=urn:imei:860000000000001`、
`securityMode=NOSEC`(端口当前固定走默认 `5683`,无需也无法改)。
2. 让该 LwM2M 客户端用**同样的** endpoint 名,注册到这台服务的 `5683` 端口(明文)。
3. 给设备绑定的[模板 Profile](../introduction/concepts/profile) 加一个温度[位号](../introduction/concepts/point)(
`READ_ONLY`),point 属性填 `objectId=3303`、`objectInstanceId=0`、`resourceId=5700`。
4. 启动驱动,设备注册成功后,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到读回的温度值。
:::
## 延伸阅读
- [驱动总览](./index) — 28 个驱动的全景与分组
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力速查
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [IoT 协议与无线网络](../foundations/iot-protocols) — LwM2M 在网络层的位置与 CoAP/MQTT 的取舍
---
# 驱动能力矩阵
URL: https://docs.dc3.site/zh/drivers/matrix
本页一览 IoT DC3 全部 **36 个驱动**的协议类别、读 / 写 / 订阅能力与实现状态,帮你在选型时快速对位。每行链接到该驱动自己的页面,属性、采集周期、最小接入示例等细节在那里展开。
读 / 写 /
订阅按驱动当前实现的真实行为标注:「✓」表示该能力已落地,「—」表示该协议方向不实现、由设计决定不提供、或骨架尚未补齐——具体以代码为准(见表后说明)。订阅 /
上报指驱动被动接收设备推送(监听端口、网络回调、设备注册等),区别于周期轮询采集。
「实现状态」列总结该驱动整体成熟度:
- **完整**:读 / 写 / 订阅按协议设计全部落地,可直接用于生产接入。
- **可用**:核心链路可用,但有局部局限(如部分数据类型、Observe、健康钩子未实现),详见备注与驱动页。
- **骨架**:协议组帧或传输层尚未补全,目前仅供结构参考,不能直接采集真实设备。
## 工业总线 / PLC
这一类驱动作为主站(client)主动连设备,按[位号](../introduction/concepts/point)
轮询读值、按[命令](../introduction/concepts/command)写值,不监听上报。`ethernet-ip` 目前是协议骨架,CIP 组帧尚未补全。
| 驱动 (dc3.driver.code) | 类别 | 读 | 写 | 订阅/上报 | 实现状态 | 备注 |
|---------------------------------------------------|----------|---|---|-------|------|----------------------------------|
| [Modbus TCP](./modbus-tcp) (`ModbusTcpDriver`) | 工业总线/PLC | ✓ | ✓ | — | 完整 | 以太网 Modbus 主站 |
| [Modbus RTU](./modbus-rtu) (`ModbusRtuDriver`) | 工业总线/PLC | ✓ | ✓ | — | 完整 | 串口 Modbus 主站 |
| [OPC UA](./opc-ua) (`OpcUaDriver`) | 工业总线/PLC | ✓ | ✓ | — | 完整 | OPC 统一架构客户端 |
| [OPC DA](./opc-da) (`OpcDaDriver`) | 工业总线/PLC | ✓ | ✓ | — | 完整 | 经典 OPC 数据访问(DCOM) |
| [S7](./plcs7) (`PlcS7Driver`) | 工业总线/PLC | ✓ | ✓ | — | 完整 | 西门子 PLC |
| [MELSEC](./melsec) (`MelsecDriver`) | 工业总线/PLC | ✓ | ✓ | — | 完整 | 三菱 PLC(MC 协议) |
| [FINS](./fins) (`FinsDriver`) | 工业总线/PLC | ✓ | ✓ | — | 可用 | 欧姆龙 PLC,支持 16/32 位整数、浮点、字符串与 BCD |
| [EtherNet/IP](./ethernet-ip) (`EthernetIpDriver`) | 工业总线/PLC | — | — | — | 骨架 | 罗克韦尔 / CIP,组帧待补 |
## SCADA / 电力 / 计量
楼宇、电力与计量类协议。`bacnet-ip` 与 `snmp` 主动读写;`sl651` 是水文遥测,开 TCP 服务端被动收报文,故仅订阅;`iec104`、`dlms`
当前为骨架。
| 驱动 (dc3.driver.code) | 类别 | 读 | 写 | 订阅/上报 | 实现状态 | 备注 |
|---------------------------------------------|-------------|---|---|-------|------|-----------------|
| [BACnet/IP](./bacnet-ip) (`BacnetIpDriver`) | SCADA/电力/计量 | ✓ | ✓ | — | 完整 | 楼宇自控 |
| [IEC 104](./iec104) (`Iec104Driver`) | SCADA/电力/计量 | — | — | — | 骨架 | 电力 SCADA,协议层待补 |
| [DLMS](./dlms) (`DlmsDriver`) | SCADA/电力/计量 | — | — | — | 骨架 | 智能电表,传输层待补 |
| [SL651](./sl651) (`Sl651Driver`) | SCADA/电力/计量 | — | — | ✓ | 完整 | 水文遥测,TCP 服务端收报文 |
| [SNMP](./snmp) (`SnmpDriver`) | SCADA/电力/计量 | ✓ | ✓ | — | 完整 | 网络设备监控 |
| [DL/T645](./dlt645) (`Dlt645Driver`) | SCADA/电力/计量 | ✓ | ✓ | — | 完整 | 电能表(DL/T645-2007) |
| [DNP3](./dnp3) (`Dnp3Driver`) | SCADA/电力/计量 | ✓ | ✓ | — | 可用 | 电力自动化,原生栈已实现待联调 |
| [IEC 61850](./iec61850) (`Iec61850Driver`) | SCADA/电力/计量 | ✓ | ✓ | — | 完整 | 变电站自动化(MMS 客户端) |
| [KNX](./knx) (`KnxDriver`) | SCADA/电力/计量 | ✓ | ✓ | — | 完整 | 楼宇自动化总线(Calimero) |
| [M-Bus](./mbus) (`MbusDriver`) | SCADA/电力/计量 | ✓ | ✓ | — | 完整 | 仪表总线(EN 13757)自研组帧 |
## IoT / 无线
物联网与无线类。`mqtt` 走发布/订阅,值由订阅被动到达(无主动读),命令可下发;`lwm2m` 内嵌服务端、收设备注册与通知,读写已落地但
Observe 订阅尚未实现;
`coap`、`http`、`ble` 为请求-响应式主动读写(`coap` 的 Observe 未实现);`can`、`zigbee` 当前为骨架实现,`zigbee`
仅监听协调器网络状态、尚未监听节点入网与属性上报,`can` 底层走 can-utils。
| 驱动 (dc3.driver.code) | 类别 | 读 | 写 | 订阅/上报 | 实现状态 | 备注 |
|-------------------------------------|--------|---|---|-------|------|--------------------------------|
| [MQTT](./mqtt) (`MqttDriver`) | IoT/无线 | — | ✓ | ✓ | 可用 | 发布/订阅,值经订阅到达;`initial()` 钩子为骨架 |
| [CoAP](./coap) (`CoapDriver`) | IoT/无线 | ✓ | ✓ | — | 可用 | 受限设备 RESTful,Observe 未实现 |
| [LwM2M](./lwm2m) (`Lwm2mDriver`) | IoT/无线 | ✓ | ✓ | — | 可用 | 内嵌服务端,读写已落地,Observe 订阅未实现 |
| [HTTP](./http) (`HttpDriver`) | IoT/无线 | ✓ | ✓ | — | 完整 | 通用 HTTP 采集 |
| [BLE](./ble) (`BleDriver`) | IoT/无线 | ✓ | ✓ | — | 完整 | 低功耗蓝牙 GATT |
| [Zigbee](./zigbee) (`ZigbeeDriver`) | IoT/无线 | ✓ | ✓ | — | 骨架 | 骨架实现,订阅(入网/上报)未实现 |
| [CAN](./can) (`CanDriver`) | IoT/无线 | ✓ | — | — | 骨架 | 控制器局域网,底层走 can-utils |
| [LoRaWAN](./lorawan) (`LorawanDriver`) | IoT/无线 | ✓ | ✓ | ✓ | 可用 | ChirpStack MQTT 上行接入,Cayenne LPP |
| [Kafka](./kafka) (`KafkaDriver`) | IoT/无线 | ✓ | ✓ | ✓ | 可用 | 流式数据源,消费 + 生产 |
## 串口 / 通用网络
按命令模板组帧的通用透传驱动,主动收发,不监听。
| 驱动 (dc3.driver.code) | 类别 | 读 | 写 | 订阅/上报 | 实现状态 | 备注 |
|---------------------------------------------|---------|---|---|-------|------|--------------|
| [串口 Serial](./serial) (`SerialDriver`) | 串口/通用网络 | ✓ | ✓ | — | 完整 | 通用串口透传 |
| [TCP/UDP](./tcp-udp) (`TcpUdpDriver`) (raw) | 串口/通用网络 | ✓ | ✓ | — | 完整 | 通用 socket 透传 |
## 数据库
把库表当数据源:读走 `executeQuery`、写走 `executeUpdate`,由[位号](../introduction/concepts/point)上的 SQL 模板驱动,不监听变更。
| 驱动 (dc3.driver.code) | 类别 | 读 | 写 | 订阅/上报 | 实现状态 | 备注 |
|-------------------------------------------------|-----|---|---|-------|------|---------|
| [MySQL](./mysql) (`MysqlDriver`) | 数据库 | ✓ | ✓ | — | 完整 | 从库表采集位号 |
| [PostgreSQL](./postgresql) (`PostgresqlDriver`) | 数据库 | ✓ | ✓ | — | 完整 | 从库表采集位号 |
| [Oracle](./oracle) (`OracleDriver`) | 数据库 | ✓ | ✓ | — | 完整 | 从库表采集位号 |
| [SQL Server](./sqlserver) (`SqlserverDriver`) | 数据库 | ✓ | ✓ | — | 完整 | 从库表采集位号 |
| [Redis](./redis) (`RedisDriver`) | 数据库 | ✓ | ✓ | — | 完整 | 读写 STRING/HASH 键 |
## 虚拟 / 测试
无真实设备的两个驱动:`virtual` 按位号类型生成模拟读值(写为占位、不落设备);`listening-virtual` 反向开 TCP/UDP
服务端收外部推送,并可经连接通道向设备回写。
| 驱动 (dc3.driver.code) | 类别 | 读 | 写 | 订阅/上报 | 实现状态 | 备注 |
|--------------------------------------------------------------------------|-------|---|---|-------|------|--------------------|
| [虚拟 Virtual](./virtual) (`VirtualDriver`) | 虚拟/测试 | ✓ | — | — | 可用 | 生成模拟数据,写为占位 |
| [监听虚拟 Listening Virtual](./listening-virtual) (`ListeningVirtualDriver`) | 虚拟/测试 | — | ✓ | ✓ | 完整 | TCP/UDP 服务端收推送,可回写 |
::: info 能力标记以代码为准
表中「✓ / —」反映各驱动 `*DriverCustomServiceImpl` 当前的真实实现:「—」可能是协议方向本就不提供(如 `virtual` 的写、`mqtt`
的主动读),也可能是骨架未补齐(如 `ethernet-ip`、`iec104`、`dlms`、`can`)。「实现状态」列是对成熟度的整体概括,局部的数据类型或子能力缺口以各驱动页的
`::: warning` / `::: info` 标注为准。最终行为请以对应模块的 `read()` / `write()` / `initial()` 源码为准;驱动迭代后此表会同步更新。
:::
## 延伸阅读
- [驱动总览](./index) — 按类别挑选协议,进入各驱动页
- [自定义驱动](../development/driver-authoring) — 基于 `virtual` 模板实现自己的协议驱动
---
# M-Bus 驱动
URL: https://docs.dc3.site/zh/drivers/mbus
`dc3-driver-mbus` 通过 M-Bus(EN 13757)总线读取热表、水表、气表。
## 协议背景
M-Bus 是欧洲远程抄表标准,主站发短帧按主地址寻址,仪表回长帧携带 DIF/VIF 数据记录。
驱动 code:MbusDriver,类型:DRIVER_CLIENT,底层库:jSerialComm
## 属性配置
### 驱动属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Serial Port | port | STRING | /dev/ttyUSB0 | 串口设备路径 |
| Baud Rate | baudRate | INT | 2400 | 波特率 |
| Parity | parity | INT | 2 | 校验位(2=偶校验) |
| Primary Address | primaryAddress | INT | 0 | M-Bus 主地址(0-250) |
### 位号属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Record Index | recordIndex | INT | 0 | 数据记录 0 基索引 |
| Data Format | dataFormat | STRING | FLOAT | FLOAT, HEX, ASCII |
## 能力矩阵
| 能力 | 支持 |
|------|------|
| 读 | ✓ |
| 写 | ✓ |
| 订阅 | — |
::: info 实现状态:可用 :::
帧为自研实现(无原生 jrxtx 依赖),MbusFrame 负责组帧、校验与记录解析。
## 最小接入示例
1. 选 M-Bus 驱动创建设备,填 port、baudRate=2400、parity=2、primaryAddress=0。
2. 加位号(READ_ONLY),填 recordIndex=0、dataFormat=FLOAT。
3. 启动驱动,30 秒内即可看到采集值。
## 延伸阅读
- [驱动总览](./index)
- [驱动能力矩阵](./matrix)
- [设备接入](../operation/device-onboarding)
---
# Melsec 驱动
URL: https://docs.dc3.site/zh/drivers/melsec
`dc3-driver-melsec` 把三菱(Mitsubishi)PLC 通过 MC 协议接入 IoT DC3:作为 MC 客户端主动连上
PLC,按[位号](../introduction/concepts/point)上配置的软元件地址周期性采数,并支持向软元件写值的命令。读完本页你能配出
driver / point 属性、把一台三菱 PLC 接进平台,并知道接不上时从哪查起。
## 协议背景
MC 协议(MELSEC Communication)是三菱电机 PLC 的原生通信协议,A、QnA、Q/L、iQ-R 等系列广泛在用。在工业现场,三菱 PLC 通过以太网模块或
CPU 内置网口开放一个 MC 服务端口,上位机以 MC 客户端身份连上来,按**软元件地址**(如 `D100`、`M0`)读写 PLC
内存里的数据单元——数据寄存器存工艺参数、内部继电器存逻辑状态、输入/输出继电器映射现场 IO。
在物联网四层架构里,MC 协议属于**网络层**的工业有线侧:它是 PLC 与上位系统之间"最后一公里"
的语言规约,规定字节怎么排、软元件怎么寻址、一问一答的请求-响应时序。它和西门子 S7、欧姆龙 FINS 一样,都是厂商私有的主从协议——主站不问,PLC
不会主动上报。要理解它在协议谱系里的位置、以及为什么各家 PLC
协议互不兼容,见[物联网网络层:工业总线与协议](../foundations/fieldbus)。
本驱动底层基于 `iot-communication` 协议库的 `McPLC` 实现,读写时按位号的数据类型自动选用对应的字宽与编解码方式。
- **驱动名 / code**:`Mitsubishi Melsec Driver` / `MelsecDriver`
- **类型**:`DRIVER_CLIENT`(主动连 PLC)
::: tip 先认识几个 MC 概念
**软元件(Device,亦称内存地址)**:三菱 PLC 里按用途划分的数据单元——如 `D`(数据寄存器,最常用)、`M`(内部继电器)、`X`(输入继电器)、
`W`(链接寄存器)。**软元件地址**:区前缀加编号组成的一个完整地址字符串,如 `D100`、`M0`、`X10`、`W200`——区和编号写在一起,不拆成两个字段。
**PLC 系列(Series)**:不同系列的 MC 帧封装格式略有差异,需按实际 PLC 选 `A` / `QnA` / `Q_L` / `IQ_R`。
:::
## 属性配置
Melsec 驱动的配置分两层:**driver 属性**描述"连哪台 PLC"(设备级,一台设备一份),**point 属性**描述"读哪个软元件"
(位号级,一个位号一份)。两层属性都来自驱动 `application.yml` 的声明,接入时在控制台对应填值。
### 驱动属性(设备级 `driver-attribute`)
`host` / `port` 决定 TCP 连到哪台 PLC 的哪个 MC 服务端口;`series` 决定 MC 帧用哪种封装格式,必须与真实 PLC 系列对得上。接入一台
Melsec PLC 时,在[设备](../introduction/concepts/device)上填这些[属性](../introduction/concepts/attribute-config):
| 属性 | code | 类型 | 默认值 | 说明 |
|------------|----------|--------|----------------|-------------------------------|
| Host | `host` | STRING | `192.168.0.20` | PLC 主机地址(Ip) |
| Port | `port` | INT | `6000` | MC 服务端口 |
| PLC Series | `series` | STRING | `QnA` | PLC 系列,`A`/`QnA`/`Q_L`/`IQ_R` |
### 位号属性(`point-attribute`)
`address` 指定要读写的软元件(整体写法,区+编号同写一串);`length`
只在字符串类型时用到——读取的字符串字节长度。每个[位号](../introduction/concepts/point)上填:
| 属性 | code | 类型 | 默认值 | 说明 |
|----------------|-----------|--------|--------|-----------------------------------|
| Device Address | `address` | STRING | `D100` | 软元件地址(`D100`、`M0`、`X10`、`W200` 等) |
| String Length | `length` | INT | `0` | 字符串读取长度(非字符串类型填 `0`) |
::: tip 数据类型决定读几个字、怎么解码
驱动按位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)自动选用读写宽度与编解码方式:`BOOLEAN`
读/写一个位,`BYTE` 读/写 8 位,`SHORT` 读/写 16 位(int16),`INT`/`FLOAT` 读/写 32 位,`LONG`/`DOUBLE` 读/写 64 位,`STRING`
读/写字符串。只有 `STRING` 类型才用到 `length`(读取的字符串字节数,**填 0 或留空时驱动按 64 处理**);非字符串位号保持
`length=0` 即可,驱动会忽略它。
:::
### 写命令:复用位号 `address`,无单独属性
本驱动支持向位号写值(数值、布尔、字符串均可),但**没有单独的 `command-attribute`**——写命令复用位号上已配置的 `address`
,目标软元件即位号的 `address`
,写值的字宽由下发值的数据类型决定。因此可写位号无需额外配置,只要在[模板](../introduction/concepts/profile)里把该位号设为可写即可。
### 采集与健康调度
这些 cron 来自 `application.yml` 的 `schedule` / `health` 段,决定采集节奏与在线判定:
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮)。
- **自定义任务**:默认 cron `0/5 * * * * ?`(Melsec 驱动当前未使用自定义任务,`schedule()` 为空实现,保留该调度位)。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`——驱动以 TCP
连接是否存活判定在线,读写抛异常会主动断开并从连接缓存移除该连接,下一轮采集自动重连。在线状态机制见[设备](../introduction/concepts/device)。
## 故障排查
::: warning address 写成完整软元件地址,区和编号不拆开
`address` 直接填三菱习惯的整体写法,如 `D100`、`M0`、`X10`、`W200`——区前缀和编号写在同一个字符串里。这与按"区 + 数字偏移"
两字段配置的协议(如 [FINS](./fins))不同,**不要**把区和编号拆成两项填,否则 `McPLC` 会按非法地址解析而读写失败。
:::
::: warning series 必须匹配真实 PLC 系列
`series` 取值仅限 `A` / `QnA` / `Q_L` / `IQ_R`,决定 MC 帧的封装格式。填错或填了识别不了的值时,驱动会打印
`Unknown series ... fallback to QnA` 并回退到 `QnA`——对其它系列 PLC 可能读不到正确数据或直接报错。接入前请按实机型号确认系列。
:::
::: warning 连不上 / 读写失败时优先查 PLC 侧 MC 服务
驱动用 `host:port` 直接发起 TCP 连接,建连失败会抛 `Driver connection failed`。常见原因:PLC 以太网模块未启用 MC 服务、端口与
`port` 不一致(不少机型默认并非 `6000`)、PLC 侧 IP 过滤或连接数已满、防火墙拦截。先用 `telnet host port` 确认端口可达,再核对
PLC 工程里 MC 服务的端口与协议(TCP)设置。
:::
::: warning 字符串/字节序读出来不对,检查类型与 length
读出的字符串乱码或截断,多半是 `length` 与 PLC 侧实际字符串字节数不匹配(留 0 时驱动按 64 读)。数值类型读出来异常,先确认位号的
`pointTypeFlag` 与软元件里实际存的字宽一致——把一个 32 位浮点软元件配成 `SHORT` 只会读到低 16 位。
:::
::: tip 一个驱动实例可接多台 PLC,连接按设备缓存
同一个 Melsec 驱动进程可服务多台设备,每台设备各自维护一条 `McPLC` 连接(按设备 ID 缓存在 `connectMap` 里),并用各自的
`ReentrantLock` 串行化读写。多台 PLC 用各自的 `host` 区分。设备被更新或删除时,驱动会收到元数据事件并关闭、移除对应连接。
:::
## 在 IoT DC3 中如何落地
::: info 实现状态:可用
Melsec 驱动的读、写路径均已完整实现——`read()` / `write()` 通过 `iot-communication` 的 `McPLC` 调用真实的 MC 读写(
`readInt16` / `writeInt16` / `readString` 等),按数据类型选用正确字宽。这是一个可用驱动,行为与 `application.yml` 声明一致。
:::
- **dc3.driver.code**:`MelsecDriver`——驱动在平台内的稳定路由标识,设备绑定驱动、消息分发都按它寻址,不要随意更改。
- **读能力**:✓ 支持。按采集周期周期性读取,覆盖 `BOOLEAN`/`BYTE`/`SHORT`/`INT`/`LONG`/`FLOAT`/`DOUBLE`/`STRING`
全部类型,与[驱动能力矩阵](./matrix)中 MELSEC 的"读 ✓"一致。
- **写能力**:✓ 支持。下发写命令时按值的数据类型写对应字宽,与能力矩阵"写 ✓"一致。
- **订阅能力**:— 不支持。MC 协议是主从轮询模型,本驱动靠采集周期轮询取值,不提供 PLC 主动上报式订阅,与能力矩阵"订阅 —"一致。
**最小接入示例**:把 IP `192.168.0.30:6000` 的一台 QnA 系列三菱 PLC 接进来,采集 `D100` 的一个 16 位整数:
1. 选 `Mitsubishi Melsec Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.0.30`、
`port=6000`、`series=QnA`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个[位号](../introduction/concepts/point)(
`pointTypeFlag=SHORT`、`READ_ONLY`),point 属性填 `address=D100`、`length=0`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到 `D100` 的采集值。
完整的接入流程(建模、绑定、下发)见[设备接入](../operation/device-onboarding)。
## 延伸阅读
- [驱动总览](./index) — 全部驱动与分类入口
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力速查
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — MC 协议在网络层协议谱系里的位置
- [FINS 驱动](./fins) — 欧姆龙 PLC 的 TCP 工业协议,按"区 + 偏移"两字段寻址
---
# Modbus RTU 驱动
URL: https://docs.dc3.site/zh/drivers/modbus-rtu
`dc3-driver-modbus-rtu` 把 Modbus RTU 从站设备接入 IoT DC3:作为主站(master)经一个串口连到挂在 RS-485/RS-232
总线上的从站,周期性读取线圈/寄存器值,并支持向线圈和保持寄存器写值的命令。读完本页你能为一台串口 Modbus
设备配好串口参数、功能码与地址,并知道连不上时从哪儿查。
## 协议背景
Modbus 是 1979 年为 PLC 串口通信而生的工业协议,至今仍是现场最常见的协议之一,PLC、电表、变频器、温控仪、传感器大量在用。*
*Modbus RTU** 是它的串行链路变体:报文以紧凑的二进制帧跑在 RS-485/RS-232 总线上,靠 CRC
校验保证完整性。它与 [Modbus TCP 驱动](./modbus-tcp)的功能码、地址模型完全一致——读用 `01/02/03/04`、写用 `05/06/15/16`,地址都是
0 基偏移——区别只在物理层:RTU 走串口(波特率、数据位、校验位、停止位),而不是 IP/端口。
在物联网四层架构里,Modbus RTU 属于**网络层**:它解决的是现场设备与采集主站之间"用哪种信号、按什么规则交换字节"的问题。它是典型的
**主从(master/slave)请求-响应**协议——主站轮流向从站发请求、等应答,从站不会主动说话;一条 RS-485 总线上可挂多台从站,由从站单元号(
`slaveId`)区分。协议本身往往只搬运字节,**怎么解释这串字节由配置决定**(16 位整数还是 32
位浮点、低字节在前还是高字节在前),这也是现场最常见的坑。关于寻址、字节序、轮询模型的通用背景,见[物联网网络层:工业总线与协议](../foundations/fieldbus)。
- **驱动名 / code**:`Modbus RTU Driver` / `ModbusRtuDriver`
- **类型**:`DRIVER_CLIENT`(主动连从站)
- **底层库**:modbus4j + jSerialComm(每台设备一条独立串口连接)
## 属性配置
Modbus RTU 的配置分三层:**驱动属性**(`driver-attribute`,设备级)描述这条串口怎么开;**位号属性**(`point-attribute`
)描述每个采集点读哪台从站、哪个寄存器;**命令属性**(`command-attribute`)描述可写位号往哪儿写。下面三张表的取值默认值与说明,均来自驱动
`application.yml`,属性的三层来历见[属性与配置](../introduction/concepts/attribute-config)。
### 驱动属性(设备级 `driver-attribute`)
接入一台 Modbus RTU 设备时,在[设备](../introduction/concepts/device)上填这五项串口参数。它们会被原样传给 jSerialComm 的
`setComPortParameters` 打开串口,因此**必须与从站的串口设置逐一对上**——RTU 不像 TCP 有握手协商,参数不匹配只会读到乱码或超时:
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------|------------|--------|----------------|-------------------------------------|
| Port | `port` | STRING | `/dev/ttyUSB0` | 串口名(如 /dev/ttyUSB0、COM3) |
| Baud Rate | `baudRate` | INT | `9600` | 串口波特率(如 9600、19200、115200) |
| Data Bits | `dataBits` | INT | `8` | 数据位(7 或 8) |
| Stop Bits | `stopBits` | INT | `1` | 停止位(1 或 2) |
| Parity | `parity` | INT | `0` | 校验位(0=无, 1=奇, 2=偶, 3=Mark, 4=Space) |
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填三项,确定"读哪台从站的哪个地址、用哪个读功能码":
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|-----|-----|---------------------|
| Slave ID | `slaveId` | INT | `1` | Modbus 从站单元号 |
| Function Code | `functionCode` | INT | `1` | 读功能码 `[1, 2, 3, 4]` |
| Offset | `offset` | INT | `0` | 寄存器/线圈地址偏移(0 基) |
::: tip 功能码决定读什么,位号类型决定怎么拼字节
读取支持四个功能码:`01`(线圈)/ `02`(离散输入)/ `03`(保持寄存器)/ `04`(输入寄存器)。对寄存器类(`03`/`04`
),驱动按位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)决定取几个寄存器、怎么解释:`LONG`→4
字节有符号整型、`FLOAT`→4 字节浮点、`DOUBLE`→8 字节浮点,其余按 2 字节有符号整型。位号类型配错,浮点会读成一个无意义的大数。
:::
### 命令属性(`command-attribute`)
可写位号还需在写命令上填四项。`valueTemplate` 是写值模板,用命令参数渲染后再下发:
| 属性 | code | 类型 | 默认值 | 说明 |
|----------------|-----------------|--------|------------|----------------------------------|
| Slave ID | `slaveId` | INT | `1` | Modbus 从站单元号 |
| Function Code | `functionCode` | INT | `6` | 写功能码(驱动实际只处理 `1` 写线圈、`3` 写保持寄存器) |
| Offset | `offset` | INT | `0` | 寄存器/线圈地址偏移(0 基) |
| Value Template | `valueTemplate` | STRING | `${value}` | 写值模板,用命令参数渲染 |
### 采集与健康
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮全部位号)。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`
——驱动据串口连接是否已初始化判定设备在线/离线,在线状态机制见[设备](../introduction/concepts/device)。
一次接入的最小路径:用 `Modbus RTU Driver` 建[设备](../introduction/concepts/device),driver 属性填 `port=/dev/ttyUSB0`、
`baudRate=9600`、`dataBits=8`、`stopBits=1`、`parity=0`;给绑定的[模板](../introduction/concepts/profile)
加一个温度[位号](../introduction/concepts/point)(`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `slaveId=1`、
`functionCode=3`、`offset=0`;启动驱动后 30 秒内即可在[位号值](../introduction/concepts/point-value)里看到采集值。
## 故障排查
::: warning 串口参数错一项就连不上
`port`、`baudRate`、`dataBits`、`stopBits`、`parity` 五项必须和从站串口设置逐一对上。RTU
没有握手协商,任一项不匹配只会读到乱码或超时,而不会给出明确报错。先用万用表/串口助手确认线缆与波特率,再核对校验位(很多电表默认偶校验
`2`,不是默认的无校验 `0`)。
:::
::: warning 连续 3 次失败进入 60 秒退避,期间一律离线
驱动对每台设备维护连续失败计数:**连续 3 次连接失败后进入 60 秒退避**(`FAILURE_BACKOFF_THRESHOLD=3`、
`FAILURE_BACKOFF_MS=60000`
),退避期内健康检查直接上报离线、不再尝试连接。也就是说,即便你已修好串口参数或线缆,也要等退避结束才会重连——别在退避窗口内反复改配置后误判"
还是连不上"。
:::
::: warning offset 是 0 基协议地址,不是 40001
`offset` 是协议层的 0 基偏移。按 Modbus 习惯写法读"保持寄存器 40001",应填 `functionCode=3`、`offset=0`(第 2 个保持寄存器是
`offset=1`,以此类推)。把 `40001` 直接填进 `offset` 会读到错误地址或越界。
:::
::: warning 字节序:32 位浮点读成大数多半是寄存器顺序反了
一个 32 位 `FLOAT`/`LONG` 跨两个 16 位寄存器,寄存器先后顺序在现场有 ABCD/CDAB/BADC/DCBA 四种排法。本驱动按 modbus4j
默认顺序解释;若读出的浮点是个无意义的大数,多半是从站用了相反的寄存器顺序——需要在从站侧调整字节序设置,或改用整型按原始寄存器值在上层换算。
:::
::: tip 多台从站共用一个串口,靠 slaveId 寻址
RS-485 总线上可挂多台从站,它们共用同一个 `port`,由位号的 `slaveId` 区分。驱动按[设备](../introduction/concepts/device) ID
维护连接(`connectMap`),同一物理总线上的从站应建为指向**相同 `port`** 的设备、靠 `slaveId` 寻址,不要给每个从站配不同串口。注意:串口是独占资源,确保该
`port` 没有被其它进程占用(Linux 上还需运行用户对 `/dev/ttyUSB*` 有读写权限)。
:::
## 在 IoT DC3 中如何落地
无论底层是哪种协议,落到平台都收敛为同一个[位号 Point](../introduction/concepts/point)
的[位号值 PointValue](../introduction/concepts/point-value)。Modbus RTU 驱动以 `dc3.driver.code = ModbusRtuDriver`
注册,这是稳定的路由标识,平台据此把读/写命令分发到本驱动。
按[驱动能力矩阵](./matrix),本驱动的能力为:
| 能力 | 支持 | 实现说明 |
|----|----|-----------------------------------------|
| 读 | ✓ | 功能码 `01/02/03/04`,覆盖线圈、离散输入、保持寄存器、输入寄存器 |
| 写 | ✓ | **仅** `01`(写线圈)与 `03`(写保持寄存器)两类目标 |
| 订阅 | — | 主从轮询协议,无设备主动上报,靠采集周期定时读 |
::: info 实现状态:可用
`ModbusRtuDriverCustomServiceImpl` 的 `read()`/`write()`/`health()`/连接管理均已完整实现(基于 modbus4j +
jSerialComm),非骨架。读路径支持全部四个读功能码;连接、退避、健康判定、元数据事件(设备更新或删除时销毁旧连接)齐备。
:::
::: warning 写命令只支持线圈和保持寄存器
命令属性 `functionCode` 默认 `6`,但 `write()` 实际只处理 `01`(写线圈)与 `03`(写保持寄存器)两类目标——填 `15`/`16`
等其它写功能码会落入 `default` 分支、直接返回 `false`,写入静默失败。可写位号请按写线圈填 `functionCode=1`、按写保持寄存器填
`functionCode=3`。
:::
## 延伸阅读
- [驱动总览](./index) — 全部协议驱动与选型入口
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力速查
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — 网络层:寻址、字节序、轮询模型的通用背景
- [Modbus TCP 驱动](./modbus-tcp) — 以太网版 Modbus,功能码与地址模型一致
---
# Modbus TCP 驱动
URL: https://docs.dc3.site/zh/drivers/modbus-tcp
# Modbus TCP 驱动
`dc3-driver-modbus-tcp` 把 Modbus TCP 从站设备接入 IoT DC3。它作为 Modbus
主站(client),通过以太网周期性读取线圈/寄存器,并支持向线圈和保持寄存器写值。读完你能在[设备](../introduction/concepts/device)
上配好 `host`/`port`、在[位号](../introduction/concepts/point)上配好功能码与地址,并定位常见的"读不到值/写不下去"问题。
> 你在这里:网络层"工业有线侧"
> 的一个落地驱动。协议层面的寻址模型、字节序、功能码概念见[工业总线与协议](../foundations/fieldbus)。
## 协议背景
Modbus 诞生于 1979 年,最初是 Modicon 为 PLC 串口通信设计的主从协议,至今仍是工业现场最常见的协议之一——PLC、电表、变频器、传感器网关大量在用。它简单、开放、文档公开,因此被各家厂商广泛实现。
**Modbus TCP** 是 Modbus 的以太网封装:把原本跑在 RS-485 串口上的 Modbus 应用层报文(PDU)裹进 TCP/IP,默认监听 **502**
端口。相比串口版 [Modbus RTU](./modbus-rtu),TCP 版去掉了 CRC 校验(交给 TCP 保证),并在报文前加了 7 字节的 MBAP
头来做事务标识。一条以太网上可以挂多个从站,也可以由一个 Modbus TCP 网关桥接背后的多台串口从站。
在[物联网四层架构](../foundations/fieldbus)里,Modbus TCP 属于**网络层**
的工业有线侧:它定义现场设备如何在网络上被寻址与读写,把感知层采集到的物理量搬运到平台。它的通信模型是典型的**主从 / 请求-响应
**——主站不主动问,从站就不会说话,所以 IoT DC3 的驱动作为主站按 cron 周期轮询。
Modbus 的数据被组织成四类寄存器空间,读取由**功能码**区分:
线圈与离散输入是单比特(开关量),保持寄存器与输入寄存器是 16 位字(模拟量)。一个 32 位 `FLOAT` 或 `LONG` 要占连续两个寄存器,64
位 `DOUBLE` 占四个——驱动按位号的数据类型自动跨寄存器拼装。
## 属性配置
接入一台 Modbus TCP 设备,需要在三个层面填[属性](../introduction/concepts/attribute-config):设备级的连接参数(
`driver-attribute`)、每个采集位号的寻址参数(`point-attribute`)、每个可写位号的写命令参数(`command-attribute`
)。下面各属性、类型、默认值均取自驱动的 `application.yml`(`dc3-driver-modbus-tcp` 模块)。
### 驱动属性(设备级 `driver-attribute`)
驱动属性回答"连到哪台从站"。在[设备](../introduction/concepts/device)上为每台 Modbus TCP 设备填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|------|--------|--------|-------------|-----------------------|
| Host | `host` | STRING | `localhost` | Modbus 从站 IP 或主机名 |
| Port | `port` | INT | `502` | Modbus TCP 端口(标准 502) |
`host` + `port` 唯一确定一条 TCP 连接。驱动按设备 ID 缓存连接(一台设备一条 `ModbusMaster`),连接的 socket 超时固定为 **5 秒
**。`port` 在配置校验时会检查必须落在 `1–65535` 之间。
### 位号属性(`point-attribute`)
位号属性回答"读这台从站的哪一个数据点"。每个采集[位号](../introduction/concepts/point)填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|-----|-----|-------------------------|
| Slave ID | `slaveId` | INT | `1` | Modbus 从站单元号(unit ID) |
| Function Code | `functionCode` | INT | `1` | 读功能码,仅支持 `[1, 2, 3, 4]` |
| Offset | `offset` | INT | `0` | 寄存器/线圈地址偏移(0 基) |
`slaveId` 用于区分挂在同一 IP(网关)背后的多台从站。`functionCode` 决定读哪类寄存器空间,配置校验会强制它落在 `1–4`:
::: tip 功能码决定读什么寄存器空间
读取用 `01`(线圈)/ `02`(离散输入)/ `03`(保持寄存器)/ `04`
(输入寄存器)。位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)要和功能码取回的数据宽度对得上——驱动按类型映射为
Modbus 数据宽度:`LONG`→4 字节整型、`FLOAT`→4 字节浮点、`DOUBLE`→8 字节浮点、其余(如 `INT`)→2 字节整型。多寄存器量由位号类型自动跨寄存器拼装。
:::
### 写命令属性(`command-attribute`)
可写位号还要在写命令上填一组,回答"往哪个地址写、写成什么值":
| 属性 | code | 类型 | 默认值 | 说明 |
|----------------|-----------------|--------|------------|----------------------------------------|
| Slave ID | `slaveId` | INT | `1` | 从站单元号 |
| Function Code | `functionCode` | INT | `6` | 写功能码(yml 标注 `[5, 6, 15, 16]`,但见下方实现状态) |
| Offset | `offset` | INT | `0` | 地址偏移(0 基) |
| Value Template | `valueTemplate` | STRING | `${value}` | 写值模板,用命令参数渲染 |
`valueTemplate` 默认 `${value}` 表示直接写入命令传入的值;需要换算(如乘系数、加偏移)时可改模板。写值的类型由位号的
`pointTypeFlag` 决定如何编码进寄存器。
::: warning 写功能码:yml 列了 4 个,实现只认 2 个
`application.yml` 把写功能码标注为 `[5, 6, 15, 16]`、默认 `6`,但当前 `ModbusTcpDriverCustomServiceImpl.writeValue()` 只处理
**`functionCode=1`(写单个线圈)** 和 **`functionCode=3`(写单个保持寄存器)** 两种,其余功能码(含默认值 `6`)会落到 `default`
分支、直接返回 `false`(写失败)。因此写寄存器请把命令的 `functionCode` 显式改成 `3`、写线圈改成 `1`,不要沿用默认 `6`
。FC05/06/15/16 的语义尚未在代码里实现——以代码为准。
:::
## 故障排查
Modbus TCP 接入失败大多集中在连接、寻址、字节序三类。按下面顺序排查:
1. **端口/连接不通(设备一直 offline)**。先确认从站 IP 与 502 端口可达:`telnet 502` 或 `nc -vz 502`。驱动的
socket 超时是 5 秒,连不上会抛 `ConnectorException`。注意:连续 **3 次**连接失败后驱动进入 **60 秒退避**,期间健康检查直接报
offline、不再尝试连接——修好网络后最多等一个退避周期就会自动恢复。
2. **能连上但读不到值 / 报错**。检查位号的 `slaveId` 是否对(网关背后多从站时尤其常见错配)、`functionCode`
与该地址实际的寄存器类型是否一致(拿读保持寄存器的 `03` 去读只读的离散输入会报异常)。读失败会抛 `ReadPointException`
并使该设备连接失效、下个周期重连。
3. **`offset` 填成了 40001 这类地址**。这是最高频的错误,详见下方易错点容器——`offset` 是 0 基协议偏移,不是 PLC 习惯的
4xxxx 编号。
4. **数值不对 / 大小颠倒(字节序问题)**。32/64 位数值跨多个寄存器,不同设备的寄存器字序(word order)/字节序(byte
order)约定不同。驱动用底层 modbus4j 的默认字序读取——若读出的浮点数明显错乱(如把 `25.0`
读成天文数字),通常是设备端字序与默认不符。当前驱动属性未暴露字序开关,遇到此类设备需在设备侧调整寄存器映射,或改用整型读取后自行换算。
5. **写命令返回失败**。先按上面的「写功能码」警告确认 `functionCode` 是 `1` 或 `3`;若仍失败,检查目标是否为可写空间(离散输入
`02`、输入寄存器 `04` 物理只读,无法写)。写失败抛 `WritePointException` 并使连接失效。
6. **设备在线状态抖动**。健康检查默认每 15 秒一次、租约超时 45 秒。若设备频繁在 online/offline 间跳变,多半是网络丢包或从站响应慢于
5 秒超时——在线状态机制见[设备](../introduction/concepts/device)。
::: warning offset 是 0 基协议地址,不是 40001
`offset` 是协议层的 0 基偏移。按 Modbus 习惯写法读"保持寄存器 40001",应填 `functionCode=3`、`offset=0`(第 2 个保持寄存器是
`offset=1`,以此类推)。把 `40001` 直接填进 `offset` 会读到错误地址或越界报错。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`ModbusTcpDriver`(类型 `DRIVER_CLIENT`,主动连从站)。这是稳定的路由标识,不要随意改。
- **读能力**:✓ 已实现。支持功能码 `1/2/3/4`(线圈/离散输入/保持寄存器/输入寄存器),按位号类型自动拼装多寄存器量。
- **写能力**:✓ 已实现,但**仅** `functionCode=1`(写线圈)与 `functionCode=3`(写保持寄存器);其余功能码返回失败(见上方写功能码警告)。
- **订阅/上报**:— 不支持。Modbus 是主从轮询模型,驱动只主动读写、不被动接收推送。这与[驱动能力矩阵](./matrix)中 Modbus TCP
的「✓ / ✓ / —」一致。
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮),在驱动 `application.yml` 的 `schedule.read` 配置;`custom`
自定义调度默认关闭。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`。
::: info 实现状态:可用
本驱动是**完整实现**(非骨架),底层基于 modbus4j。读路径覆盖全部四类寄存器,写路径覆盖线圈与保持寄存器,并带连接缓存与失败退避。唯一需注意的差异是写功能码的
yml 标注(`[5,6,15,16]`)宽于代码实现(仅 `1/3`)——按上方警告显式配置即可正常落地。
:::
### 最小接入示例
把 IP `192.168.1.10:502` 的一台 Modbus 从站接进来:
1. 选 `Modbus TCP Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.10`、`port=502`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `slaveId=1`、`functionCode=3`、`offset=0`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到采集值。
4. 若该位号需可写,给它配写[命令](../introduction/concepts/command),把 `functionCode` 显式设为 `3`(写保持寄存器)。
::: tip 一个驱动实例可接多台从站
同一个 Modbus TCP 驱动进程可服务多台设备。多台从站挂在同一网关不同单元号时,`host` 相同、由位号的 `slaveId` 区分;不同 IP
的设备则各占一条缓存连接。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动入口与分类
- [驱动能力矩阵](./matrix) — 读/写/订阅能力一览,含 Modbus TCP 行
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — Modbus 等协议的寻址模型与字节序原理
- [Modbus RTU 驱动](./modbus-rtu) — 串口版 Modbus
---
# MQTT 驱动
URL: https://docs.dc3.site/zh/drivers/mqtt
# MQTT 驱动
> **`dc3-driver-mqtt` 把 MQTT 设备接入 IoT DC3**——驱动作为服务端常驻订阅 MQTT 主题,被动接收设备 publish
> 上来的报文、解析成[位号值](../introduction/concepts/point-value),并支持向命令主题 publish 报文下发写命令。这页讲清它消费什么
> broker、位号/命令/事件三类属性怎么填、收不到值时怎么排查,以及它在平台里是哪种驱动、实现到了哪一步。
读完你能:用 `MQTT Driver` 接一台"自己往主题上报、可被下发命令"的设备,并知道当链路不通时该看哪里。
## 协议背景
MQTT(Message Queuing Telemetry Transport)是物联网事实上的轻量**发布/订阅**消息总线,跑在 TCP 上,默认端口 `1883`、TLS
`8883`。它的语义和工业总线的"主站轮询每台设备"完全相反:设备不被轮询,而是主动把数据 **publish(发布)** 到某个 **topic(主题)
**;平台 **subscribe(订阅)** 这些主题就能收到上报。发布方与订阅方通过中间的 **broker**(消息中转服务器,如
EMQX、Mosquitto、RabbitMQ 的 MQTT 插件)解耦,互不需要知道对方地址、也无需同时在线——这正是海量、低功耗、广域设备场景下省电、可横向扩展的关键。
在[物联网四层参考架构](../foundations/iot-protocols)里,MQTT 属于**网络层**的"应用层消息协议"一支:它定义"
一条消息长什么样、怎么投递、可靠到什么程度",与底层用 Wi-Fi 还是 NB-IoT 等无线接入正交。关于 MQTT 与 CoAP/LwM2M/HTTP
的选型权衡,见[网络层章节](../foundations/iot-protocols)。
先解释几个本驱动会反复用到的 MQTT 概念:
- **主题(Topic)**:消息的逻辑地址,如 `device/1001/up`。发布方往主题发,订阅方按主题收。订阅可用通配符 `+`(匹配一层)和 `#`
(匹配末尾任意层)。
- **QoS(服务质量)**:消息投递保证级别,`0`=最多一次、`1`=至少一次、`2`=恰好一次。等级越高越可靠、开销也越大,发布与订阅两端各自声明、按较弱一方生效。
- **JSON 路径(Path)**:从上报报文里定位某字段的点号路径,如 `$.payload` 取根对象下的 `payload`、`$.eventCode` 取事件码字段。
与 Modbus、HTTP 这类主动连设备的驱动不同,本驱动是 **[驱动](../introduction/concepts/driver) 类型 `DRIVER_SERVER`**
:它不主动去"读"设备,而是常驻订阅、等设备把数据推上来。因此它**没有设备级 `driver-attribute` 配置表**——连哪个 broker
是部署级配置(见下文),不在设备上逐个填。
## 属性配置
MQTT 驱动的配置分两层:**broker 连接**是部署级的(整台驱动连同一个 broker),**位号 / 命令 / 事件属性**
是设备/位号级的(决定每个测点往哪个主题写、从哪个主题取事件)。
### Broker 连接(部署级,环境变量)
驱动通过 `dc3.driver.mqtt.*` 一组配置连接 broker,其取值来自部署时的环境变量。关键项:
| 配置 | 环境变量 | 默认值 | 说明 |
|-----------|------------------------------------------------|-----------------------------------------------|-----------------------------------------------------------------------------|
| broker 地址 | `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT` | `dc3-rabbitmq` / `1883`(dev profile 为 `2883`) | broker 主机与端口,拼成连接 URL(默认明文 `tcp://host:port`;TLS 走 `ssl://host:8883` 为生产可选) |
| 用户名 / 密码 | `MQTT_USERNAME` / `MQTT_PASSWORD` | `dc3` / 空(docker-compose 栈注入 `dc3dc3dc3`) | 认证凭据(认证类型支持 `NONE` / `USERNAME` / `CLIENT_ID` / `X509`);密码应用级回退为空,随部署注入 |
| 保活间隔 | 无 env 绑定(`dc3.driver.mqtt.keep-alive`) | `15`(秒) | 客户端心跳间隔,硬编码默认 |
| 完成超时 | 无 env 绑定(`dc3.driver.mqtt.completion-timeout`) | `3000`(毫秒) | 发布操作的等待超时,硬编码默认 |
| 批量阈值 | `MQTT_BATCH_SPEED` / `MQTT_BATCH_INTERVAL` | `100` / `5` | 上报批量:满 100 条或满 5 秒先到先发 |
::: info broker 默认是 RabbitMQ 的 MQTT 插件
默认的 MQTT broker 是 **RabbitMQ 的 MQTT 插件**(`dc3-rabbitmq`),由 `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT`
指定,docker-compose 栈注入 `dc3-rabbitmq:1883`(dev profile YAML 的端口回退为 `2883`)。**EMQX** 是
`docker-compose-optional.yml` 里的可选 broker(宿主机映射端口 `31883`),并非默认。RabbitMQ 的 MQTT 插件与平台内部用于服务间消息的
**RabbitMQ AMQP**(另有 `dc3.e.mqtt` 桥接交换机)是同一 broker 的两种协议——接 MQTT 设备时请把上述两个环境变量指向你真正的
MQTT broker。生产跨公网时应启用 TLS(`8883` / X509 证书)。
:::
### 位号配置(`point-attribute`)
每个[位号](../introduction/concepts/point)上填——位号的**写入目标主题**与投递质量。采集靠订阅被动接收,故位号属性只关注下行写命令:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|----------------|----------------------|
| Command Topic | `commandTopic` | STRING | `commandTopic` | 位号/设备接收下行命令的 MQTT 主题 |
| Command QoS | `commandQos` | INT | `2` | 下行命令主题的 QoS 级别 |
下发写命令时,驱动从位号属性取 `commandTopic`,按 `commandQos`(取不到或异常时回退默认 QoS)把要写的值 publish 到该主题。
### 写命令配置(`command-attribute`)
可写位号在写命令上填——往哪个主题发、用什么 QoS、报文长什么样:
| 属性 | code | 类型 | 默认值 | 说明 |
|------------------|-------------------|--------|----------------|--------------------------|
| Command Topic | `commandTopic` | STRING | `commandTopic` | 命令 publish 下行报文的 MQTT 主题 |
| Command QoS | `commandQos` | INT | `2` | 命令发布主题的 QoS 级别 |
| Payload Template | `payloadTemplate` | STRING | `{}` | 用命令参数渲染的报文模板 |
执行命令时,驱动用命令参数(外加 `deviceId` / `deviceCode` / `deviceName` / `commandId` / `commandCode` / `commandName`
等上下文)替换 `payloadTemplate` 里的 `${xxx}` 占位符,按 `commandQos` 把渲染后的报文 publish 到 `commandTopic`,并返回
`topic` / `qos` / `payload` 作为执行回执。
### 事件配置(`event-attribute`)
设备上报事件时,驱动从订阅到的报文里按主题与路径拆出"事件码"和"事件负载":
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------------|-----------------|--------|---------------|----------------------------------|
| Source Topic | `sourceTopic` | STRING | `eventTopic` | 接收事件负载的 MQTT 主题(支持 `+` / `#` 通配) |
| Event Code Path | `eventCodePath` | STRING | `$.eventCode` | 解析事件码的 JSON 路径 |
| Payload Path | `payloadPath` | STRING | `$.payload` | 解析事件负载的 JSON 路径 |
当收到的主题与 `sourceTopic` 匹配(精确或通配),驱动按 `eventCodePath` 取事件码、按 `payloadPath`
取负载,对码值对得上、且事件处于启用态的设备事件,组装成事件上报送往数据中心。
## 数据如何被接收
值的"读"在 MQTT 里不是主动发起的请求,而是订阅消息到达时的回调。下图是一条上报值从设备到平台的路径——设备与驱动都只和
broker 打交道:
报文要被收成[位号值](../introduction/concepts/point-value),须能解析出 `deviceId` 与 `pointId`(否则该条被跳过);解析失败只记一条
warn 日志、不影响其他消息。批量消息走 `receiveValues()` 合并发送,命中批量阈值(`MQTT_BATCH_SPEED` / `MQTT_BATCH_INTERVAL`
)即刷出。
## 故障排查
::: warning 它是服务端,不会去"连"设备
`DRIVER_SERVER`
意味着驱动等设备把数据推上来,而不是主动轮询。如果迟迟收不到[位号值](../introduction/concepts/point-value),*
*先确认设备端是否真的在往订阅主题发布**、主题字符串两端是否完全一致(含大小写与层级 `/`),而不是去查驱动的"采集周期"
——本驱动定时读取默认就是关的(`schedule.read.enable=false`)。
:::
- **broker 连不通**:检查 `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT` 是否指向真正的 MQTT broker(默认 `dc3-rabbitmq:1883`),以及
`MQTT_USERNAME` / `MQTT_PASSWORD` 与 broker 上的账号是否一致;公网/TLS 场景确认端口走的是 `8883` 且证书匹配。
- **收到消息但没有位号值**:报文必须能解析出 `deviceId` 与 `pointId`,否则会被静默跳过。看驱动日志里的
`MQTT point value parse failed` warn——多半是上报 JSON 结构不含这两个字段或非合法 JSON。
- **QoS 不匹配导致漏收/重复**:发布端与设备订阅端 QoS 要对齐。`commandQos` 取不到或异常时驱动会**回退默认 QoS**
发,仍尽量把命令发出去,但若两端档位不一致仍可能降级——可靠下发请让双方都用 `1` 或 `2`。
- **下发报文为空或占位符没被替换**:命令不会自动把值塞进报文,须在 `payloadTemplate` 里写好带 `${value}` 等占位符的模板(如
`{"value":${value}}`);模板留空时按 `{}` 发空对象。占位符名要和命令参数键一致才会被替换。
- **事件收不到**:确认收到的主题与 `sourceTopic` 匹配(通配符 `+` 只匹配一层、`#` 只能在末尾),`eventCodePath` 取出的码值与设备事件的
`eventCode` 对得上,且该事件处于启用态。
- **设备"在线"但没数据**:MQTT 是被动推送,"长时间没收到"不代表链路一定断。在线判断走租约/保活,而非采集周期——健康检查默认
cron `0/15 * * * * ?`、租约超时 `45 秒`,机制见[设备](../introduction/concepts/device)。
## 在 IoT DC3 中如何落地
- **驱动名 / code**:`MQTT Driver` / `MqttDriver`
- **类型**:`DRIVER_SERVER`(驱动作为服务端,被动接收设备上报)
- **能力**(与[驱动能力矩阵](./matrix)一致):读 `—`、写 `✓`、订阅/上报 `✓`——值经订阅被动到达,无主动读;命令可下发、事件可上报。
::: info 实现状态:数据接收、命令下发与健康检查已实现,`initial()` 为骨架
据 `MqttDriverCustomServiceImpl` 与 `MqttReceiveServiceImpl` 源码:**数据接收**(解析为位号值并转发、事件上报与主题匹配)、*
*写命令**(`write()` / `execute()` 发布报文、QoS 回退、模板渲染)、**`health()` 健康检查**(监听 `MqttSubscribedEvent` /
`MqttConnectionFailedEvent`,实时反映 broker 连接态)均已实现;`read()` 按 pub/sub 语义恒返回 `null`
(数据靠订阅被动到达,非缺陷)。仍为参考桩的是 `initial()` 空的初始化模板。
:::
最小接入示例——接一台往 `device/1001/up` 上报、并接收 `device/1001/down` 命令的设备:
1. 部署时把 `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT` 指向你的 broker(默认 `dc3-rabbitmq:1883`,即 RabbitMQ 的 MQTT 插件),用
`MQTT Driver` 创建[设备](../introduction/concepts/device)(本驱动无 driver 属性可填)。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个可写[位号](../introduction/concepts/point),point 属性填
`commandTopic=device/1001/down`、`commandQos=1`。
3. 设备把数据 publish 到订阅主题后,[位号值](../introduction/concepts/point-value)即被动收下;下发写命令时,驱动按
`payloadTemplate` 渲染报文并 publish 到 `device/1001/down`。
完整接入流程见[设备接入](../operation/device-onboarding)。
## 延伸阅读
- [驱动总览](./index) — 28 个驱动的全景与分类
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [网络层:物联网协议](../foundations/iot-protocols) — MQTT 与 CoAP/LwM2M/HTTP 的选型权衡
- [CoAP 驱动](./coap) — 面向受限终端的轻量请求/响应协议
---
# MySQL 驱动
URL: https://docs.dc3.site/zh/drivers/mysql
# MySQL 驱动
`dc3-driver-mysql` 把一个 MySQL 数据库当作数据源接入 IoT DC3:它作为数据库客户端,按采集周期对库里执行 `SELECT`
把查到的值当采集值,并支持用位号上配的 `UPDATE`/`INSERT`
写查询向库里写值。读完你能在[设备](../introduction/concepts/device)
上配好连库参数、在[位号](../introduction/concepts/point)上配好读/写 SQL,并定位常见的"连不上库/查不到值/写不下去"问题。
> 你在这里:把一个已有数据库当数据源接进来的落地驱动。不是所有数据都来自现场协议设备——很多业务数据、历史数据、第三方系统的结果,本身就躺在一张
> MySQL 表里。
## 协议背景
MySQL 是世界上使用最广的开源关系型数据库,诞生于 1995 年,以 SQL 为查询语言、以表/行/列组织数据。在物联网场景里,它常常不是"
现场设备",而是数据汇聚的中转站:MES/ERP 等业务系统、第三方平台、历史归档库,都习惯把结果落在一张 MySQL
表里对外提供。把这张表当数据源接进来,就能让平台像采集真实设备一样,周期性地把表里的字段拉成[位号值](../introduction/concepts/point-value)。
本驱动作为数据库客户端([驱动](../introduction/concepts/driver)类型 `DRIVER_CLIENT`),通过 JDBC(`mysql-connector-j`,驱动类
`com.mysql.cj.jdbc.Driver`)连到一个 MySQL 库,按[位号](../introduction/concepts/point)上配置的 SQL 去查值、写值。它的通信模型是典型的
**请求-响应**——驱动作为客户端主动发起查询,库不会主动推送,所以采集由 cron 周期轮询驱动。JDBC 连接、连接池、SQL
执行的通用逻辑由共享的抽象基类 `AbstractJdbcDriverCustomService`(`dc3-common-sql` 模块)负责,MySQL、PostgreSQL、Oracle、SQL
Server 四个数据库驱动都复用它,各自只提供 JDBC URL 拼装与驱动类名。
放进[物联网数据管线](../foundations/data-pipeline)看,这类数据库驱动处在"把外部已结构化的数据搬进平台"
的入口位置:感知层与现场协议负责把物理量数字化,而 MySQL 驱动负责把已经沉淀在库里的结构化结果接入同一条管线,最终和真实设备采到的位号值一样落库、可查、可被告警与
AI 使用。
下面两个本驱动特有的概念,配置表会反复用到:
- **读查询(Read Query)**:位号上配的一条 `SELECT`,驱动按采集周期执行它,取结果**第一行第一列**作为该位号的值。
- **写查询(Write Query)**:位号上配的一条 `UPDATE`/`INSERT`,里面用一个 `?` 占位符代表要写入的值——写命令触发时,命令参数以预编译参数绑定的方式填进去。
## 属性配置
接入一个 MySQL 库,需要在三个层面填[属性](../introduction/concepts/attribute-config):设备级的连库参数(`driver-attribute`
)、每个采集位号的读/写 SQL(`point-attribute`)、以及写命令上的一个保留属性(`command-attribute`)。下面各属性、类型、默认值均取自驱动的
`application.yml`(`dc3-driver-mysql` 模块)。
### 驱动属性(设备级 `driver-attribute`)
驱动属性回答"连到哪个库、用什么账号、查询超时多久"。在[设备](../introduction/concepts/device)上为每个 MySQL 库填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|-------------|-------------------|
| Host | `host` | STRING | `localhost` | MySQL 主机 IP 或主机名 |
| Port | `port` | INT | `3306` | MySQL 端口(标准 3306) |
| Database | `database` | STRING | (空) | MySQL 库名 |
| Username | `username` | STRING | `root` | MySQL 用户名 |
| Password | `password` | STRING | (空) | MySQL 密码 |
| Query Timeout | `queryTimeout` | INT | `30` | SQL 查询超时(秒) |
驱动用 `host`、`port`、`database` 拼出 JDBC URL,形如
`jdbc:mysql://host:port/database?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC`。`host`、`port`、`database`、
`username`、`password` 五项均为必填——配置校验(`validate()`)逐项检查,缺任一项都不通过。驱动按设备 ID 缓存一个 HikariCP
连接池(一台设备一个池,最大 5 连接),连接超时取 `queryTimeout × 1000` 毫秒。
::: tip queryTimeout 同时作用于建连与查询节奏
`queryTimeout`(默认 30 秒)被设为连接池的 `connectionTimeout`:拿不到连接、或建连卡住超过这个时长就会失败。它与采集周期是两回事——慢
SQL 若长期逼近或超过它,应优化 SQL 或加索引,而不是一味调大超时。
:::
### 位号属性(`point-attribute`)
位号属性回答"查这个库里的哪一个值、往哪写"。每个采集[位号](../introduction/concepts/point)填它对应的读/写 SQL:
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|--------|-----|-------------------------------------------------|
| Read Query | `readQuery` | STRING | (空) | 读取位号值的 `SELECT` 查询 |
| Write Query | `writeQuery` | STRING | (空) | 写值用的 `UPDATE`/`INSERT`,用单个 `?` 占位符代表写入值(作为参数绑定) |
::: tip Read Query 取结果的第一行第一列
`readQuery` 是一条普通 `SELECT`,驱动取其结果**第一行的第一列**(`rs.getObject(1)`)作为该位号的值——所以写成
`SELECT temperature FROM sensor WHERE id = 1` 这种只返回单行单列的查询最稳妥。结果集为空时取到 `null`
。位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)决定这个值如何被解析。`readQuery` 是位号的必填项(
`validatePoint()` 强制),缺了位号校验不通过;`writeQuery` 仅在该位号要被写时才必填。
:::
### 写命令属性(`command-attribute`)
写命令上可以配这个属性,但当前并未被实现消费:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|-----|---------------|
| Execute Query | `executeQuery` | STRING | (空) | 命令要执行的 SQL 查询 |
::: warning executeQuery 当前未被实现消费
写值走的是**位号上的 `writeQuery`**:`write()` 取 `point-attribute` 的 `writeQuery`,把命令参数 `setString(1, value)`
绑进唯一的 `?` 占位符后执行 `UPDATE`/`INSERT`。`command-attribute` 上的 `executeQuery`
仅作为配置项保留,当前驱动代码中没有任何地方读取或执行它——不存在"按命令直接执行一段 SQL"的独立路径,写值一律走
`writeQuery`。以代码为准。
:::
## 故障排查
MySQL 接入失败大多集中在连接、账号权限、查询定位、字段类型几类。按下面顺序排查:
1. **连不上库(设备一直 offline)**。先确认 `host:port` 可达:`telnet 3306` 或 `nc -vz 3306`。健康检查通过
`conn.isValid(5)`(5 秒内能否拿到有效连接)判定在线,建连或心跳失败即报 offline。常见根因:库未监听公网/容器网络、防火墙拦截、
`bind-address` 限制。
2. **能连但被拒(账号/权限/SSL)**。确认 `username`/`password` 正确,且该账号被授权从驱动所在主机连接(MySQL 的账号是
`user@host` 维度,`root@localhost` 连不上远程)。驱动拼的 URL 带 `useSSL=false&allowPublicKeyRetrieval=true`——若目标库强制
SSL 或禁用 public key retrieval,会在建连阶段失败。建连失败抛 `ConnectorException`,并使该设备的连接池失效、下个周期重建。
3. **查不到值 / 取到了错行**。驱动只取结果的第一行第一列,所以 `readQuery` 要能稳定定位到目标行。结果集为空会得到 `null`
;返回多行时只取到第一行,可能不是你要的那条。把 `WHERE` 主键条件写全,避免随表数据增长取到错行。
4. **数值/类型对不上**。位号的 `pointTypeFlag` 决定取回的字符串如何被解析。把一个文本列配成 `FLOAT`
位号、或日期/枚举列直接当数值,都可能解析异常。`readQuery` 里用 `CAST`/`CONVERT` 或只选目标数值列,保证取回的值与位号类型相符。
5. **写命令返回失败**。写值要求 `writeQuery` 里恰有一个 `?` 占位符、且语句指向可写的表与行。`write()` 返回"受影响行数 > 0"
为成功——若 `WHERE` 条件没命中任何行,`executeUpdate()` 返回 0、写被判为失败。先用同样的 `UPDATE` 在库里手工跑一遍确认能命中行。写失败抛
`WritePointException` 并使连接池失效。
6. **慢 SQL 拖垮采集**。大表全扫、缺索引的 `readQuery` 可能在采集周期内迟迟不返回,或逼近 `queryTimeout`
。给查询条件列加索引、只选必要列,比单纯调大超时更治本。
::: warning Write Query 用 `?` 占位符,不是 `${value}`
写值时 `writeQuery` 里用**一个** `?` 占位符代表写入值(如 `UPDATE sensor SET temperature = ? WHERE id = 1`),驱动通过
`PreparedStatement.setString(1, value)` 绑定——这是预编译参数绑定,不是字符串拼接,恶意值无法改写语句结构(无 SQL 注入)。不要在
SQL 里手动拼接值,也不要用 `${value}` 这类模板语法,那样既不会被替换、也失去防注入的好处。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`MysqlDriver`(类型 `DRIVER_CLIENT`,主动连库并发起查询)。这是稳定的路由标识,不要随意改。
- **读能力**:✓ 已实现。`read()` 执行位号的 `readQuery`,取结果第一行第一列作为位号值。
- **写能力**:✓ 已实现。`write()` 执行位号的 `writeQuery`,用 `?` 预编译参数绑定写入值,受影响行数 > 0 即成功。
- **订阅/上报**:— 不支持。MySQL 是请求-响应模型,驱动只主动查/写、不被动接收推送。这与[驱动能力矩阵](./matrix)中 MySQL
的「✓ / ✓ / —」一致。
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮),在驱动 `application.yml` 的 `schedule.read` 配置;另有一个
`custom` 自定义调度默认 cron `0/5 * * * * ?`(每 5 秒),但基类的 `schedule()` 为空实现、数据库驱动不使用它。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`;判定靠 `conn.isValid(5)`
。在线状态机制见[设备](../introduction/concepts/device)。
::: info 实现状态:可用
本驱动是**完整实现**(非骨架)。读、写、健康检查、按设备缓存的 HikariCP 连接池、失败时连接池失效重建均已落地,复用经测试的
`AbstractJdbcDriverCustomService` 基类。唯一需注意的差异是 `command-attribute` 上的 `executeQuery` 属性保留但未被代码消费——写值一律走位号的
`writeQuery`(见上方警告)。
:::
### 最小接入示例
把一张 `sensor` 表里 `id=1` 那行的 `temperature` 字段当温度位号采进来:
1. 选 `MySQL Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.10`、`port=3306`、
`database=iot`、`username=root`、`password=******`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `readQuery=SELECT temperature FROM sensor WHERE id = 1`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到查到的温度值。
4. 若该位号需可写,给它的 point 属性补 `writeQuery=UPDATE sensor SET temperature = ? WHERE id = 1`
,再给它配写[命令](../introduction/concepts/command)。
::: tip 一个驱动实例可接多个库
同一个 MySQL 驱动进程可服务多台设备:每个设备按其 driver 属性各自连一个库、各占一个连接池(按设备 ID
缓存)。设备元数据被删除/更新时,对应连接池会被关闭并按需重建。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动入口与分类
- [驱动能力矩阵](./matrix) — 读/写/订阅能力一览,含 MySQL 行
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [时序数据与流处理](../foundations/data-pipeline) — 位号值进入平台后如何存储、计算与查询
- [PostgreSQL 驱动](./postgresql) — 同一 JDBC 基类的另一种数据库数据源
---
# OPC DA 驱动
URL: https://docs.dc3.site/zh/drivers/opc-da
# OPC DA 驱动
`dc3-driver-opc-da` 作为 OPC DA 客户端,通过 Windows DCOM 连接现场的 OPC DA
Server,按位号上配置的分组(group)与标签(tag)周期性读取实时值,也支持把值写回标签。本页讲清 OPC DA
是什么协议、驱动暴露哪些属性、接不上时怎么排查,以及它在 IoT DC3 里的落地状态。
> 你在这里:要把一台已有 OPC DA Server 的设备接入 DC3。先了解协议,再照[属性配置](#属性配置)
> 填表,遇阻看[故障排查](#故障排查)。
## 协议背景
OPC DA(OPC Data Access)是 Windows 平台上最经典的工业数据访问规范。它诞生于 PC 时代,把 Microsoft 的 COM/DCOM
组件技术用于工业现场:SCADA、组态软件、PLC 网关大多内置一个 OPC DA Server,把底层 PLC、仪表的点位以"标签"
(item)的形式暴露出来,上位机用统一的 OPC DA 客户端去读写,而不必关心每个 PLC 的私有协议。常见版本是 OPC DA 2.0 / 3.0。
在[物联网四层架构](../foundations/fieldbus)里,OPC DA 属于**网络层**——它是现场设备与上位系统之间的"通用翻译层"
。但它的网络承载不是普通 TCP 端口,而是 **DCOM(Distributed COM)**:客户端通过 COM 类标识符(CLSID)定位 Server,调用走 DCOM
远程过程调用。这一点决定了 OPC DA 的部署与排错都带着浓重的 Windows 烙印——这也是后来跨平台的 [OPC UA](./opc-ua) 出现的原因。
OPC DA 把标签组织成"分组(group)下的若干 item":客户端先在 Server 上创建或找到一个 group,再往组里添加要订阅/读写的
item,按需读取或写入其值。每个 item 的值带 COM 变体类型(VARIANT),如 `VT_I4`(整型)、`VT_R8`(双精度)、`VT_BOOL`、`VT_BSTR`
(字符串),驱动据此转换成位号值。
::: warning DCOM 是前置条件,且只在 Windows 上
OPC DA 基于 Windows COM/DCOM。Server 必须跑在可经 DCOM 远程访问的 Windows 主机上,并在操作系统层面配好 DCOM
权限,允许驱动所在主机远程访问。这一步不属于本驱动配置,却是能否接通的决定性因素。
:::
## 属性配置
OPC DA 的接入参数分两层:**driver 属性**描述"连哪台 Server"(设备级,一台设备一份),**point 属性**描述"读哪个标签"
(位号级,一个位号一份)。下面两张表来自驱动 `application.yml` 的 `driver-attribute` / `point-attribute`,默认值即驱动内置默认值。
### 驱动属性(设备级 `driver-attribute`)
接入一台 OPC DA 设备时,在[设备](../introduction/concepts/device)
上填这些[属性](../introduction/concepts/attribute-config)。`host` 指向 Server 主机,`clsId` 定位具体的 OPC DA Server,
`username` / `password` 是用于 DCOM 远程访问的 Windows 凭据:
| 属性 | code | 类型 | 默认值 | 说明 |
|----------|------------|--------|----------------------------------------|-----------------------------|
| Host | `host` | STRING | `localhost` | OPC DA Server 所在主机(IP 或主机名) |
| CLSID | `clsId` | STRING | `F8582CF2-88FB-11D0-B850-00C0F0104305` | 目标 OPC DA Server 的 COM 类标识符 |
| Username | `username` | STRING | `dc3` | DCOM 远程访问用的 Windows 用户名 |
| Password | `password` | STRING | `dc3dc3` | 对应密码 |
::: tip CLSID 是 OPC DA Server 的 COM 标识,不是端口
OPC DA 通过 COM 类标识符(CLSID)定位 Server,而不是 TCP 端口。CLSID 由目标 OPC DA Server 的厂商决定,可在 Server 主机的注册表,或用
OPC 服务器浏览工具查到。`application.yml` 里的默认值仅为占位,接入时务必替换为真实 Server 的 CLSID。
:::
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填 `group` 与 `tag`。驱动先按 `group` 在 Server 上找到(或新建)分组,再用
`tag` 在组内定位标签 item,读取/写入其值:
| 属性 | code | 类型 | 默认值 | 说明 |
|-------|---------|--------|---------|------------------------|
| Group | `group` | STRING | `GROUP` | OPC DA 分组名(group name) |
| Tag | `tag` | STRING | `TAG` | OPC DA 标签的完整 item 名 |
::: info group / tag 以 Server 浏览出来的为准
不同厂商命名风格不同,`tag` 常见形如 `Channel1.Device1.TagA`。这些名字必须与目标 Server 的实际命名一致,应从 Server
浏览结果中取,不要凭习惯臆测——名字对不上时驱动会在该组里找不到 item 而读取失败。
:::
### 采集与健康
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮),由 `dc3.driver.schedule.read` 控制。
- **自定义任务**:`dc3.driver.schedule.custom` 默认 cron `0/5 * * * * ?`,但本驱动的 `schedule()` 为空实现(设备保活由 SDK
健康任务负责)。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`
——在线状态机制见[设备](../introduction/concepts/device)。
## 故障排查
OPC DA 接不上,绝大多数问题出在 DCOM 与命名,而不在驱动代码本身。按下面顺序排查:
1. **连不上 / `ConnectorException`**:最常见根因是 DCOM。检查 Server 主机防火墙是否放行 DCOM 端口、远程账号是否被授予"
远程激活/远程访问"权限、`username` / `password` 是否为该 Windows 主机上有效且有权访问该 Server 的账号。建议先在驱动所在主机用第三方
OPC 客户端工具验证能连上该 CLSID,再接入 DC3。
2. **CLSID 错误**:`clsId` 必须是目标 Server 的真实 CLSID(默认值只是占位)。在 Server 主机注册表或 OPC 浏览工具中确认;CLSID
写错会在连接阶段失败。
3. **读取失败 / `ReadPointException`**:通常是 `group` 或 `tag` 与 Server 命名不符,驱动在该组下 `addItem` 找不到标签。逐字符核对位号上的
`group` / `tag`。读失败时驱动会**销毁并移除该设备的连接**,下一轮采集自动重连——若 Server 侧标签名一直不对,会持续失败。
4. **写入失败 / `WritePointException` 或 `UnSupportException`**:写入只处理
`SHORT / INT / LONG / FLOAT / DOUBLE / BOOLEAN / STRING` 这几种位号类型。只有完全无法识别的类型码才会抛
`UnSupportException`;而已知但不在上述处理范围内的类型(如 `BYTE`)不会抛异常,写入会被报告为**失败返回 `false`**
(不写值)。另需确认目标 item 在 Server 上可写、当前账号有写权限。
5. **设备显示离线**:在线态由 SDK 健康任务维护(默认 15 秒一检、45 秒租约)。若连接反复因 DCOM 抖动被销毁重建,设备会在线/离线间跳变——先稳住
DCOM 链路。
6. **跨平台限制**:驱动本身基于 J-Interop(纯 Java DCOM 实现)可在 Linux 上运行,但**对端 Server 必须是 Windows DCOM**
。不要期望连接非 Windows 的 OPC DA 端。
## 在 IoT DC3 中如何落地
- **dc3.driver.code**:`OpcDaDriver`(稳定路由标识,对应消息路由与注册,不可随意改)。
- **驱动名 / 类型**:`OPC DA Driver` / `DRIVER_CLIENT`(主动连接 OPC DA Server)。
- **能力**:读 ✓、写 ✓、订阅 —,与[驱动能力矩阵](./matrix)一致。读走周期采集(cron `0/30`),写走下发命令;驱动不使用 OPC DA
的变化订阅推送,而是周期主动读。
::: warning 实现状态:代码完整,但运行依赖 Windows / DCOM 基础设施
`OpcDaDriverCustomServiceImpl` 的 `read()` / `write()` 已基于内置的 OpenSCADA OPC DA 客户端库(J-Interop)**完整实现**
:连接通过 `Server.connect()` 建立并按设备缓存,读取调用 `item.read()` 并按 COM 变体类型转换,写入构造 `JIVariant` 调
`item.write()`。源码类注释里残留一句"work-in-progress skeleton / 见 TODO 标记"的旧警告,但方法体内已无
TODO,与当前实现不符——应以方法实现为准。真正的门槛不在代码,而在于必须有一台配好 DCOM 的 Windows OPC DA Server
才能实际跑通;缺少该环境时无法验证。
:::
::: tip 最小接入示例
把一台运行在 `192.168.1.10` 的 OPC DA Server 中的某个标签接进来:
1. 选 `OPC DA Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.10`、`clsId=`(真实 Server
的 CLSID)、`username` / `password`(可远程访问该 Server 的 Windows 账号)。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个[位号](../introduction/concepts/point),point 属性填
`group=Group1`、`tag=Channel1.Device1.Tag1`(按目标 Server 实际命名)。
3. 启动驱动,30 秒内即可在[位号值](../introduction/concepts/point-value)里看到采集值。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动的统一模型与注册机制
- [驱动能力矩阵](./matrix) — 各驱动读 / 写 / 订阅能力速查
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — OPC DA 在网络层的位置与同类协议对比
- [OPC UA 驱动](./opc-ua) — 跨平台、可订阅的下一代 OPC
---
# OPC UA 驱动
URL: https://docs.dc3.site/zh/drivers/opc-ua
# OPC UA 驱动
`dc3-driver-opc-ua` 把 OPC UA 服务端接入 IoT DC3:作为 OPC UA
客户端连到一个或多个服务端,按[位号](../introduction/concepts/point)上配置的命名空间与标识符周期性读取节点值,并支持向节点写值。读完本页,你能完成一台
OPC UA 设备的接入、配置位号、排查连不上的常见原因。
## 协议背景
OPC UA(OPC Unified Architecture,统一架构)是工业自动化领域的跨平台数据互通标准,PLC、SCADA、MES 和各类边缘网关普遍内置 OPC
UA 服务端,把现场数据以「节点树」的形式对外暴露。它取代了依赖 Windows DCOM 的经典 OPC(即 [OPC DA](./opc-da)),改用平台无关的
`opc.tcp://` 二进制协议(也支持 HTTPS),并把安全(证书、签名、加密)与信息建模内建进规范。
在[物联网四层架构](../foundations/fieldbus)里,OPC UA 属于**网络层(现场总线)**:它是车间设备与上层系统之间的协议边界,向下对接
PLC/控制器,向上把数据递给数据平台。和 Modbus 用寄存器地址、Ethernet/IP 用 CIP 标签不同,OPC UA 用**对象模型**
寻址——每个数据点是一个节点(Node),由一个 **NodeId** 唯一标识。NodeId 由两部分组成:
- **命名空间索引(namespace index)**:一个整数,把不同来源的标识符空间隔开,避免重名。
- **标识符(identifier)**:节点在该命名空间下的名字,可以是字符串、数字或 GUID。本驱动用**字符串标识符**。
例如 `namespace=2`、标识符 `Demo.Static.Float`,就唯一定位到命名空间 2 下名为 `Demo.Static.Float` 的节点。本驱动基于
Eclipse Milo 实现,以 OPC UA 客户端(client)身份主动连接服务端,是典型的「主站轮询」模型——它不监听设备上报,而是按采集周期挨个读节点。
- **驱动名 / code**:`OPC UA Driver` / `OpcUaDriver`
- **类型**:`DRIVER_CLIENT`(主动连服务端)
## 属性配置
OPC UA 的接入参数分两层:**driver 属性**填在[设备](../introduction/concepts/device)上,定位「连哪台服务端」;**point 属性**
填在每个[位号](../introduction/concepts/point)上,定位「读写哪个节点」。两层属性都来自驱动的 `application.yml`
,在创建设备/位号时按需填值,留空则用下表的默认值。
### 驱动属性(设备级 `driver-attribute`)
这三个属性拼成端点地址 `opc.tcp://:`,告诉驱动去连哪个 OPC UA 服务端的哪个端点。
| 属性 | code | 类型 | 默认值 | 说明 |
|------|--------|--------|-------------|---------------------|
| Host | `host` | STRING | `localhost` | 服务端主机名或 IP |
| Port | `port` | INT | `18600` | 服务端 `opc.tcp` 监听端口 |
| Path | `path` | STRING | `/` | 端点路径(endpoint path) |
例如 `host=192.168.1.20`、`port=4840`、`path=/milo`,拼成 `opc.tcp://192.168.1.20:4840/milo`。`host` 和 `port` 是必填项(驱动
`validate()` 会校验二者非空),`path` 可留默认 `/`。驱动在发现端点时取服务端返回的**第一个**端点来连接。
### 位号属性(`point-attribute`)
每个采集位号填这两项,二者拼成目标节点的 NodeId。
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------|-------------|--------|-------|-------------|
| Namespace | `namespace` | INT | `5` | 命名空间索引 |
| Tag | `tag` | STRING | `TAG` | 字符串标识符(节点名) |
::: tip NodeId = namespace + tag
驱动把 `namespace`(命名空间索引)和 `tag`(字符串标识符)拼成 `NodeId(namespace, tag)` 去读写节点。例如 `namespace=2`、
`tag=Demo.Static.Float` 对应命名空间 2 下名为 `Demo.Static.Float`
的节点。位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`
)要和节点的实际值类型对得上——读到的值会被转成字符串上报,写值时则按位号类型选择 OPC UA 数据类型。
:::
::: info 写命令没有单独的属性
OPC UA 驱动**没有 `command-attribute`**。向节点写值时复用位号自己的 `namespace` 和 `tag` 定位目标节点,写值类型由位号类型决定——驱动支持写
`INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN` / `STRING` 六种类型(见源码 `writeNode()`)。所以一个可写位号只要配好
`namespace` 和 `tag`,无需再填任何命令属性即可下发写命令。
:::
### 采集与健康检查
这些参数来自 `application.yml` 的 `dc3.driver.schedule` 与 `dc3.driver.health`,是驱动级默认值,不在设备上逐个配置。
| 项 | 配置键 | 默认值 | 说明 |
|------|-------------------------|------------------|---------------|
| 采集周期 | `schedule.read.cron` | `0/30 * * * * ?` | 每 30 秒读一轮全部位号 |
| 健康检查 | `health.device.cron` | `0/15 * * * * ?` | 每 15 秒探活一次 |
| 租约超时 | `health.device.timeout` | `45`(秒) | 超时未续约则判离线 |
健康检查会拿设备的客户端做一次幂等 `connect()` 探活:连得上判[在线](../introduction/concepts/device),否则离线。
## 故障排查
::: warning port 默认值是 18600,不是标准 4840
yml 里 `port` 默认 `18600`(本地内置 Milo 示例服务端的端口)。生产环境绝大多数 OPC UA 服务端用标准端口 `4840`
,接入真实设备时务必按服务端实际监听端口填 `port`,不要直接用默认值。`path` 也要和服务端的 endpoint 路径一致:有的服务端是根路径(填
`/`),有的带子路径(如 `/milo`、`/OPCUA/SimulationServer`),填错会连不上。
:::
- **匿名身份被拒**:驱动以匿名身份(`AnonymousProvider`)连接服务端。若服务端强制用户名/密码、禁用匿名访问,连接会被拒绝。需先在服务端放开匿名访问,或为该端点开放匿名策略。
- **设备一直离线**:健康检查每 15 秒做一次 `connect()` 探活,连续失败即判离线。先确认 `host`/`port`/`path` 拼出的端点地址正确、网络可达(
`telnet host port` 或 `nc -vz host port` 验端口通),再确认服务端进程在跑、防火墙未拦 `opc.tcp` 端口。
- **读到的值为 null 或状态码不为 Good**:驱动读节点时若 `StatusCode` 不是 Good、或值为空,会抛 `ReadPointException` 并*
*主动断开并剔除该连接**(下一轮重连)。常见原因:NodeId 写错(namespace 或 tag 不存在)、节点无读权限、节点当前无值。用
UaExpert 等工具核对节点 `ns=;s=` 是否真实存在且可读。
- **读/写超时**:驱动的连接超时 5 秒、读超时 1 秒、写超时 1 秒。网络抖动或服务端响应慢时易超时,超时同样会剔除连接触发重连。若服务端确实慢,需在网络侧排查链路时延,而非调大单点超时。
- **写命令不生效**:写值类型必须落在 `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN` / `STRING`
之内,且要和服务端节点的实际数据类型兼容;类型不匹配时服务端返回非 Good 状态,写命令判失败。确认位号 `pointTypeFlag`
与服务端节点类型一致,且该节点对客户端有写权限。
- **证书相关报错**:驱动启动时在工作目录 `dc3/opc-ua` 下生成自签名证书 `dc3-opc-ua-client.pfx`(PKCS12,默认口令 `password`
,可用环境变量 `OPCUA_KEYSTORE_PASSWORD` 覆盖)。若该目录无写权限、或证书生成失败,驱动会**降级为纯匿名连接**
(不带客户端证书)。当服务端的安全策略要求客户端证书时,纯匿名连接会握手失败——此时需确保证书目录可写、并把生成的客户端证书在服务端「信任」。
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`OpcUaDriver`——这是驱动在系统里的稳定路由标识,数据/命令链路按它寻址,不可随意改。
- **读能力**:✓ 已实现。按采集周期对每个位号调用 `readValue()`
读节点,值转字符串后封装为 [PointValue](../introduction/concepts/point-value) 上报。
- **写能力**:✓ 已实现。下发写命令时复用位号 `namespace`/`tag` 定位节点,按位号类型写 `INT`/`LONG`/`FLOAT`/`DOUBLE`/
`BOOLEAN`/`STRING`。
- **订阅/上报**:— 不提供。本驱动是主站轮询模型,不订阅 OPC UA 服务端的数据变更通知(Subscription/MonitoredItem),只按周期主动读。
以上与[驱动能力矩阵](./matrix)的标注一致(读 ✓ / 写 ✓ / 订阅 —)。
::: info 实现状态:可用
`OpcUaDriverCustomServiceImpl` 的 `read()` / `write()` / `health()` / `validate()` / `event()` 均为完整实现(基于 Eclipse
Milo),非骨架。读节点、写六种类型、连接缓存与失效重连、自签名证书生成、设备更新或删除时清理连接等行为都已落地,可直接接入真实
OPC UA 服务端。
:::
### 最小接入示例
把端点 `opc.tcp://192.168.1.20:4840/milo` 上的一个浮点节点接进来:
1. 选 `OPC UA Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.20`、`port=4840`、
`path=/milo`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `namespace=2`、`tag=Demo.Static.Float`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到采集值。
完整接入流程见[设备接入](../operation/device-onboarding)。
## 延伸阅读
- [驱动总览](./index) — 全部驱动与通用接入模型
- [驱动能力矩阵](./matrix) — 各驱动读 / 写 / 订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — OPC UA 所在的网络层与寻址模型
- [OPC DA 驱动](./opc-da) — 经典 OPC(DCOM)版本
---
# Oracle 驱动
URL: https://docs.dc3.site/zh/drivers/oracle
# Oracle 驱动
`dc3-driver-oracle` 把一个 Oracle 数据库当作数据源接入 IoT DC3:它作为数据库客户端,按采集周期对库里执行 `SELECT`
把查到的值当采集值,并支持用位号上配的 `UPDATE`/`INSERT`
写查询向库里写值。读完你能在[设备](../introduction/concepts/device)上配好连库参数(含 Oracle 特有的 SID / Service Name
连接方式)、在[位号](../introduction/concepts/point)上配好读/写 SQL,并定位常见的"连不上库/查不到值/写不下去"问题。
> 你在这里:把一个已有数据库当数据源接进来的落地驱动。不是所有数据都来自现场协议设备——很多业务数据、历史数据、第三方系统的结果,本身就躺在一张
> Oracle 表里。
## 协议背景
Oracle Database 是企业级关系型数据库的代表,1979 年问世,以 SQL
为查询语言、以表/行/列组织数据,在金融、电力、制造等行业承载着大量核心业务系统与历史归档。在物联网场景里,它常常不是"现场设备"
,而是数据汇聚的中转站:MES/ERP 等业务系统、第三方平台、历史库,都习惯把结果落在一张 Oracle
表里对外提供。把这张表当数据源接进来,就能让平台像采集真实设备一样,周期性地把表里的字段拉成[位号值](../introduction/concepts/point-value)。
本驱动作为数据库客户端([驱动](../introduction/concepts/driver)类型 `DRIVER_CLIENT`),通过 JDBC(`ojdbc11`,驱动类
`oracle.jdbc.OracleDriver`)连到一个 Oracle 库,按[位号](../introduction/concepts/point)上配置的 SQL 去查值、写值。它的通信模型是典型的
**请求-响应**——驱动作为客户端主动发起查询,库不会主动推送,所以采集由 cron 周期轮询驱动。JDBC 连接、连接池、SQL
执行的通用逻辑由共享的抽象基类 `AbstractJdbcDriverCustomService`(`dc3-common-sql` 模块)负责,MySQL、PostgreSQL、Oracle、SQL
Server 四个数据库驱动都复用它,各自只提供 JDBC URL 拼装与驱动类名。
放进[物联网数据管线](../foundations/data-pipeline)看,这类数据库驱动处在网络层之外、"把外部已结构化的数据搬进平台"
的入口位置:感知层与现场协议负责把物理量数字化、网络层负责送达,而 Oracle
驱动负责把已经沉淀在库里的结构化结果接入同一条管线,最终和真实设备采到的位号值一样落库、可查、可被告警与 AI 使用。
Oracle 与其它数据库的最大差异在**怎么标识一个库实例**:Oracle 用 SID(System Identifier,实例标识)或 Service
Name(服务名)两种方式定位实例,驱动据此拼出不同形态的 JDBC URL。下面三个本驱动特有的概念,配置表会反复用到:
- **连接方式(Connection Type)**:`SID` 或 `ServiceName`。驱动据此拼出不同形态的 JDBC URL(见上图),二者各对应 Oracle
不同的命名方式。
- **读查询(Read Query)**:位号上配的一条 `SELECT`,驱动按采集周期执行它,取结果**第一行第一列**作为该位号的值。
- **写查询(Write Query)**:位号上配的一条 `UPDATE`/`INSERT`,里面用一个 `?` 占位符代表要写入的值——写命令触发时,命令参数以预编译参数绑定的方式填进去。
## 属性配置
接入一个 Oracle 库,需要在三个层面填[属性](../introduction/concepts/attribute-config):设备级的连库参数(`driver-attribute`
)、每个采集位号的读/写 SQL(`point-attribute`)、以及写命令上的一个保留属性(`command-attribute`)。下面各属性、类型、默认值均取自驱动的
`application.yml`(`dc3-driver-oracle` 模块)。
### 驱动属性(设备级 `driver-attribute`)
驱动属性回答"连到哪个库、用什么账号、用哪种连接方式、查询超时多久"。在[设备](../introduction/concepts/device)上为每个 Oracle
库填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------------|------------------|--------|-------------|----------------------------------------------|
| Host | `host` | STRING | `localhost` | Oracle 主机 IP 或主机名 |
| Port | `port` | INT | `1521` | Oracle 端口(标准 1521) |
| Database | `database` | STRING | (空) | Oracle 库名 |
| Username | `username` | STRING | `root` | Oracle 用户名 |
| Password | `password` | STRING | (空) | Oracle 密码 |
| Query Timeout | `queryTimeout` | INT | `30` | SQL 查询超时(秒) |
| Connection Type | `connectionType` | STRING | `SID` | 连接方式 `[SID, ServiceName]` |
| SID | `sid` | STRING | `ORCL` | Oracle SID(`connectionType=SID` 时生效) |
| Service Name | `serviceName` | STRING | (空) | Oracle 服务名(`connectionType=ServiceName` 时生效) |
驱动按 `connectionType` 拼出 JDBC URL:`SID` 时用 `sid` 拼成 `jdbc:oracle:thin:@host:port:sid`(默认 `sid=ORCL`),
`ServiceName` 时用 `serviceName` 拼成 `jdbc:oracle:thin:@//host:port/serviceName`。配置校验(`validate()`)要求 `host`、
`port`、`database`、`username`、`password`、`connectionType` 六项必填,缺任一项都不通过;`sid` / `serviceName` 哪个生效取决于
`connectionType`——见下方故障排查。驱动按设备 ID 缓存一个 HikariCP 连接池(一台设备一个池,最大 5 连接),连接超时取
`queryTimeout × 1000` 毫秒。
::: tip queryTimeout 同时作用于建连与查询节奏
`queryTimeout`(默认 30 秒)被设为连接池的 `connectionTimeout`:拿不到连接、或建连卡住超过这个时长就会失败。它与采集周期是两回事——慢
SQL 若长期逼近或超过它,应优化 SQL 或加索引,而不是一味调大超时。
:::
### 位号属性(`point-attribute`)
位号属性回答"查这个库里的哪一个值、往哪写"。每个采集[位号](../introduction/concepts/point)填它对应的读/写 SQL:
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|--------|-----|-------------------------------------------------|
| Read Query | `readQuery` | STRING | (空) | 读取位号值的 `SELECT` 查询 |
| Write Query | `writeQuery` | STRING | (空) | 写值用的 `UPDATE`/`INSERT`,用单个 `?` 占位符代表写入值(作为参数绑定) |
::: tip Read Query 取结果的第一行第一列
`readQuery` 是一条普通 `SELECT`,驱动取其结果**第一行的第一列**(`rs.getObject(1)`)作为该位号的值——所以写成
`SELECT temperature FROM sensor WHERE id = 1` 这种只返回单行单列的查询最稳妥。结果集为空时取到 `null`
。位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)决定这个值如何被解析。`readQuery` 是位号的必填项(
`validatePoint()` 强制),缺了位号校验不通过;`writeQuery` 仅在该位号要被写时才必填。
:::
### 写命令属性(`command-attribute`)
写命令上可以配这个属性,但当前并未被实现消费:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|-----|---------------|
| Execute Query | `executeQuery` | STRING | (空) | 命令要执行的 SQL 查询 |
::: warning executeQuery 当前未被实现消费
写值走的是**位号上的 `writeQuery`**:`write()` 取 `point-attribute` 的 `writeQuery`,把命令参数 `setString(1, value)`
绑进唯一的 `?` 占位符后执行 `UPDATE`/`INSERT`。`command-attribute` 上的 `executeQuery`
仅作为配置项保留,当前驱动代码中没有任何地方读取或执行它——不存在"按命令直接执行一段 SQL"的独立路径,写值一律走
`writeQuery`。以代码为准。
:::
## 故障排查
Oracle 接入失败大多集中在连接方式(SID/Service Name 选错)、网络、账号权限、查询定位几类。按下面顺序排查:
1. **连接方式选错(SID 与 Service Name 混填)**。`connectionType=SID` 时驱动只用 `sid` 拼 URL、忽略 `serviceName`;
`connectionType=ServiceName` 时只用 `serviceName`、忽略 `sid`,且此时 `serviceName` 为空会直接抛错(`getRequiredConfig`
)。先确认你的实例对外暴露的是 SID 还是服务名(`lsnrctl status` 可看监听注册的 service),按那种来填,别两个都填指望驱动自动挑。
2. **连不上库(设备一直 offline)**。先确认 `host:port` 可达:`telnet 1521` 或 `nc -vz 1521`。健康检查通过
`conn.isValid(5)`(5 秒内能否拿到有效连接)判定在线,建连或心跳失败即报 offline。常见根因:监听器(listener)未启动或未注册该实例、防火墙拦截
1521、容器网络不通。
3. **能连但被拒(账号/权限/实例名不符)**。确认 `username`/`password` 正确、账号未锁定且有 `CREATE SESSION` 权限。
`ORA-12505`/`ORA-12514` 通常是 SID 或 Service Name 写错(监听器收到了请求但找不到对应实例/服务);`ORA-01017`
是账号或口令错误。建连失败抛 `ConnectorException`,并使该设备的连接池失效、下个周期重建。
4. **查不到值 / 取到了错行**。驱动只取结果的第一行第一列,所以 `readQuery` 要能稳定定位到目标行。结果集为空会得到 `null`
;返回多行时只取到第一行,可能不是你要的那条。把 `WHERE` 主键条件写全,避免随表数据增长取到错行;如需明确取一行,可用
`WHERE ... AND ROWNUM = 1` 或 `FETCH FIRST 1 ROW ONLY`。
5. **数值/类型对不上**。位号的 `pointTypeFlag` 决定取回的字符串如何被解析。把一个文本列配成 `FLOAT` 位号、或把 `NUMBER`/
`DATE` 列直接当其它类型,都可能解析异常。`readQuery` 里用 `TO_CHAR`/`CAST` 或只选目标数值列,保证取回的值与位号类型相符。
6. **写命令返回失败**。写值要求 `writeQuery` 里恰有一个 `?` 占位符、且语句指向可写的表与行。`write()` 返回"受影响行数 > 0"
为成功——若 `WHERE` 条件没命中任何行,`executeUpdate()` 返回 0、写被判为失败。先用同样的 `UPDATE` 在库里手工跑一遍确认能命中行。写失败抛
`WritePointException` 并使连接池失效。
::: warning Connection Type 决定填 SID 还是 Service Name
`connectionType=SID` 时驱动用 `sid` 拼出 `jdbc:oracle:thin:@host:port:sid`(默认 `sid=ORCL`);`connectionType=ServiceName`
时改用 `serviceName` 拼出 `jdbc:oracle:thin:@//host:port/serviceName`,此时 `serviceName` 必填、缺了在建连前就直接抛错。两者各对应
Oracle 不同的命名方式,按你的实例实际暴露的那种来填。
:::
::: warning Write Query 用 `?` 占位符,不是 `${value}`
写值时 `writeQuery` 里用**一个** `?` 占位符代表写入值(如 `UPDATE sensor SET temperature = ? WHERE id = 1`),驱动通过
`PreparedStatement.setString(1, value)` 绑定——这是预编译参数绑定,不是字符串拼接,恶意值无法改写语句结构(无 SQL 注入)。不要在
SQL 里手动拼接值,也不要用 `${value}` 这类模板语法,那样既不会被替换、也失去防注入的好处。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`OracleDriver`(类型 `DRIVER_CLIENT`,主动连库并发起查询)。这是稳定的路由标识,不要随意改。
- **读能力**:✓ 已实现。`read()` 执行位号的 `readQuery`,取结果第一行第一列作为位号值。
- **写能力**:✓ 已实现。`write()` 执行位号的 `writeQuery`,用 `?` 预编译参数绑定写入值,受影响行数 > 0 即成功。
- **订阅/上报**:— 不支持。Oracle 是请求-响应模型,驱动只主动查/写、不被动接收推送。这与[驱动能力矩阵](./matrix)中 Oracle
的「✓ / ✓ / —」一致。
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮),在驱动 `application.yml` 的 `schedule.read` 配置;另有一个
`custom` 自定义调度默认 cron `0/5 * * * * ?`(每 5 秒),但基类的 `schedule()` 为空实现、数据库驱动不使用它。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`;判定靠 `conn.isValid(5)`
。在线状态机制见[设备](../introduction/concepts/device)。
::: info 实现状态:可用
本驱动是**完整实现**(非骨架)。Oracle 专属的 SID / Service Name 两种 JDBC URL 拼装、读、写、健康检查、按设备缓存的 HikariCP
连接池、失败时连接池失效重建均已落地,复用经测试的 `AbstractJdbcDriverCustomService` 基类。唯一需注意的差异是
`command-attribute` 上的 `executeQuery` 属性保留但未被代码消费——写值一律走位号的 `writeQuery`(见上方警告)。
:::
### 最小接入示例
把一张 `sensor` 表里 `id=1` 那行的 `temperature` 字段当温度位号采进来(用 SID 连一个 `ORCL` 实例):
1. 选 `Oracle Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.10`、`port=1521`、
`database=iot`、`username=root`、`password=******`、`connectionType=SID`、`sid=ORCL`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `readQuery=SELECT temperature FROM sensor WHERE id = 1`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到查到的温度值。
4. 若该位号需可写,给它的 point 属性补 `writeQuery=UPDATE sensor SET temperature = ? WHERE id = 1`
,再给它配写[命令](../introduction/concepts/command)。
5. 若你的实例走服务名,把 `connectionType` 改为 `ServiceName`、填 `serviceName`(不填 `sid`)即可。
::: tip 一个驱动实例可接多个库
同一个 Oracle 驱动进程可服务多台设备:每个设备按其 driver 属性各自连一个库、各占一个连接池(按设备 ID
缓存)。设备元数据被删除/更新时,对应连接池会被关闭并按需重建。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动入口与分类
- [驱动能力矩阵](./matrix) — 读/写/订阅能力一览,含 Oracle 行
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [时序数据与流处理](../foundations/data-pipeline) — 位号值进入平台后如何存储、计算与查询
- [MySQL 驱动](./mysql) — 同一 JDBC 基类的另一种数据库数据源
---
# PLC S7 驱动
URL: https://docs.dc3.site/zh/drivers/plcs7
# PLC S7 驱动
`dc3-driver-plcs7` 把西门子 S7 系列 PLC 接入 IoT DC3:它作为 S7 客户端通过 TCP 连到一台或多台
PLC,按[位号](../introduction/concepts/point)上配置的数据块号与偏移地址周期读值,并支持反向写值。读完这页,你能在设备上正确填好
`host` / `plcType` / `dbNum` 等属性,把一个 DB 变量接成可读可写的位号,并能定位最常见的连不上、读不到、写失败问题。
## 协议背景
S7(也叫 S7comm / ISO-on-TCP)是西门子 PLC(S7-200/300/400/1200/1500、S7-200 Smart 及 SINUMERIK 数控系统等)使用的私有以太网协议。它跑在
`ISO 8073 COTP` 之上、再封装进 TCP,标准端口 `102`,用于直接读写 PLC 内部存储区——尤其是工程师在 STEP 7 / TIA Portal 里定义的
**数据块(Data Block, DB)**。
从通信模型看,S7 是典型的**主从 / 请求-响应**协议:本驱动作为主站(client)主动发起连接、轮流向各 PLC 发读写请求,PLC
被动应答,自己不会主动上报。寻址采用「DB 号 + 字节偏移(+ 位偏移)」的数字地址方式,靠工程约定对齐——这意味着接入前你必须从 PLC
程序里查清每个变量落在哪个 DB、哪个字节。
在物联网四层架构里,S7 属于**网络层**的工业有线侧:它解决的是「PLC
内部的数据怎么经以太网被外部系统读到」这最后一公里。关于工业总线协议的通信模型、寻址方式与字节序权衡,见[工业总线与协议](../foundations/fieldbus)。
一个驱动进程可同时连多台 PLC,连接按 `deviceId` 复用,每台 PLC 由各自[设备](../introduction/concepts/device)上的 `host` 与
`plcType` 区分。
## 属性配置
S7 没有独立的「写命令属性」(`application.yml` 里没有 `command-attribute`)。所有寻址信息分两层:**驱动属性**定位「连哪台 PLC」,
**位号属性**定位「读 PLC 里的哪个变量」。下面两张表的字段、类型与默认值均来自驱动的 `application.yml`。
### 驱动属性(设备级 `driver-attribute`)
接入一台 S7 PLC 设备时,在[设备](../introduction/concepts/device)
上为这些[属性](../introduction/concepts/attribute-config)填值。`host` / `port` 共同决定连接目标,`plcType` 决定驱动用哪套
S7 寻址方案去解析 DB 地址与字节序。
| 属性 | code | 类型 | 默认值 | 说明 |
|----------|-----------|--------|----------------|---------------------|
| Host | `host` | STRING | `192.168.0.20` | PLC 的 IP 地址 |
| Port | `port` | INT | `102` | S7 TCP 端口,标准为 `102` |
| PLC Type | `plcType` | STRING | `S1200` | PLC 型号,取值见下 |
::: tip plcType 决定地址解析方式
不同型号的 S7 PLC 在 DB 寻址细节与数据排布上有差异,`plcType` 用来选对应的 S7 寻址方案。合法取值来自底层 `EPlcType` 枚举:
`S200` / `S200_SMART` / `S300` / `S400` / `S1200` / `S1500` / `SINUMERIK_828D`。常见对应:S7-1200 填 `S1200`、S7-1500 填
`S1500`、S7-200 Smart 填 `S200_SMART`。填错或填了枚举外的值时,驱动会打印告警并回退到 `S1200`。
:::
### 位号属性(`point-attribute`)
每个[位号](../introduction/concepts/point)定位 PLC 数据块里的一个变量。驱动把这三项拼成 S7 地址字符串后交给底层库读写——非布尔位号拼成
`DB{dbNum}.{byteOffset}`,仅当位号是布尔且 `bitOffset > 0` 时才拼成 `DB{dbNum}.{byteOffset}.{bitOffset}`。
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|-----|-----|-------------------------|
| DB Number | `dbNum` | INT | `0` | 数据块号,从 0 开始计 |
| Byte Offset | `byteOffset` | INT | `0` | 数据块内的字节偏移 |
| Bit Offset | `bitOffset` | INT | `0` | 字节内的位偏移(仅布尔位号且 > 0 时生效) |
::: tip 位号类型决定读多少字节
读写宽度由位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)从 `byteOffset` 起决定,不需要额外配置:
`BOOLEAN` 取一个 bit,`BYTE` 1 字节,`SHORT` 2 字节,`INT`/`FLOAT` 4 字节,`LONG`/`DOUBLE` 8 字节,`STRING` 读字符串。所以同一个
DB 偏移配成不同类型的位号会读出不同宽度的值。
:::
::: warning bitOffset 只对布尔位号且非零时才生效
驱动只在「类型为布尔 **且** `bitOffset > 0`」时才走位寻址(地址带第三段 `.bit`);其余情况——非布尔类型、或布尔但 `bitOffset=0`
——一律按 `DB{dbNum}.{byteOffset}` 做字节级寻址。给一个浮点位号填 `bitOffset` 不会报错也不起作用;要跨字节请改 `byteOffset`
,不要用 `bitOffset`「跳字节」。
:::
写位号时复用它自己的位号属性(`dbNum` / `byteOffset` / `bitOffset`)定位地址,写入宽度由命令携带的值类型决定。一个可写位号配好这三项后即可读可写,无需再额外配置写命令。
## 故障排查
接入 S7 时,下面这些是最常踩的坑,多数是 PLC 侧设置而非驱动问题。
::: danger PLC 侧必须放开 PUT/GET 访问
S7-1200/1500 默认**禁止**外部 PUT/GET 通信,这是接入失败最常见的原因。需在 TIA Portal 的 CPU 属性里勾选「允许来自远程对象的
PUT/GET 通信访问」。否则 TCP 连接能建立、但每次读写都会被 PLC 拒绝。
:::
::: warning DB 必须关闭「优化的块访问」
被读写的数据块若开启了「优化的块访问(Optimized block access)」,变量在 DB 内不再有固定字节偏移,按 `byteOffset` 定位会读到错值或失败。在
TIA Portal 里选中该 DB → 属性 → 取消勾选「优化的块访问」,编译下载后偏移地址才稳定可用。
:::
- **端口 `102` 不通**:S7 走 TCP `102`,确认 PLC IP(`host`)可达、防火墙未拦截、`port` 未被改成非标值。可先用 `ping` 与
`telnet 102` 验证链路再排查驱动。
- **`plcType` 选错导致地址解析异常**:型号填错会回退到 `S1200`,对 S7-300/400/1500 可能读出错位的值。日志里出现
`Unknown plcType ... fallback to S1200` 即说明取值不在枚举内,按上表改对。
- **读到的数值不对(字节序 / 偏移)**:先确认 DB 已关闭优化块访问、`dbNum` 与 `byteOffset` 与 PLC 程序里的变量地址逐一对应;再确认位号
`pointTypeFlag` 与 PLC 端变量类型宽度一致(如 PLC 是 `Real` 就用 `FLOAT`、`DInt` 用 `INT`)。
- **设备一直离线 / 频繁重连**:读或写一旦抛异常,驱动会主动作废该连接(`invalidateConnection`)并在下次访问时重建。若反复重连,多半是
PLC 侧拒绝、网络抖动或 PLC 负载过高;结合驱动日志里的 `Driver connection failed` / `read failed`
定位。在线状态与租约超时机制见[设备](../introduction/concepts/device)。
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`PlcS7Driver`(驱动名 `PLC S7 Driver`,类型 `DRIVER_CLIENT`)。这是稳定的路由标识,不要随意改。
- **读**:支持。默认采集 cron `0/30 * * * * ?`(每 30
秒读一轮),逐位号按地址读取后封装为[位号值](../introduction/concepts/point-value)。
- **写**:支持。复用位号属性定位地址,按命令值类型写回 PLC。
- **订阅 / 上报**:不提供。S7 是主从轮询模型,驱动不监听 PLC 主动推送——与[驱动能力矩阵](./matrix)中「S7:读 ✓ / 写 ✓ / 订阅
—」一致。
- **健康检查**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45` 秒。
::: info 实现状态:可用
`PlcS7DriverCustomServiceImpl` 的 `read()` / `write()` / `initial()` / `event()` / `validate()` 均已完整实现,底层基于
`iot-communication`(`S7PLC`)库,连接开启自动重连、按 `deviceId` 复用并加 `ReentrantLock` 串行化读写。这是一个可用驱动,非骨架。
:::
最小接入示例:把 IP `192.168.0.20:102` 的一台 S7-1200 接进来,读 DB1 偏移 0 处的一个 32 位浮点:
1. 选 `PLC S7 Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.0.20`、`port=102`、
`plcType=S1200`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`),位号属性填 `dbNum=1`、`byteOffset=0`、`bitOffset=0`。
3. 确认 PLC 已放开 PUT/GET 且该 DB 关闭优化块访问,启动驱动,30 秒内即可在[位号值](../introduction/concepts/point-value)
里看到采集值。
## 延伸阅读
- [驱动总览](./index) — 全部驱动的分类与选型入口
- [驱动能力矩阵](./matrix) — 各驱动读 / 写 / 订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — S7 所属网络层的通信模型、寻址与字节序
- [Melsec 驱动](./melsec) — 三菱 PLC 以太网驱动,同属工业总线 / PLC 类
---
# PostgreSQL 驱动
URL: https://docs.dc3.site/zh/drivers/postgresql
`dc3-driver-postgresql` 把一个 PostgreSQL 数据库当作数据源接入 IoT DC3:按采集周期执行 `SELECT` 把查到的值当采集值,并支持用位号上配置的
`UPDATE`/`INSERT` 写查询向库里写值。读完你能把已有库表里的字段当位号采进平台,并理解读/写两条 SQL 路径各自的边界。
## 协议背景
不是所有数据都来自现场协议设备。很多业务数据、历史数据、第三方系统沉淀的结果,本身就躺在一张 PostgreSQL 表里——MES
的工单状态、计量系统结算后的累计量、上游平台只对外开放的一个数据库视图。这类数据没有 Modbus、OPC UA
这样的现场总线协议可走,但它们确实是设备世界的延伸,需要被纳入统一的位号体系来管理和消费。
PostgreSQL 是一套开源的对象-关系型数据库(ORDBMS),以严格的 SQL 标准遵循度、强事务(MVCC)、丰富的数据类型(JSON/JSONB、数组、范围、几何)著称,默认监听
`5432` 端口,客户端通过标准 JDBC(`org.postgresql.Driver`)连接。把它接入物联网平台,本质上是把"数据库当成一类设备"
:一张表的一行一列,就是一个位号的取值来源。
在物联网四层参考架构里,数据库桥接驱动横跨网络层与平台层的边界——它不解析任何现场总线报文,而是把"已经落到库里的数据"
以位号的形式重新接入采集管线。理解这条路径如何与时序存储、流处理衔接,见[时序数据与流处理](../foundations/data-pipeline)。
::: info 与现场协议驱动的本质差异
Modbus、OPC UA 这类驱动面对的是"物理量在线"——寄存器实时变化、读到的就是此刻的现场值。数据库驱动面对的是"数据已落库"
——你读到的是某个上游系统写入的、可能已经过期的快照。把数据库字段当位号采集时,采集周期与上游写库节奏要对得上,否则会反复采到同一个旧值。
:::
## 属性配置
接入分三类属性:**驱动属性**(设备级,定位到哪个库、用什么账号)、**位号属性**(每个位号的读/写 SQL)、**写命令属性**
(保留项,当前未生效)。所有属性的默认值都来自驱动的 `application.yml`
,在[设备](../introduction/concepts/device)/[位号](../introduction/concepts/point)
上为它们填具体值即可,三层来历见[属性与配置](../introduction/concepts/attribute-config)。
### 驱动属性(设备级 `driver-attribute`)
把一个 PostgreSQL 库接进来时,在[设备](../introduction/concepts/device)
上填这些[属性](../introduction/concepts/attribute-config)。它们决定连到哪个库、用什么账号、连接超时多久:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|-------------|------------------------------------------------|
| Host | `host` | STRING | `localhost` | PostgreSQL 主机地址 |
| Port | `port` | INT | `5432` | PostgreSQL 端口 |
| Database | `database` | STRING | (空) | 要连接的数据库名 |
| Username | `username` | STRING | `root` | 连接账号 |
| Password | `password` | STRING | (空) | 账号密码 |
| Query Timeout | `queryTimeout` | INT | `30` | 连接获取超时(秒),同时作为 Hikari 连接池的 `connectionTimeout` |
驱动用 `host`、`port`、`database` 拼出 JDBC URL(形如 `jdbc:postgresql://host:port/database`)。设备配置校验(`validate()`
)会逐项检查 `host`、`port`、`database`、`username`、`password` 五项是否填写——任一为空都不通过;`queryTimeout` 非必填,缺省按
`30` 秒。每个设备在驱动内对应一个独立的 HikariCP 连接池(`maximumPoolSize=5`、`minimumIdle=1`),按设备 ID 缓存复用。
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填它对应的读/写 SQL:
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|--------|-----|----------------------------------------------|
| Read Query | `readQuery` | STRING | (空) | 读取位号值的 `SELECT` 语句 |
| Write Query | `writeQuery` | STRING | (空) | 写值用的 `UPDATE`/`INSERT`,用单个 `?` 占位待写入值(按参数绑定) |
::: tip Read Query 取结果的第一行第一列
`readQuery` 是一条普通 `SELECT`,驱动执行后取结果集**第一行的第一列**(`rs.getObject(1)`)作为该位号的值,再 `toString()`
交给位号按其数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)解析。所以写成
`SELECT temperature FROM sensor WHERE id = 1` 这种只返回单行单列的查询最稳妥。`readQuery` 是位号的必填项,位号校验(
`validatePoint()`)缺它不通过。
:::
### 写命令属性(`command-attribute`)
写命令上保留了一个属性,但当前不被实现消费:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|-----|-----------------|
| Execute Query | `executeQuery` | STRING | (空) | 命令执行用的 SQL(保留项) |
::: warning `executeQuery` 当前未被实现消费
写值实际走的是位号上的 `writeQuery`:驱动的 `write()` 取 `point-attribute` 的 `writeQuery`,用预编译参数绑定执行 `UPDATE`/
`INSERT`。`command-attribute` 上的 `executeQuery` 仅作为配置项保留,当前驱动代码中没有任何地方读取或执行它——不存在"
按命令直接执行一段 SQL"的独立路径。需要写值时,请把 SQL 配在位号的 `writeQuery` 上,配 `executeQuery` 不会生效。
:::
### 采集与健康
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮)。
- **自定义周期**:驱动还有一个 custom 调度,默认 cron `0/5 * * * * ?`(每 5 秒);JDBC 数据库驱动的 `schedule()`
为空实现,该调度当前不做任何事。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`。健康检查通过从连接池取一条连接、`conn.isValid(5)`
判断库是否可达——连不上则设备置为离线。在线状态机制见[设备](../introduction/concepts/device)。
## 故障排查
数据库桥接接入失败,多半不在协议本身,而在连接参数、SQL 形态或上游库的状态。以下按出现频率排列:
::: warning 设备一直离线 / 连接池建不起来
健康检查靠 `conn.isValid(5)` 判定。若设备始终离线,先确认 `host`/`port` 可达(容器内连宿主机别用 `localhost`)、`database`
名大小写正确、账号 `username`/`password` 有该库的登录权限。PostgreSQL 默认还受 `pg_hba.conf` 约束——来源 IP
或认证方式不被允许时会直接拒连,这类错误不在驱动侧,要去库端放行。
:::
::: warning Read Query 必须只读且能定位到单值
驱动只取 `readQuery` 结果的第一行第一列,所以查询要返回单行单列、且带 `WHERE`
主键条件稳定定位目标行。返回多行/多列时只会取到第一个,可能不是你想要的那条;查询无结果时位号值为 `null`。别在 `readQuery`
里写 `UPDATE`/`DELETE`——采集是只读路径,写进去会污染数据。把过滤条件写全,避免随表数据变化取到错行。
:::
::: warning Write Query 用 `?` 占位符,不是 `${value}`
写值时 `writeQuery` 用单个 `?` 代表写入值(如 `UPDATE sensor SET temperature = ? WHERE id = 1`),驱动用
`ps.setString(1, value)` 把命令参数作为 JDBC 参数绑定——这是预编译参数绑定,不是字符串拼接,天然防 SQL 注入。不要手动拼接值,也不要用
`${value}` 这类模板语法:那样既不会被替换、也失去防注入的好处。写操作以"受影响行数 > 0"判成功,`UPDATE` 命中 0 行(`WHERE`
没匹配到)会被当作写失败。
:::
::: tip 库名 / 表名大小写按 PostgreSQL 规则
PostgreSQL 对不加引号的标识符会折叠成小写,加双引号则按字面大小写匹配。`database` 填错大小写连不上库;`readQuery`/
`writeQuery` 里表名、列名大小写与实际建库时不符也会报"不存在"。按真实大小写填,必要时在 SQL 里用双引号包住标识符。
:::
::: tip 查询超时 / 慢 SQL
`queryTimeout`(默认 30 秒)被用作 Hikari 连接池的 `connectionTimeout`,即"取不到连接时的等待上限"。涉及大表或慢 SQL 时,应优化
SQL、加索引、缩小 `WHERE` 范围,而不是一味调大超时——慢查询会占住连接池(仅 5 条连接),拖累整批位号的采集。
:::
## 在 IoT DC3 中如何落地
- **驱动名 / code**:`PostgreSQL Driver` / `PostgresqlDriver`(`dc3.driver.code` 是稳定路由标识,平台内据它路由消息,不可随意改)。
- **类型**:`DRIVER_CLIENT`——驱动主动连库并发起查询,不监听外部推送。
- **读 / 写 / 订阅能力**:与[驱动能力矩阵](./matrix)一致——读 ✓、写 ✓、订阅 —。读走 `SELECT` 取首值,写走 `writeQuery`
的预编译绑定;数据库没有变更订阅,靠周期轮询拉取。
::: info 实现状态:可用(非骨架)
PostgreSQL 驱动是**可用实现**,不是骨架。连接、读、写、健康检查均由共享的 `dc3-common-sql` 抽象基类
`AbstractJdbcDriverCustomService` 落地(与 MySQL、Oracle、SQL Server 共用同一套逻辑),PostgreSQL 子类只提供 JDBC URL 拼装、驱动类名
`org.postgresql.Driver` 和默认端口 `5432`。唯一需要留意的是 `command-attribute` 的 `executeQuery` 属性保留但未接线(见上文属性配置)。
:::
### 最小接入示例
把一张 `sensor` 表里 `id=1` 那行的 `temperature` 字段当温度位号采进来:
1. 选 `PostgreSQL Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.10`、`port=5432`、
`database=iot`、`username=root`、`password=******`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `readQuery=SELECT temperature FROM sensor WHERE id = 1`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到查到的温度值。
需要可写位号时,再在同一位号的 `writeQuery` 上配 `UPDATE sensor SET temperature = ? WHERE id = 1`,并把位号 `rwFlag`
设为可写。一次完整流程见[设备接入](../operation/device-onboarding)。
## 延伸阅读
- [驱动总览](./index) — 全部驱动的分类与选型地图
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [时序数据与流处理](../foundations/data-pipeline) — 采到的位号值如何落到时序库、被流处理消费
- [MySQL 驱动](./mysql) — 另一种 JDBC 数据库数据源,配置结构完全一致
---
# Redis 驱动
URL: https://docs.dc3.site/zh/drivers/redis
`dc3-driver-redis` 把 Redis 当作数据源:读 STRING 键或 HASH 字段,写 SET/HSET。
## 协议背景
Redis 是内存键值存储,本驱动把位号映射到键(HASH 时可选字段),按周期轮询。
驱动 code:RedisDriver,类型:DRIVER_CLIENT,底层库:Spring Data Redis
## 属性配置
### 驱动属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| (连接) | spring.data.redis.* | — | 环境变量 | 通过 Spring Boot 配置 |
### 位号属性
| 属性 | code | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| Key | key | STRING | (空) | 待读写键 |
| Data Type | dataType | STRING | STRING | STRING 或 HASH |
| Field | field | STRING | (空) | Hash 字段 |
## 能力矩阵
| 能力 | 支持 |
|------|------|
| 读 | ✓ |
| 写 | ✓ |
| 订阅 | — |
::: info 实现状态:可用 :::
连接通过 spring.data.redis.*(REDIS_HOST/REDIS_PORT)配置。
## 最小接入示例
1. 选 Redis 驱动创建设备。
2. 加位号(READ_ONLY),填 key=counter:total、dataType=STRING。
3. 启动驱动,30 秒内即可看到采集值。
## 延伸阅读
- [驱动总览](./index)
- [驱动能力矩阵](./matrix)
- [设备接入](../operation/device-onboarding)
---
# 串口 驱动
URL: https://docs.dc3.site/zh/drivers/serial
# 串口 驱动
`dc3-driver-serial` 把跑私有报文的 RS232/RS485/RS422 串口设备接入 IoT
DC3:作为串口主站,按[位号](../introduction/concepts/point)上配的 HEX
指令周期性发送、读回原始字节,再按帧头/帧尾、校验、数据偏移与格式解析成值,并支持向设备写值的命令。读完本页你能为一台"
发一串字节、回一串字节"的串口设备配好线路参数与帧解析规则,并知道接不上时从哪儿查。
## 协议背景
串口(Serial)是工业现场最朴素也最通用的连接方式。**RS232** 点对点、**RS485/RS422** 总线广泛用于仪表、变送器、电表、扫码枪、PLC
串口模块等。它只规定了物理层与链路层"用什么电平、几根线、按什么波特率收发字节",**不规定字节里装什么**——很多设备并不跑
Modbus 这类标准协议,而是厂商自定义的私有报文:发一串固定字节,回一串固定结构的字节。
在物联网四层架构里,串口属于**网络层**:它解决的是现场设备与采集主站之间"用哪种信号、按什么线路参数交换字节"
的问题,而不关心这些字节的业务语义。本驱动正是把这条原始字节通道补成一个可配置的协议适配器——你用 HEX 指令描述"发什么"
,用帧头/帧尾/偏移/长度描述"回包怎么切",用校验类型与数据格式描述"切出来的字节怎么校验、怎么解码"
。关于寻址、字节序、轮询模型这些网络层通用背景,见[物联网网络层:工业总线与协议](../foundations/fieldbus)。
- **驱动名 / code**:`Serial Port Driver` / `SerialDriver`
- **类型**:`DRIVER_CLIENT`(主动打开串口、轮询设备)
- **底层库**:jSerialComm(每台[设备](../introduction/concepts/device)一条独立串口连接,按设备 ID 缓存)
::: tip 先认识两个词
**HEX 指令**:用十六进制书写的字节串,如 `01 03 00 00 00 0A C5 CD`;空格只为可读,对应实际发送的字节(驱动会去掉空格与 `-`
再解析)。**帧(Frame)**:设备回复的一整段字节,通常由帧头、数据区、可选校验、帧尾拼成;本驱动靠帧头/帧尾与偏移/长度从回包里"切"
出真正的数据区,再按格式解码。
:::
## 属性配置
串口的配置分三层:**驱动属性**(`driver-attribute`,设备级)描述这条串口怎么打开(线路参数);**位号属性**(`point-attribute`
)描述每个采集点发什么、回包怎么拆、按什么格式解码;**命令属性**(`command-attribute`)描述可写位号往哪儿写。下面三张表的默认值与说明均来自驱动
`application.yml`,属性的三层来历见[属性与配置](../introduction/concepts/attribute-config)。
### 驱动属性(设备级 `driver-attribute`)
接入一台串口设备时,在[设备](../introduction/concepts/device)上填这些线路参数。它们会原样传给 jSerialComm 打开串口,因此*
*必须与设备的串口设置逐一对上**——串口没有握手协商,任一项不匹配只会读到乱码或超时:
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|------------|--------|----------------|----------------------------------------------------------|
| Serial Port | `port` | STRING | `/dev/ttyUSB0` | 串口设备路径(如 /dev/ttyUSB0、COM3) |
| Baud Rate | `baudRate` | INT | `9600` | 波特率(1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200) |
| Data Bits | `dataBits` | INT | `8` | 数据位(5, 6, 7, 8) |
| Stop Bits | `stopBits` | INT | `1` | 停止位(1, 2) |
| Parity | `parity` | INT | `0` | 校验位(0=无, 1=奇, 2=偶) |
| Timeout | `timeout` | INT | `1000` | 读超时(毫秒),到点仍未读够即返回已收字节 |
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填三组:发什么(`sendCommand`/`receiveLength`)、回包怎么拆(`frameHeader`/
`frameFooter`/`dataOffset`/`dataLength`/`checksumType`)、按什么格式解码(`dataFormat`/`byteOrder`)。其中只有 `sendCommand`
是必填,其余留空则取默认值:
| 属性 | code | 类型 | 默认值 | 说明 |
|----------------|-----------------|--------|--------|---------------------------------------------|
| Send Command | `sendCommand` | STRING | (空) | 要发送的 HEX 指令(如 `01 03 00 00 00 0A C5 CD`),必填 |
| Receive Length | `receiveLength` | INT | `0` | 预期回包字节数;0=读到超时/帧间空闲为止,>0=精确读这么多字节 |
| Frame Header | `frameHeader` | STRING | (空) | HEX 帧头(如 `01 03`),用于在回包中定位帧起点 |
| Frame Footer | `frameFooter` | STRING | (空) | HEX 帧尾(如 `0D 0A`),用于定位帧终点 |
| Data Offset | `dataOffset` | INT | `0` | 数据区起点偏移(**相对帧头之后**;无帧头时相对帧起始) |
| Data Length | `dataLength` | INT | `0` | 数据区字节长度(0=取到帧尾/校验区之前) |
| Checksum Type | `checksumType` | STRING | `NONE` | 回包校验类型:NONE, CRC16, XOR |
| Data Format | `dataFormat` | STRING | `HEX` | 数据格式:HEX, ASCII, BINARY, FLOAT |
| Byte Order | `byteOrder` | STRING | `BIG` | 字节序:BIG, LITTLE |
::: tip 解析顺序:先定位帧、再校验、最后解码
驱动收到回包后,按 `parseResponse` → `SerialFrameParser.parse` 依次执行:① 用 `frameHeader` 找帧起点(`indexOf`,找不到报错)、用
`frameFooter` 从帧头之后找帧终点(`lastIndexOf`);② 从帧头之后再加 `dataOffset` 得到数据区起点,按 `checksumType`
在帧尾前留出校验区(CRC16 占 2 字节、XOR 占 1 字节);③ 若 `checksumType≠NONE`,对帧头之后到数据区结束的字节算校验并与回包中的校验字节比对,不符则报错;④
按 `dataLength`(0=取到校验区之前)切出数据区,按 `dataFormat`+`byteOrder`
解码。位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)应与 `dataFormat` 对得上:`BINARY`/`FLOAT`
下 1/2/4/8 字节会按 `byteOrder` 拼为整数或浮点(`FLOAT` 用 4 字节单精度、8 字节双精度),长度不在 1/2/4/8 之列时退化为 HEX
字符串。
:::
### 命令属性(`command-attribute`)
可写位号在写命令上填:
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------|---------------|--------|------------|----------------------------|
| Send Command | `sendCommand` | STRING | `${value}` | 带 `${value}` 占位符的 HEX 指令模板 |
| Byte Order | `byteOrder` | STRING | `BIG` | 编码写入值的字节序:BIG, LITTLE |
写入时,驱动把命令参数渲染进 `sendCommand` 模板的 `${value}` 位置,按 HEX 转成字节后整帧发出,不读回包。
::: warning 写命令的 byteOrder 当前未参与编码
`command-attribute` 列了 `byteOrder`,但 `write()` 的实现只做了 `sendCommand.replace("${value}", value)` 的字符串替换,再把整条结果当作
HEX 解析发出——`${value}` 必须是一段合法 HEX 文本,`byteOrder` 不会对它做大小端转换。要写一个多字节数值,请在上层把它编码成正确字节序的
HEX 字符串再作为命令参数传入。
:::
### 采集与健康
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒轮询一轮全部位号)。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`;驱动以"该设备的串口是否已打开(
`SerialPort.isOpen()`)"判定设备在线/离线——在线状态机制见[设备](../introduction/concepts/device)。
- **自定义任务**:yml 里有 `custom` 调度(`0/5 * * * * ?`),但驱动的 `schedule()` 为空实现,串口驱动不需要自定义周期任务。
## 故障排查
::: warning sendCommand 的校验字节要自己算对
本驱动**不替你补 CRC/校验**——`sendCommand` 是原样发送的整条字节串,若设备要求 Modbus CRC
或其它校验,校验字节必须由你算好写进指令里。算错的话,设备要么不回包,要么回异常帧,表现为读到空响应或解析报错。注意
`checksumType` 只用于校验**回包**,绝不会改写你发出的指令。
:::
::: warning dataOffset 从"帧头之后"算,不是从整帧第 0 字节
源码里数据区起点是 `start + dataOffset`,而 `start` 是**帧头之后**的位置(配了 `frameHeader` 时已跳过帧头长度)。所以:配了帧头
`01 03` 时,`dataOffset` 是相对 `01 03` 之后那一字节计的;没配帧头时,`start=0`,`dataOffset` 才相对整帧起始。把"
帧头也算进偏移"会多跳几个字节、解出错误数据。最稳妥的做法是二选一:要么只用 `frameHeader` 定位、`dataOffset` 填帧头之后的偏移;要么不配
`frameHeader`、`dataOffset` 直接从第 0 字节数。
:::
::: warning 找不到帧头/帧尾会直接报错
配了 `frameHeader` 但回包里没有这段字节,`parse()` 抛 `Frame header not found`;`frameFooter` 同理。回包被截断(`timeout`
太短、`receiveLength` 配大了读不够)也会导致帧头/帧尾缺失或数据区为空(`No data region in serial frame`)。先把
`dataFormat=HEX` 打出原始回包,确认帧结构与配置一致,再逐项收紧。
:::
::: warning 校验类型配错会判回包"校验失败"
`checksumType=CRC16`(本驱动用 Modbus CRC16,多项式 `0xA001`、低字节在前)或 `XOR` 时,驱动会对"帧头之后到数据区结束"
的字节重算校验并与回包中的校验字节比对,不符则抛 `checksum mismatch`。若设备的校验范围或算法与此不同(如校验含帧头、或用别的多项式),请改回
`NONE` 由上层自行校验,避免好数据被误判丢弃。无法识别的校验名(非 NONE/CRC16/XOR)会被当作 `NONE` 处理。
:::
::: tip 串口是独占资源,注意占用与权限
一个驱动进程按设备 ID 缓存独立串口连接,可同时服务挂在不同 `port` 的多台设备;但同一物理串口同一时刻只能被一个进程打开。确认该
`port` 没有被串口助手、其它驱动进程占用;Linux 上还需运行用户对 `/dev/ttyUSB*` 有读写权限(常见做法是把用户加入 `dialout`
组)。读/写失败时驱动会主动关闭并移除该连接(`invalidateConnector`),下一轮采集重新打开。
:::
::: tip 一根 RS485 总线上的多台设备各建一台 Device
同一物理串口(RS485 总线)上的多台设备需各自建一台[设备](../introduction/concepts/device)、指向**相同的 `port`**,用各自的
`sendCommand`(含不同站号)区分,驱动按位号顺序轮询发指令。不要给同总线的不同设备配不同 `port`。
:::
## 在 IoT DC3 中如何落地
无论底层报文多私有,落到平台都收敛为同一个[位号 Point](../introduction/concepts/point)
的[位号值 PointValue](../introduction/concepts/point-value)。串口驱动以 `dc3.driver.code = SerialDriver`
注册,这是稳定的路由标识,平台据此把读/写命令分发到本驱动。
按[驱动能力矩阵](./matrix),本驱动的能力为:
| 能力 | 支持 | 实现说明 |
|----|----|--------------------------------------------------|
| 读 | ✓ | `read()` 发 `sendCommand`、读回包、按帧结构切出数据区并解码 |
| 写 | ✓ | `write()` 渲染 `${value}` 模板、转字节整帧发出(不读回包、不做字节序编码) |
| 订阅 | — | 主从轮询模型,设备不主动上报,靠采集周期定时读 |
::: info 实现状态:可用
`SerialDriverCustomServiceImpl` 的 `initial()`/`read()`/`write()`/`health()`/`event()` 与帧解析(`SerialFrameParser`
)、连接管理(`SerialPortConnection`,基于 jSerialComm)均已完整实现,非骨架。读路径支持 HEX/ASCII/BINARY/FLOAT 解码与
CRC16/XOR/NONE 回包校验;连接按设备缓存,设备元数据 UPDATE/DELETE 事件会销毁旧连接(`connectMap.remove` 后 `close()`)。
:::
## 最小接入示例
接入一台挂在 `/dev/ttyUSB0`、9600-8-N-1 的温度变送器,发查询指令后回包为 `01 03 02 <数据 2 字节> `:
1. 选 `Serial Port Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `port=/dev/ttyUSB0`、
`baudRate=9600`、`dataBits=8`、`stopBits=1`、`parity=0`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(`READ_ONLY`
),point 属性填 `sendCommand=01 03 00 00 00 01 84 0A`、`dataOffset=3`、`dataLength=2`、`dataFormat=BINARY`、
`byteOrder=BIG`(不配 `frameHeader`,`dataOffset` 从回包第 0 字节数起,跳过 `01 03 02` 三个字节,正好落在数据区)。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到采集值。
::: tip 跑标准 Modbus RTU 优先用专用驱动
本驱动是"通用串口透传",适合私有报文或需要逐字节控制帧的场景。如果设备跑的是标准 Modbus
RTU(功能码、CRC、寻址都规范),用 [Modbus RTU 驱动](./modbus-rtu)更省事——它替你拼帧、补 CRC、按功能码寻址,无需手写
`sendCommand`。
:::
## 延伸阅读
- [驱动总览](./index) — 全部协议驱动与选型入口
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力速查
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — 网络层:寻址、字节序、轮询模型的通用背景
- [Modbus RTU 驱动](./modbus-rtu) — 跑标准 Modbus 协议的串口驱动
---
# SL651 驱动
URL: https://docs.dc3.site/zh/drivers/sl651
# SL651 驱动
`dc3-driver-sl651` 把 SL651-2014 水文遥测站接入 IoT DC3:它监听一个 TCP
端口,被动接收远端测站主动上报的遥测报文,按位号配置取出报文体里指定位置的要素值并转成[位号值](../introduction/concepts/point-value)
。读完本页,你能理解它和轮询型驱动的差别,正确填写驱动 / 位号属性,并排查"收到报文却没有位号值"这类典型问题。
## 协议背景
SL651-2014 是中华人民共和国水利行业标准《水文监测数据通信规约》,用于雨量站、水位站、流量站等遥测终端(RTU)与中心站之间的数据通信。它的典型用途是流域水情、城市内涝、水库大坝、灌区量水等场景的远程遥测——测站分散在野外,靠
GPRS / 4G / 北斗等链路把定时采集或事件触发的水文要素回传到中心。
和 Modbus 那种"主站主动去读从站"的现场总线不同,SL651 是**服务端协议**:分散在各处的测站按自己的时段(整点、加报、报警等),把采集到的遥测数据
**主动推送**给一个中心服务器。本驱动就是那个中心服务器——它启动一个 SL651 TCP
服务监听端口,等测站连上来上报,解析报文体里的遥测要素,用报文头里的测站地址匹配到对应[设备](../introduction/concepts/device)
,再把指定要素转成位号值。
在物联网四层架构里,SL651 属于**网络层**
:它定义了测站终端如何把感知层采集的数据,经广域链路传输汇聚到平台。它不规定传感器怎么测(感知层),也不规定平台怎么存储与分析(应用层),只约定报文帧结构、功能码、要素编码与上下行交互。要理解它在整体协议谱系里的位置、以及"
服务端被动收报文"这种通信模型与轮询模型的取舍,见 [物联网网络层章节](../foundations/fieldbus)。
::: info 这是监听型驱动,不是轮询型
因为数据是测站**异步、主动**送上来的,SDK 的 `read` / `write` 不参与采集——按设计 `read` 返回 `null`、`write` 返回 `false`
。定时读取被关闭(`schedule.read.enable: false`),驱动只保留一个内部 `schedule.custom` cron 和设备健康检查;真正的采集由测站上报触发。
:::
::: tip 两个核心名词
- **测站地址(station address)**:SL651 报文头里标识上报方的地址。驱动取报文头 `getRemoteStationAddress()`
的字节并转成十六进制大写串,用来和设备的 `deviceCode` 或 `deviceName` 做匹配(不区分大小写)。
- **遥测要素(element)**:一份上报报文体里按顺序排列的一组测量值(如水位、雨量、电压……)。驱动把所有报文体的
`getBodyElements()` 汇成一个有序列表,位号靠下标 `index` 从中取一个。
:::
## 属性配置
接入一台 SL651 测站,需要在两个层面填属性:**驱动属性**(`driver-attribute`,决定服务端怎么监听)与**位号属性**(
`point-attribute`,决定从报文体取哪个要素)。这些属性都来自驱动的 `application.yml`,对应代码里的 `dc3.driver.sl651.port` /
`dc3.driver.sl651.pwd` 配置项与位号配置 `index`。属性的取值在[设备](../introduction/concepts/device)
实例上填写,三层来历见[属性与配置](../introduction/concepts/attribute-config)。
### 驱动属性(设备级 `driver-attribute`)
`port` 决定整个驱动进程在哪个 TCP 端口上启动 SL651 服务端;`pwd` 是创建 `SL651Server`
时传入的鉴权密码,用于测站接入时的口令校验。两者都有默认值,未填时按默认走。
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|--------|--------|--------|---------------------|
| Listen Port | `port` | INT | `5001` | SL651 服务端监听的 TCP 端口 |
| Auth Password | `pwd` | STRING | `0000` | 远端测站接入鉴权密码 |
::: tip 端口是驱动级的,被同进程内所有测站共用
`port` 决定整个驱动进程监听哪个 TCP 端口,所有上报到这个端口的测站共用同一个服务端实例。不同测站靠**测站地址**(即设备的
`deviceCode` / `deviceName`)区分,不是靠端口。元数据变更(设备新增 / 更新)会触发 `restartServer()` 重启服务端,删除设备会
`stopServer()` 停掉服务端。
:::
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上只需填一个属性——它在报文体要素列表里的下标。驱动收到某测站的上报后,会把所有报文体的要素按顺序汇成一个列表;位号的
`index` 就是从这个列表里取第几个值。
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|---------|-----|-----|--------------------|
| Element Index | `index` | INT | `0` | 报文体要素列表的下标(从 0 开始) |
`index=0` 取第一个要素,`index=1` 取第二个,以此类推。下标越界(`index < 0` 或 `index >= elements.size()`)的位号本轮被跳过,不会报错。
`validatePoint()` 会校验 `index` 必填,缺失时该位号配置不通过。
### 端到端落地链路
驱动启动时 `startServer()` 通过反射构造 `SL651Server`,注册一个 `ISl651MessageListener`。每当测站上报,`onMessage`
回调把报文头的测站地址(十六进制串)与报文体的要素列表交给 `forwardTelemetry()`:遍历本驱动名下的设备,站址匹配上的设备再遍历其位号,按
`index` 取值组装成 `PointValue`,批量交给 `driverSenderService.pointValueSender()` 发往平台。
## 故障排查
- **收到报文却没有位号值**:最常见原因是**站址对不上设备编码**。驱动用报文头的测站地址(十六进制大写串)匹配设备的
`deviceCode` 或 `deviceName`(不区分大小写),对不上就静默丢弃。先从驱动日志确认实际上报的 `stationAddr`(DEBUG 级
`Driver SL651 message received` 会打印),再照抄到 `deviceCode`。
- **位号值取错了量**:`index` 是要素列表下标,从 0 开始,顺序由测站配置决定,不是 SL651 标识符编码、也不是寄存器地址。对照测站实际上报的要素顺序逐个核对
`index`,别凭量纲猜。
- **测站连不上 / 鉴权失败**:确认设备上填的 `port` 与测站实际目标端口一致、网络可达;`pwd` 要与测站配置的接入口令一致(默认
`0000`)。改 `port` 会触发服务端重启,短时间内会断连。
- **驱动日志出现 `sl651ApiMissing` 警告**:说明运行时缺少 `iot-communication` 的 SL651 类,服务端不会启动(
`Driver SL651 server unavailable`)。正常打包会带上该依赖;若自定义裁剪了依赖需补回。
- **设备一直离线**:设备健康检查 cron `0/15 * * * * ?`、租约超时 `45 秒`。测站若上报间隔大于 45
秒(如整点报、长间隔加报),在两次上报之间会被判为离线属正常现象;在线判定见[设备](../introduction/concepts/device)。
- **配错写命令不生效**:本驱动 `write` 按设计返回 `false`,没有 `command-attribute`
,给位号配写命令不会下发。远程控制测站请走测站自身的下行通道,不在本驱动职责内。
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`Sl651Driver`(驱动名 `SL651 Hydrological Telemetry Driver`,类型 `DRIVER_CLIENT`
)。这是稳定的路由标识,不要随意改。
- **读 / 写 / 订阅能力**:仅**订阅 / 上报**。`read` 返回 `null`、`write` 返回 `false`
,定时读取关闭;采集完全由测站上报触发。与[驱动能力矩阵](./matrix)一致:读 —、写 —、订阅 ✓。
- **采集与健康**:被动监听无主动读取周期;驱动另有内部 `schedule.custom` cron `0/5 * * * * ?`(`schedule()`
当前为空实现,不参与采集)。设备健康检查 cron `0/15 * * * * ?`、租约超时 `45 秒`。
::: warning 设备编码必须等于测站地址,否则数据被丢弃
驱动用上报报文头里的**测站地址(十六进制大写串)**匹配设备的 `deviceCode` 或 `deviceName`
(不区分大小写)。两者对不上,这份上报被静默丢弃——你会看到驱动在收报文,却没有任何位号值。接入前先确认测站实际上报的地址,并照抄到
`deviceCode`。
:::
::: warning index 是报文体要素的"第几个",不是寄存器地址
`index` 是驱动解析出的**要素列表下标**,从 0 开始,与测站报文里要素的排列顺序一一对应。它不是 SL651
的标识符编码,也不是任何寄存器地址。要素顺序由测站配置决定,接入前需对照测站的上报内容确认每个 `index` 对应哪个量。
:::
::: info 实现状态:可用(服务端依赖缺失时优雅降级)
SL651 服务端基于 `iot-communication` 库的 `SL651Server` 反射调用,报文解析与转发链路完整,是**可用**驱动。若运行时缺少该库的
SL651 类,`startServer()` 记 `sl651ApiMissing` 警告并跳过启动,不影响进程其余部分;正常打包已包含该依赖。
:::
### 最小接入示例
把一个测站地址为 `12345678`、上报到本机 `5001` 端口的水位站接进来:
1. 选 `SL651 Hydrological Telemetry Driver` 创建[设备](../introduction/concepts/device),**设备编码 `deviceCode`
填 `12345678`**(要和测站上报的地址一致),驱动属性填 `port=5001`、`pwd=0000`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个水位[位号](../introduction/concepts/point)(按上报要素的实际类型设
`pointTypeFlag`、`READ_ONLY`),位号属性填 `index=0`(取报文体第一个要素)。
3. 启动驱动,让测站把数据推上来;测站一上报,匹配上的位号就会出现在[位号值](../introduction/concepts/point-value)里。
## 延伸阅读
- [驱动总览](./index) — 全部驱动的分类与选型
- [驱动能力矩阵](./matrix) — 读 / 写 / 订阅能力逐驱动对照
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [物联网网络层章节](../foundations/fieldbus) — 服务端被动收报文与轮询模型的取舍
- [监听虚拟驱动](./listening-virtual) — 同为被动监听、上报触发采集的驱动范式
---
# SNMP 驱动
URL: https://docs.dc3.site/zh/drivers/snmp
# SNMP 驱动
`dc3-driver-snmp` 把支持 SNMP 的网络与机房设备接入 IoT DC3:以设备 MIB 树上的 OID 为目标,周期性发 SNMP GET 采数,并支持向
OID 发 SNMP SET 写值。读完你能配好一台路由器、交换机或
UPS,把它的端口状态、流量、温湿度等指标采成[位号值](../introduction/concepts/point-value)。
## 协议背景
SNMP(Simple Network Management Protocol,简单网络管理协议)是网络与数据中心设备最通用的管理协议,跑在 UDP 上、默认端口 `161`
。它属于物联网四层架构里的[网络层](../foundations/fieldbus)——和工业现场的 Modbus、OPC 一样解决"
在某个寻址空间里读/写一个值"的问题,只是它的设备不是 PLC 和电表,而是路由器、交换机、UPS、机房 PDU、打印机、服务器网卡这类 IP
网络元件。
每台被管设备内置一棵 MIB(Management Information Base)树,树上每个可读写的数据点都有唯一的对象标识符 OID(Object
Identifier,形如 `1.3.6.1.2.1.1.1.0`)。管理端(manager)通过 OID 来定位"采哪个值":
- **标量对象**末尾带实例标识 `.0`,例如系统描述 `sysDescr` 是 `1.3.6.1.2.1.1.1.0`;
- **表项对象**(如各端口的流量、状态)末尾带行索引,例如 `...10.1`、`...10.2` 分别对应 1 号、2 号端口。
SNMP 有 v1 / v2c / v3 三个版本。v1 与 v2c 用明文的 `community`(团体名)做口令,配置简单、现场最常见;v3 引入 USM(User-based
Security Model)支持认证与加密。本驱动基于 SNMP4J 库,作为 SNMP 管理端主动连接设备:读位号对其 OID 发 GET,写位号对 OID 发
SET,每台设备复用一个常驻的 SNMP 会话。
典型用途是机房与网络监控——带宽、端口 up/down、CPU/内存利用率、机房温湿度、UPS 电量等,凡是支持 SNMP 的设备,配好 OID 即可纳管。
## 属性配置
SNMP 的连接参数和采集目标在两个层面填写:连接一台设备靠**驱动属性**(device 级),定位每个数据点靠**位号属性**(point
级)。属性的名称、类型与默认值都来自驱动 `application.yml` 的 `driver-attribute` / `point-attribute` / `command-attribute`
定义。
### 驱动属性(设备级 `driver-attribute`)
接入一台 SNMP 设备时,在[设备](../introduction/concepts/device)上填这些[属性](../introduction/concepts/attribute-config)。
`host` / `port` 决定连哪台设备的哪个 UDP 端口,`version` + `community` 是 v1/v2c 的身份口令,`timeout` / `retries`
控制请求的容错。
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------------|-------------------|--------|-------------|-----------------------------|
| Host | `host` | STRING | `127.0.0.1` | SNMP 设备 IP |
| Port | `port` | INT | `161` | SNMP 端口(标准 161) |
| Version | `version` | STRING | `v2c` | SNMP 版本(`v1` / `v2c`) |
| Community | `community` | STRING | `public` | 团体名(相当于只读/读写口令) |
| USM Username | `usmUsername` | STRING | (空) | SNMPv3 USM 用户名(v1/v2c 下不使用) |
| USM Auth Protocol | `usmAuthProtocol` | STRING | `MD5` | SNMPv3 认证算法(MD5/SHA) |
| USM Auth Password | `usmAuthPassword` | STRING | (空) | SNMPv3 认证密码 |
| Timeout | `timeout` | INT | `5000` | 请求超时(毫秒) |
| Retries | `retries` | INT | `1` | 请求重试次数 |
::: warning USM 三项仅为 SNMPv3 预留,当前不生效
`usmUsername` / `usmAuthProtocol` / `usmAuthPassword` 是 SNMPv3 的 USM 安全字段,`application.yml` 里已预留但驱动
`buildTarget()` 只构造 `CommunityTarget`,按 `version` 设置 `version1` 或 `version2c`。当前实现只支持 v1 与
v2c,这三项填了也不会被读取,`version` 请填 `v1` 或 `v2c`。
:::
`validate()` 把 `host` / `port` / `version` / `community` 列为必填——缺任一项设备校验不通过。
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)上填 `oid` 指定采哪个数据点;`snmpType` 标注该值的 SNMP 数据类型。
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------|------------|--------|----------------|---------------------------------------------------------------------------|
| OID | `oid` | STRING | (空) | SNMP 对象标识符(如 `1.3.6.1.2.1.1.1.0`) |
| SNMP Type | `snmpType` | STRING | `OCTET_STRING` | SNMP 数据类型(INTEGER/GAUGE32/COUNTER32/OCTET_STRING/TIMETICKS/IPADDRESS/OID) |
::: tip OID 决定采哪个数据点,snmpType 主要供写入用
读取时驱动对配置的 `oid` 发 GET,把返回 `VariableBinding` 的变量值用 `variable.toString()`
原样作为[位号值](../introduction/concepts/point-value)上报——此时 `snmpType` 不参与取值。`snmpType` 的真正作用在写入:
`createVariable()` 据它把字符串转成正确的 SNMP 变量类型。`validatePoint()` 把 `oid` 列为必填,读位号缺 `oid` 校验不通过。
:::
### 写入复用位号属性,无需另配写命令
写入和读取共用位号上的同一份 `oid` / `snmpType`:`write()`(SET)从 `pointConfig`(point-attribute)读取 `oid` 与 `snmpType`
,对该 OID 发 SET、按 `snmpType` 构造写入值。可写位号只要在 point 上配好 `oid` 与 `snmpType` 即可,不需要在写命令上重复填。
`createVariable()` 支持的 `snmpType` 取值:`INTEGER`/`INTEGER32`、`GAUGE32`/`COUNTER32`/`UNSIGNED_INTEGER32`、`COUNTER64`、
`TIMETICKS`、`OID`、`IPADDRESS`、`NULL`,其余一律按 `OCTET_STRING` 处理。
::: info `command-attribute` 当前不被写入路径读取
`application.yml` 里声明了 `command-attribute`(`oid` / `snmpType`),但 `write()` 的签名只接收 `driverConfig` 与
`pointConfig`,并不传入命令属性,本驱动也未覆写 `execute()`,因此这组 `command-attribute` 目前是占位声明、写入时不会被读取。请把可写位号的
`oid` / `snmpType` 配在位号(point)上,而非写命令上——否则写入会落到位号缺省值(`oid` 为空、`snmpType=OCTET_STRING`)。
:::
### 采集与健康
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒采一轮,对应 `schedule.read.cron`)。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`。`health()` 以"设备是否已建立 SNMP 会话"判定在线——
`clientMap` 里有该设备则视为 online,没有则尝试建会话,建成即
online。在线状态机制见[设备](../introduction/concepts/device)。
::: info SNMP 会话的建立不等于设备可达
`getConnector()` 建的是本地 UDP transport(`DefaultUdpTransportMapping`),`listen()` 成功就缓存为"online"
,并不向设备发探测包。也就是说设备掉线后,健康检查仍可能短暂报 online,真正的失败要等下一次 `read()` 超时——此时驱动会
`clientMap.remove(deviceId)` 销毁会话,下一轮健康检查才转 offline。
:::
## 故障排查
::: warning OID 末尾常带 `.0`,别漏
标量(单值)对象的 OID 末尾要带实例标识 `.0`,例如 `sysDescr` 是 `1.3.6.1.2.1.1.1.0` 而不是 `1.3.6.1.2.1.1.1`
。表项(如各端口流量)则用行索引结尾(如 `...10.1`、`...10.2`)。OID 写错时设备返回 `noSuchObject`/`noSuchInstance`,
`variable.toString()` 会把它当普通字符串上报成位号值,看起来"采到了"却是无效数据,排查时容易被误导。
:::
::: warning community 写错会静默超时
SNMP 用 `community` 团体名做口令。团体名不匹配、或设备未对该团体开放访问时,设备通常不回包,`snmp.send()` 返回的
`response.getResponse()` 为 `null`,驱动抛 `ReadPointException("SNMP response is null...")`。这表现为请求超时而非明确的"
鉴权失败"。接入前先在命令行用 `snmpget -v2c -c public 1.3.6.1.2.1.1.1.0` 确认 `host`、`port`、`community`、`oid`
这一组能取到值。
:::
::: warning 防火墙拦 UDP 161 / 设备未启用 SNMP
SNMP 走 UDP 而非 TCP,许多防火墙默认放行 TCP 却拦 UDP;交换机/服务器也常默认关闭 SNMP agent。现象同样是超时。先确认目标设备已启用
SNMP 服务、并放行了管理端到设备 UDP `161` 的入站流量。
:::
接入前,建议先在命令行用 net-snmp 工具验证这条链路是否通——驱动用的是同一套 SNMP4J 语义,命令行取不到值就先别在 DC3 里建设备:
```bash
# 最小连通性验证
snmpget -v2c -c public 192.168.1.20:161 1.3.6.1.2.1.1.1.0
# 取不到值时逐项排除:host 可 ping 通?UDP 161 放行?community 对?SNMP 已启用?
snmpwalk -v2c -c public 192.168.1.20:161 1.3.6.1.2.1.1 # 遍历 system 子树看设备是否应答
```
::: warning version 只能填 v1 / v2c,填 v3 会被当 v2c
驱动 `buildTarget()` 只识别 `v1`(不区分大小写),其余任何值——包括 `v3`——都走 `version2c` 分支。若设备只允许
SNMPv3,用本驱动连不上,且不会有"版本不支持"的明确报错,只会表现为 community 校验失败导致的超时。请确认设备允许 v1/v2c 访问。
:::
::: warning 写入返回 true 不代表设备已接受
`write()` 只要拿到非空 `response` 就返回 `true`,并未校验响应 PDU 的 `errorStatus`。某些设备对只读 OID
或越权写会返回带错误码的响应而非不回包,此时驱动仍判成功。写关键参数后建议回读该 OID 确认实际生效。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`SnmpDriver`(驱动名 `SNMP Driver`,类型 `DRIVER_CLIENT`,主动连设备)。这是稳定的路由标识,不可随意改。
- **读 / 写 / 订阅能力**:读 ✓、写 ✓、订阅 —,与[驱动能力矩阵](./matrix)一致。驱动作为 SNMP 管理端主动轮询,不监听设备上报,因此没有订阅方向。
::: info 实现状态:可用
`SnmpDriverCustomServiceImpl` 的 `read()`(GET)、`write()`(SET)、`getConnector()`(会话管理)、`health()`、`event()`
(设备更新/删除时销毁会话)均已实现,基于 SNMP4J 的 v1/v2c 收发链路完整可用。已知边界:SNMPv3/USM 未接(见上文 USM
三项说明)、写入未校验响应 `errorStatus`、健康检查为本地会话存活判定而非端到端探测。这些是当前实现的取舍,不影响 v1/v2c
的常规采集与写值。
:::
最小接入示例——把 IP `192.168.1.20:161`、团体名 `public` 的一台交换机接进来,采集它的系统描述(`sysDescr`,OID
`1.3.6.1.2.1.1.1.0`):
1. 选 `SNMP Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.20`、`port=161`、
`version=v2c`、`community=public`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个描述[位号](../introduction/concepts/point)(
`pointTypeFlag=STRING`、`READ_ONLY`),point 属性填 `oid=1.3.6.1.2.1.1.1.0`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到设备的系统描述字符串。
完整接入流程见[设备接入](../operation/device-onboarding)。
## 延伸阅读
- [驱动总览](./index) — 驱动的通用模型、注册与生命周期
- [驱动能力矩阵](./matrix) — 28 个驱动的读/写/订阅能力一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [工业总线与协议](../foundations/fieldbus) — SNMP 所属网络层,及"协议参数即驱动属性"的统一模型
- [IoT 协议与无线网络](../foundations/iot-protocols) — 网络层的无线与轻量物联网另一半
- [CoAP 驱动](./coap) — 同样跑在 UDP 上的轻量物联网协议
---
# SQL Server 驱动
URL: https://docs.dc3.site/zh/drivers/sqlserver
# SQL Server 驱动
`dc3-driver-sqlserver` 把一个 Microsoft SQL Server 数据库当作数据源接入 IoT DC3:它作为数据库客户端,按采集周期对库里执行
`SELECT` 把查到的值当采集值,并支持用位号上配的 `UPDATE`/`INSERT`
写查询向库里写值。读完你能在[设备](../introduction/concepts/device)
上配好连库参数(含加密相关项)、在[位号](../introduction/concepts/point)上配好读/写 SQL,并定位常见的"连不上库 /
证书握手失败 / 查不到值 / 写不下去"问题。
> 你在这里:把一个已有数据库当数据源接进来的落地驱动。不是所有数据都来自现场协议设备——很多业务数据、历史数据、第三方系统的结果,本身就躺在一张
> SQL Server 表里。
## 协议背景
SQL Server 是 Microsoft 自 1989 年起推出的企业级关系型数据库,以 T-SQL 为查询语言、以表/行/列组织数据,在 Windows
与企业信息化环境里使用极广。在物联网场景里,它常常不是"现场设备",而是数据汇聚的中转站:MES/ERP、SCADA
上位机、第三方平台、历史归档库,都习惯把结果落在一张 SQL Server
表里对外提供。把这张表当数据源接进来,就能让平台像采集真实设备一样,周期性地把表里的字段拉成[位号值](../introduction/concepts/point-value)。
放进物联网四层参考架构看,数据库驱动用 TCP/IP 上的 TDS 协议(默认端口 `1433`)与库通信,属于把外部数据搬进平台的入口,其传输落在
**网络层**——参见[物联网网络层章节](../foundations/data-pipeline)
中关于数据如何进入平台管线的说明。本驱动作为数据库客户端([驱动](../introduction/concepts/driver)类型 `DRIVER_CLIENT`),通过
JDBC(驱动类 `com.microsoft.sqlserver.jdbc.SQLServerDriver`)连到一个 SQL Server
实例,按[位号](../introduction/concepts/point)上配置的 SQL 去查值、写值。它的通信模型是典型的**请求-响应**
——驱动作为客户端主动发起查询,库不会主动推送,所以采集由 cron 周期轮询驱动。JDBC 连接、连接池、SQL 执行的通用逻辑由共享的抽象基类
`AbstractJdbcDriverCustomService`(`dc3-common-sql` 模块)负责,MySQL、PostgreSQL、Oracle、SQL Server 四个数据库驱动都复用它,各自只提供
JDBC URL 拼装与驱动类名。
下面两个本驱动特有的概念,配置表会反复用到:
- **读查询(Read Query)**:位号上配的一条 `SELECT`,驱动按采集周期执行它,取结果**第一行第一列**作为该位号的值。
- **写查询(Write Query)**:位号上配的一条 `UPDATE`/`INSERT`,里面用一个 `?` 占位符代表要写入的值——写命令触发时,命令参数以预编译参数绑定的方式填进去。
## 属性配置
接入一个 SQL Server 库,需要在三个层面填[属性](../introduction/concepts/attribute-config):设备级的连库参数(
`driver-attribute`)、每个采集位号的读/写 SQL(`point-attribute`)、以及写命令上的一个保留属性(`command-attribute`
)。下面各属性、类型、默认值均取自驱动的 `application.yml`(`dc3-driver-sqlserver` 模块)。
### 驱动属性(设备级 `driver-attribute`)
驱动属性回答"连到哪个库、用什么账号、查询超时多久、连接是否加密"。在[设备](../introduction/concepts/device)上为每个 SQL
Server 库填一组:
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------------------|--------------------------|--------|-------------|------------------------|
| Host | `host` | STRING | `localhost` | SQL Server 主机 IP 或主机名 |
| Port | `port` | INT | `1433` | SQL Server 端口(标准 1433) |
| Database | `database` | STRING | (空) | SQL Server 库名 |
| Username | `username` | STRING | `root` | SQL Server 用户名 |
| Password | `password` | STRING | (空) | SQL Server 密码 |
| Query Timeout | `queryTimeout` | INT | `30` | SQL 查询超时(秒) |
| Encrypt | `encrypt` | STRING | `false` | 是否对连接加密(TLS) |
| Trust Server Certificate | `trustServerCertificate` | STRING | `true` | 是否信任服务端证书(跳过证书链校验) |
驱动用这些属性拼出 JDBC URL,形如 `jdbc:sqlserver://host:port;databaseName=...;encrypt=...;trustServerCertificate=...;`
(分号分隔,区别于 MySQL 的 `?key=value` 形式)。`host`、`port`、`database`、`username`、`password` 五项均为必填——配置校验(
`validate()`)逐项检查,缺任一项都不通过。驱动按设备 ID 缓存一个 HikariCP 连接池(一台设备一个池,最大 5 连接),连接超时取
`queryTimeout × 1000` 毫秒。
::: tip queryTimeout 同时作用于建连与查询节奏
`queryTimeout`(默认 30 秒)被设为连接池的 `connectionTimeout`:拿不到连接、或建连卡住超过这个时长就会失败。它与采集周期是两回事——慢
SQL 若长期逼近或超过它,应优化 SQL 或加索引,而不是一味调大超时。
:::
::: warning encrypt 与 trustServerCertificate 是 STRING,要配套填
这两个属性都是 STRING 类型,填字符串 `"true"`/`"false"`,不是布尔,会原样拼进 JDBC URL。SQL Server 的 JDBC 驱动在
`encrypt=true` 时会做 TLS 握手并校验服务端证书;若服务端用自签名证书,校验会失败、连接报错。开启加密连自签名证书的实例时,必须同时把
`trustServerCertificate=true` 设上以跳过证书链校验。内网明文测试时保持默认 `encrypt=false` 即可。
:::
### 位号属性(`point-attribute`)
位号属性回答"查这个库里的哪一个值、往哪写"。每个采集[位号](../introduction/concepts/point)填它对应的读/写 SQL:
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|--------|-----|-------------------------------------------------|
| Read Query | `readQuery` | STRING | (空) | 读取位号值的 `SELECT` 查询 |
| Write Query | `writeQuery` | STRING | (空) | 写值用的 `UPDATE`/`INSERT`,用单个 `?` 占位符代表写入值(作为参数绑定) |
::: tip Read Query 取结果的第一行第一列
`readQuery` 是一条普通 `SELECT`,驱动取其结果**第一行的第一列**(`rs.getObject(1)`)作为该位号的值——所以写成
`SELECT temperature FROM sensor WHERE id = 1` 这种只返回单行单列的查询最稳妥。结果集为空时取到 `null`
。位号的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`)决定这个值如何被解析。`readQuery` 是位号的必填项(
`validatePoint()` 强制),缺了位号校验不通过;`writeQuery` 仅在该位号要被写时才必填。
:::
### 写命令属性(`command-attribute`)
写命令上可以配这个属性,但当前并未被实现消费:
| 属性 | code | 类型 | 默认值 | 说明 |
|---------------|----------------|--------|-----|---------------|
| Execute Query | `executeQuery` | STRING | (空) | 命令要执行的 SQL 查询 |
::: warning executeQuery 当前未被实现消费
写值走的是**位号上的 `writeQuery`**:`write()` 取 `point-attribute` 的 `writeQuery`,把命令参数 `setString(1, value)`
绑进唯一的 `?` 占位符后执行 `UPDATE`/`INSERT`。`command-attribute` 上的 `executeQuery`
仅作为配置项保留,当前驱动代码中没有任何地方读取或执行它——不存在"按命令直接执行一段 SQL"的独立路径,写值一律走
`writeQuery`。以代码为准。
:::
## 故障排查
SQL Server 接入失败大多集中在连接、TLS 握手、账号权限、查询定位、字段类型几类。按下面顺序排查:
1. **连不上库(设备一直 offline)**。先确认 `host:port` 可达:`telnet 1433` 或 `nc -vz 1433`。健康检查通过
`conn.isValid(5)`(5 秒内能否拿到有效连接)判定在线,建连或心跳失败即报 offline。常见根因:SQL Server 的 TCP/IP
协议未启用、实例只监听命名管道、防火墙拦截 1433、或动态端口未固定到 1433。
2. **加密 / 证书握手失败**。`encrypt=true` 时驱动会做 TLS 握手并校验服务端证书;连自签名证书的实例时若没设
`trustServerCertificate=true`,会在建连阶段报证书链校验失败。要么把 `trustServerCertificate=true` 一起设上跳过校验,要么给服务端换上受信
CA 签发的证书。内网明文测试可保持 `encrypt=false`。注意两项都填字符串 `"true"`/`"false"`。
3. **能连但被拒(账号 / 权限)**。确认 `username`/`password` 正确,且该账号在目标库里有对应表的读(或写)权限。SQL Server 支持
SQL 身份验证与 Windows 身份验证,本驱动按 `username`/`password` 走 SQL 身份验证——若实例只开了 Windows 身份验证(集成认证),SQL
账号会被拒。建连失败抛 `ConnectorException`,并使该设备的连接池失效、下个周期重建。
4. **查不到值 / 取到了错行**。驱动只取结果的第一行第一列,所以 `readQuery` 要能稳定定位到目标行。结果集为空会得到 `null`
;返回多行时只取到第一行,可能不是你要的那条。把 `WHERE` 主键条件写全,避免随表数据增长取到错行。
5. **数值 / 类型对不上**。位号的 `pointTypeFlag` 决定取回的字符串如何被解析。把一个文本列配成 `FLOAT` 位号、或把
`datetime`/`bit` 列直接当数值,都可能解析异常。`readQuery` 里用 `CAST`/`CONVERT` 或只选目标数值列,保证取回的值与位号类型相符。
6. **写命令返回失败**。写值要求 `writeQuery` 里恰有一个 `?` 占位符、且语句指向可写的表与行。`write()` 返回"受影响行数 > 0"
为成功——若 `WHERE` 条件没命中任何行,`executeUpdate()` 返回 0、写被判为失败。先用同样的 `UPDATE` 在库里手工跑一遍确认能命中行。写失败抛
`WritePointException` 并使连接池失效。
::: warning Write Query 用 `?` 占位符,不是 `${value}`
写值时 `writeQuery` 里用**一个** `?` 占位符代表写入值(如 `UPDATE sensor SET temperature = ? WHERE id = 1`),驱动通过
`PreparedStatement.setString(1, value)` 绑定——这是预编译参数绑定,不是字符串拼接,恶意值无法改写语句结构(无 SQL 注入)。不要在
SQL 里手动拼接值,也不要用 `${value}` 这类模板语法,那样既不会被替换、也失去防注入的好处。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`SqlserverDriver`(类型 `DRIVER_CLIENT`,主动连库并发起查询)。这是稳定的路由标识,不要随意改。
- **读能力**:✓ 已实现。`read()` 执行位号的 `readQuery`,取结果第一行第一列作为位号值。
- **写能力**:✓ 已实现。`write()` 执行位号的 `writeQuery`,用 `?` 预编译参数绑定写入值,受影响行数 > 0 即成功。
- **订阅/上报**:— 不支持。SQL Server 是请求-响应模型,驱动只主动查/写、不被动接收推送。这与[驱动能力矩阵](./matrix)中 SQL
Server 的「✓ / ✓ / —」一致。
- **采集周期**:默认 cron `0/30 * * * * ?`(每 30 秒读一轮),在驱动 `application.yml` 的 `schedule.read` 配置;另有一个
`custom` 自定义调度默认 cron `0/5 * * * * ?`(每 5 秒),但基类的 `schedule()` 为空实现、数据库驱动不使用它。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`;判定靠 `conn.isValid(5)`
。在线状态机制见[设备](../introduction/concepts/device)。
::: info 实现状态:可用
本驱动是**完整实现**(非骨架)。读、写、健康检查、按设备缓存的 HikariCP 连接池、失败时连接池失效重建均已落地,复用经测试的
`AbstractJdbcDriverCustomService` 基类,SQL Server 子类仅自定义 JDBC URL 拼装(含 `encrypt`/`trustServerCertificate`
)、驱动类名与默认端口。唯一需注意的差异是 `command-attribute` 上的 `executeQuery` 属性保留但未被代码消费——写值一律走位号的
`writeQuery`(见上方警告)。
:::
### 最小接入示例
把一张 `sensor` 表里 `id=1` 那行的 `temperature` 字段当温度位号采进来:
1. 选 `SQL Server Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `host=192.168.1.10`、`port=1433`、
`database=iot`、`username=sa`、`password=******`(先连内网测试时保留默认 `encrypt=false`)。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `readQuery=SELECT temperature FROM sensor WHERE id = 1`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到查到的温度值。
4. 若该位号需可写,给它的 point 属性补 `writeQuery=UPDATE sensor SET temperature = ? WHERE id = 1`
,再给它配写[命令](../introduction/concepts/command)。
::: tip 一个驱动实例可接多个库
同一个 SQL Server 驱动进程可服务多台设备:每个设备按其 driver 属性各自连一个库、各占一个连接池(按设备 ID
缓存)。设备元数据被删除/更新时,对应连接池会被关闭并按需重建。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动入口与分类
- [驱动能力矩阵](./matrix) — 读/写/订阅能力一览,含 SQL Server 行
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [时序数据与流处理](../foundations/data-pipeline) — 位号值进入平台后如何存储、计算与查询
- [MySQL 驱动](./mysql) — 同一 JDBC 基类的另一种数据库数据源,配置结构相同
---
# TCP/UDP 驱动
URL: https://docs.dc3.site/zh/drivers/tcp-udp
# TCP/UDP 驱动
`dc3-driver-tcp-udp` 把任意"在一个 TCP 或 UDP 端口上裸收发字节流"的设备接入 IoT
DC3:按[位号](../introduction/concepts/point)发一条 HEX
指令、收回原始字节,再按帧规则切片并转成值。读完你能在没有标准协议栈的私有设备上完成采集与写值,并知道字节序、帧偏移与连接退避在哪里出问题。
## 协议背景
TCP 与 UDP 是 [TCP/IP 协议族](../foundations/iot-protocols)里的两个传输层协议:TCP 面向连接、提供可靠有序的字节流;UDP
无连接、以数据报尽力投递。在物联网四层架构(感知层 → 网络层 → 平台层 → 应用层)中,它们属于**网络层**
——是上层各类应用协议(MQTT、CoAP、Modbus TCP 等)共同的承载底座。
很多现场设备并没有跑标准协议栈:串口转网口模块、自研单片机、私有协议网关,往往只是在某个端口上"你发一段字节、它回一段字节"
。这类设备无法用某个特定协议驱动接入。`dc3-driver-tcp-udp` 就是它们的通用底座——它不引入任何第三方协议库,直接用 JDK 的
`Socket` / `DatagramSocket` 收发,把"发什么指令、回包怎么解析"
全部交给[属性配置](../introduction/concepts/attribute-config)。
TCP 与 UDP 在本驱动里的行为差异很重要:
- **TCP**:按设备缓存一条长连接(`tcpConnectMap`),避免每轮采集都重做三次握手;连接断开或通信异常时失效并重连。
- **UDP**:无连接,每次采集临时新建一个 `DatagramSocket` 发包、等回包、随即关闭。
::: info HEX 指令与帧(frame)
你和设备之间收发的是二进制。本驱动统一用十六进制字符串书写指令(如 `01 03 00 00 00 02`,空白会被忽略)。设备回来的一整段字节叫一帧;
`dataOffset` / `dataLength` 用来从帧里定位出真正要的数据,`dataFormat` 决定这段字节如何变成位号值。
:::
- **驱动名 / code**:`TCP/UDP Raw Driver` / `TcpUdpDriver`
- **类型**:`DRIVER_CLIENT`(驱动主动连设备、发指令)
## 属性配置
属性来自驱动的 `application.yml`,分三层:**驱动属性**填在[设备](../introduction/concepts/device)上(一台设备一份连接参数),
**位号属性**填在每个[位号](../introduction/concepts/point)上(描述这一路采什么、怎么解析),**写命令属性**
填在可写位号的写命令上。每张表前的散文先讲清每个属性干什么。
### 驱动属性(设备级 `driver-attribute`)
`protocol` 二选一决定走 TCP 还是 UDP;`host` / `port` 指向设备的网络地址(端口默认值 `502` 仅是占位,按实际设备改);
`connectTimeout` 是 TCP 建连超时、`readTimeout` 是等待设备回包的读超时,单位都是毫秒。`delimiter` 为按分隔符切包预留,当前实现以
`dataOffset`/`dataLength` 切帧为主。
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------------|------------------|--------|-------------|---------------|
| Protocol | `protocol` | STRING | `TCP` | TCP or UDP |
| Host | `host` | STRING | `localhost` | 设备 IP / 主机名 |
| Port | `port` | INT | `502` | 设备端口 |
| Connect Timeout | `connectTimeout` | INT | `5000` | TCP 连接超时,毫秒 |
| Read Timeout | `readTimeout` | INT | `3000` | 读响应超时,毫秒 |
| Delimiter | `delimiter` | STRING | (空) | Hex delimiter |
### 位号属性(`point-attribute`)
`sendCommand` 是这一路采集时发出的 HEX 指令;驱动收到回包后,按 `dataOffset` + `dataLength` 切出一段字节,再按 `dataFormat`
转换,多字节量受 `byteOrder` 控制。`frameHeader` / `frameFooter` / `receiveLength` 为帧头帧尾、定长收包预留。
| 属性 | code | 类型 | 默认值 | 说明 |
|----------------|-----------------|--------|-------|------------------------------------|
| Send Command | `sendCommand` | STRING | (空) | 采集时发送的 HEX 指令 |
| Receive Length | `receiveLength` | INT | `0` | 0 means use delimiter |
| Frame Header | `frameHeader` | STRING | (空) | 帧头 HEX |
| Frame Footer | `frameFooter` | STRING | (空) | 帧尾 HEX |
| Data Offset | `dataOffset` | INT | `0` | 数据在帧中的字节偏移 |
| Data Length | `dataLength` | INT | `0` | 数据字节长度 |
| Data Format | `dataFormat` | STRING | `HEX` | HEX/ASCII/INT16/UINT16/INT32/FLOAT |
| Byte Order | `byteOrder` | STRING | `BIG` | 字节序:BIG / LITTLE |
::: tip dataFormat 决定回包怎么变成值
驱动按 `dataOffset` + `dataLength` 从回包里切出一段字节,再按 `dataFormat` 转换:`HEX` 原样输出十六进制字符串、`ASCII`
转文本(末尾空白会被 trim)、`INT16/UINT16/INT32/FLOAT` 按数值解析(多字节量受 `byteOrder` 控制,`BIG` 为大端、`LITTLE` 为小端)。
`INT16/INT32/FLOAT` 要求切出的字节数分别 ≥2/≥4,长度不够时回退成 HEX。若 `dataLength=0`,则不切帧、整段回包以 HEX 返回。
:::
下面这张状态/数据流图把"一次采集"从发指令到落值的关键跳串起来:
### 写命令属性(`command-attribute`)
可写位号的写命令上填 `sendCommand` 模板,里面用 `${value}` 占位。写值时驱动把 `${value}` 替换成实际命令值,再作为 HEX
指令发给设备(TCP 复用长连接、UDP 临时建 socket)。
| 属性 | code | 类型 | 默认值 | 说明 |
|--------------|---------------|--------|------------|-------------------------|
| Send Command | `sendCommand` | STRING | `${value}` | 写指令模板,`${value}` 用命令值替换 |
::: warning 写命令的 `sendCommand` 读自位号属性
源码中 `write()` 的 `sendCommand` 取自**位号属性**(`pointConfig`)而非写命令属性。若可写位号没有在位号属性里配
`sendCommand`,写操作会因指令为空而直接返回失败。`command-attribute` 的 `${value}` 模板在 `execute()` 渲染流程中生效。
:::
## 故障排查
- **位号值是一长串 HEX、却期望是数字**:多半是 `dataOffset` + `dataLength` 超过了回包实际长度,或 `dataLength=0`。越界时驱动
**不报错**,而是放弃切帧、把整段回包以 HEX 原样返回。先抓一帧真实回包,数清目标数据落在第几字节、占几字节,再对上
`dataOffset` / `dataLength`。
- **数值符号/大小明显不对**:多字节量的 `byteOrder` 没对上设备。设备是大端就填 `BIG`、小端填 `LITTLE`;`UINT16` 与 `INT16`
在高位为 1 时差一个符号,按设备语义选对格式。
- **`sendCommand` 解析失败或读到错值**:`sendCommand` / `frameHeader` / `frameFooter` 都按十六进制解析(空白被忽略,可写成
`01 03 00 00`)。填入非 HEX 字符(如把十进制 `10` 当一个数)会解析失败。`dataFormat=ASCII` 只影响回包字节如何转文本,指令本身仍必须是
HEX。
- **设备一直显示离线 / 暂时连不上**:TCP 连续 3 次连接或读写失败后进入 **60 秒退避窗口**
暂停重连,其间设备按离线上报,窗口过后自动重试;成功通信一次即清零计数。看到短时离线先确认是否在退避窗口内,再查 `host`/
`port`/防火墙。
- **读超时**:`readTimeout` 默认 3000ms。设备回包慢或 UDP 丢包会触发读超时(UDP 收不到回包即抛 `SocketTimeoutException`
)。适当调大 `readTimeout`,UDP 还要确认对端确实有回包。
- **UDP 设备健康状态恒为在线**:UDP 无连接,`health()` 对 UDP 默认按在线上报(不做探测)。"在线"不代表数据通,仍要看位号是否真有新值落库。
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`TcpUdpDriver`——稳定路由标识,平台据此把命令分发到本驱动,不应随意改动。
- **读 / 写 / 订阅**:本驱动 `read()` 主动发指令采值、`write()` 渲染指令写值,二者均已实现;**不提供订阅**——`schedule()`
为空方法,无自定义周期任务。这与[驱动能力矩阵](./matrix)中本驱动"读 ✓ / 写 ✓ / 订阅 —"一致。
- **采集与健康**:默认采集 cron `0/30 * * * * ?`(每 30 秒一轮);设备健康检查 cron `0/15 * * * * ?`、租约超时 `45 秒`。TCP
设备靠缓存连接状态或一次快速建连判断在线,UDP 默认按在线上报。
::: info 自定义调度已启用但无动作
`schedule.custom` 默认启用、cron `0/5 * * * * ?`,但 `schedule()` 是空方法,本驱动未实现任何自定义周期任务,该调度实际不执行动作。这是有意为之的占位,不影响正常采集。
:::
### 最小接入示例
把一台 `192.168.1.50:8899` 的 TCP 设备接进来,采集一个 16 位温度值(设备回 `01 03 04 00 FA 12 34 ...`,温度在第 3、4 字节):
1. 选 `TCP/UDP Raw Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `protocol=TCP`、`host=192.168.1.50`、
`port=8899`。
2. 给设备绑定的[模板 Profile](../introduction/concepts/profile) 加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=INT`、`READ_ONLY`),位号属性填 `sendCommand=010300000001`、`dataOffset=3`、`dataLength=2`、
`dataFormat=INT16`、`byteOrder=BIG`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到解析出的数值(`00FA` → `250`)。
## 延伸阅读
- [驱动总览](./index) — 28 个协议驱动的全景与分组
- [驱动能力矩阵](./matrix) — 各驱动的读/写/订阅能力对照
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [IoT 协议与无线网络](../foundations/iot-protocols) — TCP/UDP 所在网络层与上层应用协议的关系
- [Modbus TCP 驱动](./modbus-tcp) — 标准化的 TCP 协议示例,可与本通用驱动对照
---
# Virtual 驱动
URL: https://docs.dc3.site/zh/drivers/virtual
`dc3-driver-virtual` 是 IoT DC3 的**虚拟(仿真)驱动**
:它不连接任何真实设备,而是按采集周期为[位号](../introduction/concepts/point)
生成随机[位号值](../introduction/concepts/point-value)
,并模拟命令执行与事件上报。读完本页你能用它把整套接入链路跑通,并理解它"哪些能力是真实实现、哪些只是占位"。
> 你在这里:还没有真实 PLC/传感器,想先把平台端到端验证一遍,或想找一份最简单的[驱动](../introduction/concepts/driver)
> 样板来照着写自己的协议。下一步可看[设备接入](../operation/device-onboarding)。
## 协议背景
Virtual 不是某种现场总线或工业协议,而是一个**仿真驱动**——它把 IoT DC3
的整套接入流程(建[设备](../introduction/concepts/device)、配[模板 Profile](../introduction/concepts/profile)
、跑采集、看[位号值](../introduction/concepts/point-value))走通,但底层不发任何网络报文,所有数据都是本地随机造的。因为没有真实协议层,所以它
**不属于**物联网四层架构里的某一层网络协议,而是平台侧用来演练、教学、压测的"无设备接入器"。典型用途:
- **跑通平台、做演示**——没有真实硬件时,先用它把端到端链路验证一遍。
- **学习驱动模型**——它是最简单的驱动,源码 `VirtualDriverCustomServiceImpl` 就是写自定义驱动的参考样板。
- **压测与联调**——批量造设备和位号,观察平台在持续数据流下的表现。
读取时按位号的数据类型造值:`STRING` 固定返回 `abcd1234`,`BOOLEAN` 返回随机真/假,其余类型返回 `0~100`
之间的随机浮点数。造什么值只看位号本身的数据类型([Point](../introduction/concepts/point) 的 `pointTypeFlag`),与 `tag`
内容无关。
::: info 虚拟驱动没有真实协议层
其他驱动页会链接到对应协议规范,本页没有——Virtual 不实现任何线缆协议。它的"链路"完全在 IoT DC3
内部,更多协议驱动请见[驱动总览](./index)。
:::
## 属性配置
属性分两类:填在[设备](../introduction/concepts/device)上的**驱动属性**(`driver-attribute`
)和填在每个[位号](../introduction/concepts/point)上的**位号属性**(`point-attribute`
)。此外驱动还声明了命令属性与事件属性,用于命令执行和事件上报的模板渲染。这些属性的定义来自驱动模块的 `application.yml`
,下面逐项说明它们的作用与取值来源。
### 驱动属性(设备级 `driver-attribute`)
接入一台虚拟设备时,在设备上填以下两项。注意:虚拟驱动**并不真正连接** `host:port`,这两项只是占位,保持与真实驱动一致的配置形态,方便照搬演练。
| 属性 | code | 类型 | 默认值 | 说明 |
|------|--------|--------|-------------|-----------------|
| Host | `host` | STRING | `localhost` | 设备 IP(占位,不实际建连) |
| Port | `port` | INT | `18600` | 设备端口(占位,不实际建连) |
### 位号属性(`point-attribute`)
每个采集位号上填一项 `tag`,作为该位号在设备上的标识占位:
| 属性 | code | 类型 | 默认值 | 说明 |
|-----|-------|--------|-------|-------|
| Tag | `tag` | STRING | `TAG` | 位号标签名 |
::: tip 造值由位号类型决定,与 tag 无关
虚拟驱动忽略 `tag` 的具体内容,造值只看位号的数据类型 `pointTypeFlag`:`STRING` 出 `abcd1234`、`BOOLEAN` 出随机布尔、数值类出
`0~100` 随机浮点。想看布尔翻转就把位号配成 `BOOLEAN`,想看连续曲线就配成 `FLOAT`。
:::
### 命令属性(`command-attribute`)
虚拟驱动实现了**命令执行**(`execute()`):下发命令时,用命令参数与设备/命令上下文渲染 `payloadTemplate` 得到请求载荷,再渲染并解析
`responseTemplate` 得到一份模拟响应。整个过程不接触任何真实设备。
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------------|--------------------|--------|------------|-----------------|
| Payload Template | `payloadTemplate` | STRING | `${value}` | 用命令参数渲染出的请求载荷模板 |
| Response Template | `responseTemplate` | STRING | `{}` | 模拟响应模板 |
::: tip 模板用 `${...}` 占位,由命令上下文渲染
模板里的 `${value}`、`${deviceCode}`、`${commandCode}`、`${deviceId}`、`${commandName}` 等占位,会被命令参数与设备/命令上下文逐一字符串替换。
`responseTemplate` 是 JSON 对象时,其字段会被原样解析进命令返回结果;非 JSON 则整体作为 `response` 字段返回。返回里还会带上渲染后的
`payload`。
:::
### 事件属性(`event-attribute`)
虚拟驱动会周期性地为设备模拟[事件](../introduction/concepts/event)上报(每 30 秒一轮)。事件属性用类 JSON Path
告诉驱动:从模拟报文里哪个路径取事件 code、哪个路径取事件载荷。
| 属性 | code | 类型 | 默认值 | 说明 |
|-----------------|-----------------|--------|---------------|---------------------|
| Event Code Path | `eventCodePath` | STRING | `$.eventCode` | 解析事件 code 的 JSON 路径 |
| Payload Path | `payloadPath` | STRING | `$.payload` | 解析事件载荷的 JSON 路径 |
::: warning 仅支持简单点号路径,不是完整 JSONPath
驱动内部 `resolvePath()` 只按 `.` 逐段在 Map 里取值(如 `$.payload.value`),不支持数组下标、过滤表达式等完整 JSONPath
语法。模拟报文形如 `{"eventCode":"...","payload":{"value":...,"deviceCode":...,"source":"virtual"}}`,取不到时回退到事件自身的
`eventCode`。
:::
## 故障排查
Virtual 几乎不会因"连不上设备"而失败(它根本不建连),所以常见问题集中在调度、类型与配置上:
- **30 秒内看不到位号值**:确认驱动已注册上线、设备绑定的 [Profile](../introduction/concepts/profile) 下挂了启用的位号,且采集调度
`dc3.driver.schedule.read.enable=true`(默认开,cron `0/30 * * * * ?`)。首轮值最长要等一个采集周期。
- **位号值形态不符合预期**(想要布尔却出数字):检查位号的 `pointTypeFlag`。类型配错只会造出不符合预期形态的值,**不会报错**
——类型是位号侧的约定。
- **写命令总是失败 / 不回显**:这是预期行为。虚拟驱动的点位写 `write()` 是占位实现,永远返回 `false`,详见下文"在 IoT DC3
中如何落地"。要看可工作的下行链路,请用[命令执行](#属性配置)(`execute()`,对应命令属性)或换接真实驱动。
- **收不到事件上报**:事件上报由内部定时任务 `dc3.driver.schedule.custom`(cron `0/5 * * * * ?`)驱动、并按 30
秒间隔节流;同时设备下必须有**启用状态**的事件定义,否则该轮跳过。
- **设备显示离线**:健康检查 cron `0/15 * * * * ?`、租约超时 `45 秒`
。若驱动进程停了或未按周期心跳,设备会被判离线——在线机制见[设备](../introduction/concepts/device)。
- **host/port 填错却照样出值**:见下方易错提示——虚拟驱动从不校验也不连接 `host:port`。
::: warning host/port 是占位,连不连得上都不影响出值
虚拟驱动从不真正建立到 `host:port` 的连接,即使填一个不存在的地址也照样产出随机值。换言之,**用它验证不了真实的网络连通性**
——要测真实链路请换对应协议的驱动。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`VirtualDriver`(驱动名 `Virtual Driver`)。这是稳定的路由标识,平台据此把设备、命令、事件路由到本驱动,不要随意改。
- **类型**:`DRIVER_CLIENT`——驱动作主动方,按周期主动产出数据。
- **能力**(与[驱动能力矩阵](./matrix)对齐):
| 能力 | 状态 | 说明 |
|-------------------|----|------------------------|
| 读 `read()` | 可用 | 按位号类型造随机值,已完整实现 |
| 写 `write()` | 占位 | 点位写永远返回 `false`,不落任何设备 |
| 命令执行 `execute()` | 可用 | 模板渲染 + 模拟响应,已完整实现 |
| 事件上报 `schedule()` | 可用 | 每 30 秒模拟一轮事件上报 |
::: warning 点位写是占位实现
`write()` 在源码中直接返回 `false`——通过位号写值的请求永远"失败",不会改变任何状态。这与[驱动能力矩阵](./matrix)里 Virtual
的「写 = —」一致。需要可工作的下行能力时,用命令执行(`execute()`,配 `command-attribute`),或换接真实驱动。
:::
::: info 事件/命令是真实实现,但与矩阵的"协议订阅"含义不同
矩阵把 Virtual 的「订阅/上报」标为 `—`,指的是它没有真实协议层的被动订阅。但代码里 `execute()` 与 `schedule()`
(事件上报)都是完整实现的仿真能力——本页据源码如实标注。校验类方法 `validate()`/`validatePoint()` 当前不做实质校验,恒为通过。
:::
### 最小接入示例
不需要任何真实硬件,把一台虚拟设备接进来看到数据流动:
1. 选 `Virtual Driver` 创建[设备](../introduction/concepts/device),驱动属性填 `host=localhost`、`port=18600`(保持默认即可)。
2. 给设备绑定的 [Profile](../introduction/concepts/profile) 加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`),位号属性填 `tag=temperature`。
3. 启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到 `0~100` 之间不断变化的随机值。
## 延伸阅读
- [驱动总览](./index) — 按类别挑选协议,进入各驱动页
- [驱动能力矩阵](./matrix) — 各驱动读/写/订阅能力的真实实现一览
- [设备接入](../operation/device-onboarding) — 一次完整的接入流程
- [自定义驱动](../development/driver-authoring) — 基于 `virtual` 模板实现自己的协议驱动
- [Listening Virtual 驱动](./listening-virtual) — 被动监听版的仿真/接入驱动
---
# Zigbee 驱动
URL: https://docs.dc3.site/zh/drivers/zigbee
> **`dc3-driver-zigbee` 把 Zigbee 设备接入 IoT DC3**——通过串口协调器(coordinator)连入 Zigbee 网络,按 ZCL
> 属性周期性读取节点数据,并支持向 ZCL 属性写值的命令。
读完这页,你能理解 Zigbee 在物联网网络层中的位置、知道接一台 Zigbee 节点要在驱动侧和位号侧填哪些属性,并清楚当前实现到了哪一步、哪些能力还不能依赖。
## 协议背景
Zigbee 是一种**低功耗、低速率的短距无线 Mesh 协议**,建立在 IEEE 802.15.4 物理/链路层之上,工作在 2.4 GHz
免授权频段。它的典型用途是智能家居与楼宇自控里大量电池供电的传感节点——温湿度、门磁、人体感应、开关、灯具、智能插座等。这类设备数据量小、要求长续航,不适合直接跑
IP 协议栈,于是组成自己的 Zigbee 网络:节点之间可互相中继(mesh),由一个**协调器(coordinator)**统一组网与进出。
在[物联网四层架构](../foundations/iot-protocols)里,Zigbee 属于**网络层**的无线接入技术——它解决"
低速设备的信号怎么省电地传出去",而不直接讲 IP。Zigbee 网络不直接连公网,而是通过协调器/网关汇聚后再上联,这一点和 BLE 类似、和
MQTT/CoAP 这类应用层消息协议不同。在 IoT DC3 中,协调器以一枚 **USB 串口 dongle** 的形式插在运行驱动的主机上,驱动作为
Zigbee 应用层 client,把每个[位号](../introduction/concepts/point)映射到 Zigbee 网络中的一个 ZCL 属性来采数、写值。
Zigbee 的寻址分多级。每个 Zigbee 设备用 **IEEE 地址**(64 位长地址,出厂固定)唯一标识;设备内部再按 **endpoint(端点)→
cluster(簇)→ attribute(ZCL 属性)** 三级定位某个具体的数据点。理解这套寻址,是配置位号属性的前提。
::: tip 三级寻址:endpoint / cluster / attribute
一个 Zigbee 节点可有多个端点(多功能设备),每个端点下挂若干 ZCL 簇(如温度测量簇 `1026`、相对湿度簇 `1029`),每个簇里再有若干属性。
`cluster` + `attribute` 决定读到的是哪一类物理量——位号的数据类型([Point](../introduction/concepts/point) 的
`pointTypeFlag`)要和该 ZCL 属性的实际类型对得上。
:::
## 属性配置
接入一台 Zigbee 设备分两层填写:**驱动属性(`driver-attribute`)** 配的是协调器侧的接入参数,一个协调器服务整个 Zigbee 网络;
**位号属性(`point-attribute`)** 把每个采集位号定位到网络中具体的一个 ZCL 属性。下面两张表的字段都来自驱动的
`application.yml`(`dc3.driver.driver-attribute` / `point-attribute`),默认值即 yml 中的 `default-value`。
### 驱动属性(设备级 `driver-attribute`)
这些属性配在[设备](../introduction/concepts/device)上,描述本机上那枚协调器 dongle 怎么连、组到哪张 Zigbee 网络里。
`serialPort` 与 `baudRate` 决定串口连接,`dongleType` 决定用哪种协调器适配器,`panId` 与 `channel` 决定加入哪张网络的哪个信道(填
`0` 表示自动)。
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------|--------------|--------|----------------|-----------------------------------------|
| Serial Port | `serialPort` | STRING | `/dev/ttyUSB0` | Zigbee 协调器串口 |
| Baud Rate | `baudRate` | INT | `115200` | 串口波特率 |
| Dongle Type | `dongleType` | STRING | `TELEGESIS` | 协调器 dongle 类型(TELEGESIS, EMBER, CONBEE) |
| PAN ID | `panId` | INT | `0` | PAN ID(0=自动) |
| Channel | `channel` | INT | `0` | 信道(0=自动,11-26) |
### 位号属性(`point-attribute`)
每个采集[位号](../introduction/concepts/point)用 IEEE 地址 + 端点 + 簇 + 属性,唯一定位到 Zigbee 网络中的一个 ZCL 属性。
`nodeIeeeAddress` 选哪台节点,`endpointId` / `clusterId` / `attributeId` 按上文三级寻址逐级缩小到那个具体属性。
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------------|-------------------|--------|-----|-----------------------------------------|
| Node IEEE Address | `nodeIeeeAddress` | STRING | (空) | Zigbee 节点 IEEE 地址(如 `00158D0001234567`) |
| Endpoint ID | `endpointId` | INT | `1` | 端点 ID |
| Cluster ID | `clusterId` | INT | `0` | 簇 ID(如 `1026`=温度测量) |
| Attribute ID | `attributeId` | INT | `0` | 属性 ID(如 `0`=测量值) |
### 写命令属性(`command-attribute`)
可写位号在写命令上填同样的四级定位,但指向要写入的目标属性。字段与位号属性同名同义,只是用于 `write` 路径。
| 属性 | code | 类型 | 默认值 | 说明 |
|-------------------|-------------------|--------|-----|-------------------|
| Node IEEE Address | `nodeIeeeAddress` | STRING | (空) | Zigbee 节点 IEEE 地址 |
| Endpoint ID | `endpointId` | INT | `1` | 端点 ID |
| Cluster ID | `clusterId` | INT | `0` | 写入目标的簇 ID |
| Attribute ID | `attributeId` | INT | `0` | 写入目标的属性 ID |
### 一个最小接入示例
把一个 IEEE 地址为 `00158D0001234567` 的温度传感节点接进来:
1. 选 `Zigbee Driver` 创建[设备](../introduction/concepts/device),driver 属性填 `serialPort=/dev/ttyUSB0`、
`baudRate=115200`、`dongleType=TELEGESIS`、`panId=0`、`channel=0`。
2. 给设备绑定的[模板](../introduction/concepts/profile)加一个温度[位号](../introduction/concepts/point)(
`pointTypeFlag=FLOAT`、`READ_ONLY`),point 属性填 `nodeIeeeAddress=00158D0001234567`、`endpointId=1`、`clusterId=1026`
(温度测量簇)、`attributeId=0`(测量值)。
3. 确保节点已加入协调器的 Zigbee 网络,启动驱动,30 秒内就能在[位号值](../introduction/concepts/point-value)里看到采集值。
## 故障排查
接入 Zigbee 设备时,问题大多出在串口、网络入网、地址格式或当前实现的边界上。下面按"先连上、再找到、再读对"的顺序排查。
::: warning 协调器找不到 / 串口被占
默认串口是 `/dev/ttyUSB0`、波特率 `115200`。先确认 dongle 已插好、宿主能看到串口设备(如 `ls /dev/ttyUSB*`
),且该串口没有被其他进程占用。容器化部署时,需把宿主串口设备透传进容器(如 `--device=/dev/ttyUSB0`),否则驱动启动后会一直连不上协调器。
**注意**:当前实现的串口与波特率是硬编码的,见下文实现状态。
:::
::: warning 节点报 "node not found"
读写时报找不到节点,通常是 `nodeIeeeAddress` 写错或节点未入网。IEEE 地址是连续 16 位十六进制字符(如 `00158D0001234567`),*
*不要加冒号或 `0x` 前缀**。地址出厂固定,可在协调器/网关的设备列表里查到。另外,节点必须先加入本协调器的 Zigbee
网络(permit-join 入网)后才能被寻址。
:::
::: warning 端点 / 簇 / 属性找不到
报 "endpoint/cluster/attribute not found" 说明三级寻址某一级填错。多功能设备的端点不一定是 `1`;簇 ID 要对应实际物理量(温度测量
`1026`、相对湿度 `1029`);属性 ID 要对应该簇下的具体属性(测量值常为 `0`)。先用协调器工具查清目标节点暴露的端点与簇,再回填位号属性。
读路径取的是该 ZCL 属性的**最近缓存值**(`attribute.getLastValue()`)。若节点尚未上报过该属性、或属性绑定/上报未配置,可能读到
`0`(默认占位)。
:::
::: warning 数据类型对不上
位号的 `pointTypeFlag` 要与 ZCL 属性的实际类型一致。温度测量值是有符号整数(单位 0.01℃),按 `FLOAT`
/数值解析;若把字符串类簇当数值读,或反之,会得到无意义的值。配置位号前先核对该 ZCL 属性的数据类型。
:::
::: warning 驱动 / 设备显示离线
驱动级健康检查依赖 `networkManager`
是否已初始化:协调器没连上时驱动级为离线。设备级在线态见[设备](../introduction/concepts/device)的租约机制(健康检查 cron
`0/15 * * * * ?`、租约超时 `45 秒`)。注意当前实现中设备记录有效时设备级健康检查固定返回在线(未按 IEEE
地址做可达性校验),不能据此判断单个节点是否真实可达,见下文实现状态。
:::
## 在 IoT DC3 中如何落地
- **`dc3.driver.code`**:`ZigbeeDriver`(驱动名 `Zigbee Driver`,类型 `DRIVER_CLIENT`——主动连协调器、轮询节点)。这是稳定的路由标识,不要随意改。
- **采集周期**:默认 cron `0/30 * * * * ?`,每 30 秒读一轮 ZCL 属性。
- **健康/在线**:设备健康检查默认 cron `0/15 * * * * ?`,租约超时 `45 秒`
,在线状态机制见[设备](../introduction/concepts/device)。
- **读 / 写能力**:读路径取协调器侧 ZCL 属性的最近缓存值(`attribute.getLastValue()`),而非每次都同步轮询设备空口;这与请求-响应式驱动(如
`ble`、`coap`)不同。
- **订阅能力(尚未实现)**:`initial()` 目前**只注册了协调器网络状态监听器**(`addNetworkStateListener`,仅记录网络 UP/DOWN
日志),**并未监听节点入网(node-join/announce)或 ZCL 属性上报**
,也未配置属性绑定/上报(binding/reporting)。因此读路径取到的缓存值依赖节点自行上报或外部工具配置上报,驱动本身不会捕获入网与上报事件——[驱动能力矩阵](./matrix)
中 Zigbee 的"订阅"一栏据此标注为未实现。
::: warning 当前为骨架实现(Work in Progress)
该驱动目前是**骨架版本**,协议层 I/O 尚未完整实现,请按"接入模板"而非生产可用驱动看待。源码方法体中有多处 `TODO` 标记,关键限制如下:
- **串口与波特率被硬编码**:`initial()` 写死了 `/dev/ttyUSB0` 与 `115200`,**不读取** `serialPort` / `baudRate`
驱动属性。若协调器不在 `/dev/ttyUSB0`,需先补齐配置读取逻辑,否则填了属性也不生效。
- **只打包了 Telegesis 适配器**:代码只引入并使用 `ZigBeeDongleTelegesis`,`dongleType` 填 `EMBER` / `CONBEE` 暂不会切换适配器。
- **设备级健康检查恒在线**:`health(driverConfig, device)` 在设备记录有效时固定返回在线(仅当 device 或其 id 为空才返回离线),未真正按
IEEE 地址校验节点可达性。
:::
::: warning 写命令当前不真正下发
写路径(`writeAttribute`)会校验节点 / 端点 / 簇 / 属性是否存在,但只记一条日志,**不会真正把值写到 ZCL 属性**
。在写能力补全前,配置了写命令的位号会"看似成功"但设备状态不变——不要依赖它做实际控制。
:::
## 延伸阅读
- [驱动总览](./index) — 全部驱动分组与选型入口
- [驱动能力矩阵](./matrix) — Zigbee 的读 / 写 / 订阅能力与同类驱动对比
- [设备接入](../operation/device-onboarding) — 一次完整的设备接入流程
- [物联网网络层](../foundations/iot-protocols) — Zigbee 在无线接入与网络融合中的定位
- [BLE 驱动](./ble) — 另一种低功耗短距无线设备接入
---
# 数据智能与 AIoT
URL: https://docs.dc3.site/zh/foundations/aiot
# 数据智能与 AIoT
数据采上来、存进时序库之后,真正的价值才刚开始:把海量位号值变成"现在怎么了、接下来会怎样、该做什么"
。这一章讲应用层的智能——实时监控、历史分析、预测性维护、异常检测,以及大模型如何走进物联网运营。读完你会有一个判断框架:什么交给规则、什么交给模型、AI
算在哪一层,以及 IoT DC3 把这套智能落在了哪里。
> 你在这里:已经理解了[时序数据与流处理](./data-pipeline)怎么把值汇聚起来。这一章在数据之上做"决策",是四层架构里应用层的智能部分。
## 这一层是什么 / 为什么存在
前几层解决的是"把物理世界搬进数字世界":感知层采集、网络层传输、平台层存储与归一。到这里,你手上有了带语义的、连续的、可查询的位号值流。但数据本身不产生价值——
**没有人会为"昨天 8 点温度是 73.2℃"付钱,他们付钱是为了"锅炉再过两小时可能过热,先降负荷"**。应用层智能存在的理由,就是把数据转成可执行的判断。
这一层做的事可以收敛成四类,按"看现在 → 看过去 → 看未来 → 不用人盯"递进:
- **实时监控**:对当前值做阈值、状态、趋势判断,第一时间发现越界。要的是**低延迟**——值刚落库(甚至在落库的同一条流上)就要算出结论。
- **历史分析**:在时间维度上做聚合、对比、关联,回答"这台设备这个月能耗为什么涨了""哪些工况下故障率最高"。要的是**大范围扫描
**与按维度切片。
- **预测性维护**:从历史模式里学出"正常长什么样",提前预判退化与失效,把"坏了再修"变成"快坏了先修"。要的是**模型**而非固定阈值。
- **异常检测与智能告警**:识别偏离正常基线的行为,并把"一堆原始越界"压缩成"少量、有上下文、可处置"的告警,避免告警风暴淹没运维。
这四类不是并列的功能清单,而是一条内在的张力线:越往"看现在"一端,越要**低延迟**、越偏轻量计算;越往"看未来"一端,越要**大数据量
**与**模型能力**。同一个系统里,实时监控可能跑在边缘的毫秒级回路上,而预测与历史分析跑在云端的离线任务里——理解这条张力,才知道每类智能该放在哪、用什么手段。
AIoT(AI of Things)是这一层的统称:**让 AI 参与到物联网的感知—决策—执行闭环中**,而不只是事后做报表。它的边界不在"
用了多高级的模型",而在"模型的判断能不能回写到物理世界"——能下发命令、能触发动作,才算真正闭环。
## 关键技术与权衡
应用层智能不是单一算法,而是一条流水线:**采集 → 分析/建模 → 决策 → 执行**
,再把执行结果反馈回采集,形成闭环。下面这张图是这套通用模式的骨架——无论工业、能源、楼宇还是城市,落地形态各异,但闭环结构一致。
把这条闭环拆开看,每一跳都有取舍:
**规则还是模型?** 这是最先要做的选择,不是"模型更高级所以都用模型"。固定阈值、状态机、简单趋势这类**确定性判断**
,用规则最划算——可解释、可审计、零训练成本、毫秒级。只有当"正常"难以用阈值描述(多变量耦合、随工况漂移、周期性波动)时,才值得上模型。多数生产系统是
**规则托底 + 模型补强**:规则覆盖已知的硬约束,模型负责发现规则写不出来的异常。
**AI 算在哪一层?** 端侧、边缘、云侧的分工本质是**延迟、带宽、算力、数据广度**之间的权衡:
- **端侧 AI**:跑在设备/传感器上,做最轻量的就地判断(如振动是否异常)。延迟最低、不依赖网络,但算力与模型规模受限,看不到全局。
- **边缘 AI**:跑在现场网关/边缘盒子上,聚合一片设备做实时检测与预处理,把"原始流"压成"事件流"再上云。平衡了延迟与视野,还能在断网时自治。
- **云侧 AI**:算力与数据最充分,适合训练模型、跨设备/跨厂区的全局分析、以及大模型驱动的运营。代价是延迟与带宽——不适合毫秒级闭环。
合理的架构往往是**云侧训练、边缘推理、端侧响应**:云上用全量历史训出模型,下发到边缘做低延迟推理,端侧只做最后一脚的快速反应。这条分工不是非此即彼,而是同一套模型在不同位置承担不同时延的职责。
**同一套模式,不同行业。** 工业、能源、楼宇、城市的应用看似千差万别,骨架却是同一条闭环——**采集 → 分析 → 决策 → 执行**
。与其逐行业堆砌,不如看清这套抽象怎么套到任何场景上:
- **采集**变的是数据源——工业是 PLC 寄存器、能源是电表读数、楼宇是温湿度与门禁、城市是路侧传感器;不变的是都归一成带语义的位号值流。
- **分析**变的是关心的指标——产线良率、负荷曲线、舒适度、车流密度;不变的是规则与模型这两种手段的组合。
- **决策**变的是触发条件与阈值,**执行**变的是动作对象——降负荷、错峰、调风机、配灯时;不变的是"判断要能回写物理世界"这条闭环要求。
换句话说,理解了这条通用闭环,再看任何一个行业方案,都能一眼定位它在"采集—分析—决策—执行"里做了什么、缺了哪一环。IoT DC3
提供的正是这条闭环的**通用底座**,而非某个行业的成品方案。
**大模型 + IoT** 是近年新增的一层能力,它不替代上面的分析栈,而是给运营套了一个**自然语言界面**与**自主编排能力**:
- **自然语言运营**:用"把 3 号锅炉过去一周的温度趋势画出来"代替写查询、点菜单,降低运维门槛。
- **工具调用(Tool/Function Calling)**:模型不是凭记忆瞎答,而是调用平台真实 API 去查设备、读位号、下命令——答案有据可查,动作真实生效。
- **检索增强(RAG)**:把设备手册、SOP、历史工单喂给模型作为上下文,让它的建议贴合本系统的实际,而非泛泛而谈。
::: warning 大模型的"能说"不等于"能信"
大模型会"一本正经地编造"。在物联网里这尤其危险——它若编出一个不存在的位号值或下错命令,后果作用在物理世界。因此可信的做法是:
**让模型只通过工具读真实数据**(而非凭训练记忆作答),**让高风险的写动作必须经人工确认**。
:::
## 工程要点
把上面的取舍落到工程上,有几条反复被验证的经验:
- **延迟分层对齐场景**
:毫秒级闭环(安全联锁、急停)绝不能依赖云端往返,必须下沉到边缘或端侧;分钟级的趋势预测、报表,放云端最合适。先问"
这个决策最坏能等多久",再决定算在哪。
- **告警要降噪,不要更吵**:原始越界往往成片出现。工程上要做**去抖动(持续 N 秒才算)、状态机(触发/恢复/关闭,避免反复跳变)、聚合与分级(P0–P3)
**,把"一万条越界"收敛成"三条该处置的告警"。否则告警越多,越没人看。
- **预测性维护先有"正常基线"**:模型的价值来自"知道正常长什么样"
。没有足够的、带标注的历史数据,再好的算法也学不出基线——所以数据采集与存储的质量,是预测能力的前提,而不是后置优化项。
- **AI 不能绕过权限与租户边界**:模型代表某个用户/账号操作,它能看到、能动的,绝不能超过这个账号本身的权限。跨租户的数据,对
AI 也必须是看不见的。这条在多租户系统里是硬约束,不是可选项。
- **模型会随时间漂移,要持续校准**:设备老化、工况变化、季节更替都会让"正常基线"
悄悄偏移,昨天准的模型今天可能频繁误报。预测与异常检测不是"训一次用一辈子",要有重训与回放评估的机制,否则误报会逐渐侵蚀运维对告警的信任。
- **写动作必须可审计、可回退、可确认**:读是安全的,写一旦做错就难收场。工程上要给写动作加上**人工确认、幂等键、超时过期、全程审计
**——AI 越自主,这层护栏越要厚。
## 在 IoT DC3 中如何落地
IoT DC3 的应用层智能集中在两条路径上,都建立在前几层已经归一好的位号值与统一鉴权之上。两者的共性是:**AI 的所有动作最终都走平台真实
API,经网关注入主体上下文,再由鉴权中心做 RBAC 权限校验与租户隔离**——模型拿不到比对应账号更多的权限。
**第一条:[Agentic 中心](../ai/agentic)(平台内建的对话式 AI 运营)。** 基于 Spring AI,把一个 OpenAI
兼容的大模型接到设备、位号、数据与命令上。用户用自然语言提问,模型按需调用平台内置工具去查元数据、读实时值,在受控授权下触发设备读写。这正是上文"
工具调用"模式的落地——模型读的是真实数据,不是训练记忆。
::: info 内置工具是 10 个,不是 8 个
智能中心内置 **10** 个 `@Tool` 工具类:`TenantTool`、`UserTool`、`DeviceTool`、`DriverTool`、`ProfileTool`、`PointTool`、
`PointValueTool`、`SystemTool`、`CommandTool`、`EventTool`。早期文案里出现过"8 个"的说法,以代码与目录为准的数量是 10。
:::
::: warning 工具调用默认开启,但可关
工具调用由环境变量 `AGENTIC_TOOL_CALLING_ENABLED` 控制,默认 `true`。设为 `false`
后模型退化为纯对话,不再触碰任何设备/数据接口——需要在受限环境里只放开问答时这样配。此外持久化会话记忆由
`AGENTIC_MEMORY_ENABLED` 控制:`.env.example` 部署模板将其置为 `false`(默认关闭);若不提供该变量,框架内置默认为开启——以实际部署环境为准。
:::
::: danger 高风险写动作不直接执行
智能中心的写工具**从不直接下发命令**,而是先生成一个待确认的 Action(状态 `PENDING`,默认 `now + 10 分钟` 过期),返回
`pendingConfirmation=true`;必须由用户携带 `action_id` 调 `POST /action/confirm` 确认后,才真正执行写命令。这与下面 MCP
网关的风险门控是两套独立实现,细节见 [Agentic 中心](../ai/agentic)。
:::
**第二条:[AI Agent / MCP](../ai/mcp)(把工具安全暴露给外部 Agent)。** 网关在 `POST /mcp` 提供 JSON-RPC 2.0 的 MCP Resource
Server,工具目录由四个中心的 OpenAPI 自动聚合(约 330+ 个工具),由外部 Agent 自主决定调用哪个。它面向"自己搭 Agent
让模型自主编排"的场景,约束比对话式更严:
- **仅 OAuth 2.1**:MCP 访问只接受 OAuth 2.1 颁发的短时 JWT(默认 15 分钟有效),公开客户端强制 PKCE(S256)、刷新令牌轮换。*
*当前没有 Personal Access Token(PAT)等长期静态令牌**这一接入方式。
- **三层工具可见性过滤**:`tools/list` 返回的工具 = 主体 RBAC 权限 ∩ 该 MCP 连接的工具白名单 ∩ 风险策略(HIGH
风险默认隐藏,需显式开启)的交集。Agent 看得见、调得动哪些工具,由这三层共同决定。
- **HIGH 风险两阶段确认**:高风险工具调用先返回 `CONFIRM_REQUIRED` + `confirmId`,客户端须携 `confirmId` +
幂等键二次调用,服务端校验未过期、参数摘要一致、单次消费,并全程写入审计日志。
::: info MCP 的 resources / prompts 尚未实现
MCP 协议里的 `resources`(资源暴露)与 `prompts`(提示词模板)在 IoT DC3 中**尚未实现**,属规划项;当前只提供 `tools`(工具)能力。此外
`tools/list_changed` 变更通知**未做事件推送**——工具目录不是实时同步给已连接的 Agent,刷新需经管理端接口
`POST /mcp/tool/catalog/refresh` 手动(或 API 注册变更后)触发重建(`PT5M` 是 HIGH 风险二阶段确认的 `confirm-ttl`,与目录刷新无关)。
:::
**告警与通知** 则承接了上文"异常检测与智能告警"的工程要点。DC3 的[告警与通知](../operation/alarms)用规则引擎做确定性判断、用
`dc3_rule_state` 做状态机(触发/恢复/关闭)去抖动、用告警分级(P0–P3)与多渠道通知(邮件/SMS/Webhook)做降噪与分发——这是"
规则托底"的那一半,与上面"模型补强"的 AI 路径互补:规则覆盖写得出来的硬约束,AI 负责帮人理解与处置。
把三者放回那张闭环图:**采集**与**分析**由前几层与告警规则承担,**决策**在规则引擎或大模型,**执行**是告警通知或经确认的命令下发——AI
不是另起炉灶,而是接进了这条既有的"感知—决策—执行—反馈"链路。
## 延伸阅读
- [时序数据与流处理](./data-pipeline) — 智能分析的输入:位号值如何汇聚、存储、可查
- [物联网安全](./security) — AI 路径同样要过的鉴权、租户隔离与传输安全
- [物联网技术总览](./) — 四层参考架构,理解应用层在整体中的位置
- [AI 概览](../ai/) — DC3 两种 AI 接入方式的总览与选型
- [Agentic 中心](../ai/agentic) — 对话式 AI 运营、10 个内置工具、高风险动作确认
- [AI Agent / MCP](../ai/mcp) — OAuth 2.1 + MCP,把工具安全接给外部 Agent
- [告警与通知](../operation/alarms) — 规则引擎、状态机降噪、分级与多渠道通知
---
# 时序数据与流处理
URL: https://docs.dc3.site/zh/foundations/data-pipeline
# 时序数据与流处理
物联网平台层真正的考验,是**怎么把源源不断的位号值存下来、算出来、查得动**。这一层既不是设备,也不是业务应用,而是夹在中间的"
数据骨架":每秒成千上万条带时间戳的读数涌入,既要写得进、留得住,又要随时被仪表盘、告警、AI
拉出来用。读完这一章,你会理解时序数据为什么需要专门的存储与管线,知道批处理与流处理各自适合什么,并能把这套通用范式对应到
IoT DC3 的[数据平面](../architecture/data-plane)——位号值经 RabbitMQ 异步投递、落进 TimescaleDB 超表、再进最新值缓存的那条链路。
## 这一层是什么 / 为什么存在
在四层参考架构里,平台层的职责是"存起来、管起来、算出来"。感知层产出物理量、网络层把它们送达之后,平台层要面对一个和传统业务系统截然不同的负载:
**时序数据(time-series data)**。
时序数据有几个共同的脾气,理解它们就理解了为什么不能拿一张普通关系表硬扛:
- **高写入、低更新**:数据几乎只追加(append-only),一条采集落库后基本不再改。写入吞吐是主要压力,事务、行级更新、外键这些传统数据库的强项在这里几乎用不上。
- **天然按时间索引**:每条记录都带一个时间戳,绝大多数查询都是"某设备某位号在某段时间内的值"。时间是第一查询维度,不是可有可无的列。
- **近期热、远期冷**:刚采到的值被频繁读取(实时大屏、当前告警判定),几个月前的值只在偶尔的趋势分析里被扫到。访问热度随时间快速衰减。
- **价值随精度衰减**:一年前的数据没人关心毫秒级细节,按小时或按天的均值/极值就够了。这给了**降采样(downsampling)**和**
保留策略(retention)**用武之地。
如果用普通关系表存这些数据,问题很快暴露:单表行数膨胀到上亿后,时间范围扫描越来越慢;B-tree
索引在持续追加下不断分裂、膨胀;没有内建的过期机制,老数据只能靠人写脚本清理。平台层需要的是**为"时间 + 追加 + 冷热分层"量身定制
**的存储与处理范式——这正是时序数据库与流处理管线存在的理由。
## 关键技术与权衡
把时序数据用好,靠的是一条管线,而不是单一组件。典型的物联网数据管线由四段串成:采集端把读数发往**消息总线**解耦,消费端把消息
**落库**进时序存储,存储之上再提供**查询**给应用与算法。
这条管线上有几处关键技术与权衡:
**时序数据库:超表与分区思想。** 通用方案(如 TimescaleDB、InfluxDB、TDengine)的共同思路是**把一张逻辑大表自动切成许多小块**
。以 TimescaleDB 的**超表(hypertable)**为例:对用户它就是一张普通的表,可以用标准 SQL 读写;底层却按时间维(再加上某个设备/标签维)自动把数据切成一个个
**分块(chunk)**。好处是显而易见的——查询带上时间范围时,规划器只扫相关的几个块(chunk pruning),而不是整张表;写入永远落在"
最新"那个块上,索引局部、不必在亿级数据里到处插入;过期数据可以整块(chunk)丢弃,比逐行 `DELETE` 快几个数量级。分区是时序存储一切性能的根基。
**降采样与保留策略:用精度换成本。** 既然数据价值随时间衰减,就没必要为远期数据付高精度的存储代价。两条互补的策略:**保留策略
**自动 drop 掉超过某个年龄的原始数据;**降采样/连续聚合**则把高频原始值预先卷成低频摘要(如每分钟原始值 →
每小时均值/最值),既保留长期趋势又大幅压缩体积。再叠加**列式压缩**,冷块通常能压到原体积的零头。这三者一起,让"留三年数据"
在经济上变得可行。
**消息总线:解耦与背压。** 采集端和落库端如果直连,任何一端的抖动都会拖垮另一端——设备一波突发会瞬间打爆数据库,数据库一次慢查询又会把采集线程堵住。中间插一条
**消息总线(message bus)**(RabbitMQ、Kafka、MQTT broker 等)就把两端解耦:采集端只管发,落库端按自己的节奏消费。这同时引出*
*背压(backpressure)**:当下游消费跟不上时,消息在队列里堆积而不是丢弃,并通过**预取限流(prefetch)**、**消费者并发**、**死信与
TTL** 等手段控制风险——堆积有上限、超时进死信、消费可水平扩展。总线让管线在流量尖峰下"弹性吸收"而非"硬碰硬"。
**批处理 vs 流处理:两种计算姿势。** 同一份时序数据,有两种算法:
- **批处理(batch)**:攒一批再一次性算/写。吞吐高、单位开销低,但有延迟——适合"每小时出报表""每天算趋势"
这类对时延不敏感的场景,也适合写入侧的批量落库以摊薄 I/O 成本。
- **流处理(stream)**:数据一到就算。延迟低,能在值落库的同时(甚至之前)就完成告警判定、滑动窗口聚合——适合"温度超限立刻报警""
实时大屏"这类要求秒级响应的场景。
二者不是二选一,而是**同一管线里的两条岔路**:热路径走流处理保实时,冷路径走批处理保吞吐。实践中常见"
写入用批量摊薄、告警用流式即时"的组合。
## 工程要点
把上面的范式落到生产,有几条经验值得记住:
- **写入永远是第一约束**。时序系统的容量先看"每秒能写多少点"。批量写入、合适的分块大小、避免写入时维护重索引,都是为写吞吐让路。读优化排第二。
- **时间戳要分清"采集时刻"与"落库时刻"**。设备采到值的时间和它被写进库的时间是两回事,二者之差就是管线延迟。把两个时间都存下来,既能正确排序,又能用来监控链路时延。
- **聚合要警惕可空值**。时序表里常混有字符串型、JSON 型的非数值载荷,对应的数值列为空。做 `AVG`/`SUM`/`MAX`
时若不显式过滤空值,结果会被悄悄带偏——这是个反复踩的坑。
- **冷热分层要尽早规划**。压缩与保留策略最好在建表时就定,等数据涨到亿级再补救会很被动。压缩后的块通常只读,要确认查询路径仍然可用。
- **背压参数要按负载调**。预取数、消费者并发、队列 TTL 这些不是拍脑袋的常数:预取太大占内存、太小压不满吞吐;并发太高争数据库连接、太低消费不过来。先用保守默认值,再按实测流量调。
- **死信不是垃圾桶**。超时、格式错误、反复处理失败的消息进死信队列后要有人看、有据可查,而不是任其堆积或静默丢弃。
## 在 IoT DC3 中如何落地
IoT DC3 的[数据平面](../architecture/data-plane)就是上面这条通用管线的一个具体实现。一条位号值从设备到可查,正好走过"
采集 → 消息总线 → 消费落库 → 缓存/查询"四段:
**消息总线:RabbitMQ 异步投递 + 7 天 TTL + 死信。** 驱动采到的位号值不直写数据库,而是发往 RabbitMQ 的 topic 交换机
`dc3.e.value`,数据中心 `dc3-center-data` 的持久队列 `dc3.q.value.point` 以通配 `dc3.r.value.point.*` 收下全部驱动的值。这条队列声明了
**7 天 TTL**(`604800000` ms)并挂了**死信交换机** `dc3.e.point_value_dead`:消息在队列里最多停 7 天,超时或被 reject
即进死信,不会静默丢失。这正是"消息总线解耦 + 背压 + 死信兜底"在 DC3 的落地。
::: info 消费者并发是默认档,非高吞吐档
位号队列的消费者 `PointValueReceiver` 没有显式指定 `containerFactory`,因此跑在**默认监听容器工厂**上:
`concurrentConsumers=2`、`maxConcurrentConsumers=8`、`prefetchCount=10`、手动 ack。`RabbitConfig` 另外提供了一个高吞吐工厂
`highThroughputRabbitListenerContainerFactory`(`concurrent=4`、`max=32`、`prefetch=100`),但**当前没有监听器 opt-in**
——高吞吐工厂存在,默认未启用。高吞吐工厂需要时给 `@RabbitListener` 显式加 `containerFactory`。
:::
**批量落库:按入站速率二选一。** 数据中心消费时不是无脑逐条写库。它按入站速率分流——速率低于 `POINT_BATCH_SPEED`(默认 `100`
)时即时落库;超过阈值时改交 Quartz 定时任务批处理,由 `POINT_BATCH_INTERVAL`(默认 `5`,单位**秒**)参与速率计算。这正是"
写入侧用批处理摊薄 I/O、低峰时用即时写保实时"的工程取舍。
**时序存储:TimescaleDB 超表 `dc3_point_value`。** 位号值落在 TimescaleDB **超表** `dc3_point_value`(位于 `dc3_history`
schema)。它实践了上文的分区思想——按时间维 `create_time` **每 1 天一个 chunk**、按设备维 `device_id` **16 个哈希桶**
双维分区;并配了两条数据生命周期策略:**7 天前的 chunk 自动列式压缩**、**超过 180 天的数据自动清理**
。冷热分层、压缩、保留,在这里都是开箱即用的硬事实。
::: danger num_value 可空:聚合查询必须 num_value IS NOT NULL
`dc3_point_value.num_value`(`DOUBLE PRECISION`)对非数值或 JSON 载荷为 `NULL`。任何 `AVG`/`SUM`/`MAX`/`MIN` 聚合都**必须**
加 `WHERE num_value IS NOT NULL`,否则字符串型位号的空值会混进来、把结果带偏;超表上还有一条只覆盖 `num_value IS NOT NULL`
的部分索引,不加这个谓词连索引都会错过。这正是"工程要点"里那条"聚合警惕可空值"在 DC3 的具体体现。
:::
**最新值缓存:Caffeine 热路径。** 写路径在落库的同时把最新值塞进本地 **Caffeine 最新值缓存**;读最新值(`point_value/latest`
)时先吃这层缓存、未命中再回源 TimescaleDB 补齐。历史区间查询(`point_value/list`)则不走缓存、直接扫超表。这就是"
近期热、远期冷"在读路径上的落地:热点最新值走内存缓存,冷数据走时序库的时间范围扫描。
链路的可靠性由三处叠加保证:消息发布前被统一标 `PERSISTENT`(配合 `durable` 队列扛 broker 重启)、消费者手动
ack(成功才确认、异常重回队列、校验失败进死信)、publisher confirms(确认回调追踪投递)。值一旦落库,还会**同步**
交告警引擎评估——这是流处理"数据一到即算"在 DC3
的体现。完整链路、模型变换与读接口示例见[数据平面](../architecture/data-plane)。
## 延伸阅读
- [边缘与云架构](./edge-cloud) — 这条管线的上游:采集在边缘还是在云、驱动如何下沉
- [数据智能与 AIoT](./aiot) — 这条管线的下游:落库后的数据如何被分析与大模型消费
- [物联网技术总览](./) — 四层参考架构全景与 DC3 的逐层映射
- [数据平面](../architecture/data-plane) — DC3 里一条位号值从设备落库的完整链路与硬约束
- [服务与拓扑](../architecture/services) — 数据中心、消息总线、时序库在 DC3 服务图里的位置
---
# 边缘与云架构
URL: https://docs.dc3.site/zh/foundations/edge-cloud
# 边缘与云架构
物联网的平台层不只是"一台服务器"
,而是一条从现场到数据中心的连续光谱:哪些计算靠近设备做、哪些放到云上集中做,决定了系统的时延、带宽、可用性与隐私边界。这一章讲清云-边-端如何分工、边缘计算与雾计算到底差在哪、什么时候必须把计算下沉到边缘,最后落到
IoT DC3 的平台层形态——一个网关加四个中心服务,协议驱动可贴近现场、中心服务可集中部署,由 Facade 模式在分布式与同进程之间一键切换。
读完你能判断:一个采集任务、一条告警规则、一次命令下发,应该落在端、边、还是云。
## 这一层是什么 / 为什么存在
把物联网的平台层拆成三段,是因为这三段的物理约束根本不同。
**端**是设备本身——传感器、执行器、PLC、电表。它算力极弱、只懂自己的协议,目标是把物理量变成可传输的信号、把命令落到寄存器。它不该承担业务逻辑,更扛不住大模型推理。
**云**是远端的数据中心——算力近乎无限、存储廉价、便于集中管理与全局分析。它擅长把成千上万台设备的数据汇到一处,跑历史分析、训练模型、提供统一的
API 与界面。它的代价是"远":每一跳都要穿过广域网,时延、带宽、连接稳定性都不由你掌控。
**边**是夹在两者之间的那一层——部署在靠近设备的现场(车间、变电站、楼宇机房),算力比设备强、比云弱,但**离设备只有一跳局域网**
。它存在的全部理由,就是把那些"等不起云、也不该等云"的计算就地做掉。
为什么需要边缘?归根到底是四个现实约束:
- **时延**:一条产线上的急停联锁要求毫秒级响应,命令绕一圈云端往返几十到上百毫秒,可能已经造成事故。控制闭环越紧,越必须就近做决策。
- **带宽**
:一台振动传感器每秒上千个采样点,几百台设备的原始波形全量上云,广域带宽既贵又扛不住。边缘先做降采样、特征提取、聚合,只把"
有意义的结果"上云。
- **可用性**:现场到云的网络会断。断网期间,采集不能停、本地联锁不能失效、告警不能哑——边缘要能脱网自治,等网络恢复再补传。
- **隐私与合规**:摄像头画面、产线工艺参数、能耗明细,往往不允许或不愿意离开厂区。敏感数据在边缘就地处理、只上传脱敏结果,是很多行业的硬约束。
换句话说,**云负责"广",边负责"快"与"稳",端负责"接"**。三者不是替代关系,而是按职责切开的连续体。
## 关键技术与权衡
### 边缘计算与雾计算:常被混用,但侧重不同
两个词都指"把计算从云下沉到靠近数据源处",差别在**下沉到哪一层、谁来承担**:
- **边缘计算(Edge Computing)**:计算发生在网络的最边缘——设备本身或紧邻设备的边缘网关上。强调"尽可能贴近数据产生点"
,单点、轻量、面向具体现场。
- **雾计算(Fog Computing)**:由 OpenFog 等组织提出,强调在"端与云之间"构建一个**分层、分布式的计算与网络层**
——可能横跨多个网关、本地服务器乃至区域机房,是一张协同的网,而不只是单个边缘节点。
实践中可以这样记:边缘计算是"把活儿放到边上做"这件事,雾计算是"把边上这些算力组织成一层有协同、有调度的基础设施"
。本章后续不强行区分二者,统一用"边缘"指代"端与云之间的就近计算层"。
### 端-边-云三层如何协同
下面这张图把三层的职责与数据/命令流向铺开。关键不在每个框里有什么,而在**哪条边是局域网、哪条边是广域网**——这决定了什么计算该放在哪一侧。
图里实线是上行数据、虚线是下行命令;端到边是"局域网一跳",边到云才跨广域网。把这条边界看清,"该放哪"的答案就自然浮现:时延敏感、断网仍要工作的逻辑放在
**边**(甚至端),全局视角、海量存储、模型训练放在**云**。
### 边缘网关的职责
边缘网关是这一层的承重墙,它至少要扛起五件事:
- **协议适配**:把现场五花八门的协议(Modbus、OPC UA、BACnet……)归一成平台能理解的统一数据形态。这是它最基础也最不可省的职责。
- **过滤与聚合**:在上云前做降采样、去重、特征提取、窗口聚合,把带宽用在刀刃上。
- **本地缓存与补传**:断网时把数据落到本地,恢复后按序补传,保证不丢点。
- **边缘自治**:脱网期间维持采集、执行本地规则与联锁、就地产生告警,不依赖云端心跳。
- **安全边界**:作为现场网络与外网的唯一出入口,承担鉴权、加密、最小暴露面——现场设备不直接裸露到公网。
权衡在于:网关承担越多,现场越自治、越抗网络抖动,但运维与一致性也越复杂(边缘节点多了,配置同步、版本升级、状态可观测都成问题)。
**边缘做多少、云做多少,是这一层最核心的设计取舍。**
### 数字孪生:物理实体在数字侧的镜像
数字孪生(Digital Twin)是给每一台物理设备/产线/工厂建一个**持续同步的数字镜像**
:它聚合该实体的实时位号值、历史曲线、模型结构与运行状态,让你在数字侧观察、推演、甚至预测物理侧的行为。
它依赖前面整条链路:端采集、边聚合、云汇总,孪生体才有"活"的数据喂养。孪生通常落在云侧(需要全局数据与算力),但其**实时刷新**
依赖边缘把最新值及时上行。它的价值在于把"散落的位号值"重新组织成"以实体为中心"的视图——这正是从原始数据走向智能运营的关键一跃。
### 云平台的核心能力
云侧平台通常围绕四类能力构建,它们正好对应"设备怎么管、连接怎么管、数据怎么用、规则怎么跑":
- **设备管理(Device Management)**:设备/模板/位号的建模、注册、生命周期、远程配置与固件管理——回答"
系统里有哪些设备、它们能干什么"。
- **连接管理(Connection Management)**:设备在线/离线状态、心跳与超时、鉴权与会话——回答"谁连着、连得稳不稳"。
- **规则引擎(Rule Engine)**:在数据流上配置条件触发——阈值告警、联动、转发,把"数据"变成"动作"。
- **数据服务(Data Service)**:时序存储、查询聚合、对外 API——把海量位号值变成可消费的数据资产。
这四类能力共同构成云平台的骨架。值得强调的是:它们并非全都必须留在云。**规则引擎与部分数据服务完全可以下沉到边缘**
——这恰恰是"边云如何分工"的实践空间。
## 工程要点
设计一套云-边-端系统,几条经验贯穿始终:
- **按"等不等得起云"分配计算**。先问一个问题:这段逻辑断网时还能不能停?不能停的(控制联锁、本地告警、采集缓存)下沉到边;能等、需要全局视角的(趋势分析、模型训练、跨厂对比)放到云。
- **上云的是"结果"而非"原始流"**。边缘先做聚合与特征提取,广域带宽永远是稀缺资源,别拿它传可以在本地压缩掉的冗余。
- **边缘必须能脱网自治**。把云当成"会断的依赖"来设计:断网期间核心功能不能瘫,恢复后能自动补传与对齐。
- **命令链路要有明确的失败语义**。下行命令跨广域网更易超时,必须区分"成功""失败""超时",失败不能伪造成成功——否则上层据假数据决策会酿成更大问题。
- **统一数据模型贯穿三层**。端的原始信号、边的聚合值、云的入库值如果各说各话,孪生与分析就无从谈起。一套稳定的"位号语义"
应当从边一直贯通到云。
## 在 IoT DC3 中如何落地
IoT DC3 的平台层不是一个大单体,而是 [一个网关加四个中心服务](../architecture/services)
,南向由协议驱动接入现场。这套形态天然能在"边"与"云"之间分布。
**协议驱动 = 可下沉到边缘的那一层。** DC3 的协议驱动(`dc3-driver-*`)负责协议适配与就近采集,正对应"边缘网关"
的核心职责。驱动与数据中心之间**不直连**,而是经 RabbitMQ 异步收发——位号值往北上行、命令往南下行。这层异步解耦正是边云能分开部署的前提:驱动可以贴近现场跑,把广域网的抖动挡在
MQ 这道缓冲之外,采集不会因为云端变慢而反压掉线。
**四个中心服务 = 可集中部署的云侧能力。
** [鉴权中心 dc3-center-auth、管理中心 dc3-center-manager、数据中心 dc3-center-data、智能中心 dc3-center-agentic](../architecture/services)
覆盖了前面说的"设备管理 / 连接管理 / 规则引擎 / 数据服务"——其中连接管理(设备/驱动的在线离线状态、租约过期检测)与规则(告警)引擎主要落在数据中心
`dc3-center-data`,设备/模板/位号的元数据管理由管理中心承担;此外中心服务还提供鉴权租户、LLM 与工具调用等能力。位号值最终落入
TimescaleDB 时序库并对外可查,这是云侧"数据服务"的具体形态。
**Facade 模式 = 边云分工的开关。** 中心服务之间的相互调用面向 `dc3-common-facade-api` 的契约接口编程,运行时由
`DC3_FACADE_MODE` 决定实现:[`grpc`(分布式默认)](../architecture/facade-modes) 让各中心独立成进程、跨进程协作,适合"
中心集中在云、驱动散布在边"的拓扑;`local`(单进程)把所有中心合一跑在一台机器上,适合本地与小型单机。也就是说,"
边云如何分工、要不要分布式"在 DC3 里被收敛成一个部署开关,而不是两套代码——同一份业务逻辑,换 `DC3_FACADE_MODE` 即可在两种形态间切换。
::: tip 把这一章的概念映射到 DC3
- 边缘网关的"协议适配 / 过滤 / 采集" → 协议驱动 `dc3-driver-*`
- 边云之间的异步解耦缓冲 → RabbitMQ(位号值上行 / 命令下行)
- 云侧"设备元数据管理" → 管理中心 `dc3-center-manager`
- 云侧"连接/状态管理"(在线离线、租约过期)→ 数据中心 `dc3-center-data`
- 云侧"数据服务" → 数据中心 `dc3-center-data` + TimescaleDB
- 边云分工的部署开关 → Facade 模式 `DC3_FACADE_MODE`
:::
::: info 关于"边缘自治"的实现边界
本章描述的"边缘脱网自治"是云-边-端架构的通用目标。DC3 中驱动贴近现场部署、经 MQ
与中心解耦,为这种分工提供了结构基础;具体到某个驱动在断网期间维持多少本地能力,取决于该驱动的实现。需要确认时,以对应驱动源码与 [系统架构](../architecture/)
描述为准。
:::
完整的服务拓扑、端口分配与启动依赖见 [系统架构](../architecture/) 与 [服务与拓扑](../architecture/services)
;边云分工背后的接口装配细节见 [Facade 模式](../architecture/facade-modes)。
## 延伸阅读
- [时序数据与流处理](./data-pipeline) — 上云后的位号值如何存储、聚合与流式处理
- [IoT 协议与无线网络](./iot-protocols) — 端到边那一跳走什么协议、如何取舍
- [物联网技术总览](./) — 四层参考架构与 DC3 的整体定位
- [系统架构](../architecture/) — DC3 的网关 + 四中心 + 驱动如何协作
- [服务与拓扑](../architecture/services) — 六个可部署单元、端口与启动依赖
- [Facade 模式](../architecture/facade-modes) — `grpc` 与 `local`:分布式与同进程的切换
---
# 工业总线与协议
URL: https://docs.dc3.site/zh/foundations/fieldbus
# 工业总线与协议
工业现场的设备说着几十种互不相通的"方言"——PLC
用厂商私有协议、电表用计量标准、楼宇用自控总线。这一章讲清这些协议各自解决什么问题、按什么模型通信、怎么寻址一个数据点,以及选型时该权衡什么。读完你能看懂一台现场设备的协议参数(寄存器地址、字节序、功能码),并知道
IoT DC3 怎么把它们统一成位号值。
> 你在这里:网络层的"工业有线侧"。无线与轻量物联网协议见[IoT 协议与无线网络](./iot-protocols)
> ,上游的物理量从哪来见[传感与测量](./sensing)。
## 这一层是什么 / 为什么存在
把一个温度从传感器送到平台,物理量先被变送器变成电信号、再被采集设备数字化,最后要通过某种**协议**
在网络上传输。工业协议就是这"最后一公里"的语言规约:它规定字节怎么排、地址怎么编、一问一答还是订阅推送、谁主动谁被动。
这些协议为什么这么多、这么乱?因为它们诞生于不同年代、不同行业、不同厂商的封闭生态:
- **历史包袱**。Modbus 1979 年为 PLC 串口通信而生,至今仍是现场最常见的协议;OPC DA 绑定 Windows COM/DCOM,是 PC
时代的产物;后来才有跨平台的 OPC UA。
- **厂商壁垒**。Siemens 的 S7、Mitsubishi 的 MELSEC(MC 协议)、Omron 的 FINS、Rockwell 的 EtherNet/IP——各家 PLC
都有自己的私有协议,互不兼容,绑定客户。
- **行业标准**。电力调度有 IEC 60870-5-104,楼宇自控有 BACnet,公用事业计量有 DLMS/COSEM,汽车与嵌入式有
CAN——每个行业按自己的需求立了标准。
结果是:同一个"读一个数值"的动作,在不同协议里寻址方式、报文格式、数据类型表示全不一样。这一层的价值,就是理解这些差异背后的*
*共性模型**——一旦看穿它们都是"在某个寻址空间里读/写一个值",异构就不再可怕。
## 关键技术与权衡
抛开语法细节,工业协议的差异集中在四个维度:**通信模型、寻址方式、字节序与数据类型、轮询节奏**。理解这四点,任何陌生协议都能快速上手。
### 三种通信模型
- **主从(Master/Slave)/ 请求-响应**。一个主站轮流向从站发请求、等应答;从站不会主动说话。Modbus、IEC
104(客户端发总召唤)、S7、MELSEC、FINS 基本都是这个模型。简单、确定,但主站不问就拿不到数据,实时性受轮询周期限制。(Modbus
即典型主从协议——主站发请求、从站应答,见《物联网之魂:物联网协议与物联网操作系统》孙昊等,机械工业出版社·2019,第 1 章 1.14.2
节,p165)
- **客户端-服务器(Client/Server)**。比主从更对称:OPC UA 客户端可以浏览服务端的地址空间、按需读写,还能**订阅**
——服务端在值变化时主动推送,省去无谓轮询。功能强但握手与会话开销大。(C/S 请求-响应是 Web
协议的基础模型——客户端发请求、服务端应答,见《物联网之魂:物联网协议与物联网操作系统》孙昊等,机械工业出版社·2019,第 1 章
1.6.1–1.6.2 节,p49、p51)
- **发布-订阅(Pub/Sub)**。CAN 把带 ID 的帧广播到总线,接收方按 ID
过滤;MQTT(见无线侧)按主题订阅。没有中心轮询,天然适合多接收方、事件驱动的场景。(发布/订阅模型将发布者与使用者分离,由代理按主题路由消息,见《物联网之魂:物联网协议与物联网操作系统》孙昊等,机械工业出版社·2019,第
1 章 1.13.2 节,p155)
### 寻址:寄存器 vs 标签 vs 对象
"读哪个点"在不同协议里是完全不同的概念:
- **数字地址(寄存器/IOA/OBIS)**。Modbus 用功能码 + 0
基偏移定位线圈/寄存器(其标准功能码按读/写归纳通信任务,见《物联网之魂:物联网协议与物联网操作系统》孙昊等,机械工业出版社·2019,第
1 章 1.14.2 节,p165);IEC 104 用信息对象地址 IOA 定位遥测遥信;DLMS 用
6 段 OBIS 编码(如 `1.0.1.8.0.255` = 总有功电能)定位 COSEM 对象。地址是数字,靠工程约定对齐。
- **符号标签(Tag/Item)**。EtherNet/IP(CIP)按标签名寻址:PLC 里的变量叫 `Motor_Speed`,驱动按名字读写,不关心物理地址;OPC 按
NodeId/ItemId 寻址。可读性好,但名字必须逐字一致。
- **对象 + 属性**。BACnet 把每个量建模为对象(如 Analog Input #1)+ 属性(Present_Value);DLMS 的 COSEM 对象也有带编号的属性(属性
2 = 当前值)。
### 字节序与数据类型
工业设备多为大端(Big-Endian),但一个 32 位 `FLOAT` 跨两个 16 位寄存器时,**寄存器顺序**还可能颠倒(ABCD / CDAB / BADC /
DCBA 四种排法),这是 Modbus 现场最常见的坑。协议本身往往只搬运字节,**怎么解释这串字节由配置决定**:是 16 位整数还是 32
位浮点、低字节在前还是高字节在前、要不要乘系数加偏移。配错字节序,浮点会读成一个无意义的大数。
### 轮询机制
主从协议靠定时轮询取数:周期太短压垮设备和总线,太长则实时性差。订阅型协议(OPC
UA、CAN、MQTT)能改善这点——值变才推。工程上常按点位重要性分组轮询:关键量高频、辅助量低频。
下图把本章协议按**应用领域**归类,每类对应一种典型的通信模型与寻址方式:
::: tip 没有"最好"的协议,只有"最合适"的
选型先看设备本身支持什么——多数现场设备协议是固定的、由厂商决定,你只能适配。能选时再权衡:要跨厂商互操作选 OPC
UA;要省带宽、事件驱动选订阅型;纯抄表选 DLMS;轻量、低成本嵌入选 Modbus 或 CAN。
:::
## 工程要点
- **协议端口各不相同,别张冠李戴**。Modbus TCP 是 `502`,EtherNet/IP 是 `44818`,IEC 104 是 `2404`,DLMS TCP 常见 `4059`
。沿用错端口连不上。
- **地址是工程约定,接入前必须核对**。Modbus 的 `offset` 是 0 基协议地址("40001"应填 `offset=0`,不是 `40001`);IEC 104 的
COT/CA/IOA 字节长度必须与对端一字不差,否则整条报文解析错位;CIP 的标签名区分大小写、必须逐字一致。
- **数据类型与字节序要和设备对齐**。位号的数据类型决定怎么拼字节:多寄存器的 32 位浮点要选对寄存器顺序;CAN 帧载荷要按
`dataOffset`/`dataLength`/`byteOrder` 切分。把 `REAL` 配成 `DINT`,浮点字节会被当整数解析出无意义大数。
- **读写要分清功能码/服务**。Modbus 读用 `01/02/03/04`、写用 `05/06/15/16`;很多协议读写是不同的服务,可写位号要单独配置写命令。
- **失败要"显性失败",不要伪造成功**。设备不可达或解析失败时,正确做法是记录失败并退避,而不是回显缓存值或假装写成功——后者会让上层基于错误数据决策。
## 在 IoT DC3 中如何落地
面对这么多异构协议,IoT DC3 的策略是:**每种协议一个[协议驱动](../drivers/),把协议层差异收敛在驱动内,对上统一成带语义的位号值
**。无论底层是 Modbus 寄存器、CIP 标签还是 OBIS 编码,落到平台都是同一个[位号 Point](../introduction/concepts/point)
的[位号值 PointValue](../introduction/concepts/point-value),上层的存储、查询、告警、AI 完全无需关心协议细节。
DC3 共内置 **28 个驱动**,本章涉及的工业协议大多有对应驱动:
- [Modbus TCP](../drivers/modbus-tcp) / [Modbus RTU](../drivers/modbus-rtu) — 以太网 / 串口 Modbus 主站
- [OPC UA](../drivers/opc-ua) / [OPC DA](../drivers/opc-da) — OPC 统一架构客户端 / 经典数据访问
- [S7](../drivers/plcs7) — 西门子 PLC
- [MELSEC](../drivers/melsec) — 三菱 PLC(MC 协议)
- [FINS](../drivers/fins) — 欧姆龙 PLC
- [BACnet/IP](../drivers/bacnet-ip) — 楼宇自控
- [SNMP](../drivers/snmp) — 网络设备监控
- [EtherNet/IP](../drivers/ethernet-ip)、[IEC 104](../drivers/iec104)、[DLMS](../drivers/dlms)、[CAN](../drivers/can) —
罗克韦尔 CIP / 电力 SCADA / 智能电表 / 控制器局域网
### 协议参数即驱动属性,设备实例填值
本章讲的每个协议参数,在 DC3 里都落成驱动声明的**属性**,由设备实例为其填**配置值**
——这套机制见[属性与配置](../introduction/concepts/attribute-config)。以 Modbus TCP 为例:驱动级属性 `host`/`port`
标识从站,位号级属性 `slaveId`/`functionCode`/`offset` 定位寄存器,写命令属性指定写功能码与值模板。换成 EtherNet/IP,位号属性就变成
`tagName`/`tagType`;换成 DLMS,则是 `logicalName`(OBIS)/`attributeId`。前文说的字节序、寄存器顺序,也都体现为相应位号属性。
也就是说,本章的协议知识不是抽象理论——它直接对应你在 DC3 接入设备时要填的每一个字段。读懂协议,就读懂了驱动的属性表。
::: warning 部分驱动当前为协议骨架(WIP)
并非所有驱动都已完成协议层
I/O。[EtherNet/IP](../drivers/ethernet-ip)、[IEC 104](../drivers/iec104)、[DLMS](../drivers/dlms)、[CAN](../drivers/can) 当前为
**骨架实现**:属性表、采集周期、寻址语义已就位且可照填,但实际协议收发的完成度各不相同——
- [IEC 104](../drivers/iec104)、[DLMS](../drivers/dlms):`read()`/`write()` 直接显式抛"未实现"异常快速失败(由 SDK
记录失败并退避,而非伪造成功),协议 I/O 尚未编写。
- [EtherNet/IP](../drivers/ethernet-ip):上层流程(按标签取数、编解码、套接字连接)已就位,但 CIP 协议组帧(`RegisterSession`/
`ForwardOpen`/封装帧)尚未补全。
- [CAN](../drivers/can):`read()`/`write()` 通过 `ProcessBuilder` 调 Linux `can-utils`(`candump`/`cansend`
)已能实际收发,但字节切分/类型转换(`dataOffset`/`dataLength` 等)与原生 SocketCAN I/O 尚未补齐,写路径的 `data`
模板当前也未真正接通。
把它们当作接入对应协议的**起点模板**,而非生产可用成品。各驱动页的实现状态以页内标注为准。
:::
::: info Modbus/OPC UA/S7/BACnet 等为可用驱动
[Modbus TCP](../drivers/modbus-tcp)、[Modbus RTU](../drivers/modbus-rtu)、[OPC UA](../drivers/opc-ua)、[OPC DA](../drivers/opc-da)、[S7](../drivers/plcs7)、[MELSEC](../drivers/melsec)、[BACnet/IP](../drivers/bacnet-ip)、[SNMP](../drivers/snmp)
等已实现协议层读写;[FINS](../drivers/fins) 可用但当前读路径仅支持 16 位类型(详见其驱动页)。接入前以各驱动页的属性表与说明为准。
:::
## 参考文献
1. 孙昊 等. 物联网之魂:物联网协议与物联网操作系统[M]. 北京:机械工业出版社,2019. ISBN 978-7-111-62931-3.(第 1 章 1.14.2 节
基于现场总线的协议转换器 p165 — 主从模型/Modbus 功能码;1.6.1–1.6.2 节 HTTP 协议 p49、p51 — 客户端-服务器模型;1.13.2 节
发布和订阅模型 p155 — 发布-订阅模型)
## 延伸阅读
- [IoT 协议与无线网络](./iot-protocols) — 网络层的无线侧:MQTT、CoAP、LwM2M、NB-IoT
- [传感与测量](./sensing) — 协议搬运的值从哪来:传感器、变送、量程与精度
- [物联网技术总览](./) — 四层参考架构与本部分的阅读地图
- [设备接入与驱动](../drivers/) — 28 个驱动如何把异构设备统一接进来
- [属性与配置](../introduction/concepts/attribute-config) — 协议参数如何成为驱动属性、由设备实例填值
---
# 自动识别与定位
URL: https://docs.dc3.site/zh/foundations/identification
# 自动识别与定位
物联网的第一步,是让物理世界里的每一个物、每一个位置都能被机器"认出来"。这一章讲感知层里两类不靠传感器测物理量、而靠**身份**
和**坐标**说话的技术:自动识别——条码、RFID、NFC,回答"这是什么、是哪一个";定位——GNSS、基站、UWB、蓝牙信标,回答"它在哪里"
。读完你会清楚每种技术的频段、距离、成本边界,以及"给每个物一个身份"的思想在 IoT DC3 里如何落到 `deviceId` 与 `tenantId` 上。
> 你在这里:感知层已用[传感与测量](./sensing)把物理量变成信号;这一章补上"身份与位置"
> 这条平行的感知支线。下一步可看[工业总线与协议](./fieldbus),了解这些数据怎么被现场总线传出去。
## 这一层是什么 / 为什么存在
传感器解决"物理量是多少",识别与定位解决"这是谁、它在哪"。两者都属感知层,但产生的不是连续的模拟量,而是**离散的标识符**和*
*空间坐标**——它们是把现实世界对象映射成数字记录的"主键"和"地址"。
为什么需要单独一类技术?因为一台机器面对成千上万个物理对象时,没有身份就无法区分、无法追踪、无法绑定历史数据。一箱货物从出厂到上架,要被几十个节点扫到;一台叉车在仓库里穿行,要被系统持续知道位置。识别给对象一个
**稳定的名字**,定位给对象一个**实时的坐标**,二者合起来,物理世界才真正"可寻址"。
这类技术的共同特征是:信息密度低(往往只是一个编号)、读取速度快、单点成本要足够低以便规模化铺设。正因如此,它们的工程取舍几乎都围绕一个三角——
**作用距离、信息容量、单件成本**——展开。距离要远就得加功率或加电池,容量要大就得加芯片,而规模化又逼着成本压到极致。理解这个三角,就理解了下面每一种技术的定位。
## 关键技术与权衡
先看自动识别。**条码(一维码)**
是最便宜的身份载体:黑白条纹编码十几位数字,一张纸、一滴墨就能承载,但容量小、必须近距离对准光学扫描、被污损就读不出。**
二维码(QR / DataMatrix)**在两个维度上编码,容量跃升到上千字节,还自带纠错,破损一部分仍可恢复,因此从支付到设备铭牌广泛使用——但它仍是光学识别,需要视线可达。
**RFID**用无线电波取代光学,最大价值是**无需视线、可批量读**。按频段分三档(LF 30 kHz–300 kHz、HF 3 MHz–30 MHz、UHF 300
MHz–3000 MHz,见《物联网:射频识别(RFID)核心技术教程》黄玉兰编著,人民邮电出版社·2016,第 4 章 4.1.1,PDF p67):**低频 LF(约 125
kHz)**
穿透性好、抗金属液体干扰,但读距仅几厘米、速率低,多用于动物芯片、门禁(LF 常用 125 kHz/135
kHz,可穿透水、有机组织和木材,典型应用含动物识别、电子闭锁防盗,见上书第 4 章 4.1.4,PDF p70–71);**高频 HF(13.56
MHz)**读距十几厘米、速率适中,是 NFC
的物理基础(13.56 MHz 为全球 ISM 频段,对应 ISO/IEC 14443、ISO/IEC 15693、ISO/IEC 18000-3 等标准,见上书第 4 章 4.1.4,PDF
p71);**超高频 UHF(860–960 MHz)*
*读距可达数米、支持几百个标签同时盘点,是仓储物流批量识别的主力,但易受金属和液体反射干扰(860–960 MHz 是 EPC Gen2
标准规定的读写器与标签通信频率,见上书第 4 章 4.1.4,PDF p72)。按供电方式又分两类:**无源标签**
没有电池,靠读写器发射的电磁场感应取电,便宜(可低至几分钱)、寿命近乎无限,但读距受限;**有源标签**
自带电池主动发射,读距可达几十米、能附带传感数据,但贵、有寿命(微波标签可分有源、无源,另有半无源标签用钮扣电池供电、读距较远,见上书第
2 章 2.2,PDF p34)。一套 RFID 系统总是**读写器(Reader)**加**标签(Tag)**
:读写器供能并收发,标签携带 ID 并响应(电感耦合多用于无源标签、从读写器近场取电;电磁反向散射读距一般大于 1 m、典型 4–7 m、最大
10 m 以上,见上书第 4 章 4.1.4,PDF p70–71)。
**NFC**本质是 13.56 MHz HF RFID 的近距子集(读距通常 4 cm
内),特点是点对点、双向、可主动可被动,且已内置在几乎每一部手机里——这让它成为"碰一碰"配网、移动支付、电子名片的事实标准。
再看定位。**GNSS(全球卫星导航)**是室外定位的基石,靠测量多颗卫星信号到达的时间差解算三维坐标,代表系统有美国 **GPS** 和中国
**北斗(BDS)**(北斗系统全球范围 95% 置信度下水平 10 m、高程 10 m,地基增强可至实时厘米级、后处理毫米级,见上书前言,PDF
p10–11),现代芯片多为多系统兼容,精度米级、差分增强后可达厘米级(GPS 基本原理为"测时−测距",PRN 码单点定位精度 5–10
m,伪距/载波相位差分可达亚米级、厘米级甚至毫米级,见《北斗卫星导航系统应用》王博、刘向升、张存杰编,电子工业出版社·2020,第 1 章
1.1.2,PDF p22)——但卫星信号穿不透屋顶,**室内基本失效**(卫星信号到达地面已极微弱,易受高大建筑物、树木等遮挡导致精度下降,见上书第
1 章 1.1.2,PDF p23),且首次定位耗时、功耗较高。
**基站定位**借蜂窝网络的小区信息估算位置,无需额外硬件、室内外都能用,但精度只到几十米到几百米,适合粗略定位和兜底。**
UWB(超宽带)**用纳秒级窄脉冲测飞行时间,室内精度可达 **10–30 厘米**
,是高精度室内定位(人员、资产、机器人)的领先方案,代价是需要预先部署锚点基站、成本较高。**蓝牙信标(Beacon)**
周期广播信号,接收端按信号强度(RSSI)估距,部署便宜、手机即可接收,但 RSSI 受环境干扰大,精度通常只到米级,适合区域级("
在哪个展区")而非精确定位。
把这两组技术放进"距离—成本"的取舍平面,脉络就清楚了:
::: tip 没有"最好",只有"最合适"
仓库批量盘点选 UHF RFID,户外车辆调度选 GNSS,室内人员精确追踪选 UWB,移动端轻配网选 NFC。同一个场景常常组合使用——例如 UWB
实时定位 + 二维码资产登记。
:::
## 工程要点
落地这类系统时,反复踩到的坑往往不在"选哪种技术",而在物理与工程细节。
**介质与环境**决定成败。UHF RFID
在金属货架、液体容器上读不准,需要专用抗金属标签或调整天线极化;条码在油污、高温、户外暴晒环境下会失效,工业现场常改用激光打标或金属铭牌二维码。选型前必须按真实工况验证读取率,而非看实验室参数。
**频段即合规**。RFID/UWB 工作在受管制的无线电频段,不同国家划分不同(如 UHF RFID 欧洲 865–868 MHz、北美 902–928 MHz、中国
920–925 MHz——我国规划 840–845 MHz 及 920–925 MHz 用于 RFID,见《物联网:射频识别(RFID)核心技术教程》黄玉兰编著,人民邮电出版社·2016,第
4 章 4.1.4,PDF p72),跨区域部署要确认设备频段与发射功率合规,否则会干扰他人或被禁用。
**标识体系要全局唯一**。光有一个芯片不够,编号必须在足够大的范围内不重复才有意义。业界为此建立了编码标准,最典型的是 *
*EPC(Electronic Product Code)**——一套用于 RFID 标签的全球统一对象编码体系,把"厂商 + 商品 + 序列号"
编进一个标识里,让每一件单品(而不只是每一类商品)都有独一无二的身份(EPC
由版本号加域名管理者、对象分类代码、序列号三段组成,分别描述厂商、物品分组和唯一标识每一个物品,见《物联网:射频识别(RFID)核心技术教程》黄玉兰编著,人民邮电出版社·2016,第
3 章 3.1.3,PDF p50)。EPC 的思想正是物联网标识的缩影:**先有全局唯一的
ID,物才能被全网追踪**(EPCglobal 网络以发现服务模块支撑物品寻迹、跟踪与监控,其唯一识别标准基于 RFID 技术,见《物联网 RFID
多领域应用解决方案》拉纳辛哈等著,唐朝伟等译,机械工业出版社·2013,第 9 章,PDF p159、p175)。
**精度与成本要按需匹配**。不要为"在哪个房间"的需求上 UWB,也不要指望蓝牙信标做到厘米级。定位精度每提高一个数量级,硬件与部署成本往往跳一个台阶;先问清业务到底需要多准,再选技术。
::: warning 读不到 ≠ 不存在
RFID/扫码都有漏读率,定位都有误差。系统设计上必须容忍"暂时读不到"
——用多次重读、多点冗余、超时与状态机来兜底,而不是假设每次读取都成功。这一点与传感采集的"可能缺值"是同一类工程现实(RFID
主要采用时分多路接入,冲突分标签冲突与读写器冲突两类;HF 标签多用 ALOHA 算法,UHF
多用树型搜索等确定性方案,见《物联网:射频识别(RFID)核心技术教程》黄玉兰编著,人民邮电出版社·2016,第 10 章 10.1.2,PDF p213)。
:::
## 在 IoT DC3 中如何落地
物联网识别技术的核心思想——**给每个物一个全局唯一、可归属的身份**——在 IoT DC3 里有直接对应,只不过 DC3 处在更上层:它不直接读
RFID 标签或扫码(那是现场设备/采集终端的事),而是为接入平台的每一个对象建立**数字身份与归属边界**。
DC3 用 **`deviceId` 唯一标识**一个[设备 Device](../introduction/concepts/device)。现场一台具体的机器——一台
PLC、一块电表、一个温控器——在平台里就对应一个 `Device`,由它的 `deviceId`
在整个系统中被稳定地寻址、绑定历史数据、关联指令与事件。这与"给每个物一个身份"的标识思想一脉相承:EPC 给每件商品一个全网唯一编号,
`deviceId` 给每台接入设备一个平台内唯一标识;只是 DC3 的身份是软件层登记的,不依赖某种特定的物理标签技术。
DC3 用 **[租户 Tenant](../introduction/concepts/tenant)(`tenantId`)划定归属与隔离边界**。每一条业务记录都带一个
`tenantId`,平台据它把数据切成互不串台的几份——A 公司的设备、位号、数据,B 公司看不见。如果说 `deviceId` 回答"这是哪一个设备",
`tenantId` 就回答"这个设备归谁、谁能看见它"。识别技术里"身份 + 归属"的二元结构(一个 EPC 编号 + 它属于哪个厂商前缀),在 DC3
里正是 `deviceId + tenantId` 的组合。
::: info DC3 不做"RFID 标签管理"
DC3 的身份模型是平台层的设备登记与租户隔离,并不内置 RFID 标签发卡、读卡器管理或扫码出入库这类现场识别功能。本章把识别技术与
DC3 放在一起,是为了点明二者**共享的标识思想**(全局唯一 ID + 归属边界),而非声称 DC3 提供这些现场能力。若现场用
RFID/扫码采集,它们通过协议驱动以普通数据接入,仍归到某个 `deviceId` 与 `tenantId` 之下。
:::
一句话收束:识别与定位让物理世界**可寻址**,DC3 让接入平台的每个对象**可寻址且可归属**——前者是物联网的入口,后者是平台治理这些对象的起点。
## 参考文献
1. 黄玉兰. 物联网:射频识别(RFID)核心技术教程[M]. 北京: 人民邮电出版社, 2016.
2. 拉纳辛哈 (Ranasinghe, D. C.), 等. 物联网 RFID 多领域应用解决方案[M]. 唐朝伟, 邵艳清, 王恒, 译. 北京: 机械工业出版社,
2013. (国际信息工程先进技术译丛)
3. 王博, 刘向升, 张存杰. 物联网与北斗应用[M]. 北京: 电子工业出版社, 2020.
## 延伸阅读
- [传感与测量](./sensing) — 感知层的另一半:用传感器把物理量变成可计算的信号
- [工业总线与协议](./fieldbus) — 识别与传感产生的数据,如何经现场总线被传出去
- [物联网技术总览](./) — 回到四层参考架构,看识别与定位在全局中的位置
- [设备 Device](../introduction/concepts/device) — `deviceId` 如何在 DC3 里唯一标识一台现场设备
- [租户 Tenant](../introduction/concepts/tenant) — `tenantId` 如何划定数据归属与隔离边界
---
# 物联网技术总览
URL: https://docs.dc3.site/zh/foundations/
物联网不是单一技术,而是一套把物理世界接入数字世界的分层体系。这一部分按业界经典的**四层参考架构**
——感知层、网络层、平台层、应用层,外加贯穿四层的安全——梳理物联网的核心知识,并在每一层都说明 IoT DC3 如何落地。读完你会有一张"
从传感器到 AI 运营"的完整地图,并清楚 DC3 站在这张地图的哪个位置。
## 四层参考架构
把一套物联网系统拆开,数据自下而上流动、控制自上而下下达:感知层把物理量变成数字信号,网络层把信号可靠地送出去,平台层把海量数据存起来、管起来、算出来,应用层再把数据变成业务价值。安全不属于某一层,而是贯穿每一层的横切关注点。
这套分层不是教条,而是**职责边界**:每层只解决自己的问题,并通过清晰的接口与相邻层协作。它的价值在于——任何一个物联网平台、任何一个
IoT 产品,都能被放进这张图里定位。IoT DC3 也不例外。
## DC3 如何坐落在四层之上
IoT DC3 不是又一份"通用 IoT 理论"的复述,而是这套四层架构的一个**可运行实现**。逐层对应如下:
- **感知层 → 模板与位号**。现场的传感器、执行器、电表产生的物理量,在 DC3
里被抽象为[模板 Profile](../introduction/concepts/profile)、[设备 Device](../introduction/concepts/device)
与[位号 Point](../introduction/concepts/point)——把"温度""开关"
这类语义稳定地建模下来。详见[传感与测量](./sensing)、[自动识别与定位](./identification)。
- **网络层 → 协议驱动**。Modbus、OPC UA、MQTT、BACnet……几十种异构协议,由 DC3 的 [28 个协议驱动](../drivers/)
统一适配,归一成带语义的位号值。详见[工业总线与协议](./fieldbus)、[IoT 协议与无线网络](./iot-protocols)。
- **平台层 → 中心服务与数据平面**。设备元数据由[管理中心](../architecture/services)
管理,位号值经[数据平面](../architecture/data-plane)写入 TimescaleDB
时序库并对外可查。详见[边缘与云架构](./edge-cloud)、[时序数据与流处理](./data-pipeline)。
- **应用层 → 运营与 AI**。在数据之上,DC3 提供[运营与告警](../operation/),并通过 [Agentic 中心与 MCP](../ai/)
让大模型参与"感知—决策—执行—反馈"闭环。详见[数据智能与 AIoT](./aiot)。
- **安全 → 鉴权·租户·RBAC**。贯穿四层的安全在 DC3 体现为[鉴权、租户隔离与 RBAC](../architecture/auth-rbac),以及传输层的
TLS。详见[物联网安全](./security)。
## 如何阅读这一部分
这一部分既可以当作物联网知识的体系化读本通读,也可以按层检索:想了解传感器怎么选型,看**感知层**;想搞懂 MQTT 与 NB-IoT
的取舍,看**网络层**;想理清边缘与云怎么分工,看**平台层**。每一章都以"这一层是什么、关键技术与权衡、工程要点"展开,并在结尾用"
在 IoT DC3 中如何落地"接回产品,让理论与实现形成闭环。
## 延伸阅读
- [核心概念](../introduction/concepts) — DC3 的模板、设备、位号、租户等基础对象
- [系统架构](../architecture/) — DC3 自身的服务拓扑、数据平面与命令平面
- [设备接入与驱动](../drivers/) — 28 个协议驱动如何把异构设备接进来
---
# IoT 协议与无线网络
URL: https://docs.dc3.site/zh/foundations/iot-protocols
# IoT 协议与无线网络
工业总线把车间里的设备连起来,但更广阔的物联网——电池供电的传感器、远在郊野的水表、跑在公网上的智能硬件——靠的是另一套"
轻、省、远"的协议与无线技术。这一章讲清网络层在 IoT 这一侧的两个面:上层的**应用层消息协议**(MQTT、CoAP、LwM2M、HTTP、AMQP)与下层的
**无线与广域接入**(BLE、Zigbee、LoRa/LoRaWAN、NB-IoT、5G),以及它们之间"功耗—带宽—距离—成本"的权衡。读完你能为一类设备选对协议栈,并知道这些选择在
IoT DC3 里落到哪个驱动上。
> 你在这里:上一章[工业总线与协议](./fieldbus)讲的是现场近端的确定性通信;这一章往外走一层,进入面向海量、低功耗、远距离终端的
> IoT 协议世界。
## 这一层是什么 / 为什么存在
回到[四层参考架构](./):网络层负责"把感知层产生的信号可靠地送出去"
。工业总线解决的是工厂围墙之内、线缆可达、强实时的连接;而物联网的另一半场景完全不同——设备数量动辄成千上万、分散在广域、靠电池供电、带宽以
KB 计、还经常隔着不可靠的无线链路和公网。在这种约束下,传统的"主站轮询每台设备"模型既费电又不 scalable。
于是 IoT 协议演化出两条主线。一条是**应用层消息协议**:它们定义"一条消息长什么样、怎么投递、可靠到什么程度",运行在 TCP/UDP
之上,与底层用什么无线无关。MQTT 用发布/订阅把设备与平台解耦,CoAP 把 HTTP 的请求/响应模型压缩到 UDP 上的几十字节,LwM2M 在
CoAP 之上叠加了设备管理的对象模型,HTTP/REST 则因通用而仍被大量上游系统采用。另一条是**无线与广域接入**:它们决定"
信号怎么在空中传",从几米的 BLE 到几公里的 LoRa,再到运营商网络的 NB-IoT 与 5G。
两条线正交:同一个 MQTT 报文,可以跑在 Wi-Fi 上,也可以跑在 NB-IoT 蜂窝链路上。理解网络层,关键就是把"消息怎么组织"和"
信号怎么传输"这两件事分开看,再按场景把它们组合起来。
## 关键技术与权衡
### 应用层协议:消息怎么组织与投递
**MQTT** 是物联网事实上的消息总线。它的核心是**发布/订阅(pub/sub)**模型:设备不直接和平台对话,而是把消息**发布**到一个*
*主题(topic)**,平台**订阅**这些主题就能收到——双方通过中间的 **broker**(消息中转服务器,如 EMQX、Mosquitto、RabbitMQ 的 MQTT
插件)解耦,互不需要知道对方地址,也无需同时在线。这种"被订阅推送、而非被轮询"的语义,正是海量设备场景下省电、可横向扩展的关键。
MQTT 用三档 **QoS(服务质量)**描述投递保证,发布与订阅两端各自声明、按较弱一方生效:
- **QoS 0(最多一次)**:发出即忘,不确认、不重传。最省,丢了就丢了,适合高频、可容忍丢点的遥测。
- **QoS 1(至少一次)**:要求接收方回 `PUBACK`,未确认则重发——保证不丢,但**可能重复**,下游需幂等。
- **QoS 2(恰好一次)**:四次握手(`PUBREC`/`PUBREL`/`PUBCOMP`)确保不丢不重,最可靠也最重,适合不可重复的关键命令。(MQTT
提供至多一次、至少一次、只有一次三档 QoS,运行在 TCP 之上并支持 TLS,见《物联网之魂:协议与物联网操作系统》孙昊
等,机械工业出版社·2019,第 1 章 1.4.3 MQTT 协议,p41)
另两个常用机制:**retain(保留消息)**让 broker 为每个主题缓存"最后一条"
消息,新订阅者一连上就立刻收到当前值,而不必干等下一次上报,很适合"状态类"主题;**LWT(遗嘱消息)**让设备在异常掉线时由 broker
代发一条预设消息,平台据此感知离线。
版本上,业界长期以 **MQTT 3.1.1** 为主流;**MQTT 5.0** 进一步加入了原因码(reason
code,让错误更可诊断)、用户自定义属性、请求/响应模式、共享订阅(多个消费者负载均衡分担同一主题)等增强。选型时先确认设备固件与
broker 双方都支持目标版本——能力以两端取交集为准,单边支持 5.0 并不会自动启用其特性。
下图是 MQTT 发布/订阅的基本拓扑——设备与平台都只和 broker 打交道:
**CoAP(受限应用协议)**走另一条路:它保留 HTTP 熟悉的**请求/响应 + 方法(GET/PUT/POST/DELETE)+ 资源路径**模型,但把报文压到几十字节、跑在
**UDP** 上(默认端口 `5683`,加密用 DTLS 的 CoAPS `5684`)。无连接的 UDP 省去了 TCP 握手与保活开销,对电池供电、偶尔醒来上报一次的终端极友好;代价是可靠性要靠
CoAP 自己的 CON/NON 确认机制补回来。CoAP 还支持 **Observe**(观察)扩展,让客户端"订阅"一个资源、由服务端在值变化时推送,弥补纯请求/响应的不足。(CoAP
建立在 UDP 之上以减少开销、支持组播,提供 GET/PUT/POST/DELETE 方法与 URI 访问,见《物联网之魂:协议与物联网操作系统》孙昊
等,机械工业出版社·2019,第 1 章 1.4.2 CoAP 协议,p39–40)
**LwM2M(轻量级 M2M)**不是另起炉灶,而是**架在 CoAP 之上**,补齐了 CoAP 缺的"设备管理"那一层。它把设备能力抽象成一棵**对象树
**:`Object`(如 `3303`=温度)/`Object Instance`(同类的多个实例)/`Resource`(如 `5700`=传感器读数),访问一个值就是给出
`///` 路径。固件升级、远程配置、订阅上报都被标准化进这套模型,因此 LwM2M
在电信级、需要远程运维的终端(NB-IoT 模组、智能表计)里很常见。
**HTTP/REST** 在 IoT 里仍有一席之地:它不为受限设备设计、报文头臃肿、保活成本高,因此**不适合**电池终端高频上报;但它通用、调试方便、几乎所有上游系统都讲
REST,所以大量"从第三方平台 API、从带 RESTful 接口的网关取数"的场景仍用它。**AMQP** 则定位在另一端——它是面向企业消息中间件的可靠队列协议(RabbitMQ
即其实现),报文与状态机比 MQTT 重,一般用于**平台与后端之间**的可靠消息流转,而非直连受限终端。
简言之:高频遥测、海量设备解耦选 MQTT;极受限、偶发上报选 CoAP;要远程管理设备选 LwM2M;对接现成 REST 接口用 HTTP;后端可靠队列用
AMQP。
### 无线与广域接入:信号怎么传
应用层协议解决"消息长什么样",但消息终究要落到一段物理链路上。无线技术的选型本质是一道**多目标权衡**
:传得越远往往越费电或越慢,省电的往往牺牲带宽,免费频段省钱但易拥塞。把主流技术按"覆盖距离"
分三档铺开,能直观看出各自的生态位——同一档内再按速率与功耗细分:
把权衡量化成一张参考表,按场景倒推选型时更直观:
| 技术 | 覆盖距离 | 速率 | 功耗 | 频段 | 典型场景 |
|---------|---------------|----|----|-------------|--------------|
| BLE | 十米级 | 低 | 极低 | 2.4 GHz 免授权 | 可穿戴、信标、近场配网 |
| Zigbee | 十~百米(mesh 可扩) | 低 | 低 | 2.4 GHz 免授权 | 智能家居/楼宇低速设备 |
| LoRaWAN | 数公里 | 极低 | 极低 | Sub-GHz 免授权 | 远程抄表、农业环境监测 |
| NB-IoT | 广域(运营商) | 低 | 低 | 授权频段 | 计量、井盖、固定低频上报 |
| 5G | 广域(运营商) | 高 | 高 | 授权频段 | 视频、AGV、远程控制 |
- **BLE(低功耗蓝牙)**:典型十米级、低速、极省电,靠纽扣电池可跑数月到数年。适合可穿戴、信标、近场传感与配网,常需手机或网关做中继接入互联网。
- **Zigbee**:基于 IEEE 802.15.4 的短距**自组网(mesh)**,节点可互相中继扩大覆盖,省电、适合智能家居/楼宇里大量低速设备;通过协调器/网关汇聚后再上联。
- **LoRa / LoRaWAN**:**LPWAN(低功耗广域网)**代表。LoRa 是物理层调制(远距、抗干扰),LoRaWAN 是其上的网络协议。工作在免授权
Sub-GHz 频段,城区可达数公里、郊野更远,速率极低(几百 bps 到几十 kbps),终端极省电——典型"广覆盖、低速率、自建网"
场景,如远程抄表、农业与环境监测。(LoRa 之名源于 Long Range,由 Semtech 公司采用并推广,是 LPWAN
中的关键一员,见《5G物联网及NB-IoT技术详解》江林华编著,电子工业出版社·2018,第 2 章 2.5.2 LoRaWAN,p71)
- **NB-IoT(窄带物联网)**:运营商蜂窝 LPWAN,跑在授权频段、由电信网络承载,覆盖与穿透好(地下室、井盖)、终端省电、海量连接,但速率低、时延较高。无需自建基站,适合广域、固定、低频上报的计量类终端,常与
LwM2M/CoAP 搭配。(NB-IoT 属低功耗广域网 LPWAN,其设计针对物联网终端"懒、静止、上行为主"
的特点,以低速率与传输延迟上的折中换取覆盖增强、功耗降低与成本减少,见《物联网之魂:协议与物联网操作系统》孙昊
等,机械工业出版社·2019,第 1 章 1.12.3 NB-IoT 节能原理,p119)
- **5G**:高带宽、低时延、海量连接三位一体,覆盖从增强移动宽带到工业控制级的
uRLLC。能力最强但功耗、模组与资费也最高,适合视频、AGV、远程控制等对带宽/时延敏感的高价值场景,而非纽扣电池传感器。
把权衡浓缩成一句话:**没有"最好"的无线,只有"最匹配"的无线**——先定场景的距离、上报频率、电池预算与单点成本,再倒推选型。
## 工程要点
- **协议与无线解耦**:选型分两步——先按消息模型选应用层协议(pub/sub 还是请求/响应、是否要设备管理),再按物理约束选无线/接入。二者正交,别混为一谈。
- **QoS 不是越高越好**:QoS 2 的四次握手在弱网/海量设备下会显著放大开销与时延。多数遥测用 QoS 0/1 足矣,把"恰好一次"
留给真正不可重复的关键命令;用 QoS 1 时务必让下游**幂等**。
- **UDP 协议先查防火墙**:CoAP/LwM2M 走 UDP,连不通常是 UDP 端口(`5683`/`5684`)被防火墙挡、或 NAT 映射失效,而非应用配置错——排错先验链路。
- **被动接收 ≠ 没数据时也"在线"**:pub/sub 与 Observe 是设备主动推。平台侧的"在线"判断要靠租约/保活/LWT,而不是"采集周期"
;长时间没收到不代表链路一定断,但也别默认它还活着。
- **省电要省在链路上**:终端功耗大头常在无线收发与保活握手,而非 MCU 计算。降功耗优先减少上报频率、用更省的
QoS、开启长休眠/DRX,而非优化业务逻辑。
- **公网必加密**:跑在公网/蜂窝上的 MQTT、CoAP 要分别启用 TLS(`8883`)、DTLS(`5684`),并做好设备身份(证书/PSK)与主题级授权,避免被仿冒或越权订阅。
### 网络融合趋势
早期物联网是"协议孤岛"——每种设备一套私有协议、一个专用网关。趋势正在收敛:应用层向 **MQTT + CoAP/LwM2M** 两强格局集中(MQTT
管高频遥测、CoAP/LwM2M 管受限与管理);接入侧 **LPWAN(NB-IoT/LoRa)与 5G** 互补分层,覆盖从"广而省"到"快而强"的全谱。平台侧则用
**统一的协议适配层**把这些异构接入归一成同一套数据模型——这正是 IoT DC3 驱动层在做的事。
## 在 IoT DC3 中如何落地
DC3 把上述"轻协议"各自实现为一个独立的**协议[驱动](../drivers/)**(`dc3-driver-*`
),启动时把自己和可接受的[属性配置](../introduction/concepts/attribute-config)
注册到管理中心,再按[位号](../introduction/concepts/point)采数、按[指令](../introduction/concepts/command)
写值。本章涉及的应用层协议对应四个驱动:
- **[MQTT 驱动](../drivers/mqtt)**(`dc3-driver-mqtt`):类型为 `DRIVER_SERVER`——它**作为服务端订阅 MQTT 主题、被动接收**
设备上报,而不是主动轮询。下行有两条路径:位号 `write()` 按 `commandTopic`/`commandQos` 直接 publish 原始值;命令
`execute()` 才用命令属性 `payloadTemplate` 渲染报文后再发出。连哪个 broker 由部署环境变量 `MQTT_BROKER_HOST` /
`MQTT_BROKER_PORT` 决定,因此该驱动**没有设备级 driver 属性**;MQTT 侧可配合 **EMQX** 这类 broker,docker-compose 栈默认注入
RabbitMQ 的 MQTT 插件(`dc3-rabbitmq:1883`;dev profile 的 YAML 端口回退为 `2883`)。
- **[CoAP 驱动](../drivers/coap)**(`dc3-driver-coap`):类型 `DRIVER_CLIENT`,基于 Eclipse Californium 主动连设备——读对位号
`readPath` 发 GET、写对 `writePath` 发 PUT,走 UDP `5683`;采集周期 base 配置默认 30 秒,dev profile(默认激活)覆盖为 5 秒。
- **[LwM2M 驱动](../drivers/lwm2m)**(`dc3-driver-lwm2m`):内嵌一个基于 Eclipse Leshan 的 LwM2M 服务端,设备用 `endpoint`
名注册上来,按位号的 `objectId/objectInstanceId/resourceId` 三段路径读写资源。
- **[HTTP 驱动](../drivers/http)**(`dc3-driver-http`):类型 `DRIVER_CLIENT`,用 `WebClient` 周期调 REST 端点、按
`responsePath` 从 JSON 响应取值。
::: warning MQTT 驱动是"订阅被动到达"语义
`dc3-driver-mqtt` 的 `read()` 不会主动返回采集值——定时读取默认关闭(`schedule.read.enable=false`),位号值是设备 publish 后
**经订阅被动收下**的。若长时间收不到值,先确认设备端是否真的在往订阅主题发布、主题字符串两端是否完全一致,而不是去查"
采集周期"。
:::
::: warning MQTT / LwM2M 驱动当前为骨架实现
源码中 `dc3-driver-mqtt` 的 `read()` 是参考桩、`health()` 恒返回在线,`dc3-driver-lwm2m` 的类注释亦标注 "work-in-progress
skeleton",协议级 I/O 尚未完整实现。请把它们当作接入模板与配置参考,而非生产就绪驱动;具体以各[驱动页](../drivers/)与源码为准。
:::
至于本章下半部分的**无线/接入技术**(BLE、Zigbee、LoRa、NB-IoT、5G),它们属于物理链路层:DC3 不直接"讲空口",而是接在它们之上。BLE
与 Zigbee 各有对应驱动(见[驱动总览](../drivers/)的"物联网/无线"分组);LoRa/NB-IoT/5G 终端通常先汇聚到一个 broker 或 REST
网关,再由 DC3 的 MQTT/CoAP/HTTP 驱动统一接入——即上文"统一协议适配层"在产品里的具体形态。
## 参考文献
1. 孙昊 等. 物联网之魂:协议与物联网操作系统[M]. 北京:机械工业出版社,2019. ISBN 978-7-111-62931-3.(第 1 章 1.4.2 CoAP 协议
p39–40、1.4.3 MQTT 协议 p41、1.12.3 NB-IoT 节能原理 p119)
2. 江林华. 5G物联网及NB-IoT技术详解[M]. 北京:电子工业出版社,2018. ISBN 978-7-121-33831-1.(第 2 章 2.5.2 LoRaWAN p71、2.3
物联网技术分类 p58)
3. 黄宇红,杨光 主编. NB-IoT物联网技术解析与案例详解[M]. 北京:机械工业出版社,2018. ISBN 978-7-111-60888-2.(第 1 章 1.3
典型物联网技术对比,表 1.2:带宽/覆盖/功耗/速率四维横向对比 p5)
## 延伸阅读
- [工业总线与协议](./fieldbus) — 现场近端、确定性强的另一半网络层
- [边缘与云架构](./edge-cloud) — 协议接入之上,数据如何在边与云之间分工
- [物联网技术总览](./) — 四层参考架构与 DC3 全景对照
- [MQTT 驱动](../drivers/mqtt) — 发布/订阅、QoS、broker 在 DC3 里的落地
- [设备接入与驱动](../drivers/) — 28 个协议驱动如何把异构设备接进来
---
# 物联网安全
URL: https://docs.dc3.site/zh/foundations/security
# 物联网安全
物联网把"能联网的设备"和"能动手脚的物理世界"
接在一起,于是一处缺口往往同时威胁数据与控制——读到一台电表的读数是隐私问题,篡改一台阀门的指令就是安全事故。这一章按设备、通信、平台、数据四个面,梳理物联网安全的威胁与对策,并说明这套思路在
IoT DC3 里如何落到鉴权、租户隔离与传输加密上。
> 你在这里:已读完[四层参考架构](./),想理解贯穿四层的安全是怎么回事。读完你能画出一张"威胁—对策"地图,并知道 DC3
> 在每一层放了哪些防线。
## 这一层是什么 / 为什么存在
安全不属于感知、网络、平台、应用中的某一层,而是贯穿四层的**横切关注点**
。原因很直白:攻击者不会按你的分层走,他会找最薄的那一处下手。设备端固件可以被刷写,通信链路可以被监听和重放,平台接口可以被越权调用,数据库里的数据可以被脱库——任何一环失守,前面几环做得再好也白搭。所以谈物联网安全,必须四层一起谈,并且默认"
边界之外皆不可信"。
物联网的安全又比传统 IT 更难,难在三处。其一是**设备资源受限**:现场的传感器、网关算力小、内存少、可能靠电池供电,跑不动重型加密,也难做频繁的密钥轮换。其二是
**物理可达**:设备就摆在车间、田间、街头,攻击者能直接拿到手,拆芯片、读 Flash、接调试口,纯软件防护挡不住物理攻击。其三是**规模与异构
**:一个平台可能接入几十种协议、成千上万台设备,统一打补丁、统一轮换证书的成本极高,任何一台老旧设备都可能成为整片网络的入口。
正因为这些约束,物联网安全的目标不是"绝对安全",而是**纵深防御**:每一层都设一道关,任何单点被攻破都不至于全盘失守。下面按四层逐一展开,再用一张图把威胁与对策对齐。
## 关键技术与权衡
### 设备安全:信任从硬件开始
设备是物理可达的一环,防护必须从启动那一刻就建立信任。**安全启动(Secure Boot)**让 Bootloader
逐级校验固件签名,签名不对就拒绝运行,从根上挡住"刷入恶意固件";其信任链的根,是固化在芯片里、不可篡改的根密钥。**密钥存储**
则决定了私钥会不会被读走——把密钥放在普通 Flash 里等于裸奔,正确做法是用安全元件(SE)或可信执行环境(TEE)隔离存储,让密钥"
可用而不可读"。**固件升级(OTA)**必须验签后再写入,并支持回滚到已知良好版本,否则一次被劫持的升级就能批量沦陷整片设备。
权衡在于成本:带 SE/TEE、支持安全启动的芯片更贵,OTA 通道也要额外的签名与灰度机制。资源极受限的设备往往只能做到"验签升级 +
软件层密钥保护",把更强的硬件信任根留给关键节点。
### 通信安全:加密、认证与防重放三件套
设备到平台的链路天然暴露在网络上,三件事必须同时做到。**加密**用 TLS(面向 TCP,如 MQTT over TLS、HTTPS)或 DTLS(面向 UDP,如
CoAP)把链路保护起来,防窃听与篡改。**认证**要双向:服务端证书防止设备连到假平台(中间人),设备侧用证书或预共享密钥(PSK)证明自己是谁,防止设备伪造。
**防重放**则要防"录下一条合法报文、稍后原样重放"——靠时间戳、单调递增的序号或一次性随机数(nonce),让旧报文失效。
权衡在于受限设备:完整 TLS 握手的非对称运算和证书链对小设备是负担,于是出现
TLS-PSK、会话复用、用更轻的椭圆曲线算法等折中。再轻也不能省掉"认证 + 防重放"这两条,否则加密只是给攻击者也加了密。
### 平台安全:认证、授权、租户隔离、审计
设备的数据汇聚到平台,平台就成了高价值目标。**认证**回答"你是谁"——登录换取令牌,令牌可验证、有时效。**授权**回答"你能做什么"
——按 RBAC(基于角色的访问控制)把主体、角色、资源绑起来,并坚持**最小权限**与**fail-closed**(查不到权限就拒绝,绝不默认放行)。*
*多租户隔离**回答"你能碰哪条数据"——它与授权正交:有"读设备"的权限,不代表能读别家租户的设备;隔离做不好,一个租户就能看到甚至操作另一个租户的现场设备。
**审计**则把"谁在什么时候做了什么"记下来,既用于事后追责,也用于实时发现异常。
### 数据安全:隐私、脱敏、合规
物联网数据常常关联到个人与现场——一台智能电表的用电曲线能反推出家里有没有人,一段定位轨迹就是行踪。**隐私**
要求遵循最小采集与目的限定,不该收的别收。**脱敏**要求在展示、导出、给第三方(包括喂给大模型)之前,把敏感字段做掩码或匿名化。*
*合规**
则把这些上升为硬要求:GDPR、个人信息保护法等法规对采集、存储、跨境传输都有约束,违规的代价远高于一次技术故障。数据安全还包括静态加密(落库加密、磁盘加密)与最小留存(过期即删),让"
即使被脱库,拿到的也是密文或残缺数据"。
### 威胁模型:把攻击面摊开看
把上面四层的对策对齐到具体威胁,安全设计才有靶子。下图按数据流标注五类典型威胁与对应的防线:
- **设备伪造**:冒充合法设备上报假数据或骗取指令——靠设备侧强认证(一机一密、证书)破解。
- **固件篡改**:刷入带后门的固件——靠安全启动 + 验签 OTA + 回滚机制守住。
- **重放攻击**:录下合法报文原样重放——靠时间戳、序号、nonce 让旧报文失效。
- **中间人(MITM)**:在链路中间窃听或改包——靠双向认证 + 加密,让伪造的对端通不过证书校验。
- **DDoS**:用海量请求打垮入口——靠把入口收敛到单一网关、配合限流与防火墙。
- **越权 / 跨租户**:合法身份越界访问别人的资源——靠 RBAC 的 fail-closed 与租户隔离双重把关。
## 工程要点
- **默认不可信,边界之外皆需验证**:不要假设"内网就是安全的"。后端服务即便不直接对外,也要校验调用方身份,防止有人绕过网关直连。
- **失败要 fail-closed,不要 fail-open**:鉴权组件遇到瞬时故障时,宁可把请求当作"无权限"拒掉,也不要"出错就放行"
——后者会把一次抖动变成一道后门。
- **密钥分级、按环境收紧**:开发环境可以用弱默认值图方便,但生产必须强制强随机密钥;最好让程序在生产环境发现弱密钥时**直接启动失败
**,把问题挡在上线前。
- **入口越少越好**:把对外暴露面收敛到单一网关,现场协议端口(Modbus、裸 TCP/UDP)绝不直接上公网——它们大多没有认证,一旦暴露就是开门揖盗。
- **传输默认加密**:消息总线、Broker、HTTP 入口在跨网络时一律走 TLS;明文只在本地自测可接受。
- **审计可追溯**:关键操作(登录、改授权、下发命令)都要留痕,且日志里不能出现明文密钥或口令。
## 在 IoT DC3 中如何落地
DC3 的安全主线集中在[鉴权 · 租户 · RBAC](../architecture/auth-rbac),并辅以[安全策略](../community/security)
里的部署基线。它把上面四层的思路具体化为下面几条,而且对"已实现"和"设计已规划未实现"严格区分。
### 平台认证:两步登录与令牌
DC3 对外只有网关 `dc3-gateway` 一个入口。登录是**两步握手**,对应通信安全里的"防重放"思路:
1. `POST /api/v3/auth/token/salt`:传 `tenant`、`name`,先确认租户存在,返回一个随机盐。盐是**无状态**
的——服务端不存储、不做过期校验,仅随下一次登录请求一并核对;"5 分钟"只是接口文案里的建议使用时限,由客户端自律,当前服务端未强制过期。
2. `POST /api/v3/auth/token/generate`:传 `tenant`、`name`、`salt` 和用盐哈希后的 `password`,校验通过返回 access token,*
*有效期 12 小时**。
盐的作用是避免口令明文或固定哈希在链路上被重放。签发的 JWT **绑定 `principal_id` + `tenant_id`**(而非用户名),注销时把身份写入
Caffeine 注销名单(denylist),旧令牌即便签名合法也会因签发时间早于注销点而失效。
### 平台信任传递:网关验签 + HMAC 透传
中心服务各有 HTTP 端口、默认不对外。隐患是:任何能直连后端端口的人,只要伪造一个"我是租户 A 管理员"的请求头,后端若无条件信任就被冒充。DC3
的解法是把**认证**与**信任**分开——认证只在网关做一次,信任靠一段 HMAC-SHA256 签名传递:
- 网关 `AuthenticGatewayFilter` 用三个头 `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` 去鉴权中心核验,解析出真实
principal,序列化为 `X-Auth-Principal`,再用共享密钥签名得到 `X-Auth-Sign` 透传给后端。
- 后端 `GatewayJwtConverter` 用同一密钥重算 HMAC,与 `X-Auth-Sign` 做**常量时间比对**,不符即拒;缺 `tenantId` 或
`principalId` 一律拒。
- HMAC 未启用时,网关会**主动删除入站的 `X-Auth-Sign`**,防止下游被客户端自带的假签名诱骗。
::: danger 生产 HMAC/密钥 fail-fast(硬约束)
在 `pre` / `pro` 环境下,若 `AUTH_HMAC_SECRET` 为空、或仍等于默认值 `io.github.pnoker.dc3`,服务**启动即失败**(抛
`IllegalStateException`,判定见 `HmacAuthConfig.isProtectedEnvironment()`)。这是有意为之:宁可不启动,也不让生产实例跑在开发密钥上。生产请用强随机值(如
`openssl rand -base64 48`)通过环境变量注入,绝不硬编码或写进日志。鉴权中心签发令牌用的 `DC3_SECURITY_KEY`(默认
`dc3.security.key.2026.io.github.pnoker`)则**只做"必须存在"的启动检查**——缺失即启动失败,但不做"不得等于默认弱值"
的拒绝;因此仍必须主动替换为强随机值,一旦泄露,攻击者可伪造登录令牌。
:::
### 平台授权:RBAC 的 fail-closed
验签拿到 principal 后,由 RBAC 决定"能做什么":`principal → 角色(租户内)→ 资源码(全局)`。权限解析带 5 分钟短缓存(键为
`(tenantId:principalId)`)。最关键的是失败语义:
::: danger 查不到权限 = 拒绝
权限加载发生瞬时故障时,`GatewayJwtConverter` 仍创建一个"已认证但权限为空"的令牌,任何 `@PreAuthorize` 守卫返回 **403**
。这是有意的 fail-closed——绝不把后端抖动伪装成放行。
:::
### 多租户隔离:控制器层校验
RBAC 决定"能不能做这类操作",租户隔离决定"能不能碰这条数据",两者正交。隔离落在**控制器层**:
- 按 ID 查到实体后,`BaseController.requireTenant()` 比对实体 `tenantId` 与调用方租户,不一致(或不存在)抛
`NotFoundException`,**返回 404 而非 403**——刻意用"不存在"避免泄露"某个跨租户资源是否存在"。批量查询走 `filterTenant()`
剔除非本租户条目。
::: warning 没有数据库层的自动租户兜底
当前实现**没有** MyBatis-Plus 租户行拦截器,隔离完全靠控制器层的 `requireTenant` / `filterTenant`。新增查询时务必主动带上租户校验,SQL
层不会自动按租户裁剪。
:::
### 通信与数据安全:部署基线
传输加密与数据保护落在[安全策略](../community/security)的生产基线里:RabbitMQ 与 EMQX 默认关闭 TLS,跨网络时必须启用(如
`RABBITMQ_SSL_ENABLED=true`,走 TLS 端口);网关 HTTP 入口应置于反向代理后终止 HTTPS;现场协议端口绝不直接上公网(
`DC3_BIND_HOST` 默认 `127.0.0.1`,需显式改为 `0.0.0.0` 才对外)。
::: info 外部身份(IdP)尚未实现
`dc3_identity_provider`(OIDC/SAML 等外部 IdP 配置)与 `dc3_external_identity`(外部身份绑定)两张表已在
`02-iot-dc3-auth.sql` 中建好,`principal.source_type` 也预留了 `EXTERNAL` 取值,但对应的**登录端点未实现、处于关闭状态**
。当前可用的登录路径只有上面的本地凭据两步握手。
:::
::: tip 面向 AI 调用方的 OAuth 2.1
面向大模型/MCP 客户端的访问,DC3 提供了独立的 OAuth 2.1 授权服务(强制 PKCE、刷新令牌轮换、按 scope
与风险等级过滤工具),详见 [Agentic 与 MCP](../ai/) 与 [鉴权 · 租户 · RBAC](../architecture/auth-rbac)。
:::
## 延伸阅读
- [数据智能与 AIoT](./aiot) — 数据在安全防线之内如何转化为洞察与自动决策
- [物联网技术总览](./) — 四层参考架构与安全的横切定位
- [鉴权 · 租户 · RBAC](../architecture/auth-rbac) — 登录、HMAC 透传、RBAC 与租户隔离的完整链路
- [安全策略](../community/security) — 版本维护、漏洞上报与上线前的最小安全基线
---
# 传感与测量
URL: https://docs.dc3.site/zh/foundations/sensing
# 传感与测量
感知层是物联网的"皮肤与神经末梢"
——它把温度、压力、振动这些看不见摸不着的物理量,变成机器能读的数字。这一章讲清传感器怎样把物理世界编码成电信号、有哪些常见门类与关键指标、信号要经过哪些调理才能送进处理器,以及这些物理量最终如何在
IoT DC3 里被建模成可读写的[位号 Point](../introduction/concepts/point)。
> 你在这里:四层参考架构的最底层。读完可继续看[自动识别与定位](./identification),或回到[物联网技术总览](./)。
## 这一层是什么 / 为什么存在
数字系统只会处理数字,而真实世界全是连续变化的物理量。这中间需要一座桥:**传感器**。它的本质是一个**能量转换器**
——把某种物理量(温度、力、光强、位移……)转换成一个便于测量的电学量(电压、电流、电阻、电容、频率)。没有这一步,再强的算力也"看不见"
现场。
感知层之所以单独成层,是因为它有一组别处不具备的约束:它直接面对物理世界的噪声、非线性、温漂与老化,输出的是**带误差的模拟量
**而非干净的数字。把这些误差控制好、把模拟量可靠地数字化,是这一层的全部职责。它向上只交付一件东西:**一个带单位、带量程、可信的数字读数
**。在 DC3 里,这个读数就是一个[位号](../introduction/concepts/point)的取值。
国家标准 GB/T 7665—2005 把传感器定义为"
能感受被测量并按照一定的规律转换成可用输出信号的器件或装置,通常由敏感元件和转换元件组成"
(见《物联网与传感器技术》范茂军主编,机械工业出版社·2012,第5章 5.1.1,PDF p124)。按转换原理,传感器大致分几族,理解分类有助于选型时判断它的脾气:
- **电阻型**:物理量改变电阻。热电阻(PT100)测温、应变片测力与压力、光敏电阻测光。线性较好,但需激励电流、自热会引入误差。
- **电容/电感型**:物理量改变电容或电感。电容式测位移、湿度、液位;电感式(LVDT)测位移。非接触、寿命长,但对寄生参数敏感。
- **压电型**:受力产生电荷,**只能测动态量**(振动、冲击、声)。带宽高,但无法测静态力。
- **热电型**:温差产生电动势(热电偶),量程极宽(可达上千度),但需冷端补偿。
- **半导体/光电型**:PN 结、霍尔元件、光电二极管,把温度、磁场、光照转成电信号,是 MEMS 与片上集成的基础。
## 关键技术与权衡
一颗传感器从感受物理量到交出数字,要走完一条固定的链路:**敏感元件**先把物理量变成微弱的电学量,**信号调理**
把它放大、滤波、线性化到合适范围,**A/D 转换(ADC)**再把连续的模拟电压量化成离散的数字码(见《物联网之源:信息物理与信息感知基础》李同滨等,机械工业出版社·2018,第6章
6.1,PDF p275)。这条链路的每一跳都决定最终读数的质量。
**信号调理**是模拟世界的"预处理"。敏感元件输出常常只有毫伏级、阻抗很高、还混着噪声,无法直接喂给
ADC。调理电路要做:放大(仪表放大器抬升幅度)、滤波(抗混叠低通滤掉高频噪声)、电平搬移(对齐 ADC
输入区间)、激励(给电阻型传感器供恒流/恒压),以及对桥式电路做差分以抑制共模干扰。调理做得好不好,往往比 ADC 位数更影响精度。
**A/D 转换**有两个独立维度,别混为一谈。ADC
转换电路的基本指标包括分辨率、转换速率、量化误差、偏移误差、满刻度误差和线性度等(见《物联网之源:信息物理与信息感知基础》李同滨等,机械工业出版社·2018,第4章
4.2,PDF p190):
- **采样率**决定时间分辨率。按奈奎斯特定理,采样率必须高于信号最高频率的两倍,否则发生**混叠**——高频假装成低频,无法事后挽救。测振动要几
kHz 甚至更高,测室温每分钟一次就够。
- **量化位数**决定幅度分辨率。分辨率(Resolution)指数字量变化为一个最小量时模拟信号的变化量,定义为满刻度与 2^n
的比值,通常以数字信号的位数来表示(同上,PDF p190)。12 位把量程切成 4096 份,16 位切成 65536
份。位数越高分辨越细,但也越贵、越慢、越受噪声限制(有效位数
ENOB 常低于标称位数)。
选型与评估传感器,看的是下面这组指标,它们之间几乎总是相互制约。其中灵敏度(稳态下输出量变化值与相应被测变化值之比)、重复性(相同条件下多次测量结果的一致性)、分辨力(规定测量范围内能检测出的被测量最小变化量)、漂移(一定时间间隔内与被测量无关的不希望的变化量,含零点漂移与灵敏度漂移)等静态特性指标的定义,见《物联网与传感器技术》范茂军主编,机械工业出版社·2012,第5章
5.1.2,PDF p125–p126:
| 指标 | 含义 | 工程上的权衡 |
|-------------------|-------------|-------------------|
| 量程 (Range) | 能测的物理量上下限 | 量程越宽,同等位数下分辨越粗 |
| 精度 (Accuracy) | 读数与真值的接近程度 | 高精度器件单价显著上升 |
| 分辨率 (Resolution) | 能分辨的最小变化 | 受 ADC 位数与噪声地板共同限制 |
| 采样率 (Sample Rate) | 单位时间采样次数 | 越高越占带宽、功耗、存储 |
| 线性度 (Linearity) | 输出与输入成正比的程度 | 非线性需查表/多项式校正 |
| 漂移 (Drift) | 随时间/温度的缓慢偏移 | 决定多久要重新标定一次 |
::: warning 精度 ≠ 分辨率
分辨率高不代表准。一支能显示到 0.001℃ 的温度计,若没标定,绝对误差可能有 2℃。**分辨率是"看得多细",精度是"看得多对"**
,两者要分开评估。
:::
::: tip 标定(Calibration)是工程值可信的前提
出厂参数会随时间漂移。标定就是在明确传感器输出与输入关系的前提下,利用标准器具对传感器进行标定,且需要使用标准量值传递系统中至少高一级的标准装置进行检定(见《物联网之源:信息物理与信息感知基础》李同滨等,机械工业出版社·2018,第6章
6.4.4,PDF p308)。标定就是用已知标准量去测传感器、记录偏差并建立修正关系(零点 + 斜率,必要时整条曲线)。在 DC3
里,最常见的线性修正直接落在位号的换算参数上(见下文)。
:::
**MEMS(微机电系统)** 是把敏感结构和电路一起做在硅片上的技术,用半导体工艺批量制造微米级的可动结构。它让传感器变得极小、极廉、极省电——今天手机里的加速度计、陀螺仪、麦克风、气压计几乎都是
MEMS。代价是单颗精度与长期稳定性通常不及传统工业级器件,因此工业现场仍是 MEMS 与传统传感器按场景共存。
## 工程要点
- **先定量程再谈分辨率**。量程是被测对象决定的硬约束;在固定量程下,靠提高 ADC 位数换分辨率,但别忘了噪声地板才是真正的下限。
- **采样率服从信号,不服从习惯**。测什么物理量、它变化多快,决定采样率与是否需要抗混叠滤波。过采样再抽取,常比一味堆位数更划算。
- **线性化与标定要留出位置**。非线性传感器(热电偶、热敏电阻)必须做曲线校正;线性器件也要做零点与斜率标定。把"原始码 →
工程值"的换算固化下来,现场才好维护。
- **漂移决定运维节奏**。器件手册里的温漂、时漂直接换算成"多久复标一次",写进运维计划,而不是等读数明显跑偏才补救。
- **执行器是感知的镜像**。如果说传感器把物理量变成电信号(输入),**执行器(Actuator)**
就是反向的能量转换器,把电信号变回物理动作(输出):电机转动、阀门开合、继电器通断、加热器升温。一个完整的控制回路是"
传感→决策→执行→再传感"的闭环——感知层既要读得准,也要写得动。在 DC3 里,读对应只读位号,写对应可写位号,二者由同一套位号模型统一表达。
## 在 IoT DC3 中如何落地
物理世界的"一个量",在 DC3 里被抽象为**一个位号**。这套建模分三层,正好对应"类型—模板—实例":
- [模板 Profile](../introduction/concepts/profile) 是**一类设备的能力模板**。给"温湿度传感器 ZS-100"建一个
Profile,把它共有的能力定义一次,所有同型号设备复用。
- [位号 Point](../introduction/concepts/point) 是 Profile 下**一个具体的测点**。每个被采集或被写入的物理量对应一个位号,它带着这个量的全部元数据:数据类型
`pointTypeFlag`、读写能力 `rwFlag`、工程单位 `unit`、换算参数 `multiple`/`baseValue`/`valueDecimal`。
- [设备 Device](../introduction/concepts/device) 是**现场一台实物**,通过 `profileId` 绑定一个 Profile,从而继承它的全部位号。
于是本章讲的物理概念在 DC3 里有了精确落点:
- **单位与量程** → 位号的 `unit` 字段(如 `℃`、`kPa`),描述这个量是什么。
- **读 vs 写(传感器 vs 执行器)** → 位号的 `rwFlag`:传感器读数配 `READ_ONLY`,可控点(执行器、设定值)配 `READ_WRITE` 或
`WRITE_ONLY`。一个位号能不能被写,唯一由 `rwFlag` 决定。
- **标定与线性换算** → 位号把驱动采到的**原始码**换算成**工程值**,公式与本章信号链的最后一跳完全对应:
```text
工程值 = 原始值 × multiple + baseValue (再按 valueDecimal 保留小数)
```
例:一个温度变送器寄存器读数 `2531`,配 `multiple=0.01`、`baseValue=0`、`unit=℃`、`valueDecimal=2`,换算后这个位号的取值就是
`25.31 ℃`。这正是把传感器线性标定参数沉淀进模型的方式。
::: info 一个温度读数 = 一个位号的取值
现场一支温度传感器此刻 25.3℃,在 DC3 里就是它所属设备上那个温度位号的一条[位号值](../introduction/concepts/point)
。物理量经传感→调理→ADC→换算,最终落成的就是这一个数。本章对字段含义的描述以位号概念页与源码为准。
:::
这样,感知层的工程细节(敏感原理、调理、ADC、标定)被收敛成几个稳定的位号属性;上层服务无需关心传感器型号,只面对"
一个带单位、带读写能力、已换算到工程值的数字量"。物理世界的复杂度,到位号这一层被一次性吸收。
## 参考文献
1. 范茂军. 物联网与传感器技术[M]. 北京: 机械工业出版社, 2012.
2. 李同滨, 等. 物联网之源: 信息物理与信息感知基础[M]. 北京: 机械工业出版社, 2018.
## 延伸阅读
- [自动识别与定位](./identification) — 感知层的另一半:身份与位置的获取
- [物联网技术总览](./) — 四层参考架构与本层的定位
- [位号 Point](../introduction/concepts/point) — 物理量在 DC3 中的落点:类型、读写、单位、换算
- [模板 Profile](../introduction/concepts/profile) — 一类设备的能力模板,聚合全部位号
- [设备 Device](../introduction/concepts/device) — 现场实物的镜像,绑定 Profile 继承位号
---
# Frontend Testing Guardrails
URL: https://docs.dc3.site/zh/frontend/frontend-testing-guardrails
This project uses tests as guardrails for AI-assisted development. Every change
should make the smallest safe code edit and update the test layer that protects
the behavior being changed.
## Required Test Mapping
| Change area | Required test layer | Command |
|-------------------------------------|-----------------------------------|---------------------------------|
| `src/api/**` | API contract tests | `pnpm run test:api` |
| `src/utils/**` | Unit tests | `pnpm run test:unit` |
| `src/config/axios/**` | Unit tests | `pnpm run test:unit` |
| `src/store/**` | Unit tests | `pnpm run test:unit` |
| `src/composables/**` | Unit tests | `pnpm run test:unit` |
| `src/components/**` | Component contract tests | `pnpm run test:component` |
| `src/views/**` | Component or Playwright E2E tests | `pnpm run test:e2e` when routed |
| `src/config/router/**` | Playwright route/auth smoke tests | `pnpm run test:e2e` |
| build, lint, test, CI configuration | Guardrail tests and full quality | `pnpm run test:ci` |
Run `pnpm run test:impact` before finishing a feature to print the checks that
match the current changed files.
## AI Change Rules
- Do not change production code without updating or confirming the relevant
test layer from the mapping above.
- Do not commit focused or disabled tests such as `test.only`, `describe.only`,
`test.skip`, or `test.todo`.
- Do not add a new API wrapper file unless it is included in
`tests/api/api-contracts.test.ts`.
- Do not use URL query strings or path interpolation inside API wrapper URLs.
Pass dynamic values through Axios `params` or request bodies.
- Do not add fixed production IDs to tests. Test data must be discovered or
created at runtime.
- Do not add new scenarios to `tests/e2e/browser-sweep.mjs`; it is a thin
browser sweep entrypoint. Prefer Playwright specs for new browser scenarios.
- Do not hide broken coverage by loosening thresholds without a clear reason.
## E2E Data Rules
- E2E tests must create missing data instead of skipping scenarios.
- Runtime fixture data must use the `e2e_` prefix.
- Data created by a test must be registered in the cleanup stack.
- Delete checks must target disposable fixture data only.
- Playwright tests must report console errors, page errors, and failing
business API responses.
## CI Gates
Pull requests and pushes run the non-environment-dependent quality gate:
1. `pnpm run lint:check`
2. `pnpm run type-check`
3. `pnpm run test:guard`
4. `pnpm run test:ci`
5. `pnpm build`
Playwright E2E runs through the manual workflow when a disposable backend URL is
provided with `e2e_base_url`.
## Adding New Features
1. Add the production code.
2. Add or update the matching unit, component, API contract, or E2E test.
3. Run `pnpm run test:impact` and the recommended commands.
4. Run the full local gate for broad changes:
```bash
pnpm run lint:check
pnpm run type-check
pnpm run test:guard
pnpm run test:ci
pnpm run build
```
For route or page changes, also run Playwright against a disposable test
environment:
```bash
E2E_BASE_URL=http://localhost:8080 E2E_START_SERVER=0 pnpm run test:e2e
```
---
# 前端开发
URL: https://docs.dc3.site/zh/frontend/
IoT DC3 前端基于 **Vue 3 + TypeScript + Vite + Element Plus** 构建,源码位于仓库的 `dc3-web/` 目录。
## 环境准备
| 工具 | 最低版本 | 说明 |
|---------|--------|----------------------------------|
| Node.js | 20 LTS | 推荐使用 fnm/nvm 管理版本 |
| pnpm | 9+ | 包管理器,项目 `packageManager` 字段已锁定版本 |
```bash
# 安装 pnpm(如未安装)
corepack enable && corepack prepare pnpm@latest --activate
# 确认版本
node -v # ≥ v20
pnpm -v # ≥ 9
```
## 快速启动
```bash
# 1. 进入前端项目目录
cd dc3-web
# 2. 安装依赖
pnpm install
# 3. 启动开发服务器(默认 http://localhost:8080)
pnpm dev
```
开发服务器启动后:
- 前端页面:`http://localhost:8080`
- 默认代理后端 API 到 `http://localhost:8000`(网关端口)
- 修改后端地址:编辑 `vite.config.ts` 中的 proxy 配置
::: tip 后端依赖
前端开发需要后端服务运行。至少需要起网关 `dc3-gateway`(端口 8000)。推荐用 docker-compose 起全栈:
```bash
# 在仓库根目录
make up-dev # 起网关 + 4 个中心 + 常用驱动
```
:::
## 项目结构
```
dc3-web/
├── src/
│ ├── api/ # REST API 请求封装
│ ├── components/ # 可复用组件
│ │ ├── card/ # InfoCard 模式(单实体表单 + 保存/重置)
│ │ ├── chart/ # 图表组件(AntV G2/G6)
│ │ ├── entity/ # 实体详情/列表组件
│ │ ├── layout/ # 布局、菜单、导航栏
│ │ └── agentic/ # AI 对话组件
│ ├── composables/ # Vue Composables
│ ├── config/ # 应用配置
│ │ ├── axios/ # Axios 实例与拦截器
│ │ ├── i18n/ # 国际化(zh / en)
│ │ ├── router/ # 路由定义
│ │ └── types/ # 实体类型定义
│ ├── store/ # Pinia 状态管理
│ ├── styles/ # 全局样式
│ ├── utils/ # 工具函数
│ └── views/ # 页面组件
│ ├── device/ # 设备管理
│ ├── driver/ # 驱动管理
│ ├── home/ # 仪表盘
│ ├── login/ # 登录
│ ├── point/ # 位号管理
│ ├── profile/ # 模板管理
│ └── settings/ # 系统设置
├── tests/ # 测试(Vitest + Playwright)
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
└── package.json # 依赖与脚本
```
## 菜单系统
前端菜单由两层控制:
1. **后端数据库** `dc3_menu` 表 — 存储菜单项的定义和权限
2. **前端路由配置** `src/config/router/` — 将菜单映射到 Vue 页面组件
新增一个菜单项的完整链路:
```
dc3_menu 表写入 → 前端路由注册 → i18n 翻译 → 权限点绑定
```
四层必须全部更新,缺一不可。详见 [贡献指南](../community/contributing)。
## 常用命令
| 命令 | 说明 |
|-------------------|-----------------------|
| `pnpm dev` | 启动开发服务器 |
| `pnpm build` | 生产构建 |
| `pnpm preview` | 预览生产构建 |
| `pnpm test` | 运行单元测试 |
| `pnpm test:e2e` | 运行 E2E 测试(Playwright) |
| `pnpm lint` | ESLint 检查 |
| `pnpm type-check` | TypeScript 类型检查 |
## 测试
项目包含三层测试:
- **单元测试**(Vitest):`tests/unit/` 和 `tests/component/`
- **API 契约测试**:`tests/api/` — 快照测试确保 API 封装接口不变
- **E2E 测试**(Playwright):`tests/e2e/` — 浏览器端到端测试
CI 门禁:`pnpm lint && pnpm type-check && pnpm test && pnpm build`
详见 [测试调试指南](./test-debugging)。
## 环境变量
前端环境变量在 `src/config/env/` 下按模式组织:
```typescript
// .env.development
VITE_API_BASE_URL=http://localhost:8000
VITE_APP_TITLE=IoT DC3 (Dev)
```
修改后端地址后需重启 dev server。
---
# 测试调试常见问题
URL: https://docs.dc3.site/zh/frontend/test-debugging
这里汇总编写或运行前端测试时最常反复出现的报错,并给出快速解答。测试约定参见 `dc3-web/` 项目下的 `tests/README.md`
,机械执行的规则参见
`tests/guardrails/ai-guardrails.test.ts`。
## 测试抛出 "Unexpected Vue warning: ..."
`tests/setup/vitest.setup.ts` 这个 setup 文件会把 `[Vue warn]` / `[Vue error]` 提升为抛出的错误。最常见的几类原因:
- **Failed to resolve component: el-xxx** —— 组件模板里用到了一个你还没有 stub 的 Element Plus 组件。把它加到
`tests/setup/stubs/element-plus.ts`
的 `layoutStubs` 中(推荐),或在挂载时通过 `global.stubs` 传入。
- **injection "Symbol(router)" not found** —— 组件调用了 `useRouter()` / `useRoute()`,但测试挂载时没有提供 router。使用内存
router:
```ts
import { createMemoryHistory, createRouter } from 'vue-router';
const router = createRouter({ history: createMemoryHistory(), routes: [...] });
mount(Comp, { global: { plugins: [i18n, router] } });
```
- **Failed setting prop "modelValue"** —— 已在允许名单中;如果报的是别的 prop,把对应的正则加到 `VUE_WARN_ALLOWLIST`
,并附注释说明为何无法在源头修复。
本地调试时如需临时绕过:
```bash
VITEST_ALLOW_VUE_WARN=1 pnpm test
```
不要提交需要绕过的代码 —— 这条警告意味着存在真实的契约缺口。
## vi.mock 工厂里出现 "Cannot access X before initialization"
现象:`ReferenceError: Cannot access 'someMock' before initialization`,指向某个 `vi.mock(...)` 调用。根因是:工厂函数引用了一个顶层
`const`,而
`vi.mock` 会被 Vitest 提升到文件顶部,此时这个 const 尚未初始化。
修复方式:用 `vi.hoisted` 包裹 spy:
```ts
const apiMocks = vi.hoisted(() => ({
doSomething: vi.fn(),
}));
vi.mock('@/api/foo', () => apiMocks);
```
对于包含多个 `vi.mock` 的文件,shared-mocks guardrail 会强制要求这种写法。
## 本地通过、CI 上因 "leftover state" 失败
排查在测试之间残留的模块级状态:
- **模块级响应式缓存**(例如 `useEntityNames` 的 `cache` / `inflight` 对象)。用 `vi.resetModules()` 并在 `beforeEach` 中重新
import 来重置。
- **Pinia store 在测试间持久化**。始终在 `beforeEach` 中 `setActivePinia(createPinia())`。
- **localStorage / sessionStorage**。全局 setup 会在 `afterEach` 清理它们,但如果你的测试依赖"运行开始时数据不存在",也要在
`beforeEach` 中清理。
## "as unknown as T" 或 "as never" guardrail 失败
这条 guardrail 禁止用双重断言抹除类型。改为:
- 类型化的 fixture 构造器:`function makeRequest(): Request { return { … }; }`
- 在违反契约的那一行单点标注 `// @ts-expect-error — intentionally invalid input for whitelist test`
如果测试确实需要 `as never` 才能满足某个泛型约束,通常说明生产代码的类型标注有误,应在源头修复。
## "tests/component/foo.test.ts contains wrapper.vm.x()" guardrail 失败
请通过组件的公开表面驱动它 —— props、slots、emits、DOM 事件。调用 `wrapper.vm.someMethod()`
会让测试耦合到内部实现,一旦组件重构就会失效。如果确实需要逃生口,在组件里用 `defineExpose` 暴露,再通过 expose 表面调用(仍然是
`wrapper.vm.someMethod()`,但因为 expose 属于契约的一部分,测试在重构后仍能存活)。
## "describe must use lowercase verb (no should...)" guardrail 失败
改写措辞。describe 块命名主语,it 块命名行为:
```ts
// ❌
it('should validate before searching', …);
// ✅
it('validates before searching', …);
```
## 覆盖率低于阈值
本地运行 `pnpm test:coverage`。控制台报告会显示每个文件的缺口。如果确实新增了未覆盖的代码,补上测试;如果你删除了被覆盖的代码(有意的重构),百分比可能足以掩盖下降,否则在
`vitest.config.ts` 中调低阈值并在 PR 描述中说明。
不要靠写同义反复的测试来应付阈值 —— "forbids tautological assertions" guardrail 会捕获 `expect(true).toBe(true)` 这类写法。
## 一次小的 API 改动引发巨大的 snapshot diff
`tests/api/api-contracts.test.ts.snap` 大约 2800 行,因为它覆盖了每一个 API wrapper。对某个 wrapper的小改动会触发其中一小段聚焦的
diff;如果出现巨大 diff,说明很多 wrapper 都发生了变化。自检:
- 鉴权头的结构变了吗?(存储格式变更)
- URL 前缀移动了吗?(代理 / 版本升级)
- 是否往被追踪的模块里新增了 wrapper?
只有在人工阅读过 diff 之后,才运行 `pnpm test:api -u` 更新快照。
## 新的 fixture 应该放在哪里?
放在 `tests/fixtures/`。如果某个兄弟测试可以复用同样的数据结构,就不要在测试里内联一个 30 行的样例树。guardrail
会检查该目录是否存在;约定要求你持续往里添加文件(`auth.ts`、`menu.ts`、`rows.ts` 可作为起点)。
---
# 生产部署指南
URL: https://docs.dc3.site/zh/guide/deployment
从单机 Compose 到 Kubernetes / Helm 的完整部署路径:五种形态怎么选、每一条命令、以及上线前必须完成的安全加固清单。
> 你在这里:已经用 [部署模式与镜像源](./usage) 把平台跑起来了,现在要决定生产环境怎么摆。环境变量细节见
> [环境变量详解](../quickstart/environment)。
## 五种部署形态
| 形态 | 文件(均在 `iot-dc3/`) | 运行时 | 适用场景 | 扩容方式 |
|------|------------------------|--------|----------|----------|
| 单机 Compose | `dc3/docker-compose-db.yml` + `dc3/docker-compose.yml` | Docker / Podman Compose | 评估、演示、小规模生产 | 无(单例) |
| Compose 扩容 | `dc3/docker-compose-db.yml` + `dc3/docker-compose-scale.yml` | Docker Compose v2 | 单机生产 + 服务副本 | `docker compose up --scale =N` |
| Docker Swarm | `dc3/docker-compose-swarm.yml` | Docker Swarm 模式 | 多节点 swarm 集群 | `docker service scale dc3_=N` |
| Kubernetes | `dc3/deploy/k8s/`(kustomize) | 任意 k8s 集群 | 生产 Kubernetes | `kubectl scale` / HPA |
| Helm | `dc3/deploy/helm/dc3/` | Kubernetes | GitOps / 可重复安装 | values + HPA |
所有形态跑的是同一批镜像、同一套环境变量——在 Compose 上调通的拓扑,换到别的形态行为一致,区别只在副本放哪、流量怎么进。
完整命令与排错细节见主仓库的 [`dc3/doc/DEPLOYMENT.md`](https://github.com/pnoker/iot-dc3/blob/main/dc3/doc/DEPLOYMENT.md)。
### 镜像可得性(先知道这个再选形态)
发布 CI 只把 **应用镜像**(web、网关、四个中心、驱动)推到 Docker Hub `pnoker/*` 与阿里云
`registry.cn-beijing.aliyuncs.com/dc3/*`。**依赖镜像** `dc3-postgres` 与 `dc3-rabbitmq` 由 `docker-compose-db.yml`
本地构建,**不发布**——swarm / k8s / helm 形态需要先构建并推送到你自己的仓库:
```bash
DC3_IMAGE_REGISTRY=my.registry/dc3 ./dc3/deploy/k8s/scripts/push-images.sh
```
## 形态一:单机 Compose(最短路径)
```bash
make up-db # PostgreSQL + RabbitMQ(docker-compose-db.yml)
make up STACK=app # 应用栈:web、网关、四个中心、驱动(docker-compose.yml)
make logs
```
对外只暴露 `web`(8080/8443)与 `listening-virtual`(设备 TCP 6270 / UDP 6271),其余端口一律在内网。详见
[部署模式与镜像源](./usage)。
## 形态二:Compose 扩容(单机多副本)
`docker-compose-scale.yml` 是去掉 `container_name`/`hostname` 固定名、去掉多副本端口冲突、并带资源限制的 app 栈:
```bash
docker compose -f dc3/docker-compose-db.yml up -d
docker compose -f dc3/docker-compose-scale.yml up -d \
--scale gateway=2 --scale data=2 --scale modbus-tcp=2
```
## 形态三:Docker Swarm
`docker-compose-swarm.yml` 是自包含全栈(含 postgres/rabbitmq),overlay 网络,`deploy:` 块管理副本/更新/重启/资源:
```bash
docker swarm init # 单节点即可起步
DC3_IMAGE_REGISTRY=my.registry/dc3 ./dc3/deploy/k8s/scripts/push-images.sh
docker stack deploy -c dc3/docker-compose-swarm.yml dc3
docker service scale dc3_gateway=3 dc3_modbus-tcp=2
docker stack rm dc3
```
注意:swarm 忽略 `depends_on`/`build`,启动顺序靠健康检查 + 重启策略;`web` 用 ingress 模式发布端口可多副本,
`listening-virtual` 必须保持 1 副本(设备连接亲和);多节点 swarm 要把有状态服务的卷放到共享存储(NFS/Ceph)。
## 形态四:Kubernetes(kustomize)
`dc3/deploy/k8s/` 提供生产级 manifests:gateway/web 带 CPU 自动扩缩(HPA)与 PodDisruptionBudget,滚动更新
`maxUnavailable: 0`,postgres/rabbitmq 为 StatefulSet + PVC,Ingress 路由 `/api/` → 网关、`/` → web:
```bash
cp dc3/deploy/k8s/secret.env.example dc3/deploy/k8s/secret.env # 先改密钥
DC3_IMAGE_REGISTRY=my.registry/dc3 ./dc3/deploy/k8s/scripts/push-images.sh
kubectl apply -k dc3/deploy/k8s
kubectl -n dc3 get pods -w
```
## 形态五:Helm
`dc3/deploy/helm/dc3` 把同一拓扑参数化:服务清单由 `services:` / `drivers:` map 驱动,加驱动/调副本不碰模板:
```bash
helm upgrade --install dc3 dc3/deploy/helm/dc3 -f dc3/deploy/helm/dc3/values-production.yaml \
--set image.registry=my.registry/dc3 \
--set-string secrets.DC3_SECURITY_KEY=<随机值> \
--set-string secrets.AUTH_HMAC_SECRET=<随机值>
helm upgrade dc3 dc3/deploy/helm/dc3 --reuse-values --set services.gateway.replicas=4
helm rollback dc3 1
```
## 谁可以扩容,谁不能
| 服务 | 能否扩容 | 负载均衡语义 |
|------|----------|--------------|
| `web` | 1 副本(占用宿主端口) | 需要更多容量时在前面放自己的 LB |
| `gateway` | ✅ | `dc3-web` 里的 nginx 把 `dc3-gateway` 解析到全部副本并轮询(扩容后重启 web 刷新地址) |
| 四个中心 | ✅(HA 语义) | 网关的 HTTP 路由由 Spring Cloud Gateway 负载均衡;中心间 gRPC 是固定目标的一个长连接——副本重启会切换,但连接不做请求级均衡 |
| 驱动 | ✅ | 副本消费同一条 RabbitMQ 队列,一条消息恰好一个副本处理 |
| `listening-virtual` | ❌ 必须 1 副本 | 入站设备连接钉死在单个容器 |
| postgres / rabbitmq | ❌ 有状态单例 | 高可用请用托管服务或自建主从/集群 |
::: warning 中心间 gRPC 的均衡边界
中心间调用走 `static://` 固定目标,每个客户端一条通道。要拿到请求级均衡,用 Kubernetes Service(kube-proxy 按连接轮询)
或加客户端 LB;经网关的 HTTP 流量在所有形态下都是均衡的。
:::
## 生产加固清单
1. **密钥**:把 `DC3_SECURITY_KEY`、`AUTH_HMAC_SECRET`、数据库/消息队列口令、LLM API Key 全部换成强随机值。`pro`
profile 对弱密钥**拒绝启动**。不要把真实密钥提交进 `secret.env` / values 文件。
2. **TLS**:边缘终止 TLS(web 自带加固 nginx 配置;k8s 用 ingress + cert-manager;swarm 在 `web` 前放反代)。跨节点开启
RabbitMQ TLS(`RABBITMQ_SSL_ENABLED=true`,端口 5671)与 PostgreSQL TLS。
3. **备份**:定时 `pg_dump`/pgBackRest + 异地存储,并演练恢复;TimescaleDB 时序数据持续增长,按
[FAQ](../community/faq) 的硬件建议规划容量(全栈最低 8 核 / 16GB / 100GB SSD)。
4. **高可用**:PostgreSQL 主备或托管实例 + RabbitMQ 集群;swarm/k8s 多节点时有状态卷放共享存储。
5. **可观测**:叠加 [可观测性](./observability)(Prometheus + Grafana + ELK),对就绪/存活探针告警。
6. **网络**:出口收敛(中心只需访问 LLM 端点)、后端端口不映射宿主、k8s 开启 Pod Security Admission `baseline`。
7. **API 面**:`pro` profile 已关闭 Swagger/OpenAPI(发布镜像用 `PROFILE=pro` 构建),上线前确认无调试端点可达。
## 常见问题
- **中心扩了副本为什么 gRPC 不是每条请求都均衡?** 中心间 gRPC 用 `static://` 固定目标、单通道。副本提供的是故障切换与滚动安全;
真正按请求均衡需要客户端 LB 或 k8s(ClusterIP 按连接轮询)。HTTP 每一层都均衡(nginx → Spring Cloud Gateway → 中心)。
- **驱动可以 2 副本吗?** 出站协议驱动可以(共享队列的 worker);`listening-virtual` 不行(持有入站设备连接,保持 1 副本)。
- **PostgreSQL / RabbitMQ 可以多副本吗?** 本仓库配置不支持——它们是有状态单例。要 HA 就用托管服务,然后把 ConfigMap/环境变量指过去。
- **k8s / helm 需要依赖镜像吗?** 需要;先用 `scripts/push-images.sh` 构建推送(单节点集群也可 `kind load`)。
部署相关的原始配置与仓库内文档:`dc3/docker-compose-scale.yml`、`dc3/docker-compose-swarm.yml`、`dc3/deploy/`、
`dc3/doc/DEPLOYMENT.md`。
---
# 部署运维
URL: https://docs.dc3.site/zh/guide/
# 部署运维
把 IoT DC3 从一台开发机的 `java -jar`
推到一组容器编排,再让它在生产里可观测、可排障——这一栏覆盖部署形态、镜像源、可观测栈、日志规范与故障排查。读完本页,你会知道每个话题在哪、以及"
本地开发"和"容器化部署"两条路线的分界在哪里。
> 你在这里:已经[本地起栈跑通第一个设备](../quickstart/),现在要把它部署、观测、运维起来。
## 两条路线,先分清边界
部署运维的所有话题,最终都落在两条路线之一,两者的环境变量来源不同,混用是最常见的坑:
- **本地开发**:依赖(PostgreSQL、RabbitMQ、可选 EMQX/ELK/Prometheus)跑在容器里,但 Java 进程(网关与四个中心、驱动)在宿主机
IDE 或 `java -jar` 里直接运行。这条路线由 [快速开始](../quickstart/) 负责,调试快、改代码即时生效。
- **容器化部署**:网关、四个中心、驱动连同依赖全部以容器形式编排启动。这条路线由 [部署模式与镜像源](./usage) 负责。
::: warning 环境变量不会自动串台
根目录 `.env` **只服务 Docker Compose**——它不会自动注入到本机的 Java 进程。本地以 IDE 或 `java -jar` 跑 Java 时,必须改用
`dc3/env/dev.env`(IDE EnvFile 插件读取)或 `source dc3/env/dev.env.sh`(shell 导出),把服务指向 Compose 在 `localhost`
上发布的端口(如 PostgreSQL `35432`、RabbitMQ `35672`)。把容器内主机名(`dc3-postgres`、`dc3-rabbitmq`)填进本地 Java
进程,连接必然失败。
:::
## 这一栏怎么读
五个子页各管一段运维生命周期:先把服务**起起来**(部署与镜像源、生产部署指南),再让它**看得见**(可观测性、日志),最后在出问题时**修得动**
(故障排查)。
- **[部署模式与镜像源](./usage)** — 容器镜像选择、镜像仓库切换、Compose 编排。`make` 选择镜像源用 `REGISTRY`(`auto`/
`global`/`cn`),`global` 走默认仓库、`cn` 走中国大陆镜像;最快验证起栈是 `make up-db`,国内网络改用 `make up-db-cn`。
- **[生产部署指南](./deployment)** — 从单机 Compose 到 Docker Swarm / Kubernetes / Helm 的完整部署路径:五种形态的选型、
镜像可得性、谁可以扩容谁不能,以及上线前的生产加固清单。
- **[可观测性](./observability)** — 应用与依赖如何接入 Grafana / Prometheus / ELK(可选 `optional` 栈)。用
`make up-optional` 拉起 EMQX/ELK/Prometheus/Grafana 这套可选栈,端口见环境变量目录里的"Observability Stack"一节(Grafana
`3000`、Kibana `5601`)。
- **[日志规范](./logging)** — `dc3-common-log` 统一输出控制台彩色日志(人工调试)与滚动 JSON 文件日志(机器解析,含
timestamp/logger/thread/level/MDC/message/stack);消息用英文稳定事件名 + SLF4J 参数化占位符,便于跨模块搜索关联。
- **[故障排查](./troubleshooting)** — 构建慢、JDK 版本、端口占用、DB/MQ 连接失败、Gateway 401/403、驱动无法注册等高频问题的定位与处理。
## 几个最常用的命令
容器栈的生命周期统一走 `make`,命令模式是 `make -[-]`。下面是部署运维里最常敲的几条(在 `iot-dc3/`
目录执行):
::: code-group
```bash [启动依赖栈]
# 启动 PostgreSQL + RabbitMQ(最小依赖)
make up-db
# 国内网络改用中国大陆镜像源
make up-db-cn
# 追加可选可观测栈:EMQX / ELK / Prometheus / Grafana
make up-optional
```
```bash [查看日志]
# 跟随某个栈的日志(最后 200 行)
make logs STACK=db
# 只看指定服务
make logs SERVICES="gateway agentic"
```
```bash [本地源码运行前置]
# 让本地 Java 进程指向 Compose 发布到 localhost 的端口
source dc3/env/dev.env.sh
```
:::
::: tip 启动顺序
分布式起栈时按 Auth → Manager → Data → Agentic → Gateway → Driver 顺序启动:Auth 无依赖最先起,四个中心健康后 Gateway 才启动(
`gateway` 的 `depends_on` 为 auth/manager/data/agentic 均 `service_healthy`),驱动依赖 Manager Center 与 RabbitMQ
就绪后才能注册。详见 [故障排查 · 驱动无法注册](./troubleshooting)。
:::
## 验证服务通了:调一次黄金路径
部署完成后,确认网关与鉴权链路是否打通,最快的方式是走一遍登录:先取盐,再用加盐口令换 token。所有对外请求都经唯一 HTTP
入口网关(默认 `8000`)。
```bash
# 1) 取盐(公开端点,建议 5 分钟内使用;以下租户/用户名为示例值)
curl -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# 2) 用盐对口令做哈希后换取 token(12 小时有效)
curl -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"<上一步返回的盐>","password":"<加盐哈希>"}'
```
拿到 token 后,受保护端点需要带上三个鉴权头:`X-Auth-Tenant`、`X-Auth-Login`、`X-Auth-Token`。若此处返回 401/403,多半是 token
缺失或过期,处理见 [故障排查 · Gateway 返回 401 或 403](./troubleshooting)。
## 延伸阅读
- [部署模式与镜像源](./usage) — 容器镜像、镜像仓库切换与 Compose 编排
- [可观测性](./observability) — Grafana / Prometheus / ELK 对接
- [日志规范](./logging) — 日志消息风格、级别与输出格式约定
- [故障排查](./troubleshooting) — 启动与连接问题的定位与处理
- [快速开始](../quickstart/) — 本地起栈并跑通第一个设备(本地开发路线起点)
---
# 日志规范
URL: https://docs.dc3.site/zh/guide/logging
# 日志规范
IoT DC3 的日志要同时服务两个读者:本地开发时的人,和线上排障时的机器。这页讲清两者怎么兼顾——结构化消息、MDC
上下文、级别约定、脱敏红线,以及容器日志怎么轮转,让你写出"能被搜索、不会泄密"的日志。
> 你在这里:写业务代码或排查线上问题,想知道日志该怎么打、去哪儿看。配套阅读 [可观测性](./observability)
> 与 [故障排查](./troubleshooting)。
## 为什么这样设计
一条日志的价值不在它被打出来的那一刻,而在三天后有人 `grep` 它的时候。微服务下,一次设备命令会横跨网关、数据中心、驱动多个进程,日志散落在不同容器里——如果每条消息措辞各异、关键
ID 缺失、上下文不可关联,排障就退化成大海捞针。
所以 IoT DC3 把日志拆成两层职责:
- **应用代码**只负责写出**稳定的事件名 + 结构化参数**——同一件事在所有模块用同样的措辞和参数顺序,跨进程才能拼成一条链路。
- **`dc3-common-log` 的 Appender**负责最终格式——本地输出彩色控制台便于人读,文件输出 JSON 便于机器解析与采集。
业务代码绝不为某种输出格式硬编码内容。这样换采集方案(ELK、Loki…)时,改的是 Appender,不是几百处 `log.info`。
## 日志怎么流动
下图是一条日志从代码到落盘/采集的完整路径:应用写出事件,经 MDC 上下文槽位,再由两个 Appender 分别格式化。
两个 Appender 都挂在 `root`(默认级别 `INFO`),由 `dc3-common-log` 的 `logback.xml` 配置。JSON Appender 用
`net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder`,逐字段输出 `timestamp`、`version`、`message`、`loggerName`、
`threadName`、`logLevel`、`logLevelValue`、**`mdc`**、`contextName`、`stackTrace`——其中 `mdc` 这一项,是下一节要讲的链路关联的预留槽位。
## MDC:预留的链路上下文槽位
MDC(Mapped Diagnostic Context)是 SLF4J 的线程级上下文:往 MDC 里放的键值,会随本线程后续每一条日志自动渲染进输出。
`dc3-common-log` 的 `logback.xml` 已在 JSON encoder 里挂上 `` provider,为这套能力预留了输出槽位——任何被放进 MDC
的字段都会逐条出现在 JSON 的 `mdc` 项里。
MDC 的目标用法是在请求入口放入 `traceId`、`tenantId`、`userId`,从而支撑两个能力:
- **跨服务关联**:同一个 `traceId` 贯穿网关 → 数据中心 → 驱动,按 `traceId` 一搜即可聚齐整条调用链。
- **租户归因**:每条日志带 `tenantId`,直接回答"是哪个租户触发的"——与平台的[租户隔离](../architecture/auth-rbac)边界一致。
::: info MDC 自动注入尚未接线
encoder 的 `` provider 已就位,但当前代码库**没有**任何过滤器/拦截器/AOP 把 `traceId`/`tenantId`/`userId` 写入
MDC(全仓无 `MDC.put`),所以现阶段 JSON 输出里的 `mdc` 项实际为空。上面描述的链路关联是**已规划、尚未实现**
的能力。在它接线之前,需要关联的字段请按下一节作为消息参数显式传入(如 `tenantId`、`deviceId`)。
:::
## 写出结构化的消息
日志正文用英文、稳定事件名和 SLF4J 参数化占位符 `{}`。占位符让消息模板保持恒定(便于 `grep` 与日志聚合按模板归类),变量作为参数传入:
```java
log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}", toolName, tenantId, deviceId);
log.warn("Agentic tool failed, tool={}, tenantId={}, deviceId={}", toolName, tenantId, deviceId, e);
```
参数适用时按下面的顺序组织,保证同类事件在不同模块长得一样:
```text
module/action, tenantId, userId, resource IDs, filters, status/result, durationMs
```
示例:
```java
log.info("Device registered, tenantId={}, deviceId={}, driverId={}", tenantId, deviceId, driverId);
log.debug("Agentic chat request received, mode={}, model={}, messageCount={}, conversationIdPresent={}, skill={}, tenantId={}, userId={}",
mode, model, messageCount, conversationIdPresent, skill, tenantId, userId);
```
::: warning 不要字符串拼接,不要丢堆栈
避免 `+` 拼接和 `String.format`——它们破坏消息模板、且无论级别是否输出都会先求值。捕获异常后,除非明确要隐藏堆栈,否则把异常对象作为
**最后一个参数**传入(而非 `e.getMessage()`),SLF4J 会自动渲染完整堆栈:
```java
// ✅ 模板稳定 + 完整堆栈
log.warn("Point read command failed, tenantId={}, deviceId={}, pointId={}", tenantId, deviceId, pointId, e);
// ❌ 拼接 + 丢失堆栈
log.info("Device registered: " + deviceId);
log.error("Failed to register device: {}", e.getMessage());
```
:::
### 声明式方法日志:`@Logs`
`dc3-common-log` 提供 `@Logs` 注解(由 `LogsAspect` 这个 Spring AOP 切面拦截),可在方法上声明式地记录一条日志,免去手写。注解成员为
`value`(日志消息)、`type`(`LogsTypeEnum`,取值 `INFO`/`WARN`/`DEBUG`/`ERROR`,默认 `INFO`)、`tag`(分类标签)、`save`(是否持久化,默认
`false`):
```java
@Logs(value = "warn-resource", type = LogsTypeEnum.WARN, tag = "resource", save = true)
public void someMethod() {
// 方法体
}
```
::: info 当前仅测试用例使用
`@Logs` 切面已实现,但平台生产代码暂未在任何 Controller/Service 上使用它(仅 `LogsAspectTest`
覆盖)。它作为可选的声明式日志能力存在,业务日志现行约定仍以本页前述的 SLF4J 参数化写法为主。
:::
## 日志级别约定
级别不是随手选的,它决定了线上默认输出量和告警噪声。`root` 默认 `INFO`,意味着 `trace`/`debug`
默认不落盘——把信息放对级别,排障时才能"该有的有、该静的静"。下表给出约定,先理解每一档的判断标准,再对照使用:
| 级别 | 判断标准(什么时候用) |
|---------|----------------------------------------|
| `trace` | 高频诊断细节,默认关闭,只在深挖单点问题时临时打开 |
| `debug` | 请求细节、工具调用、查询条件、排障时有用的分支判断;默认不输出 |
| `info` | 生命周期事件、启动摘要、长任务成功摘要、重要状态转换——线上稳态下应当能看到 |
| `warn` | 可恢复失败、非法客户端输入、重试、降级、外部依赖异常但已有兜底 |
| `error` | 不可恢复、需要运维关注、或导致当前操作失败的错误 |
判断要点:**当前操作是否失败、是否需要人介入**——失败且无兜底用 `error`,失败但已降级/重试用 `warn`,正常流程的关键节点用
`info`,其余诊断信息压到 `debug`。第三方框架噪声已在 `logback.xml` 里逐包压到 `WARN`(如 `org.springframework.*`、
`com.zaxxer.hikari`、MyBatis 等),不要在业务里把它们重新放大。
## 脱敏:密钥与隐私绝不明文
::: danger 密钥、token、密码绝不明文落日志
不要在任何级别记录密钥、Bearer token、密码、完整 `Authorization` 头、原始私有载荷或任意请求体。一旦写进日志,它就进了文件、进了采集系统、进了备份——撤不回来。
需要佐证时只记录**派生信息**:是否存在、长度、前几位 + 长度、或资源 ID。例如校验失败时记 `tokenPrefix=eyJ0..., tokenLen=212`
,而不是整段 token。
:::
这条红线对两个高风险字段尤其关键,它们的明文一旦泄露等于整个鉴权链失守(参见 [env 目录](../quickstart/environment)):
- `DC3_SECURITY_KEY` — Auth Center 的 Token 签名密钥。
- `AUTH_HMAC_SECRET` — 网关到后端签发 `X-Auth-Principal` 的 HMAC-SHA256 密钥。
对"可能敏感"的命令值(如写位号下发的 `value`),同样只记录长度/是否存在/资源 ID 等派生信息,不记原值:
```java
// ✅ 只留派生信息
log.info("Point write accepted, tenantId={}, deviceId={}, pointId={}, valueLen={}", tenantId, deviceId, pointId, value.length());
// ❌ 把原始命令值/凭证打进日志
log.info("Point write, value={}, token={}", value, token);
```
## 容器日志轮转
应用内的 `logback.xml` 已自带文件滚动(`SizeAndTimeBasedRollingPolicy`,应用默认单文件 200MB、总量上限 20GB、保留 30
个历史文件、按天 `.gz` 归档)。但在容器部署下,进程的 `stdout`/`stderr` 由容器运行时接管,需要在 Compose
层另行限制磁盘占用——否则一个长跑容器的日志能把宿主机磁盘写满。
`dc3` 的 Compose 文件用一个共享的 `x-logging` 锚点,给每个应用服务统一挂上 Docker `json-file` 驱动的轮转策略:
```yaml
# dc3/docker-compose-dev.yml
x-logging: &default-logging
driver: json-file
options:
max-size: ${DC3_LOG_MAX_SIZE:-10M} # 单个容器日志文件达到此大小即轮转
max-file: "${DC3_LOG_MAX_FILE:-20}" # 保留的轮转文件数
```
两个开关在根 `.env`(Compose-only,不注入本地 Java 进程)里调整:
| 变量 | 默认值 | 作用 |
|--------------------|-------|--------------|
| `DC3_LOG_MAX_SIZE` | `10M` | 单个容器日志文件轮转阈值 |
| `DC3_LOG_MAX_FILE` | `20` | 保留的轮转日志文件数 |
按默认值,每个容器最多占用约 `10M × 20 = 200M` 磁盘。查看与跟随容器日志:
::: code-group
```bash [podman]
podman logs -f --tail 200 dc3-center-data
```
```bash [make]
# 从 iot-dc3/ 执行,跟随当前栈最近 200 行
make logs
```
:::
::: info 应用内轮转 vs 容器轮转
两套轮转独立生效:`logback.xml` 管的是容器内 `LOG_FILE` 写出的滚动文件(默认在临时目录,体量较大);`DC3_LOG_MAX_SIZE`/
`DC3_LOG_MAX_FILE` 管的是容器运行时捕获的 `stdout`/`stderr`。生产采集通常以后者(`json-file`)为采集源,前者作为容器内的二级保留。
:::
## 延伸阅读
- [可观测性](./observability) — 日志、指标、追踪如何协同,以及 ELK/Grafana 栈怎么起
- [故障排查](./troubleshooting) — 拿到 `traceId`/`tenantId` 后如何定位一次失败
- [env 目录](../quickstart/environment) — `DC3_LOG_*` 与 `DC3_SECURITY_KEY`/`AUTH_HMAC_SECRET` 等变量的来源与边界
---
# 可观测性
URL: https://docs.dc3.site/zh/guide/observability
# 可观测性
IoT DC3 的日志聚合与指标监控是一套**可选**栈:一条命令 `make up-optional` 拉起 EMQX、ELK(Elasticsearch + Logstash +
Kibana)、Prometheus 与 Grafana。读完这页你能知道每个组件干什么、暴露在哪个端口、怎么把服务日志接进 Kibana、把服务指标接进
Grafana,以及用哪些环境变量调堆内存和开关 APM。
> 你在这里:已能[部署并启动平台](./usage),想给运行中的环境加上日志检索与指标看板。
::: info 可观测性栈为可选
这套栈不在默认启动范围内。`make up-db`(PostgreSQL + RabbitMQ)与应用栈起来后平台即可运行;可观测性组件需要单独
`make up-optional` 才会启动,对内存有额外要求,按需开启。
:::
## 为什么单独成栈
把可观测性从核心栈里拆出来,是为了让"跑通平台"和"观测平台"两件事互不绑定。评估者只想验证黄金路径时,不必为 Elasticsearch
付出几百兆堆内存;运维要排障、看趋势时,再把这套栈叠加上来即可。它由 `dc3/docker-compose-optional.yml` 定义,与 db/dev/app
三个核心栈平行,共享同一个 `dc3net` 网络,因此各服务能用容器别名(如 `dc3-elasticsearch`、`dc3-prometheus`)互相寻址。
这套栈解决两类问题:
- **日志(logs)**——服务输出的 JSON 文件日志由 Logstash 收集、归一,写入 Elasticsearch,在 Kibana 里按字段检索与关联。
- **指标(metrics)**——Prometheus 周期抓取各服务与 exporter 暴露的指标,Grafana 把它们画成看板。
此外 EMQX 作为 MQTT broker 一并放在这个栈里,供 MQTT 类驱动与设备直连接入。
## 组件与端口
`make up-optional` 一次拉起以下容器(端口为宿主机发布端口,默认绑定 `127.0.0.1`,由 `DC3_BIND_HOST` 控制)。下表只作速查,逐项作用见后文。
| 容器 | 作用 | 宿主机端口(默认) | 控制变量 |
|-------------------------|-------------------------|---------------------------------------------------------|--------------------------------------------------|
| `dc3-emqx` | MQTT broker + Dashboard | MQTT `31883`、Dashboard `18083`、WS `38083`、MQTTS `38883` | `DC3_EMQX_MQTT_PORT` / `DC3_EMQX_DASHBOARD_PORT` |
| `dc3-elasticsearch` | 日志存储与检索引擎 | 内部 `9200`(不发布) | `DC3_ES_JAVA_OPTS` |
| `dc3-logstash` | 日志采集与归一管道 | 内部(不发布) | `DC3_LS_JAVA_OPTS` |
| `dc3-kibana` | 日志检索与可视化 UI | `5601` | `DC3_KIBANA_PORT` |
| `dc3-apm` | APM(应用性能)数据接收 | 内部(不发布) | `APM_AGENT_ENABLE`(应用侧开关) |
| `dc3-prometheus` | 指标抓取与时序存储 | 内部 `9090`(不发布),保留 `7d` | — |
| `dc3-postgres-exporter` | PostgreSQL 指标导出 | 内部(不发布) | — |
| `dc3-nginx-exporter` | 前端 nginx 指标导出 | 内部(不发布) | — |
| `dc3-grafana` | 指标看板 UI | `3000` | `DC3_GRAFANA_PORT` / `GF_SERVER_ROOT_URL` |
::: tip 只想起其中几个?
不必整栈拉起。用 `SERVICES` 过滤,例如只要监控:
```bash
make up STACK=optional SERVICES="prometheus grafana"
```
:::
EMQX 的端口在 [部署模式与镜像源](./usage) 里也会用到——MQTT 驱动连 `31883`、运维登录 Dashboard 看连接情况走 `18083`
。Kibana(`5601`)与 Grafana(`3000`)是两个面向人的入口:前者查日志,后者看指标。Elasticsearch、Logstash、APM、Prometheus 与两个
exporter 都**不对宿主机发布端口**,只在 `dc3net` 内部互通——它们是后端管线,不直接给人访问。
## 日志如何接入(ELK)
服务以 JSON 文件形式落日志(消息风格见[日志规范](./logging)),落盘目录通过名为 `logs` 的 Docker 卷共享:核心栈把日志写进该卷,Logstash
把同一个卷挂载到 `/usr/share/logstash/dc3/logs` 读取。Logstash 解析、打标后写入 Elasticsearch,最终在 Kibana 里检索。
接入步骤就是把这条链路跑起来:
1. 先确保核心栈(dev 或 app)在跑,日志已写进 `logs` 卷。
2. `make up-optional` 启动 ELK,Logstash 自动从 `logs` 卷读取。
3. 浏览器打开 `http://localhost:5601` 进入 Kibana,按服务名、`tenantId`、事件名等字段检索。
::: warning Elasticsearch 吃内存,先调堆
Elasticsearch 与 Logstash 的 JVM 堆默认偏小,便于在开发机起得来:`DC3_ES_JAVA_OPTS` 默认 `-Xms512m -Xmx512m`,
`DC3_LS_JAVA_OPTS` 默认 `-Xms256m -Xmx256m`。生产或日志量大时按机器内存上调,例如:
```bash
DC3_ES_JAVA_OPTS="-Xms2g -Xmx2g" make up-optional
```
:::
### APM 默认关闭
`dc3-apm` 容器随 ELK 一起起来,但**应用是否上报 APM 数据由 `APM_AGENT_ENABLE` 决定,默认 `false`**。该变量作用在核心栈(
`docker-compose.yml` / `docker-compose-dev.yml`)上,控制服务是否挂载 Java APM Agent 向 `dc3-apm` 上报性能数据。要启用需在启动核心栈时显式打开:
```bash
APM_AGENT_ENABLE=true make up STACK=app
```
::: info 起了 apm 容器 ≠ 开了 APM
只 `make up-optional` 不会自动采集 APM——`dc3-apm` 仅是接收端。没有把核心栈的 `APM_AGENT_ENABLE` 设为 `true`,应用不会挂载
Agent,也就没有数据上报。
:::
## 指标如何接入(Prometheus / Grafana)
各服务通过 Micrometer 暴露 Prometheus 格式的指标端点;Prometheus 按其配置周期抓取这些端点,并抓取两个 exporter——
`postgres-exporter`(数据库指标)与 `nginx-exporter`(前端 nginx 指标)。Prometheus 本地保留 7 天时序数据(
`--storage.tsdb.retention.time=7d`),Grafana 以它为数据源画看板。
接入步骤:
1. `make up-optional` 启动 Prometheus、两个 exporter 与 Grafana。
2. 浏览器打开 `http://localhost:3000` 进入 Grafana 看板。
3. Grafana 的外部访问地址由 `GF_SERVER_ROOT_URL` 控制(默认 `http://localhost:3000`);若通过反向代理或非本机访问,相应调整该变量,避免生成的链接指向
`localhost`。
```bash
# 反代/远程访问时修正 Grafana 根地址(示例值)
GF_SERVER_ROOT_URL="https://ops.example.com/grafana" make up-optional
```
Prometheus 自身不对宿主机发布端口,通常通过 Grafana 间接查询;要直接看 Prometheus,可临时在 compose 里为其加一个端口映射,或用
`podman exec` 进容器排查。
## 约束与边界
- **非默认启动**:核心链路不依赖这套栈。它宕了不影响设备接入、命令下发与数据落库——只是少了日志检索与指标看板。
- **内存门槛**:Elasticsearch 是这套栈里最重的组件。在 8GB 内存的开发机上,建议按 `DC3_ES_JAVA_OPTS` 默认值或更低运行,并优先用
`SERVICES` 过滤只起需要的组件。
- **数据保留**:Prometheus 固定保留 7 天;更长留存需改 `--storage.tsdb.retention.time` 或外接长期存储。Elasticsearch 与
Logstash 的数据落在各自的命名卷(`elasticsearch`、`logstash`、`logs`),`make reset` 会一并删除,谨慎使用。
- **APM 双重开关**:`dc3-apm` 容器在可选栈、`APM_AGENT_ENABLE` 开关在核心栈,两者都到位才有 APM 数据(见上文)。
- **端口绑定**:默认只绑 `127.0.0.1`。要从其他机器访问 Kibana/Grafana/EMQX Dashboard,需把 `DC3_BIND_HOST` 设为 `0.0.0.0`
(或具体网卡 IP),并自行评估暴露面。
## 延伸阅读
- [部署模式与镜像源](./usage) — 四个 compose 栈(db/dev/app/optional)与 `make` 启动方式的全貌
- [日志规范](./logging) — 服务日志的消息风格与字段约定,决定你在 Kibana 里能按什么检索
- [故障排查](./troubleshooting) — 起不来、连不上、端口冲突时的排查路径
---
# 故障排查
URL: https://docs.dc3.site/zh/guide/troubleshooting
# 故障排查
这页帮你在本地起不来、连不上、被拒绝时快速定位:每条问题都按 **症状 → 根因 → 定位** 展开,不只给解法,还告诉你"
为什么会这样、该读哪条日志、该看哪个端口"。读完你能独立判断卡在依赖、环境变量、端口还是鉴权上。
> 你在这里:多半是在跟着[从源码本地开发](../quickstart/)或起容器栈,遇到了启动或连接报错。先按下面的决策流程把问题归类,再跳到对应小节。除非特别说明,命令都在
`iot-dc3/` 目录执行。
## 先把问题归类:排障决策流程
绝大多数"起不来 / 连不上"都能归到五类:依赖未就绪、环境变量没加载、端口被占、依赖服务启动顺序错、以及鉴权链路问题。按下图自上而下排除,比逐个猜测快得多——平台对外只有网关一个
HTTP 入口(`8000`),中心服务靠 gRPC facade 互联、驱动与数据中心靠 RabbitMQ 解耦,所以一旦底层依赖(PostgreSQL /
RabbitMQ)没起,上层会连环失败。
这条决策链的顺序不是随意的:变量没加载会让所有连接指向错误主机,端口占用会让进程在绑定阶段就退出,而依赖服务的启动顺序决定了
gRPC facade 和驱动注册能否成功。把前四关排掉之后,剩下的几乎都能在日志关键字里看到根因。
## 依赖未就绪:PostgreSQL / RabbitMQ 连不上
**症状**:应用启动日志反复打印 `Connection refused`、`Connection to localhost:35432 refused`,或 RabbitMQ 报
`Channel shutdown` / `vhost not found`;中心服务起来后又退出。
**根因**:PostgreSQL 或 RabbitMQ 容器尚未启动、健康检查未通过,或本地源码运行时连接参数指向了错误的主机/端口。关键陷阱是 *
*host 与容器内地址不同**:容器内服务之间用 `dc3-postgres:5432`、`dc3-rabbitmq:5672`,而宿主机上的本地 Java 进程要走对外发布端口
`localhost:35432`、`localhost:35672`。
**定位与处理**:先确认依赖栈在跑、且发布端口与应用变量一致。
::: code-group
```bash [make]
make ps STACK=db # 看 postgres / rabbitmq 容器是否 healthy
make config STACK=db # 打印生效的 compose 配置,核对发布端口
make logs STACK=db # 跟随依赖容器日志(最近 200 行)
```
```bash [podman]
podman ps # 列出运行中的容器与端口映射
podman exec dc3-postgres psql -U dc3 -d dc3 -c "select 1" # 直连验证库可用
```
:::
确认容器 `healthy` 后,本地源码运行**必须先加载环境变量**,让连接指向 Compose 发布到 localhost 的依赖:
```bash
source dc3/env/dev.env.sh
```
RabbitMQ 单独排查时,核对这几个变量与容器实际一致(默认值见 [环境变量详解](../quickstart/environment)):`RABBITMQ_HOST`、
`RABBITMQ_PORT`(本地 `35672`,容器内 `5672`)、`RABBITMQ_USERNAME`、`RABBITMQ_PASSWORD`、`RABBITMQ_VIRTUAL_HOST`(默认 `dc3`
)。vhost 不一致会直接表现为连接建立后立刻 `Channel shutdown`。
::: tip 为什么先等健康检查
中心服务在启动早期就要建立数据库连接池与 RabbitMQ 通道。若依赖还在初始化(PostgreSQL 首次启动会跑 7 个 initdb
脚本建表与种子数据),过早启动的上层服务会因连接失败而退出。等 `make ps STACK=db` 显示 healthy 再起上层,能省掉一轮无谓的重启。
:::
## 环境变量没加载:连接指向错误主机
**症状**:依赖容器明明在跑,本地源码却连不上,或连到了非预期的主机/端口;改了根目录 `.env` 却对本地 Java 进程"不生效"。
**根因**:根目录 `.env` **只服务 Docker Compose**,不会自动注入到本地 Java 进程。本地用 IDE 或命令行直接跑 jar 时,必须显式加载
`dc3/env/dev.env(.sh)`,它把连接指向 Compose 发布在 localhost 上的依赖端口。三个文件分工不同:
| 文件 | 给谁用 | 作用 |
|----------------------|-----------------|-------------------------------------|
| 根目录 `.env` | Docker Compose | 镜像仓库、标签、发布端口;**不注入本地 Java** |
| `dc3/env/dev.env` | IDE(EnvFile 插件) | 本地 Java 运行,无 `export` |
| `dc3/env/dev.env.sh` | Shell | 本地 Java 运行,带 `export`,用 `source` 加载 |
**处理**:命令行/脚本启动前先 `source dc3/env/dev.env.sh`;IDE 里用 EnvFile 插件挂 `dc3/env/dev.env`。完整变量目录、host
与容器内地址的对应关系,见 [环境变量详解](../quickstart/environment)。
## 端口被占用:进程绑定阶段就退出
**症状**:启动失败,日志含 `Address already in use` / `Web server failed to start. Port 8400 was already in use`,提示
`8000`、`8300`、`8400`、`8500`、`8600`、`9300`、`9400`、`9500` 等端口已占用。
**根因**:同一端口被上一轮没退干净的进程、或别的程序占用。这些端口分别对应网关 HTTP(`8000`)、四个中心 HTTP(Auth `8300` /
Manager `8400` / Data `8500` / Agentic `8600`)和三个 gRPC 端口(Auth `9300` / Manager `9400` / Data `9500`)。
**定位(跨操作系统)**:先查是谁占了端口,拿到 PID 再决定是结束它还是换端口。
::: code-group
```bash [macOS / Linux (lsof)]
lsof -i :8400 -sTCP:LISTEN # 列出监听 8400 的进程与 PID
kill # 确认是残留进程后再结束
```
```bash [Linux (ss)]
ss -ltnp 'sport = :8400' # 显示监听 8400 的进程(含 PID)
```
```bash [Linux (netstat)]
netstat -ltnp | grep ':8400' # 旧系统用 netstat 同样能查到 PID
```
```powershell [Windows (PowerShell)]
Get-NetTCPConnection -LocalPort 8400 -State Listen | Select-Object OwningProcess
Stop-Process -Id # 核实后结束占用进程
```
:::
**处理**:若端口被合法程序占用、不便结束,就改用环境变量或根目录 `.env` 覆盖端口,让 DC3 服务避开冲突。常用覆盖变量:
- `DC3_GATEWAY_PORT`、`DC3_AUTH_PORT`、`DC3_MANAGER_PORT`、`DC3_DATA_PORT`、`DC3_AGENTIC_PORT`(Compose 发布端口)
- `SERVER_PORT`、`GRPC_SERVER_PORT`(单服务本地运行时覆盖该进程的 HTTP / gRPC 端口)
::: warning 本地同时跑多个服务时
`SERVER_PORT` / `GRPC_SERVER_PORT` 是**单进程级**覆盖。只在本地单独起某个服务、需要避让默认端口时设置;多服务并行时要分别赋不同值,否则它们会互相抢同一个端口。
:::
## 启动顺序错:依赖服务尚未就绪
**症状**:驱动注册失败、gRPC 调用报 `UNAVAILABLE`,或中心服务起来后因拿不到下游而异常。
**根因**:中心服务之间通过 gRPC facade 协作,驱动启动时要向管理中心注册并依赖 RabbitMQ。若上游还没就绪就起下游,连接会失败。正确的启动次序是
**Gateway → Auth → Manager → Data → Agentic → Driver**。
**定位与处理**:
1. 按 Gateway → Auth → Manager → Data → Agentic → Driver 顺序启动,每起一个等它就绪再起下一个。
2. 本地源码运行确认已 `source dc3/env/dev.env.sh`。
3. 查看管理中心与驱动日志,确认 gRPC 目标地址(`CENTER_MANAGER_HOST` 等,默认 `localhost`)可达。
4. 确认 `dc3.driver.code` 唯一且稳定——编码重复会让注册被拒。
::: danger 驱动编码不可随意改
`dc3.driver.code` 是驱动的稳定路由标识,数据中心据此把命令路由回对应驱动实例。一旦上线就不要改动;改了会导致已绑定该驱动的设备命令无法送达。
:::
## 鉴权失败:401 / 403 与 HMAC
**症状**:通过网关访问受保护接口返回 `401`(未认证)或 `403`(无权限)。
**根因**:请求没有携带有效 token,或租户/登录名/token 三件套不齐。平台登录是两步:先取盐、再用盐哈希密码换 token;后续每个受保护请求要带上
`X-Auth-Tenant`、`X-Auth-Login`、`X-Auth-Token` 三个请求头。
**定位与处理**:先走登录拿 token,再带头访问。下面是黄金路径的真实接口(示例值需替换为你环境的实际值):
```bash
# 1) 取盐(公开端点,建议 5 分钟内使用)
curl -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# 2) 用盐哈希密码后换 token(12 小时有效)
curl -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"<上一步返回的盐>","password":"<用盐哈希后的密码>"}'
# 3) 带三件套访问受保护接口
curl -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: <上一步返回的 token>' \
-H 'Content-Type: application/json' \
-d '{"current":1,"size":10}'
```
若 401 集中出现在网关到后端这一跳(而非用户 token 问题),多半与 HMAC 签名有关。网关会用 `AUTH_HMAC_SECRET` 对注入的
`X-Auth-Principal` 做 HMAC-SHA256 签名,后端校验签名后才信任 principal。Swagger UI
的认证方式见 [API 文档](../development/api-documentation)。
::: danger 生产/预发环境 HMAC 会 fail-fast
当 Spring profile(或 `spring.env`)命中 `pre` 或 `pro` 时,若 `AUTH_HMAC_SECRET` 为空、或仍是默认弱密钥
`io.github.pnoker.dc3`,服务会在启动时直接抛 `IllegalStateException` 拒绝启动。这是有意为之的安全闸门:上 `pre`/`pro` 前*
*必须**把 `AUTH_HMAC_SECRET` 与 `DC3_SECURITY_KEY` 换成强随机值,且不得记录或硬编码。
:::
## pre/pro profile 本地起不来
**症状**:用 `pre` / `pro` profile 在本地启动时连接报错(`UnknownHostException` / `Connection refused`),或服务直接
fail-fast 退出。
**根因**:`pre` / `pro` 面向容器栈部署,连接参数默认指向容器主机名而非 localhost:数据源 `POSTGRES_HOST` 默认
`dc3-postgres`、`RABBITMQ_HOST` 默认 `dc3-rabbitmq`、gRPC 通道用 `static://${CENTER_AUTH_HOST:dc3-center-auth}:9300`
这类容器内地址。本地没有这些主机名解析,连接自然失败。叠加 HMAC 安全闸门:`pre` / `pro` 下 `AUTH_HMAC_SECRET` 为空或仍是默认弱密钥时服务会
fail-fast 拒绝启动(见上一节)。
**处理**:本地源码调试一律用 `dev` profile——它把连接指向 Compose 发布到 localhost 的依赖端口。只有在真正验证容器部署形态时才用
`pre` / `pro`,并确保容器主机名可解析、HMAC / 安全密钥按生产要求配置就位。
## 构建与镜像类问题
下面几类不属于运行时连接,而是构建/打包阶段,单独归一处。
**Maven 构建很慢**——根因是并行度或堆内存没生效。仓库已配默认参数:`.mvn/maven.config` 含 `-T 1C`、`.mvn/jvm.config` 含
`-Xms512m -Xmx1024m`。仍慢可适当加大堆内存或减少本机后台 CPU 占用。
**Java 版本错误**——出现 `unsupported class file major version` 或 Maven Enforcer 报错,根因是项目要求 JDK 21。用下面两条确认
**Maven 实际使用的 Java** 也是 21(两者可能不一致):
```bash
java -version
mvn -version
```
**Docker 镜像构建失败**——根因多为镜像内 Maven 打包失败或依赖未提前构建。先在宿主机确认 Maven 通过,再构建镜像:
```bash
make package
make build STACK=db
```
**镜像源不符合预期**——根因是镜像仓库选择。用 `REGISTRY` 切换:`global` 走默认仓库(Docker Hub `pnoker`),`cn`
走中国大陆镜像(Aliyun)。
```bash
make up STACK=db REGISTRY=global # 默认仓库
make up STACK=db REGISTRY=cn # 大陆镜像
```
## 想更快调试
把 Auth、Manager、Data 的能力放进单个 JVM,用 `dc3-center-single` 起一个进程,省去多服务间的启动协调:
```bash
source dc3/env/dev.env.sh
java -jar dc3-center/dc3-center-single/target/dc3-center-single.jar
```
::: info 单进程仅供本地调试
单 JVM 模式方便本地快速验证,**不代表生产部署形态**——生产仍是网关 + 四中心 + 驱动的分布式拓扑。
:::
## 延伸阅读
- [从源码本地开发](../quickstart/) — 本地起依赖、加载环境变量、跑通第一个驱动的完整步骤
- [环境变量详解](../quickstart/environment) — host 与容器内地址对应、端口与连接变量的完整目录
- [服务与拓扑](../architecture/services) — 网关、四中心与驱动如何编排,理解启动顺序背后的依赖关系
- [API 文档](../development/api-documentation) — Swagger UI 与鉴权头的使用方式
---
# 部署模式与镜像源
URL: https://docs.dc3.site/zh/guide/usage
# 部署模式与镜像源
IoT DC3 用四个 Compose 栈拼出完整环境:`db` 起依赖、`dev` 从源码构建、`app` 拉预构建镜像、`optional` 叠可观测性。这页讲清每个栈的用途、
`make` 生命周期命令、以及镜像从哪个仓库拉、对外暴露哪些端口——读完你能挑对栈、选对镜像源、知道生产上该锁哪些口。
> 你在这里:准备真正把平台跑起来。想先备齐环境变量看 [环境变量详解](../quickstart/environment)
> ;想从源码本地开发看 [快速开始](../quickstart/)。
## 四个 Compose 栈各司其职
平台没有"一个大 compose 包打天下",而是按职责切成四个栈,分别对应 `dc3/` 下四个 Compose 文件。它们叠加使用:依赖层先起,应用层再起,可观测层按需加。
- **`db`**(`docker-compose-db.yml`):基础设施层,只有两个容器——`dc3-postgres`(PostgreSQL +
AGE/TimescaleDB/pgvector,承载元数据、时序值、告警、Agentic 会话)和 `dc3-rabbitmq`(数据流/命令流的消息总线)。任何应用栈都依赖它先就绪。
- **`dev`**(`docker-compose-dev.yml`):源码构建栈,从本地 `Dockerfile` 现场 `build` 出 `dc3-gateway`
与四个中心(auth/manager/data/agentic)等。给改后端代码、要本地调试的人用;前端不在此栈内,单独 `pnpm dev` 起。
- **`app`**(`docker-compose.yml`):预构建镜像栈,直接拉远端镜像跑,含前端 `dc3-web`、网关、四个中心、以及一组驱动容器。给评估、演示、生产部署用——不编译、起得快。
- **`optional`**(`docker-compose-optional.yml`):可观测性与可选依赖栈,含
EMQX、Elasticsearch/Logstash/Kibana、Prometheus、Grafana、APM 及若干
exporter。按需叠加,不影响核心链路。详见 [可观测性](./observability)。
::: info dev 与 app 的取舍
要改 Java 代码就用 `dev`(现场构建,改完重 build);只想把平台跑起来评估或上生产就用 `app`(拉镜像,最快)。两者用的是同一套
`db`/`optional` 依赖栈。
:::
## 部署拓扑:谁对外、谁只在内网
下图给出四栈如何叠加,以及 ingress 边界——**生产形态(app 栈)只有 `dc3-web` 和 `dc3-driver-listening-virtual` 发布到宿主机
**,网关与四个中心都在内部 `dc3net` 网络里,靠前端反代或内部调用访问,不直接对外。
::: danger 对外入口只有 web 与 listening-virtual
在 `app`(生产)栈里,只有 `dc3-web`(8080/8443)和 `dc3-driver-listening-virtual`(TCP 6270 / UDP 6271)发布到宿主机端口;网关
8000、四个中心的 HTTP/gRPC 端口、数据库、消息队列**一律只在内部网络**,不要额外暴露到公网。
注意 `dev` 栈为方便调试会额外发布网关 8000 与各中心 HTTP 端口(8300/8400/8500/8600)及 auth/manager/data 对应的
gRPC(9300/9400/9500,agentic 无 gRPC server),这是开发便利,**不要照搬到生产**。所有发布默认绑定 `DC3_BIND_HOST=127.0.0.1`
(仅本机),需要跨机访问才改成 `0.0.0.0`,且改之前先收敛端口。
:::
## make 生命周期:一套命令操作任意栈
所有栈共用同一组 `make` 目标,靠变量选择"对哪个栈、哪些服务、用哪个镜像源"。命令都在 `iot-dc3/` 目录下跑。
核心生命周期目标:
- `make build` — 构建镜像(`dev` 栈现场编译;`app` 栈一般无需 build)
- `make up` — 启动(`-d` 后台)
- `make down` — 停止并移除容器(保留数据卷)
- `make config` — 渲染并校验 Compose 配置,不实际启动
- `make logs` — 跟随日志(`-f --tail=200`)
- `make reset` — ⚠️ down + **删除数据卷**,需显式确认(见下方 danger)
选择"操作哪个栈、哪些服务"的变量:
| 变量 | 默认 | 作用 |
|------------|------------------|--------------------------------------------------------|
| `STACK` | `dev` | 选栈:`db` / `dev` / `app` / `optional` |
| `SERVICES` | (空=全部) | 只操作指定服务,空格分隔,如 `SERVICES="gateway agentic"` |
| `GROUP` | (空) | 预定义服务组:`center`(四个中心)/ `core`(中心 + 网关)/ `drivers`(驱动组) |
| `COMPOSE` | `podman compose` | 容器运行时(本仓库统一用 podman) |
`GROUP` 是 `SERVICES` 的快捷写法——`center` 展开为 `auth manager data agentic`,`core` 再加 `gateway`,`drivers` 展开为内置驱动集合。
`SERVICES` 与 `GROUP` 可叠加。
::: code-group
```bash [起完整环境]
# 依赖 → 可观测性 → 源码构建栈
make up STACK=db
make up STACK=optional
make up STACK=dev
```
```bash [只起部分服务]
make up STACK=db
make up SERVICES="gateway agentic" # 只起网关 + 智能中心
make up GROUP=core # 起四个中心 + 网关
make logs SERVICES="gateway agentic" # 只看这两个的日志
```
```bash [校验与下线]
make config STACK=app # 只渲染配置,不启动
make down STACK=dev # 停 dev 栈,保留数据
```
:::
::: danger reset 会删数据卷
`make reset` 会执行 down 并**删除数据卷**——PostgreSQL 里的元数据、时序值、告警全部丢失。它带硬性闸门:必须显式
`CONFIRM_RESET_VOLUMES=true` 才会执行,否则直接拒绝。
```bash
make reset STACK=db CONFIRM_RESET_VOLUMES=true
```
生产环境慎用。删卷后下次起库会重新跑一遍 initdb 种子脚本(见末节)。
:::
## 镜像源:REGISTRY 选仓库,DC3_IMAGE_TAG 选版本
镜像从哪个仓库拉由 `REGISTRY` 决定,它在 `make` 层把 `DC3_IMAGE_REGISTRY`(Compose 实际读的命名空间)解析出来:
| `REGISTRY` | 解析出的 `DC3_IMAGE_REGISTRY` | 适用 |
|------------|-------------------------------------------------|-------------------|
| `auto`(默认) | 读环境/`.env` 里的 `DC3_IMAGE_REGISTRY`,未设则 `pnoker` | 自定义私有仓库或沿用 `.env` |
| `global` | `pnoker`(Docker Hub) | 海外/通用网络 |
| `cn` | `registry.cn-beijing.aliyuncs.com/dc3`(阿里云) | 中国大陆,拉取更快 |
传入其它值会直接报错 `Unsupported REGISTRY`。镜像版本由 `DC3_IMAGE_TAG`(默认 `2026.6`)统一控制——所有服务与依赖镜像共用同一个
tag,生产建议钉死具体版本而非 `latest`。
::: code-group
```bash [Docker Hub(global)]
make up STACK=db REGISTRY=global
make up STACK=app REGISTRY=global
```
```bash [阿里云(cn,国内更快)]
make up STACK=db REGISTRY=cn
make up STACK=app REGISTRY=cn
```
:::
举例:在 `cn` 下,网关镜像解析为 `registry.cn-beijing.aliyuncs.com/dc3/dc3-gateway:2026.6`;在 `global` 下则是
`pnoker/dc3-gateway:2026.6`。完整镜像清单见末节折叠的命令参考。
::: warning Makefile 用 REGISTRY,Compose 用 DC3_IMAGE_REGISTRY
两个名字别混。`REGISTRY=auto|global|cn` 是 `make` 的选择器,它负责把对应的 `DC3_IMAGE_REGISTRY` 命名空间注入 Compose;直接跑
`podman compose` 时要自己设 `DC3_IMAGE_REGISTRY`。
:::
## 起栈后:种子数据与对外验证
`app`/`dev` 栈起来后,平台已可用。验证对外入口最直接的方式是走前端 `dc3-web`(默认 `http://127.0.0.1:8080`)。若要从命令行打
API,需经网关——但网关在 `app` 栈不对外,通常在 `dev` 栈(发布 8000)下验证。登录是两步:先 `POST /api/v3/auth/token/salt` 取盐,再
`POST /api/v3/auth/token/generate` 换取 12 小时有效的 token,后续请求带上 `X-Auth-Tenant` / `X-Auth-Login` /
`X-Auth-Token` 三个鉴权头。
```bash
# 仅在网关对外的 dev 栈下可用;值均为示例,按你的租户/账号替换
# 1) 取盐(公开)
curl -s -X POST http://127.0.0.1:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# → 返回盐字符串(示例;建议 5 分钟内使用)
# 2) 用盐对密码哈希后换取 token(公开),返回 12 小时有效的访问令牌
curl -s -X POST http://127.0.0.1:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"<上一步返回>","password":"<哈希后密码>"}'
```
数据库首次在**空数据卷**上启动时,`dc3-postgres` 入口会按文件名顺序执行 `initdb` 下的 **7 个种子脚本**,一次性建好库结构与基础数据:
| 顺序 | 脚本 | 内容 |
|----|-----------------------------|-----------------------|
| 00 | `00-iot-dc3-extensions.sql` | 启用扩展 |
| 01 | `01-iot-dc3-common.sql` | 公共表 |
| 02 | `02-iot-dc3-auth.sql` | 菜单、资源、用户、角色、OAuth/MCP |
| 03 | `03-iot-dc3-data.sql` | 运行时数据:告警、通知、规则 |
| 04 | `04-iot-dc3-manager.sql` | 实体管理:设备、驱动、位号、模板 |
| 05 | `05-iot-dc3-history.sql` | 时序超表(hypertable) |
| 06 | `06-iot-dc3-agentic.sql` | 会话、消息、附件 |
::: warning 种子脚本只在空库首次跑
这些脚本仅在数据卷为空时执行一次。卷里已有数据时不会重跑,改了 SQL 也不会自动生效——要重新种子,得先
`make reset ... CONFIRM_RESET_VOLUMES=true` 清卷(会丢数据)。
:::
::: danger 生产 secrets 必须随机
`.env.example` 里的 `DC3_SECURITY_KEY`、`AUTH_HMAC_SECRET`,以及 `POSTGRES_PASSWORD` / `RABBITMQ_PASSWORD`(默认
`dc3dc3dc3`)都是**公开的弱默认值**,仅供本地。生产部署前必须替换为强随机值。
其中 `AUTH_HMAC_SECRET` 带 fail-fast 保护:当 Spring profile 命中 `pre` 或 `pro`、而密钥为空或仍等于默认
`io.github.pnoker.dc3` 时,服务启动直接抛 `IllegalStateException`
拒绝起来。各密钥含义见 [环境变量详解](../quickstart/environment)。
:::
## 完整命令与镜像参考
下面折叠的是 `dc3/doc/USAGE.md` 的完整原文,列出所有 `make` 快捷命令与每个服务在 Docker Hub / 阿里云两套仓库的镜像坐标,作为操作时的速查表。
::: details 展开完整命令与镜像清单
:::
## 延伸阅读
- [环境变量详解](../quickstart/environment) — 每个 `DC3_*` / 运行时变量的默认值、作用域与生产取值
- [可观测性](./observability) — `optional` 栈里 EMQX/ELK/Prometheus/Grafana 怎么接、看什么
- [从源码本地开发](../quickstart/) — 用 `dev` 栈 + IDE 起后端、`pnpm dev` 起前端的本地开发流程
---
# IoT DC3 · 多协议接入的开源工业物联网平台
URL: https://docs.dc3.site/zh/
## IoT DC3 是什么
IoT DC3 是一个多协议接入、云原生、AI 赋能的开源工业物联网平台,面向智能体演进(基于 AGPL-3.0),覆盖**设备接入、数据采集、运营管理与智能分析**,帮助构建工业
IoT 解决方案。它内置 **28 个接入驱动模块**,把异构设备的数据采上来、归一为带语义的位号值;再通过 **Spring AI**
把大语言模型接入运营流程——模型不仅能查询设备、读写位号、执行命令,还能做告警分析与数据洞察,把"感知—决策—执行—反馈"打通成闭环。
它适合需要接入多类工业协议、管理设备与位号、查询实时/历史数据,并希望在 Spring 生态里做二次开发、甚至引入 AI
辅助运营的团队。想先理解它解决什么问题、与同类平台的差异,请看 [平台定位](./introduction/)。
## 架构一览
平台由一个网关、四个中心服务和一组协议驱动组成,对外只暴露网关的 HTTP 入口;中心服务之间通过 gRPC 协作,驱动与数据中心之间通过
RabbitMQ 异步解耦。
每一跳如何流转、为什么这样设计,见 [系统架构](./architecture/)。
## 技术栈
- **语言与框架
**:[Java 21](https://www.java.com) · [Spring Boot 4](https://spring.io/projects/spring-boot) · [Spring Cloud 2025](https://spring.io/projects/spring-cloud) · [Spring AI 2.0.0](https://spring.io/projects/spring-ai)
- **数据、缓存与调度**:PostgreSQL(+ TimescaleDB / AGE / pgvector)· Caffeine · MyBatis-Plus · Quartz
- **消息与通信**:RabbitMQ · gRPC · MQTT(Paho + EMQX)· Protobuf
- **安全与认证**:Spring Security · JWT · BouncyCastle
- **前端**:Vue 3 · TypeScript 6 · Vite 8 · Element Plus · AntV G2/G6(源码在本仓库 `dc3-web/` 目录,原独立仓库
`iot-dc3-web` 已归档)
完整说明见 [技术栈](./development/technology-stack)。
## 开源协议
IoT DC3 基于 [AGPL-3.0 License](https://github.com/pnoker/iot-dc3/blob/release/LICENSE-AGPL.txt)
发布。仓库许可证说明与商业授权关系请参阅 [LICENSE.txt](https://github.com/pnoker/iot-dc3/blob/release/LICENSE.txt)。
---
# 属性与配置 Attribute & Config
URL: https://docs.dc3.site/zh/introduction/concepts/attribute-config
# 属性与配置 Attribute & Config
> **属性(Attribute)是[驱动](./driver)声明"接入一台设备需要填哪些配置项"的定义,配置(Config)是某台[设备](./device)
给这些配置项填的具体值。** 一个回答"有哪些格子要填",一个回答"这台设备的格子里填了什么"。
设备能不能被采集,往往不取决于"温度位号叫什么",而取决于一些很具体的协议细节:Modbus 要读哪个寄存器地址、HTTP 要请求哪个
URL、MQTT 要订阅哪个 Topic。这些细节因协议而异、因设备而异,写死在代码里行不通。IoT DC3 把它拆成两层来管:*
*驱动声明需要哪些配置项(Attribute)**,**设备实例填这些配置项的值(Config)**。
理解它的关键,是先分清这里其实有**三摊**互不相同的东西:
| 层 | 归属 | 回答的问题 | 谁来填 |
|--------------------|-------------------------|---------------|-------------|
| **Param 业务参数** | [模板 Profile](./profile) | 指令/事件携带哪些业务字段 | 建模者,在模板里定义 |
| **Attribute 属性定义** | [驱动 Driver](./driver) | 接这种协议需要哪些配置项 | 驱动开发者,启动时注册 |
| **Config 配置值** | [设备 Device](./device) | 这台设备这些配置项填什么 | 集成者,在设备编辑页填 |
`Param` 是"业务语义"(温度、模式、故障码),属于[模板](./profile),和具体协议无关;`Attribute` / `Config` 是"协议映射"
(寄存器地址、Topic、报文模板),属于驱动和设备。本页只讲后两层,Param 见[指令](./command)与[事件](./event)。
## 经典例子:一句话讲清 Attribute vs Config
> Modbus 驱动声明"读一个位号,需要一个寄存器地址"——这是 **Attribute**(驱动注册的"有这么个配置项")。
> 给 3 号设备的温度位号填上"地址 = 40001"——这是 **Config**(这台设备这个配置项的具体值)。
同一个 `PointAttribute(registerAddress)`,1 号设备可能填 40001,3 号设备填 40003;换 MQTT 驱动,声明的就不再是寄存器地址,而是
`topic`。属性是"模具",配置是"浇出来的件"。
## Attribute 定义从哪来
::: tip 属性不是在数据库里手建的,是驱动启动时注册的
驱动在自己的 `application.yml` 里声明支持哪些属性,**启动时上报给 Manager**,Manager 以
`tenant_id + driver_id + attribute_code` 为唯一键落库。不同协议需要的属性不同,所以属性的"权威来源"是驱动,而不是页面手填。
:::
```yaml
dc3:
driver:
driver-attribute: # 连接级配置项:连这台设备网关需要什么
- attribute-name: Host
attribute-code: host
attribute-type-flag: STRING
default-value: localhost
point-attribute: # 位号级配置项:采每个位号需要什么
- attribute-name: Register Address
attribute-code: registerAddress
attribute-type-flag: INT
default-value: ''
```
`DriverAttribute` 和 `PointAttribute` 的区别只在**作用范围**:前者一台设备填一份(连接信息),后者每个[位号](./point)
各填一份(采集映射)。
## 关键字段
属性定义 `DriverAttributeBO` / `PointAttributeBO`(两者字段完全一致,结构同源):
| 字段 | 类型 | 含义 |
|---------------------|----------------------------------------|-----------------------------------------|
| `attributeName` | String | 属性名称(页面展示用) |
| `attributeCode` | String | 属性编码,配置按它匹配(如 `host`、`registerAddress`) |
| `attributeTypeFlag` | AttributeTypeEnum | 值类型,见下 |
| `defaultValue` | String | 默认值,设备未填时兜底 |
| `driverId` | Long | 归属的[驱动](./driver) |
| `attributeExt` | DriverAttributeExt / PointAttributeExt | 扩展信息(如 UI 控件、校验规则) |
| `enableFlag` | EnableFlagEnum | 启停状态 |
| `tenantId` | Long | 归属[租户](./tenant) |
配置值 `DriverAttributeConfigBO` / `PointAttributeConfigBO`:
| 字段 | 类型 | 含义 |
|---------------|----------------|--------------------------------------------------|
| `attributeId` | Long | 指向哪条属性定义 |
| `configValue` | String | 实际填的值(如 `40001`) |
| `deviceId` | Long | 归属哪台[设备](./device) |
| `pointId` | Long | **仅 `PointAttributeConfig` 有**,指向哪个[位号](./point) |
| `configExt` | JsonExt | 配置扩展信息 |
| `enableFlag` | EnableFlagEnum | 启停状态 |
| `tenantId` | Long | 归属[租户](./tenant) |
::: warning DriverConfig 按设备、PointConfig 按位号
`DriverAttributeConfig` 只有 `deviceId`,因为连接信息一台设备一份;`PointAttributeConfig` 多一个 `pointId`
,因为每个位号都要单独填采集映射。这正是两层作用范围不同的直接体现。
:::
## 值类型 AttributeTypeEnum
`attributeTypeFlag` 与[位号](./point)共用同一套类型系统:
| 值 | `STRING` | `BYTE` | `SHORT` | `INT` | `LONG` | `FLOAT` | `DOUBLE` | `BOOLEAN` |
|---|----------|--------|---------|-------|--------|---------|----------|-----------|
## 与其它概念的关系
属性挂在驱动下、被设备配置引用;位号配置还额外绑定到具体[位号](./point)。建模时定义[模板](./profile)
(含位号、指令、事件),接入时由驱动声明属性、设备填配置,两条线在设备处汇合。
## 注册与配置流程
1. 驱动从 `application.yml` 读取 `driver-attribute` / `point-attribute`,启动时上报。
2. Manager 按唯一键写入或更新属性定义。
3. 设备编辑页按当前 `driverId` 加载属性列,集成者逐项填值。
4. 配置值落到 Config 表,运行时驱动拉取并组装成实际协议报文。
## Attribute / Config 能解决什么、不能解决什么
`Attribute + Config` 解决的是**协议映射**:把"这台设备的这个位号/连接"翻译成驱动能执行的具体地址、Topic、模板。它**不能**
单独表达"位号本身是什么""指令要传哪些业务参数"——那是[模板](./profile)与 Param 的职责。
| 能解决 | 不能单独解决(需要其它模型) |
|----------------------|------------------------------------------------|
| 连这台设备网关用什么 Host / 端口 | 这类设备有哪些位号、指令、事件 → [模板 Profile](./profile) |
| 采这个位号读哪个寄存器 / 路径 | 指令携带哪些业务入参出参 → [指令 Command](./command) 的 Param |
| 同一模板在不同驱动下填不同映射值 | 事件上报哪些业务字段 → [事件 Event](./event) 的 Param |
::: tip 一句话定位
缺采集?多半是 **Config 没填或填错**(地址/Topic 写错)。缺能力?那是 **Attribute 没声明**(驱动根本没注册这个配置项)——后者要改驱动
`application.yml` 重启,前者在页面改值即可、无需重启。
:::
## 示例
3 号温度传感器,绑定 Modbus 驱动:
- 驱动注册的属性:`DriverAttribute(host)`、`PointAttribute(registerAddress)`。
- 设备填的配置:`DriverAttributeConfig{ deviceId: 3, configValue: "192.168.1.10" }`(连接到这台网关);温度位号填
`PointAttributeConfig{ deviceId: 3, pointId: 温度位号, configValue: "40001" }`。
运行时驱动据此连接 `192.168.1.10`、读寄存器 `40001`,把读数封装成该位号的[位号值](./point-value)上报。换成 1 号设备,
`configValue` 改成另一个地址即可,属性定义完全复用。
## 延伸阅读
- [驱动 Driver](./driver) — 属性的声明者与注册来源
- [位号 Point](./point) — `PointAttributeConfig` 绑定的对象
- [指令 Command](./command) — Param(业务参数)与 Attribute(协议映射)的分工
- [事件 Event](./event) — 事件侧同样区分 Param 与 Attribute
- [设备接入 Device Onboarding](../../operation/device-onboarding) — 在页面上填这些配置的完整流程
- [核心概念总览](../concepts) — 回到概念地图
---
# 指令 Command
URL: https://docs.dc3.site/zh/introduction/concepts/command
# 指令 Command
> **指令是下发给设备的一次动作请求**——重启、校准、切换模式、设定温度……定义归属[模板 Profile](./profile)
> ,调用归属[设备](./device),带一组输入/输出参数,由[驱动](./driver)执行后回执结果。
指令回答的是"让设备做一件事",它是[事件](./event)的对偶:事件上行(设备说"发生了什么"),指令下行(平台说"去做什么")。
## 它是什么、为什么需要
工业设备除了"上报数值"和"被读写某个量",还需要被触发**动作型能力**:重启、固件升级、模式切换、按模板下发一段配置。这些动作往往带参数、需要回执、要做超时与审计——把它们建模成
`Profile` 下的结构化子资源,就是**指令 Command**。定义沉淀在模板里(这类设备能做哪些动作、每个动作收哪些参数),调用落到具体设备实例(某台设备某时刻执行了一次)。
### 最关键的区分:两类下行不要混淆
DC3 有两条独立的下行链路,初学者最容易混淆:
| 维度 | 写位号 PointCommand | 自定义指令 Command / CommandCall |
|------|-------------------------------------|-------------------------------------------|
| 改什么 | 改**某一个量**(一个 [Point](./point) 的值) | 触发**一个带参数的动作** |
| 定义来源 | [Point](./point) 的 `rwFlag` 含 WRITE | `dc3_command` 独立定义表 |
| 驱动接口 | `DriverCustomService.write()` | `DriverCommand.execute()` |
| 参数 | 单值(一个目标值) | 结构化输入/输出参数 Map |
| DTO | `PointCommandDTO` | `CommandCallDTO` / `CommandCallResultDTO` |
| 队列前缀 | `dc3.e.point_command` | `dc3.e.command` |
一句话边界(见设计稿 point-command.md §1.2):**写位号是属性维度的运行态访问,自定义指令是模板层的动作能力。**
::: tip 一个例子讲清边界
给空调"把目标温度设为 26℃"——这是**写位号**:`targetTemp` 是一个可写 Point,写入 `26` 即可,本质是改一个量。
给空调"执行一次自清洁"——这是**自定义指令** `selfClean`:它不对应某个量,而是一个动作,可能还带参数(如 `duration=30`),执行完返回
`resultCode`。
判断口诀:能落到"改某个 Point 的值"就是写位号;是"触发一段动作流程"就是指令。
:::
本页讲的"指令 Command"指**自定义指令**。写位号请看 [位号 Point](./point)。
## 关键字段
指令定义 `CommandBO`(归属 [Profile](./profile),表 `dc3_command`):
| 字段 | 类型 | 含义 |
|-------------------|-----------------|------------------------------------------------------|
| `commandName` | String | 指令名称(展示用) |
| `commandCode` | String | 指令标识符,同一 `profileId` 下唯一,调用时按它匹配(如 `setTemperature`) |
| `commandTypeFlag` | CommandTypeEnum | 指令类型,见下 |
| `callTypeFlag` | CallTypeEnum | 调用方式:`sync` / `async` |
| `timeout` | Integer | 调用超时时间(秒) |
| `commandExt` | CommandExt | 扩展配置(协议映射、驱动指令模板、幂等等) |
| `profileId` | Long | 归属的[模板](./profile) |
| `enableFlag` | EnableFlagEnum | 启停状态 |
| `tenantId` | Long | 归属[租户](./tenant) |
指令参数 `CommandParamBO`(声明指令的输入/输出参数,归属指令定义):
| 字段 | 类型 | 含义 |
|---------------------------|------------------------|------------------------------|
| `paramName` / `paramCode` | String | 参数名 / 标识符 |
| `paramDirectionFlag` | ParamDirectionTypeEnum | 方向:`input`(入参)/ `output`(出参) |
| `paramTypeFlag` | PointTypeEnum | 参数数据类型 |
| `requiredFlag` | Boolean | 是否必填 |
| `defaultValue` | String | 默认值 |
| `commandId` | Long | 归属的指令定义 |
::: tip CommandParam 复用位号的类型系统
`paramTypeFlag` 用的是和位号一样的 `PointTypeEnum`(`STRING` / `INT` / `FLOAT` / `DOUBLE` / `BOOLEAN`…)。注意区分:**入参**
是调用时由调用方传入(如 `temperature`),**出参**是执行后由设备回写(如 `resultCode`)。
:::
调用入参 `CommandCallBO`(一次调用的提交体):`deviceId`、`commandId`、`commandCode`、`paramValues`(`Map`,按参数
`paramCode` 键控)。
## 指令类型
| 类型 `commandTypeFlag` | 说明 |
|----------------------|-------|
| `custom` | 自定义指令 |
| `config` | 配置型指令 |
| `action` | 动作型指令 |
## 与其它概念的关系
- 指令**定义**挂在模板下,与[位号](./point)、[事件](./event)并列,共同描述"这类设备有什么能力"。
- 指令**调用**由[设备](./device)发起;驱动执行所需的协议映射由 [指令属性配置](./attribute-config)(`CommandConfig`
)提供,与业务参数 `CommandParam` 分属两层。
## 调用生命周期与回执
一次调用(`CommandCallDTO`)携带:`recordId`、`tenantId`、`deviceId`、`commandId`、`commandCode`、`paramValues`、`source`、
`occurredAt`、`expireAt`。数据中心持久化为一条 `dc3_command_history` 记录(PENDING),投递到 RabbitMQ,驱动执行后回执
`CommandCallResultDTO`(`status`、`resultValues`、`errorCode`、`errorMessage`、`finishedAt`)。
调用状态机(`PointCommandStatusEnum`,与写位号共用):
```
PENDING → SENT → SUCCESS / FAILED / TIMEOUT / EXPIRED / DUPLICATE / DEAD
```
| 状态 | 含义 |
|-----------------------|---------------------------|
| `PENDING` | 已创建记录,等待投递 |
| `SENT` | 已投递到 RabbitMQ,等待驱动执行 |
| `SUCCESS` / `FAILED` | 驱动执行成功 / 失败,结果已回写 |
| `TIMEOUT` / `EXPIRED` | 应用层超时 / `expireAt` 已过期未执行 |
| `DUPLICATE` / `DEAD` | 重复命令被去重拦截 / 拒入死信队列 |
::: warning 同步指令不等于"调用即完成"
`callTypeFlag = sync` 只表示调用方愿意等回执,**不代表 HTTP 立即返回执行结果**。当前 `/call` 返回 `recordId`,调用方据此轮询
`get_by_record_id` 拿终态。是否真正"做完"以回执里的 `status` 为准,别凭 HTTP 200 就认为设备已执行。
:::
## 示例
空调的模板里定义一个指令:`commandCode = setTemperature`、`commandTypeFlag = action`、`callTypeFlag = sync`、
`timeout = 10`,带一个输入参数 `temperature`(`paramDirectionFlag = input`、`paramTypeFlag = DOUBLE`、`requiredFlag = true`
)和一个输出参数 `resultCode`(`output`、`STRING`)。
调用时提交 `CommandCallBO{ deviceId: 1001, commandCode: "setTemperature", paramValues: { temperature: "26" } }`。数据中心校验设备
`profileId` 是否包含该指令,落一条 `dc3_command_history`(PENDING→SENT),投递给驱动;驱动 `execute()` 渲染协议报文下发到空调,回执
`CommandCallResultDTO{ status: SUCCESS, resultValues: { resultCode: "OK" } }`,记录推进到 SUCCESS。
## API
数据中心服务挂载于 `/data`:
| 方法 | 路径 | 说明 |
|------|------------------------------------------|--------------------------|
| POST | `/data/command_history/call` | 下发自定义指令,返回 `recordId` |
| GET | `/data/command_history/get_by_record_id` | 按 `recordId` 查询调用记录详情与终态 |
| POST | `/data/command_history/list` | 分页查询调用记录 |
## 延伸阅读
- [模板 Profile](./profile) — 指令定义挂在模板下
- [位号 Point](./point) — 写位号 vs 自定义指令的边界另一侧
- [事件 Event](./event) — 下行的对偶:指令下行、事件上行
- [指令/事件属性配置](./attribute-config) — `CommandConfig` 如何把参数映射成协议报文
- [命令平面](../../architecture/command-plane) — 下行链路的交换机 / 队列 / 回执 / 可靠性
- [数据与命令操作](../../operation/data-commands) — 如何在控制台下发指令
---
# 设备 Device
URL: https://docs.dc3.site/zh/introduction/concepts/device
# 设备 Device
> **设备是现场一台具体设备在平台里的镜像**——一台 PLC、一个温控器、一块电表,在 DC3 里就对应一个 `Device`
> 。它绑定一个[模板 Profile](./profile) 决定"有哪些能力",绑定一个[驱动 Driver](./driver) 决定"怎么通信"
> ,运行时它的在线/离线由心跳租约维护。
设备回答的是"现场到底有哪一台机器"。它不是数据本身,也不是设备的类型定义:某型号温控器"应该有温度、湿度两个位号"
是[模板](./profile)说的事;车间里编号 `TC-001` 的那一台、此刻在线、刚上报温度 25.3℃,才是一个 `Device`。
可以这样类比:[模板](./profile)像类(class),设备像它的实例(instance)。一个模板可被许多设备复用——100 台同型号温控器共用一个
Profile;但一个设备**只能归属一个** Profile(多对多绑定已在模板改造中收敛为单归属)。设备能采哪些[位号](./point)
、能下哪些[指令](./command)、会报哪些[事件](./event),全部由它绑定的那个 Profile 决定。
设备的另一条绑定是[驱动](./driver):Profile 说"这台设备有温度位号",但"用 Modbus 去哪个寄存器读这个温度"是驱动的事。
`profileId` 决定能力模型,`driverId` 决定通信通道,两者缺一不可。
## 关键字段
设备业务对象 `DeviceBO`(表 `dc3_device`),字段名与类型取自源码:
| 字段 | 类型 | 含义 |
|--------------|----------------|-----------------------------------|
| `deviceName` | String | 设备名称(展示用,如"1号车间温控器") |
| `deviceCode` | String | 设备标识符 |
| `profileId` | Long | 归属的[模板 Profile](./profile),决定能力模型 |
| `driverId` | Long | 归属的[驱动 Driver](./driver),决定通信方式 |
| `deviceExt` | DeviceExt | JSON 扩展,存放协议无关的自定义配置 |
| `enableFlag` | EnableFlagEnum | 启停标记,见下 |
| `tenantId` | Long | 归属[租户](./tenant),多租户隔离 |
继承自 `BaseBO` 的通用字段:`id`、`remark`(描述)、`creatorId`/`creatorName`、`operatorId`/`operatorName`、`createTime`/
`operateTime`。
::: tip profileId 是单值,不是集合
早期一个设备可绑定多个 Profile(`Set profileIds`)。模板改造后收敛为 `Long profileId` 单值:一个 Profile
可被多设备复用,但一个设备只归属一个 Profile。
:::
## 启停标记
| `enableFlag` | `0` enable 启用 | `1` disable 停用 |
|--------------|---------------|----------------|
`enableFlag` 是配置态开关(这台设备是否纳入采集),与下文的运行态在线/离线是两回事:停用的设备不参与采集,启用的设备才会被驱动轮询并维护心跳租约。
## 与其它概念的关系
- 设备通过 `profileId` 取得自己的[位号](./point)、[指令](./command)、[事件](./event)定义。
- 设备运行时产生[位号值](./point-value)(`device_id + point_id`)和事件实例。
- 设备通过 `driverId` 找到[驱动](./driver)完成实际读写。
## 在线状态与心跳租约
设备的"在线/离线"不是 `dc3_device` 上的字段,而是一份独立的**运行态状态租约**,由设备/驱动的超时管理机制维护,状态事实源是
`dc3_entity_state` 表(`entity_type_flag = 6` 表示设备)。机制是"心跳续租 + 超时维护":
- **续租**:驱动按配置周期对设备做健康检查,上报 `DeviceStateDTO`,数据中心把 `expire_time` 顺延(`now + timeout`),
`lease_version` 递增。
- **超时**:扫描器每隔固定 tick 唤醒,把 `expire_time <= now()` 仍处在线族的设备批量判为离线。不同设备的超时长短体现在各自的
`expire_time` 上,而非扫描周期上。
状态共四种(沿用设计稿 `EntityStateStatus` 契约):`0` online、`1` offline、`2` maintain、`3` fault。
::: warning 在线状态查的是 dc3_entity_state,不是 dc3_device
`dc3_device` 是设备的**配置元数据**(名称、归属、扩展),不存高频心跳;当前在线/离线请查 `dc3_entity_state`。把心跳写进
`dc3_device` 会污染元数据表,这正是超时方案要避免的。
:::
## 示例
车间里一台 Modbus 温控器接入 DC3:先选一个描述"温控器"这类设备的[模板](./profile)(含 `temperature`、`humidity`
两个[位号](./point)),再选一个 Modbus [驱动](./driver),创建一个设备
`DeviceBO{ deviceName: "1号车间温控器", deviceCode: "TC-001", profileId: 1024, driverId: 2048, enableFlag: enable }`
。启用后,Modbus 驱动按 Profile 的位号配置去读寄存器,产出[位号值](./point-value);同时每 15 秒上报一次设备健康,数据中心据此把
`dc3_entity_state` 里这台设备的 `expire_time` 续租 45 秒(租约时长);某次驱动连不上、心跳断了,扫描器在 `expire_time`
过期后把它判为 `offline`。
## 延伸阅读
- [模板 Profile](./profile) — 决定设备有哪些位号 / 指令 / 事件
- [驱动 Driver](./driver) — 决定设备怎么通信
- [位号 Point](./point) — Profile 下的数据点定义
- [设备接入操作](../../operation/device-onboarding) — 一步步把现场设备接入平台
- [概念概览](../concepts) — 回到概念地图
---
# 驱动 Driver
URL: https://docs.dc3.site/zh/introduction/concepts/driver
# 驱动 Driver
> **驱动是一个独立运行的协议适配服务实例(`dc3-driver-*`)**——它把某种工业协议(Modbus、OPC UA、MQTT……)翻译成 DC3
> 内部统一的[位号](./point)读写与[位号值](./point-value)上报。一类协议对应一个驱动模块,启动时驱动把自己和它能接受的配置项注册到管理中心。
驱动回答的是"DC3 怎么和这台[设备](./device)通上话"。设备只描述"接入了什么",真正握着协议会话、按周期采集、把寄存器值翻译成位号值的,是驱动这个
**服务进程**。换句话说:设备是一行元数据,驱动是一个在跑的程序。
容易混淆的是"驱动"和"设备":一个 Modbus TCP 驱动实例(`dc3-driver-modbus-tcp`)可以同时连接成百上千台 Modbus
设备;每台设备通过[连接配置](./attribute-config)告诉驱动"我的 IP、端口、从站地址是多少"。**驱动是一对多的协议网关,设备是挂在它下面的接入点。
**
## 它是什么 / 为什么需要
工业现场协议五花八门,DC3 核心不可能内置所有协议栈。于是 DC3 把"协议怎么说"
这件事下沉到独立的驱动服务里,核心只和驱动约定一套统一的位号读写契约。新增一种协议 = 新写一个 `dc3-driver-*` 服务,核心和
Web 不用改。
每个驱动启动时做一件关键的事:**自注册**。它带着自己的身份(`DriverBO`)和"我能接受哪些配置项"(一组 `DriverAttribute`
)向管理中心登记。管理中心据此知道:这个驱动叫什么、跑在哪、属于哪个[租户](./tenant)、给它配设备时该填哪些字段。
## 关键字段
驱动 `DriverBO`(驱动服务在管理中心登记的身份元数据):
| 字段 | 类型 | 含义 |
|-------------------------|------------------|------------------------------------------|
| `driverName` | String | 驱动展示名称(如 `Modbus Tcp Driver`) |
| `driverCode` | String | 驱动编码,配置中定义的唯一标识 |
| `serviceName` | String | 驱动服务名,用于注册与路由(如 `dc3-driver-modbus-tcp`) |
| `serviceHost` | String | 驱动服务主机地址 |
| `driverTypeFlag` | DriverTypeEnum | 驱动运行类型,见下 |
| `driverExt` | DriverExt | 扩展元数据(JSON) |
| `enableFlag` | EnableFlagEnum | 启停标记 |
| `tenantId` | Long | 归属[租户](./tenant) |
| `signature` / `version` | String / Integer | 数据签名与版本 |
驱动配置项 `DriverAttributeBO`(声明该驱动能接受哪些[连接配置](./attribute-config)字段,注册时随驱动一并上报):
| 字段 | 类型 | 含义 |
|---------------------|--------------------|-----------------------------------------------|
| `attributeName` | String | 配置项名称(如 `Host`、`Port`) |
| `attributeCode` | String | 配置项标识符,设备配值时按它匹配 |
| `attributeTypeFlag` | AttributeTypeEnum | 配置项数据类型(`string` / `int` / `long` / `float`…) |
| `defaultValue` | String | 默认值 |
| `driverId` | Long | 归属的驱动 |
| `attributeExt` | DriverAttributeExt | 扩展配置(JSON) |
| `enableFlag` | EnableFlagEnum | 启停标记 |
| `tenantId` | Long | 归属租户 |
::: tip DriverAttribute 是"配置项的声明",不是"配置值"
`DriverAttribute` 描述的是"这个驱动需要你填 `Host`、`Port`",是一份**模板**;某台设备真正填入的 `192.168.1.10`、`502`
是[连接配置](./attribute-config)(`DriverAttributeConfig`)。前者由驱动注册产生,后者由你配设备时产生。
:::
## 驱动类型
| 类型 `driverTypeFlag` | code | 说明 |
|---------------------|-----------------|----------------------------------|
| `DRIVER_CLIENT` | `driver-client` | 客户端模式协议驱动,主动连设备(如 Modbus TCP 轮询) |
| `DRIVER_SERVER` | `driver-server` | 服务端模式协议驱动,等设备来连(如 MQTT、监听类) |
| `GATEWAY` | `gateway` | 网关驱动 |
| `CONNECT` | `connect` | 连接驱动 |
## 与其它概念的关系
- 一个驱动**注册一次身份**,可承载**多台**[设备](./device)的采集。
- 驱动注册的 `DriverAttribute` 是模板;每台设备用[连接配置](./attribute-config)按这份模板填值。
- 驱动按[模板](./profile)定义的[位号](./point)采集,把结果翻译成[位号值](./point-value)上报。
## 启动注册与在线状态
驱动启动时由 `DriverInitRunner`(`ApplicationRunner`)触发注册:构造 `RegisterBO`(含 `tenant`、`driver`=`DriverBO`、
`driverAttributes` 等)调用 `DriverRegisterService.initial()` 上报管理中心,注册失败按指数退避重试直至成功。注册后驱动并非"
一注册就永远在线"——它的**在线状态是一份租约**:SDK 周期触发 `DriverHealth.health()` 上报心跳,在 `dc3_entity_state`(
`entity_type_flag = 3` 表示驱动)续租 45 秒;租约到期未续即判定 `offline`。状态取值为 `online` / `offline` / `maintain` /
`fault`。
::: warning 在线状态不在元数据表里
`dc3_driver` 存的是驱动**配置元数据**(名字、服务名、租户),改它不代表驱动在跑。驱动当前是否在线看运行态状态表
`dc3_entity_state`,它由心跳续租维护,进程崩溃 / 网络断开后租约会自然过期翻为离线。查"有哪些驱动"看前者,查"驱动现在通不通"
看后者。
:::
## 示例
你要接入车间一批 Modbus TCP 仪表:
1. 部署 `dc3-driver-modbus-tcp` 服务实例并启动,它注册
`DriverBO{ serviceName: "dc3-driver-modbus-tcp", driverTypeFlag: DRIVER_CLIENT }`,并声明配置项
`DriverAttribute{ attributeCode: "host", type: string }`、`{ attributeCode: "port", type: int }`。
2. 在 Web 上新建[设备](./device)挂到该驱动,按声明填[连接配置](./attribute-config):`host=192.168.1.10`、`port=502`。
3. 驱动据此建立 Modbus 会话,按[模板](./profile)里的[位号](./point)
周期读寄存器,把读到的原始值翻译成[位号值](./point-value)上报数据中心。
4. 驱动每 15 秒上报一次心跳续租;某天该服务进程被 kill,45 秒后租约到期,平台把这个驱动标记为 `offline`,它名下设备随之转入离线扫描。
## 内置驱动
DC3 自带 **28 个**开箱即用的协议驱动,覆盖工业现场协议(Modbus RTU/TCP、OPC UA/DA、PLC
S7、Melsec、BACnet/IP、IEC104、DLMS、SNMP、CAN…)、物联协议(MQTT、CoAP、LwM2M、HTTP、ZigBee、BLE…)、串口/网络透传(Serial、TCP/UDP)以及数据库接入(MySQL、PostgreSQL、Oracle、SQLServer)。完整清单与各驱动职责见[模块地图](../../architecture/modules)。
## 延伸阅读
- [设备 Device](./device) — 挂在驱动下的接入点,一驱动多设备
- [连接配置 DriverAttributeConfig](./attribute-config) — 设备按驱动声明的 DriverAttribute 填的连接值
- [位号 Point](./point) — 驱动采集的目标数据点
- [核心概念概览](../concepts) — 对象模型与三层配置的全景
- [模块地图](../../architecture/modules) — 28 个内置驱动清单与服务拓扑
- [驱动开发指南](../../development/driver-authoring) — 如何自己写一个 `dc3-driver-*`
---
# 事件 Event
URL: https://docs.dc3.site/zh/introduction/concepts/event
# 事件 Event
> **事件是设备主动上报的一次业务发生**——故障、告警、模式切换、生命周期变化……定义归属[模板](./profile)
> ,实例归属[设备](./device),上报后既留原始流水,又可触发告警。
事件回答的是"设备身上发生了什么",而不是"某个量现在是多少"。后者是[位号值](./point-value)
(周期采集的数值快照),前者是离散的、带语义的一次发生:一台门禁设备的"温度 = 25.3℃"是位号值,"门被强行打开"是一个事件。
事件分两层:**定义**(这类设备会上报哪些事件、每个事件带哪些参数)沉淀在模板里,由 `dc3_event` / `dc3_event_param` 承载;*
*实例**(某台设备某时刻真的报了一次)由驱动上报、落到 `dc3_event_history`。
## 关键字段
事件定义 `EventBO`(表 `dc3_event`):
| 字段 | 类型 | 含义 |
|------------------|-------------------|------------------------------------|
| `eventName` | String | 事件名称(展示用) |
| `eventCode` | String | 事件标识符,上报与告警规则按它匹配(如 `DOOR_FORCED`) |
| `eventTypeFlag` | EventTypeFlagEnum | 事件类型,见下 |
| `eventLevelFlag` | EventLevelEnum | 事件级别,见下 |
| `profileId` | Long | 归属的[模板](./profile) |
| `eventExt` | JSON | 扩展配置 |
事件参数 `EventParamBO`(表 `dc3_event_param`,声明事件携带哪些输出参数):
| 字段 | 类型 | 含义 |
|---------------------------|---------------|-----------|
| `paramName` / `paramCode` | String | 参数名 / 标识符 |
| `paramTypeFlag` | PointTypeEnum | 参数数据类型 |
| `eventId` | Long | 归属的事件定义 |
::: tip 事件参数复用位号的类型系统
`paramTypeFlag` 用的是和位号一样的 `PointTypeEnum`(`STRING` / `INT` / `FLOAT` / `BOOLEAN`
…),事件参数的取值范围与[位号](./point)数据类型完全一致。
:::
## 事件类型与级别
| 类型 `eventTypeFlag` | 说明 |
|--------------------|--------|
| `info` | 信息事件 |
| `alert` | 告警事件 |
| `fault` | 故障事件 |
| `lifecycle` | 生命周期事件 |
| 级别 `eventLevelFlag` | `0` LOW | `1` MEDIUM | `2` HIGH | `3` CRITICAL |
|---------------------|---------|------------|----------|--------------|
## 与其它概念的关系
- 事件**定义**挂在模板下,与[位号](./point)、[指令](./command)并列,共同描述"这类设备有什么能力"。
- 事件**实例**由[设备](./device)通过[驱动](./driver)上报,带上 `eventCode`、级别和一组参数值。
## 上报链路与生命周期
一次上报(`EventReportDTO`)携带:`recordId`(UUID)、`deviceId`、`eventId`、`eventCode`、`eventTypeFlag`、`eventLevelFlag`、
`paramValues`、`message`、`occurTime`。数据中心先把它落成**原始流水** `dc3_event_history`,再提交给告警规则引擎;命中规则才会在
`dc3_entity_alarm` 生成/更新一条**运行态告警**。
::: warning EventHistory 不等于告警
`dc3_event_history` 是"设备说发生了什么"的原始流水,**每次上报都记**;`dc3_entity_alarm` 是"告警引擎判定需要关注"的结果,*
*只有命中规则才有**。查"设备报过哪些事件"看前者,查"当前有哪些告警"看后者,别混为一谈。
:::
## 示例
门禁设备的模板里定义一个事件:`eventCode = DOOR_FORCED`、`eventTypeFlag = alert`、`eventLevelFlag = 3`,带参数
`openMethod`(String)。现场设备被撬开时,驱动上报
`EventReportDTO{ eventCode: "DOOR_FORCED", paramValues: { openMethod: "pry" }, occurTime: ... }`;数据中心记入
`dc3_event_history`,并因级别为 CRITICAL 命中告警规则,在 `dc3_entity_alarm` 生成告警。
## 上报 API
| 方法 | 路径 | 说明 |
|------|--------------------------------------------------|-----------------------|
| POST | `/data/event_history/report` | 上报事件 |
| GET | `/data/event_history/get_by_record_id?recordId=` | 按 `recordId` 查询事件记录详情 |
| POST | `/data/event_history/list` | 分页查询事件记录 |
## 延伸阅读
- [模板 Profile](./profile) — 事件定义挂在模板下
- [指令 Command](./command) — 下行的对偶:事件上行、指令下行
- [位号值 PointValue](./point-value) — 互补:连续数值 vs 离散发生
- [告警与通知](../../operation/alarms) — 事件如何变成告警
- [数据平面](../../architecture/data-plane) — 上行链路的交换机 / 队列 / 可靠性细节
---
# 位号值 PointValue
URL: https://docs.dc3.site/zh/introduction/concepts/point-value
# 位号值 PointValue
> **位号值是某个[位号](./point)在某一时刻的一次取值快照**——"3 号水泵的出水温度在 14:05:03 这一瞬间是 25.3℃"。它归属
`device + point` 并带时间戳,由[驱动](./driver)采集上行,落入 TimescaleDB 时序库。
位号值回答的是"某个量**现在/那一刻**是多少"。[位号](./point)是模板里的"列定义"(这类设备有"出水温度"
这个测点),位号值则是这一列在一行行时间上的"取数"——同一个位号会随时间产生成千上万条位号值。
它和[事件](./event)是一对互补的上行数据:位号值是**连续数值**的周期采样(温度每秒一条),事件是**离散发生**的一次业务动作("
温度过高告警"触发了一次)。门禁设备的"温度 = 25.3℃"是位号值,"门被强行打开"是事件。
不要把位号值和"位号当前值"混为一谈:位号值是一条条**历史流水**,每次采集都新增一条、只追加不更新;"当前值"只是按
`device_id + point_id` 取最新一条位号值的查询结果。
## rawValue 与 calValue
一条位号值同时保留两个值:
- **`rawValue` 原始值**——驱动从设备原样读到的数据,未经任何换算。例如 4-20mA 变送器回传的寄存器原码 `6400`。
- **`calValue` 换算后工程值**——按位号上配置的换算规则(`baseValue` / `multiple` 等)算出的、人能直接读的工程量。例如 `6400`
经换算得到 `25.3`(℃)。
保留原始值的意义在于可回溯、可重算:换算规则改了,历史 `rawValue` 还在,能重新算出新的工程值。
## 关键字段
位号值 `PointValueBO`(表 `dc3_point_value`):
| 字段 | 类型 | 含义 |
|------------------|---------------|----------------------------------------------------------|
| `deviceId` | Long | 归属[设备](./device) |
| `pointId` | Long | 归属[位号](./point) |
| `rawValue` | String | 原始值,设备原样回传,未换算 |
| `calValue` | String | 换算后的工程值,人可读 |
| `numValue` | Double | `calValue` 的数值投影;能干净解析为 double 时填充,布尔 / JSON / 文本则为 NULL |
| `hasLatestValue` | Boolean | 取最新值查询时,是否真取到了采样值 |
| `driverId` | Long | 采集该数据的[驱动](./driver) |
| `tenantId` | Long | 归属[租户](./tenant) |
| `createTime` | LocalDateTime | 采集 / 写入时间,即该快照的时间戳 |
| `operateTime` | LocalDateTime | 最近操作时间 |
::: tip 为什么有 numValue
`rawValue` 和 `calValue` 都是 `String`(要同时容纳数值、布尔、JSON、文本)。`numValue` 是 `calValue` 能解析成数字时的副本,专供
AVG / MIN / MAX / SUM、时序聚合等查询走数值索引,省掉每次现场 cast。非数值位号(开关量、字符串、JSON)的 `numValue` 为
NULL,聚合查询用 `num_value IS NOT NULL` 直接跳过它们。
:::
## 与其它概念的关系
- 位号值由 `deviceId + pointId` 共同定位:**哪台设备**的**哪个测点**。
- [位号](./point)给出列定义(类型、单位、换算规则),位号值是这列的一行行运行态取数。
- [事件](./event)与位号值并列于上行链路,一个连续、一个离散,互为补充。
## 采集与上行链路
驱动从设备读到 `rawValue`,按位号换算规则算出 `calValue`,能解析为数字时再填 `numValue`,连同 `deviceId` / `pointId` /
`driverId` / `tenantId` / `createTime` 打包上行;数据中心把它**追加**写入 `dc3_point_value` 超表(hypertable,按
`create_time` 1 天分片 + 按 `device_id` 哈希分片,压缩与 180 天保留策略由 TimescaleDB 自动维护)。
::: warning 位号值只增不改,注意保留策略
`dc3_point_value` 是append-only 的历史流水:每次采集都新增一行,不做 UPDATE。查"当前值"是取最新一条,不是某个会被覆盖的字段。另外它配置了
180 天保留策略——超期数据会被自动清理,需要长期留存请提前归档。
:::
## 示例
3 号水泵(`deviceId=1024`)的出水温度位号(`pointId=2048`),位号上配了换算规则把 4-20mA 原码映射到 0-100℃。14:05:03 驱动(
`driverId=8`)读到寄存器原码 `6400`,换算得 `25.3`:
```text
PointValueBO{
deviceId: 1024, pointId: 2048, driverId: 8, tenantId: 1,
rawValue: "6400", // 设备原样回传
calValue: "25.3", // 换算后工程值(℃)
numValue: 25.3, // 可数值聚合
createTime: 2026-06-24T14:05:03
}
```
一秒后又采到一条 `25.4`……如此持续累积成时序流水。查"当前出水温度"= 取 `device_id=1024, point_id=2048` 的最新一条;查"
今天均温"= 对 `num_value` 在时间窗内做 AVG。
## 查询 API
| 方法 | 路径 | 说明 |
|------|-------------------------------------------------------|-----------------------------------------------|
| POST | `/point_value/latest` | 分页查询各位号的最新值 |
| POST | `/point_value/list` | 分页查询位号值历史 |
| GET | `/point_value/list_history_by_device_id_and_point_id` | 按 `device_id + point_id` 查历史值(`count` 默认 100) |
## 延伸阅读
- [位号 Point](./point) — 位号值是位号的运行态取数
- [事件 Event](./event) — 互补:连续数值 vs 离散发生
- [核心概念总览](../concepts) — 回到概念地图
- [数据平面](../../architecture/data-plane) — 上行采集链路的交换机 / 队列 / TimescaleDB 细节
---
# 位号 Point
URL: https://docs.dc3.site/zh/introduction/concepts/point
# 位号 Point
> **位号是一个数据项**——一类设备身上要采集或要写入的**一个具体的量**。定义归属[模板](./profile)
> ,运行态取值即[位号值](./point-value)。
位号回答的是"这类设备身上有哪些量可以读、可以写"。一台空调上的"室温""设定温度""开关状态"
,各自就是一个位号;它们的瞬时数值快照是[位号值](./point-value)。可以这样类比:[模板](./profile)是一张表格的表头定义,每个位号就是其中的一
**列**,而[位号值](./point-value)是某台设备某时刻填进这一列的那个**单元格**。
容易混淆的两点:
- **位号 ≠ 位号值**。位号是"列"的定义(叫什么、什么类型、能不能写、单位是什么),稳定不变;位号值是"格"
的取值,随采集不断变化。详见[位号值](./point-value)。
- **位号 ≠ 指令**。位号是"量",[指令](./command)是"动作"(重启、校准、切换模式)。一个位号能不能被写,由它自己的 `rwFlag` 决定,*
*不需要、也不会**在 `dc3_command` 指令表里登记。读写位号走的是 `PointCommand` 链路,自定义指令才走 `Command` 链路。
## 关键字段
位号 `PointBO`(表 `dc3_point`):
| 字段 | 类型 | 含义 |
|-----------------|----------------|----------------------------|
| `pointName` | String | 位号名称(展示用,如"室温") |
| `pointCode` | String | 位号标识符,同一[模板](./profile)下唯一 |
| `pointTypeFlag` | PointTypeEnum | 数据类型,见下 |
| `rwFlag` | RwTypeEnum | 读写能力,见下 |
| `unit` | String | 工程单位,如 `℃`、`kPa` |
| `baseValue` | BigDecimal | 线性换算的偏移量(默认 `0`) |
| `multiple` | BigDecimal | 线性换算的倍率(默认 `1`) |
| `valueDecimal` | Byte | 小数精度,浮点取值的保留位数(默认 `6`) |
| `profileId` | Long | 归属的[模板](./profile) |
| `pointExt` | PointExt | 扩展配置(协议映射、约束、采集策略等) |
| `enableFlag` | EnableFlagEnum | 启停状态 |
| `tenantId` | Long | 归属[租户](./tenant) |
## 数据类型 `pointTypeFlag`
| 枚举 | code | 说明 |
|-----------------------------------|-----------------------------------|---------|
| `STRING` | `string` | 字符串(默认) |
| `BYTE` / `SHORT` / `INT` / `LONG` | `byte` / `short` / `int` / `long` | 整数 |
| `FLOAT` / `DOUBLE` | `float` / `double` | 浮点 |
| `BOOLEAN` | `boolean` | 布尔 |
## 读写能力 `rwFlag`
| 枚举 | code | 说明 |
|--------------|------|--------------------|
| `READ_ONLY` | `r` | 只读,只能采集,不能下发写值(默认) |
| `WRITE_ONLY` | `w` | 只写,只能下发写值 |
| `READ_WRITE` | `rw` | 可读可写 |
::: warning 能否写由 rwFlag 决定,不是指令表
某个位号能不能被写,**唯一**取决于它的 `rwFlag` 是否包含写能力(`WRITE_ONLY` 或 `READ_WRITE`)。这跟[指令](./command)(
`dc3_command`)没有关系——位号读写不在指令表里建模。中心侧在写命令前会校验 `rwFlag`:只读位号收到写请求会被直接拒绝。
:::
## 原始值与工程值换算
驱动从设备采到的常常是**原始值**(寄存器整数、ADC 计数等),位号通过线性公式换算成人能看懂的**工程值**:
```text
工程值 = 原始值 × multiple + baseValue (再按 valueDecimal 保留小数)
```
例:一个温度变送器寄存器读数 `2531`,配置 `multiple = 0.01`、`baseValue = 0`、`unit = ℃`、`valueDecimal = 2`
,换算后存入[位号值](./point-value)的工程值就是 `25.31 ℃`。默认 `multiple = 1`、`baseValue = 0` 表示原始值即工程值,不做换算。
## 与其它概念的关系
- 位号**定义**挂在[模板](./profile)下,与[指令](./command)、[事件](./event)并列,共同描述"这类设备有什么能力"。
- [设备](./device)归属一个模板,因此自动拥有该模板下的全部位号;运行态数据按 `device_id + point_id`
落成[位号值](./point-value)。
## 示例
给空调模板配三个位号:
| pointCode | pointName | pointTypeFlag | rwFlag | unit | 说明 |
|---------------|-----------|---------------|--------------|------|--------------------------|
| `indoor_temp` | 室温 | `FLOAT` | `READ_ONLY` | `℃` | 只采集,配 `multiple=0.1` 做换算 |
| `set_temp` | 设定温度 | `FLOAT` | `READ_WRITE` | `℃` | 可读回,也可下发新设定值 |
| `power` | 开关状态 | `BOOLEAN` | `READ_WRITE` | — | 读当前开关,写控制启停 |
某台空调(`device_id=1001`)采到 `indoor_temp` 的工程值 `26.5℃` → 落成一条[位号值](./point-value);要把它调到 22℃,对
`set_temp` 发一条写 `PointCommand`,因为它 `rwFlag=READ_WRITE`,写请求通过校验后下发到驱动。
::: tip 一个位号同时具备类型、读写、单位、换算
新建位号若不显式配置,类型默认 `STRING`、读写默认 `READ_ONLY`、`baseValue=0`、`multiple=1`、`valueDecimal=6`、`unit`
为空。采集数值量时记得改成对应的数值类型并配好换算,否则会按字符串原样存。
:::
## 延伸阅读
- [模板 Profile](./profile) — 位号定义挂在模板下
- [位号值 PointValue](./point-value) — 位号的运行态取值快照
- [指令 Command](./command) — 动作型能力;位号读写不在此表
- [事件 Event](./event) — 与位号并列的另一类能力
- [设备接入](../../operation/device-onboarding) — 设备如何选定模板并继承其位号
---
# 模板 Profile
URL: https://docs.dc3.site/zh/introduction/concepts/profile
# 模板 Profile (Thing Model)
> **模板是"一类设备的能力模板"**
> ——它把同型号设备共有的[位号](./point)、[指令](./command)、[事件](./event)
> 聚合在一起,描述"这类设备能采什么、能控什么、会报什么"。一个[设备](./device)恰好归属一个模板,多个设备可以复用同一个模板。
## 它是什么 / 为什么需要
想象你接入 100 台同型号的温湿度传感器。如果每台都单独配置"温度位号、湿度位号、校准指令、故障事件",那就是 100 份重复劳动,改一处要改
100 次。模板解决的正是这件事:**把能力定义抽出来沉淀成一份模板**,设备实例只引用它。
类比产品和实物:模板像"产品说明书 / 出厂规格",设备像"按这份规格出厂的一台台实物"。说明书写一遍,实物可以造很多台。
::: tip 模板与"物模型"的关系
"物模型(Thing Model)"是行业里常见的设备能力建模设计,DC3 的**模板 `Profile`** 与它属于**同级抽象**——都在回答"
一类设备有哪些能力"。DC3
没有沿用 `Product` / `ThingModel` 这类叫法,而是选了**模板**,并且能力比典型物模型**更强**:模板支持[共享范围](#枚举)(租户
/ 驱动 / 用户三档复用)、版本演进、弱结构化扩展 `profileExt` 等,比"一产品一物模型"的固定结构更灵活。可以理解为:**模板 ⊇ 物模型
**——物模型能表达的,模板都能表达,反之未必(见[设计哲学](../../architecture/domain-model))。
:::
**模板 vs 物模型(一眼看懂"强在哪"):**
| 维度 | 物模型 Thing Model(行业通用) | 模板 Profile(DC3) |
|------|-----------------------|--------------------------------------------------|
| 定位 | 设备能力建模的抽象 | 同级抽象,能力更强(超集) |
| 能力聚合 | 属性 / 服务 / 事件 | [位号](./point) / [指令](./command) / [事件](./event) |
| 复用范围 | 通常按产品固定 | 共享范围三档:租户 / 驱动 / 用户(`profileShareFlag`) |
| 版本演进 | 一般无显式版本 | `version` 显式版本,可查询、可演进 |
| 扩展字段 | 结构相对固定 | `profileExt` 弱结构化扩展(可承载 category / tags 等) |
| 创建来源 | — | `profileTypeFlag`:系统 / 驱动 / 用户 |
| 设备绑定 | 视实现而定 | 恰好绑定一个(`Device.profileId` 单一外键) |
> 一句话:**模板是物模型的加强版**——保留"一类设备的能力模板"这一同级抽象,再叠加共享、版本、扩展等平台化能力。
**容易混淆的三组概念:**
- **模板 vs 设备**:模板是"类"(定义一遍),设备是"实例"(接入多台)。位号"温度"定义在模板上,而"3 号传感器此刻的温度 =
25.3℃"这一[位号值](./point-value)是设备的运行态数据。
- **模板 vs 驱动**:模板描述"设备有哪些能力"(业务语义),[驱动](./driver)描述"用什么协议怎么连"
(连接方式)。同一个模板可以配不同驱动,二者正交。
- **聚合 vs 拥有**:模板不"存"位号 / 指令 / 事件的数据,它只是它们的**归属根**——`Point`、`Command`、`Event` 都通过
`profileId` 挂回模板。
## 关键字段
模板 `ProfileBO`(表 `dc3_profile`):
| 字段 | 类型 | 含义 |
|--------------------|----------------------|----------------------------------------|
| `profileName` | String | 模板名称(展示用) |
| `profileCode` | String | 模板编码,同租户下唯一,作为模型标识 |
| `profileShareFlag` | ProfileShareTypeEnum | 共享范围,见下 |
| `profileTypeFlag` | ProfileTypeEnum | 创建来源,见下 |
| `version` | Integer | 模型版本,可查询、由人工设置 |
| `profileExt` | ProfileExt (JSON) | 弱结构化扩展字段(设计上可承载 `category`、`tags` 等内容) |
| `enableFlag` | EnableFlagEnum | 启停状态 |
| `tenantId` | Long | 归属的[租户](./tenant) |
::: tip 模板不直接持有子能力的字段
`ProfileBO` 上看不到位号 / 指令 / 事件列表——它们是独立实体,靠各自的 `profileId` 外键挂回来。查"这个模板有哪些能力"要分别查
`Point` / `Command` / `Event`,而不是读 `ProfileBO` 的某个字段。
:::
## 枚举
**共享范围 `profileShareFlag`(`ProfileShareTypeEnum`)**——控制这份模板能被谁复用:
| 枚举 | code | 含义 |
|----------|--------|-------------------|
| `TENANT` | tenant | 租户内共享,租户下所有设备可引用 |
| `DRIVER` | driver | 驱动内共享,归属某驱动的设备可引用 |
| `USER` | user | 用户私有,仅创建者可见 |
**创建来源 `profileTypeFlag`(`ProfileTypeEnum`)**:
| 枚举 | code | 含义 |
|----------|--------|------|
| `SYSTEM` | system | 系统内置 |
| `DRIVER` | driver | 驱动创建 |
| `USER` | user | 用户创建 |
## 与其它概念的关系
模板是[位号](./point)、[指令](./command)、[事件](./event)三类能力的归属根,三者并列地回答"
这类设备有什么能力"。[设备](./device)通过 `profileId` **恰好绑定一个**
模板——这是单一外键,不是多对多。设备如何连接由[驱动](./driver)决定,与模板正交。
## 生命周期
先建模板并补齐位号 / 指令 / 事件,再让多台同型号设备绑定它;运行期设备按模板采集位号值、接收指令、上报事件;能力变更时递增
`version`。
::: warning 一个设备只能绑一个模板
早期版本支持设备绑定多个模板(`dc3_profile_bind` 多对多),现已收敛为 `Device.profileId` 单一外键:**一个设备恰好归属一个模板
**,一个模板可被多个设备复用。设备的位号集合只来自它 `profileId` 指向的那一个模板,不会跨模板混取。
:::
## 示例
为"温湿度传感器 ZS-100"建一个模板:`profileCode = ZS-100`、`profileShareFlag = TENANT`(租户内共享)、`version = 1`
。在它下面定义两个位号(`temperature`、`humidity`)、一条指令(`CALIBRATE` 校准)、一个事件(`SENSOR_FAULT` 传感器故障)。随后接入的
100 台该型号传感器,每台 `Device` 都把 `profileId` 指向这一个模板即可复用全部能力;下次给温度位号加个 `max`
约束,只改模板一处,100 台设备同时生效,`version` 升到 2。
## API
模板管理接口前缀 `/profile`(Manager 服务):
| 方法 | 路径 | 说明 |
|------|------------------------------|-----------|
| POST | `/profile/add` | 新增模板 |
| POST | `/profile/update` | 更新模板元数据 |
| POST | `/profile/delete` | 删除模板 |
| GET | `/profile/get_by_id` | 按 ID 查询模板 |
| POST | `/profile/list` | 分页查询模板 |
| GET | `/profile/list_by_device_id` | 查某设备绑定的模板 |
## 延伸阅读
- [位号 Point](./point) — 模板聚合的数据点 / 控制点
- [指令 Command](./command) — 模板聚合的动作型能力
- [事件 Event](./event) — 模板聚合的上报能力
- [设备 Device](./device) — 模板的实例,通过 `profileId` 绑定
- [概念概览](../concepts) — 全部核心概念一览
- [领域模型](../../architecture/domain-model) — Profile 在 DC3 领域语言中的定位
---
# 租户 Tenant
URL: https://docs.dc3.site/zh/introduction/concepts/tenant
# 租户 Tenant
> **租户是平台里业务数据的隔离边界**——同一套部署里,A 公司的[设备](./device)、位号、数据和 B 公司的彼此看不见。每一条业务记录都带一个
`tenantId`,平台据它把数据切成互不串台的几份。
租户回答的是"这条数据归谁、谁能看见它"。它不是一个功能、也不是一个角色,而是一道**数据围墙**:你登录后拿到的令牌绑定了一个
`tenantId`,之后你创建的设备、采到的位号值、下发的指令,全都自动打上这个标签;按 ID 或批量访问别家租户的记录会被判为不存在、或被剔除。
容易混淆的是租户和[主体](../../architecture/auth-rbac)(principal)、角色。一句话区分:**租户管"能碰哪条数据",角色管"
能做哪类操作",主体是"谁在操作"**。三者正交——你可能有 `device:get` 权限(角色给的),但去 get
别家租户的设备依然失败(租户拦的)。就像一栋写字楼:门禁卡决定你能进哪一层(租户),职级决定你在自己那层能开哪些会议室(角色),工牌上印的是你本人(主体)。
## 关键字段
租户 `TenantBO`(表 `dc3_tenant`,继承 `BaseBO` 的 `id` / `remark` / 审计字段):
| 字段 | 类型 | 含义 |
|--------------|-----------------|--------------------------------------------|
| `tenantName` | String | 租户名称(展示用) |
| `tenantCode` | String | 租户唯一编码,登录时用它定位租户;编码为 `default` 的租户是系统管理员租户 |
| `tenantExt` | TenantExt(JSON) | 扩展配置,预留字段 |
| `enableFlag` | EnableFlagEnum | 启用标志,见下 |
租户不是孤立的:身份"属于哪个租户"由租户成员关系 `TenantMembershipBO`(表 `dc3_tenant_membership`)一行一行声明,唯一索引建在
`(tenant_id, principal_id)`:
| 字段 | 类型 | 含义 |
|--------------------|----------------------|--------------------------------------------------|
| `tenantId` | Long | 归属的租户 |
| `principalId` | Long | 归属的[主体](../../architecture/auth-rbac)(principal) |
| `principalType` | PrincipalTypeEnum | 主体类型:`USER` / `SERVICE_ACCOUNT` / `SYSTEM` |
| `membershipStatus` | MembershipStatusEnum | 成员状态:`ACTIVE` / `SUSPENDED` / `INVITED` |
| `joinedTime` | LocalDateTime | 加入时间 |
::: tip 一个人可以属于多个租户
因为唯一索引在 `(tenant_id, principal_id)`,同一个 `USER` 主体可以在多个租户下各有一行成员关系(多租户成员)。登录时由
`name + tenant` 一起定位是哪一段成员关系。按设计 `SERVICE_ACCOUNT` 服务账号只属于一个租户。
:::
## 启用标志 `enableFlag`
| 值 `EnableFlagEnum` | 数据库 | 说明 |
|--------------------|-----|----|
| `ENABLE` | `0` | 启用 |
| `DISABLE` | `1` | 禁用 |
## 与其它概念的关系
- 一切实现了 `TenantOwned`(提供 `getTenantId()`)的业务实体都归某个租户拥有,是隔离的施加对象。
- 主体经 `dc3_tenant_membership` 加入租户;进入租户后再由 RBAC(`dc3_role_principal_bind`
)决定能做哪些操作。详见 [鉴权 · 租户 · RBAC](../../architecture/auth-rbac)。
## 隔离是怎么落实的
租户隔离落在控制器层:取数后比对实体 `tenantId` 与调用方租户,跨租户访问被判为不存在或被剔除。
- **控制器层(单条按 ID)**:查到实体后,`BaseController.requireTenant()` 比对实体的 `tenantId` 与调用方租户,不一致(或实体不存在)就抛
`NotFoundException`,对外返回 **404**。
- **控制器层(批量)**:`BaseController.filterTenant()` 只保留属于本租户的条目,直接剔除别家租户的记录。
- **库级自动追加 `WHERE tenant_id = ?`**:当前未启用(`MybatisPlusConfig` 只注册了 `PaginationInnerInterceptor`
),作为统一兜底仍在规划中。
::: warning 跨租户访问返回 404,不是 403
故意用"不存在"而非"无权限"——避免泄露"某个跨租户资源是否存在"。所以你查不到一台设备时,可能它真不存在,也可能它属于别的租户:对你而言两者无差别。批量查询走
`filterTenant()`,直接把不属于本租户的条目剔除,而不是报错。
:::
## 示例
开发环境通常只有一个默认租户,其 `tenantCode = default`——它同时是**系统管理员租户**:只有 `default` 租户里的用户才能创建/删除/修改其它租户(
`TenantController` 显式判定 `"default".equals(tenantCode)`)。
设想 SaaS 部署里再开一个客户租户 `tenantCode = acme`。`acme` 的运维 `alice` 登录后(令牌绑定 `acme` 的 `tenantId`)创建设备
`泵房-01`,这台设备落库时 `tenant_id` 自动写成 `acme`。此时 `default` 租户的管理员即便手握 `device:get` 权限,按 `泵房-01`
的 ID 去查,也会因 `requireTenant()` 比对失败而得到 404——除非他先切换到 `acme` 租户上下文。`alice` 反过来也看不到
`default` 租户的任何数据。
## 管理 API
租户管理端点在鉴权中心,前缀 `/tenant`(经网关为 `/api/v3/auth/tenant`)。非管理员只能操作自己所属的租户:
| 方法 | 路径 | 说明 |
|------|-----------------------|-------------------------|
| POST | `/tenant/add` | 新增租户(仅 `default` 租户管理员) |
| POST | `/tenant/delete` | 删除租户 |
| POST | `/tenant/update` | 修改租户 |
| GET | `/tenant/get_by_id` | 按 ID 查询 |
| GET | `/tenant/get_by_code` | 按编码查询 |
| POST | `/tenant/list` | 分页查询 |
## 延伸阅读
- [设备 Device](./device) — 最典型的"被租户隔离"的业务实体
- [核心概念与心智模型](../concepts) — 租户边界在整个对象模型中的位置
- [鉴权 · 租户 · RBAC](../../architecture/auth-rbac) — 主体、成员关系、RBAC 与接口层租户隔离的完整链路
- [快速开始](../../quickstart/) — 用默认 `default` 租户在本地起栈
---
# 核心概念与心智模型
URL: https://docs.dc3.site/zh/introduction/concepts
# 核心概念与心智模型
要用好 IoT DC3,先要在脑子里建立一个简单的对象模型。这页用一句话心智模型 +
一张实体关系图把它讲清,然后解释每个对象、最容易混淆的"三层配置",以及贯穿一切的租户边界。读完你就能看懂后面所有操作和架构文档里的术语。
> 你在这里:刚了解了 [平台定位](./),准备动手前先理清概念。下一步可看 [按角色选择路径](./paths)
> 或直接 [快速开始](../quickstart/)。
## 一句话心智模型
> **驱动接入设备,模板描述能力,设备绑定模板,位号承载数据;数据中心存值并下发命令。**
也就是说:协议**驱动(Driver)**负责和设备通信;**模板(Profile)**抽象同类设备的能力(有哪些位号、命令、事件);**设备(Device)**
是绑定了某个模板和某个驱动的具体实例;**位号(Point)**是要采集或写入的一个数据项;采到的值是 **位号值(PointValue)**。
## 对象与关系
这些对象的关系是固定的:一个模板下挂多个位号/命令/事件;一个设备**只绑定一个**模板(自 Phase-1 起,`Device.profileId`
是单一外键,不再是多对多)和一个驱动;一个位号会产生很多位号值。
## 逐个对象
- **驱动 Driver(`dc3-driver-*`)**:一个协议适配服务实例,负责和设备或数据源通信。启动时它会把自己和它能接受的配置项(属性)注册到管理中心。平台内置
28 个驱动,覆盖 Modbus、OPC UA、S7、MQTT 等,详见 [模块地图](../architecture/modules)。
- **模板 Profile**:同类设备的能力模板。它把"这类设备有哪些位号、支持哪些自定义命令、会上报哪些事件"沉淀下来,设备复用它即可。
- **设备 Device**:现场一台具体设备的平台镜像。它绑定一个模板(决定有哪些位号)和一个驱动(决定怎么通信)。
- **位号 Point**:一个数据项。关键字段是 `pointTypeFlag`(数据类型)和 `rwFlag`(读写方向)。
::: tip 位号的读写由 Point 自己决定
某个位号能不能写,取决于它的 `rwFlag`(`READ_ONLY` / `WRITE_ONLY` / `READ_WRITE`),**不是**由命令表决定。写一个 `READ_ONLY`
位号会被拒绝。位号还可带单位 `unit` 和换算(`baseValue` / `multiple`),把原始值线性变换成工程值。
:::
## 三层配置:Param、Attribute、Config
这是最容易混淆的地方。IoT DC3 把"配置"拆成三个不同层次,各自回答不同问题:
| 层 | 对象 | 回答的问题 | 来源 |
|---------------|------------------------------------------------------------------------------|-------------------------|-----------------------------|
| 业务层 Param | `CommandParam` / `EventParam` | 这个命令/事件有哪些输入输出参数 | 模板模型里定义 |
| 协议层 Attribute | `DriverAttribute` / `PointAttribute` / `CommandAttribute` / `EventAttribute` | 这个驱动**有哪些**配置项 | 驱动启动时从 `application.yml` 注册 |
| 实例层 Config | `PointAttributeConfigDO` 等 | **这台设备**给这些配置项填的**具体值** | 用户为设备/位号配置 |
举例:Modbus 驱动声明"位号需要一个寄存器地址"(这是 Attribute,驱动注册的),而"3 号设备的温度位号地址是 40001"(这是
Config,设备实例填的值)。理解这层区分,才能看懂 [设备接入](../operation/device-onboarding) 里"配置位号属性"那一步。
## 数据流与命令流
围绕这些对象,平台跑着两条相反的链路:**数据流**把设备的值采上来存好、对外可查;**命令流**把读写请求下发到设备执行。
两条链路的完整实现(交换机、队列、生命周期、回执)分别见 [数据平面](../architecture/data-plane)
和 [命令平面](../architecture/command-plane)。
## 租户边界
业务数据以**租户(`tenantId`)**为边界隔离。调用 API、创建设备、查询数据、下发命令时都应保持租户上下文一致——平台在控制器层校验租户上下文(
`requireTenant` / `filterTenant`)——按 ID 或批量访问别的租户的记录会被判为不存在(返回 404 而非数据)。开发环境默认租户通常是
`default`,生产环境按实际组织与权限模型配置。隔离是怎么一层层落实的,见 [鉴权 · 租户 · RBAC](../architecture/auth-rbac)。
## 概念详解
每个核心概念都有独立词条,讲清定义、关键字段、与其它概念的关系、生命周期与易错点:
- [模板 Profile](./concepts/profile) — 同类设备的能力模板,聚合位号 / 指令 / 事件
- [设备 Device](./concepts/device) — 现场一台设备的平台镜像
- [驱动 Driver](./concepts/driver) — 协议适配服务,负责和设备通信
- [位号 Point](./concepts/point) — 一个数据项(要采集或写入的量)
- [位号值 PointValue](./concepts/point-value) — 某位号某时刻的取值快照
- [指令 Command](./concepts/command) — 触发设备动作(区别于写位号)
- [事件 Event](./concepts/event) — 设备主动上报的一次业务发生
- [属性与配置 Attribute & Config](./concepts/attribute-config) — Param / Attribute / Config 三层
- [租户 Tenant](./concepts/tenant) — 业务数据隔离边界
## 延伸阅读
- [按角色选择路径](./paths) — 按你的目标选择阅读顺序
- [设备接入](../operation/device-onboarding) — 把概念落成一次真实接入
- [领域模型](../architecture/domain-model) — DO/BO/VO 分层与字段细节
- [快速开始](../quickstart/) — 本地起栈
- [物联网技术总览](../foundations/) — 把这些概念放进物联网四层架构通盘理解
---
# 术语表
URL: https://docs.dc3.site/zh/introduction/glossary
这页统一全站术语:DC3 平台对象、物联网通用名词、以及文档与代码里会出现的协议与接口标识。技术标识符(类名、表名、路由键、HTTP
路径、鉴权头)一律保留原文,遇到拿不准的称呼以这里为准。
> 你在这里:读 [核心概念](./concepts) 或操作文档时碰到术语,回这页对照。每条平台术语都给了到详解页的链接。
## DC3 平台术语
这组是 IoT DC3 自己的对象模型。它们之间的关系是固定的:驱动接入设备,模板描述能力,设备绑定模板,位号承载数据,数据中心存值并下发命令;下面逐条给出统一称呼与去向。
| 中文 | 英文 · 标识 | 说明 | 所属 |
|------|---------------------------------------|--------------------------------------------------------------------------------|-------|
| 驱动 | Driver · `dc3-driver-*` | 协议适配服务实例,负责和设备或数据源通信。详见 [驱动](./concepts/driver) | 设备接入层 |
| 模板 | Profile | 设备能力模板,聚合位号 / 指令 / 事件。详见 [模板](./concepts/profile) | 元数据 |
| 设备 | Device | 绑定一个 Profile 与一个 Driver 的现场设备实例。详见 [设备](./concepts/device) | 元数据 |
| 位号 | Point | 一个数据项;能否写由 Point 的 `rwFlag` 决定。详见 [位号](./concepts/point) | 元数据 |
| 位号值 | PointValue | 采集到的实时 / 历史值(统一用「位号值」,勿用「点位值 / 测点」)。详见 [位号值](./concepts/point-value) | 数据 |
| 网关 | Gateway · `dc3-gateway` | 唯一对外 HTTP 入口(`8000`),聚合各中心路由并注入鉴权上下文。详见 [服务清单](../architecture/services) | 接入 |
| 鉴权中心 | Auth Center · `dc3-center-auth` | 认证 / 租户 / RBAC / OAuth。详见 [鉴权 · 租户 · RBAC](../architecture/auth-rbac) | 中心服务 |
| 管理中心 | Manager Center · `dc3-center-manager` | 元数据管理(驱动 / 模板 / 设备 / 位号)。详见 [服务清单](../architecture/services) | 中心服务 |
| 数据中心 | Data Center · `dc3-center-data` | 位号值落库与命令分发。详见 [数据平面](../architecture/data-plane) | 中心服务 |
| 智能中心 | Agentic Center · `dc3-center-agentic` | LLM 对话与工具调用。详见 [服务清单](../architecture/services) | 中心服务 |
| 租户 | Tenant · `tenantId` | 业务数据的隔离边界。详见 [租户](./concepts/tenant) | 横切 |
| 属性 | Attribute | 驱动协议层的配置项,由驱动启动时从 `application.yml` 注册。详见 [属性与配置](./concepts/attribute-config) | 配置 |
| 配置 | Config | 设备实例层为属性填的具体值。详见 [属性与配置](./concepts/attribute-config) | 配置 |
::: tip 不要混用"点位 / 测点 / 位号"
全站统一用「位号(Point)」指数据项的定义,用「位号值(PointValue)」指它的运行态取值。中心服务首次出现给「中文名 + 标识」,后续可用其一。
:::
## 物联网通用术语
这组是物联网领域的通用名词,不特指 DC3。理解它们有助于把 DC3 的对象放进更大的体系里看——比如驱动大致工作在感知层与网络层之间,中心服务工作在平台层。
| 中文 | 英文 · 标识 | 说明 | 所属 |
|--------|-------------------|----------------------------------------|------|
| 感知层 | Perception Layer | 物联网最底层,由传感器与执行器直接与物理世界交互,负责采集和执行 | 体系分层 |
| 网络层 | Network Layer | 把感知层的数据通过有线 / 无线网络传输到平台的一层 | 体系分层 |
| 平台层 | Platform Layer | 汇聚、存储、管理设备与数据,并向应用提供能力的一层 | 体系分层 |
| 应用层 | Application Layer | 面向具体业务场景消费平台能力(如监控、调度、分析)的一层 | 体系分层 |
| 传感器 | Sensor | 把温度、压力等物理量转换为可读电信号或数字量的器件 | 感知层 |
| 执行器 | Actuator | 接收控制指令并对物理世界施加动作(如开关阀门、启停电机)的器件 | 感知层 |
| RFID | RFID | 射频识别,用无线电频率非接触读写电子标签以标识物体 | 标识技术 |
| NB-IoT | NB-IoT | 窄带物联网,面向低功耗、广覆盖、海量连接场景的蜂窝通信制式 | 网络层 |
| MQTT | MQTT | 轻量级发布 / 订阅消息协议,常用于带宽受限或不稳定网络的设备上报 | 应用协议 |
| CoAP | CoAP | 受限应用协议,为低功耗受限设备设计的类 REST 协议,基于 UDP | 应用协议 |
| LwM2M | LwM2M | 轻量级 M2M 设备管理协议,构建在 CoAP 之上,用于设备注册与远程管理 | 设备管理 |
| 边缘计算 | Edge Computing | 在靠近数据源的设备或网关侧就近处理数据,降低时延与回传带宽 | 计算范式 |
| 雾计算 | Fog Computing | 在边缘与云之间的网络节点上分布式处理数据,是边缘与云的中间层 | 计算范式 |
| 时序数据 | Time-series | 以时间戳为主键、按时间顺序产生的测量数据,位号值即典型时序数据 | 数据模型 |
| 数字孪生 | Digital Twin | 物理实体在数字空间的实时映射,用于仿真、监测与预测 | 建模 |
| AIoT | AIoT | 人工智能与物联网融合,在采集与连接之上叠加智能分析与决策 | 融合范式 |
## 协议与接口标识
这组是文档示例与源码里会直接出现的标识符:消息总线的交换机、驱动注册键、登录端点、以及网关注入的鉴权头。用途以代码为准,这里给出它们在链路中的角色。
| 名称 · 标识 | 类型 | 说明 | 所属 |
|------------------------|--------------|------------------------------------------------------------------|-----|
| `dc3.driver.code` | 驱动路由标识 | 驱动的稳定路由标识,用于消息总线寻址;为稳定标识不可随意改 | 驱动 |
| `dc3.e.value` | RabbitMQ 交换机 | 位号值上报走的交换机,驱动把采集封装为 `PointValue` 发往此处 | 数据流 |
| `dc3.e.point_command` | RabbitMQ 交换机 | 读写命令下发走的交换机,数据中心据此把命令路由到对应驱动 | 命令流 |
| `X-Auth-Tenant` | HTTP 鉴权头 | 受保护端点上携带的租户标识,参与下游租户隔离 | 鉴权 |
| `X-Auth-Login` | HTTP 鉴权头 | 受保护端点上携带的登录身份标识 | 鉴权 |
| `X-Auth-Token` | HTTP 鉴权头 | 受保护端点上携带的访问令牌 | 鉴权 |
| `POST /token/salt` | HTTP 端点(公开) | 登录第一步:传 `tenant`、`name` 取盐,建议 5 分钟内使用(服务端不强制过期) | 登录 |
| `POST /token/generate` | HTTP 端点(公开) | 登录第二步:传 `tenant`、`name`、`salt`、用盐哈希后的 `password` 取访问令牌,有效期 12 小时 | 登录 |
::: info 登录是两步换取令牌
先 `POST /token/salt` 取盐,再用盐对密码哈希后 `POST /token/generate` 换访问令牌;拿到令牌后,受保护请求通过网关时带上
`X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` 三个头。具体字段以代码为准。
:::
## 延伸阅读
- [核心概念](./concepts) — 一句话心智模型 + 实体关系图,把平台术语串成对象模型
- [领域模型](../architecture/domain-model) — DO/BO/VO 分层与字段细节
---
# 平台定位
URL: https://docs.dc3.site/zh/introduction/
# 平台定位
IoT DC3 是一个多协议接入、云原生、AI 赋能的开源工业物联网平台,面向智能体演进,覆盖设备接入、数据采集、运营管理与智能分析。它把"设备接入"和"AI 运营"
两件事连成一条闭环:先用多协议驱动把异构设备的数据采上来、归一成带语义的位号值,再让大模型读取这些数据并反向下发命令到设备。读完这页,你会知道它解决什么问题、给谁用、以及它和常见
IoT 平台到底差在哪。
> 想直接上手,请跳到 [快速开始](../quickstart/);想理解对象模型,请看 [核心概念](./concepts)。
## 它解决的两个缺口
大多数工业现场都卡在两处:
1. **数据出不来,AI 用不上。** 设备数据散落在各种协议和寄存器里,格式各异、缺少语义,AI 拿到也无从消费。
2. **AI 只能看,不能动。** 即便接入了分析或大模型,通常也只能"观察",无法把决策落到设备执行,闭环断在最后一步。
传统 IoT 平台往往只解决其一:要么强在设备连接,要么强在数据分析,少有把"采集—归一—分析—执行—反馈"打通成闭环的。IoT DC3
的设计目标正是补上这两个缺口。
## 一个闭环:从设备数据到 AI 执行
把上面的目标拆成可运行的链路,就是 IoT DC3 的核心工作方式:驱动采集 → 数据中心归一存储 → 大模型读取分析 →
通过工具调用下发命令 → 设备执行并回执。
闭环的关键在于:位号值不是裸数据,而是带语义标签、单位、时间戳和租户上下文的结构化 `PointValue`;大模型通过 Spring AI 的原生
`@Tool` 调用平台 API,既能查也能写,且每一步都受权限与确认机制约束(见 [Agentic 中心](../ai/agentic))。
## 给谁用
- 需要把多类工业协议设备统一接入、集中管理的**物联网平台搭建者**;
- 做产线监控、设备健康、预测性维护的**智能工厂/设备团队**;
- 能源、农业、城市基础设施等**远程监测与控制运营方**;
- 习惯 Spring 生态、要在平台上做**二次开发**的后端开发者;
- 想探索"AI 辅助运营/智能体操作设备"的团队。
## 典型场景
| 场景 | 用 IoT DC3 做什么 |
|------|------------------------|
| 智能工厂 | 产线监控、设备健康与预测性维护、OEE 统计 |
| 能源监测 | 远程抄表与计量、异常告警 |
| 智慧农业 | 大棚环境监测、灌溉控制、产量预测 |
| 智慧城市 | 路灯/环境/市政设施的监测与远程操作 |
## 能力支柱
1. **多协议设备接入**——28 个驱动覆盖工业现场总线、IoT 无线、数据库桥接、基础通信与仿真。
2. **AI 能力集成**——基于 Spring AI 的智能体中心,大模型经 Tool-Calling 读写位号、执行命令、分析告警,兼容
GPT、Claude、DeepSeek、通义千问等主流模型,对话记忆持久化到数据库。
3. **云原生微服务**——Spring Boot 4 + Spring Cloud 2025,网关统一入口、gRPC 服务间通信、无状态可横向扩展。
4. **实时数据引擎**——驱动采集经 RabbitMQ 异步传输,时序存储支撑实时与历史查询,规则引擎驱动多级告警与通知,命令与事件全量历史可追溯。
5. **多租户安全与隔离**——JWT + Spring Security + RBAC,数据库/缓存/API 全链路租户隔离,支持 TLS 加密与审计。
6. **开发者友好**——Driver SDK 热插拔注册自定义驱动,前后端分离,Podman / Docker Compose 一键启动,提供 Kubernetes 部署路径。
## 与传统 IoT 平台的差异
IoT DC3 的差异点不是"支持多协议"——这一点很多平台都有。真正的组合优势在于:
- **AI 原生集成**:通过 Spring AI 内建,而非外挂一个独立分析服务;
- **协议广度**:28 个驱动,含数据库桥接这类少见能力;
- **结构化 AI 输出**:`PointValue` 带语义标签,便于模型直接消费;
- **闭环命令执行**:把 LLM 的决策落回设备执行;
- **全开源**:没有专有内核;
- **多租户设计**:隔离是地基而非补丁。
::: info 诚实的边界
"多协议"本身不构成差异化——它是入场券。IoT DC3 当前也**不提供** RTSP/H.264 视频流接入这类能力;如果你的核心诉求是视频,需要另外评估。
:::
## 技术栈一览
Java 21 · Spring Boot 4.0.6 · Spring Cloud 2025.1.1 · Spring AI 2.0.0· PostgreSQL(+ TimescaleDB / AGE / pgvector)·
RabbitMQ · gRPC / Protobuf · MyBatis-Plus(Snowflake ID)。
## 系统全景
平台对外只暴露网关一个 HTTP
入口;四个中心各司其职,驱动在南向接入设备。下图是各角色的协作全景,逐层展开见 [系统架构](../architecture/)。
## 延伸阅读
- [核心概念与心智模型](./concepts) — 驱动、模板、设备、位号之间的关系
- [按角色选择路径](./paths) — 评估、接入、开发、贡献各有入口
- [快速开始](../quickstart/) — 本地起栈并跑通第一个设备
- [系统架构](../architecture/) — 把闭环拆成每一跳的实现细节
---
# 开源与许可
URL: https://docs.dc3.site/zh/introduction/license
这页写给想搞清楚 IoT DC3 用什么许可证、有什么权利和义务的开发者、法务和项目决策者。
> 你在这里:评估引入或分发自研。关键决策也请阅读[核心概念](./concepts)和[贡献指南](../community/contributing)。
## 许可证
IoT DC3 社区版基于 **GNU Affero General Public License v3.0 or later**(AGPL-3.0-or-later)授权。完整条款见仓库根的
`LICENSE-AGPL.txt` 和 `LICENSE.txt`。
AGPL v3 是 GPL v3 的强化版,多了一条关键条款:**如果你通过网络提供服务(SaaS),你修改过的代码也必须对用户开源**。这跟 IoT DC3
的定位——工业物联网平台——直接相关。
| 你能做的 | 你必须做的 |
|------------------|---------------------------------|
| ✅ 商业使用 | ⚠️ 保留版权声明和许可证原文 |
| ✅ 修改代码 | ⚠️ 修改后的代码同样以 AGPL v3 发布 |
| ✅ 内部分发 | ⚠️ 通过网络提供服务(含 SaaS)必须提供完整源码 |
| ✅ 提供付费服务、运维、定制开发 | ⚠️ 在显著位置说明代码基于 AGPL v3,并附上许可证文本 |
::: warning 网络分发即触发 copyleft
AGPL 跟 GPL 最大的区别就在这里:GPL 只在你"分发二进制"时触发开源义务;AGPL 在你"通过网络让用户使用"时就触发。也就是说,即使你把
IoT DC3 部署成 SaaS 不对外分发二进制,只要修改了代码,就必须向用户提供源码。
:::
## 版权
```
Copyright 2016-present the IoT DC3 original author or authors.
```
本项目版权归 IoT DC3 原始作者及所有贡献者所有。提交代码即表示你同意将贡献以 AGPL v3 授权给项目,同时保留你的个人版权。
## 第三方依赖
IoT DC3 依赖大量开源组件(Spring Boot、RabbitMQ、PostgreSQL、Netty、gRPC 等),它们各自携带独立的许可证。构建时 Maven
会自动拉取并受各自许可证约束。如果你需要完整的依赖许可证清单,运行:
```bash
mvn -s .mvn/settings.xml license:aggregate-add-third-party
```
## 为什么选择 AGPL v3
工业物联网平台的典型场景——工厂部署、设备接入、数据采集——天然是"服务端部署"模式。选择 AGPL v3 是为了:
- **防止代码被封闭**:厂商无法把 IoT DC3 改一改就变成私有产品不发源码。
- **保护用户权利**:任何使用 IoT DC3 衍生版本的用户都有权获取源码。
- **鼓励上游贡献**:AGPL 的传染性让商业公司更愿意把改动推回上游而非维护私有 fork。
## 延伸阅读
- [贡献指南](../community/contributing) — 如何提交代码,许可合规注意事项
- [署名文件](https://github.com/pnoker/iot-dc3/blob/main/COPYRIGHT) — 仓库根的 COPYRIGHT 原文
- [AGPL v3 常见问题](https://www.gnu.org/licenses/agpl-3.0.html) — GNU 官方 FAQ
- [贡献者公约](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) — 社区行为准则参考
---
# 按角色选择路径
URL: https://docs.dc3.site/zh/introduction/paths
# 按角色选择路径
文档覆盖了从评估到贡献的全过程,但不同角色的最短路径不同。先找到最像你的那一行,照着给的顺序读,少走弯路。
下面这张决策图帮你快速对号入座,每条路径的详细阅读顺序见后续小节。
## 我想先评估这个平台
你关心它是什么、值不值得投入。建议顺序:
1. [平台定位](./) — 解决什么问题、与同类的差异
2. [核心概念](./concepts) — 对象模型与心智模型
3. [系统架构总览](../architecture/) — 一张图看清整体
4. 跑个 demo:按 [快速开始](../quickstart/) 起栈,导入示例数据 `iot-dc3/dc3/dependencies/postgres/demo/iot-dc3-demo.sql`
看真实数据
## 我要接入设备、做日常运营
你是设备接入或运维角色,目标是把设备接上、看到数据、能下命令、能告警:
1. [核心概念](./concepts) — 先分清驱动/模板/设备/位号
2. [第一个设备:端到端](../quickstart/first-device) — 用虚拟驱动跑通整条链路
3. [设备接入](../operation/device-onboarding) — 接入真实协议设备
4. [数据与命令](../operation/data-commands) — 采集、历史查询、读写命令
5. [告警与通知](../operation/alarms) — 配置规则与通知渠道
## 我是后端开发者,要二次开发
你要在平台上扩展能力(最常见是写一个新协议驱动):
1. [系统架构总览](../architecture/) → [服务与拓扑](../architecture/services)
2. [数据平面](../architecture/data-plane) 与 [命令平面](../architecture/command-plane) — 两条核心链路
3. [领域模型](../architecture/domain-model) — DO/BO/VO、facade 边界、CRUD 动词约定
4. [驱动开发](../development/driver-authoring) — 从 `dc3-driver-virtual` 模板派生新驱动
5. [API 文档](../development/api-documentation) 与 [测试](../development/testing)
## 我要做自动化 / 接 AI
你想把平台能力接给脚本或 AI Agent:
1. [CLI 使用指南](../automation/cli) — 用 `dc3` 命令行操作平台
2. [AI Agent / MCP 集成](../ai/mcp) — 通过 MCP 让智能体安全读写设备
3. [Agentic 中心](../ai/agentic) — 平台内建的会话与工具调用
## 我想参与贡献
欢迎提交驱动、修复和文档改进:
1. [开发概览与规范](../development/) — 编码约定、提交规范
2. [测试](../development/testing) — 本地与 CI 测试门禁
3. [贡献指南](../community/contributing) · [行为准则](../community/code-of-conduct) · [安全策略](../community/security)
---
# 模块清单
URL: https://docs.dc3.site/zh/modules/
本页按仓库目录列出 IoT DC3 当前模块。每个模块链接指向 GitHub `release` 分支中的原始 `README.md` 或源码目录,便于进一步查看实现。
::: tip 事实来源
驱动数量和模块名称以仓库当前目录为准:`dc3-driver/` 下共有 28 个接入驱动模块。
:::
## 网关
| 模块 | 说明 | 文档 |
|---------------|-----------------------------------|--------------------------------------------------------------------------------|
| `dc3-gateway` | Spring Cloud Gateway,对外 HTTP 统一入口 | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-gateway/README.md) |
## 中心服务
| 模块 | 说明 | 文档 |
|----------------------|------------------------------------|--------------------------------------------------------------------------------------------------|
| `dc3-center-auth` | 认证中心,管理租户、用户、角色、资源和 Token | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-center/dc3-center-auth/README.md) |
| `dc3-center-manager` | 管理中心,管理驱动、模板、设备、位号和元数据 | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-center/dc3-center-manager/README.md) |
| `dc3-center-data` | 数据中心,处理位号值、查询和命令分发 | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-center/dc3-center-data/README.md) |
| `dc3-center-agentic` | Agentic Center,承载 AI 会话、模型提供方和工具调用 | [README](https://github.com/pnoker/iot-dc3/tree/release/dc3-center/dc3-center-agentic) |
| `dc3-center-single` | 单进程聚合启动,适合本地调试 | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-center/dc3-center-single/README.md) |
## 协议驱动
| 分类 | 模块 | 协议 / 用途 |
|---------|--------------------------------|----------------------|
| 工业协议 | `dc3-driver-modbus-tcp` | Modbus TCP |
| 工业协议 | `dc3-driver-modbus-rtu` | Modbus RTU |
| 工业协议 | `dc3-driver-opc-ua` | OPC UA |
| 工业协议 | `dc3-driver-opc-da` | OPC DA |
| 工业协议 | `dc3-driver-plcs7` | Siemens S7 |
| 工业协议 | `dc3-driver-bacnet-ip` | BACnet/IP |
| 工业协议 | `dc3-driver-ethernet-ip` | EtherNet/IP |
| 工业协议 | `dc3-driver-fins` | Omron FINS |
| 工业协议 | `dc3-driver-melsec` | Mitsubishi MELSEC |
| 工业协议 | `dc3-driver-iec104` | IEC 60870-5-104 |
| 工业协议 | `dc3-driver-sl651` | SL651 水文监测协议 |
| 工业协议 | `dc3-driver-dlms` | DLMS / COSEM |
| 物联网协议 | `dc3-driver-mqtt` | MQTT |
| 物联网协议 | `dc3-driver-coap` | CoAP |
| 物联网协议 | `dc3-driver-lwm2m` | LwM2M |
| 物联网协议 | `dc3-driver-http` | HTTP |
| 物联网协议 | `dc3-driver-ble` | Bluetooth Low Energy |
| 物联网协议 | `dc3-driver-zigbee` | Zigbee |
| 数据桥接 | `dc3-driver-mysql` | MySQL 数据源 |
| 数据桥接 | `dc3-driver-postgresql` | PostgreSQL 数据源 |
| 数据桥接 | `dc3-driver-oracle` | Oracle 数据源 |
| 数据桥接 | `dc3-driver-sqlserver` | SQL Server 数据源 |
| 基础通信与管理 | `dc3-driver-tcp-udp` | TCP / UDP |
| 基础通信与管理 | `dc3-driver-serial` | Serial |
| 基础通信与管理 | `dc3-driver-snmp` | SNMP |
| 基础通信与管理 | `dc3-driver-can` | CAN |
| 仿真与调试 | `dc3-driver-virtual` | 虚拟驱动 |
| 仿真与调试 | `dc3-driver-listening-virtual` | 监听式虚拟驱动 |
驱动开发方式见 [驱动开发](../development/driver-authoring)。
## API 合约
| 模块 | 用途 | 文档 |
|-------------------|-----------------------------------|--------------------------------------------------------------------------------------------|
| `dc3-api-auth` | Auth Center gRPC / Protobuf 合约 | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-api/dc3-api-auth/README.md) |
| `dc3-api-manager` | Manager Center gRPC / Protobuf 合约 | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-api/dc3-api-manager/README.md) |
| `dc3-api-data` | Data Center gRPC / Protobuf 合约 | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-api/dc3-api-data/README.md) |
| `dc3-api-driver` | Driver gRPC / Protobuf 合约 | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-api/dc3-api-driver/README.md) |
## 公共组件
| 分类 | 模块 | 用途 |
|-------|-----------------------------------|----------------------------------------|
| 基础模型 | `dc3-common-model` | BO / VO / DTO / Builder / Ext 等共享模型 |
| 基础能力 | `dc3-common-public` | `R` 响应封装、`BaseService`、租户标记等公共能力 |
| Web | `dc3-common-web` | WebFlux、BaseController、OpenAPI、安全基础配置 |
| 常量与异常 | `dc3-common-constant` | 常量、枚举和值对象 |
| 常量与异常 | `dc3-common-exception` | 异常体系 |
| 数据访问 | `dc3-common-dal` | 共享 DAL 基础能力 |
| 数据访问 | `dc3-common-postgres` | PostgreSQL / MyBatis-Plus 配置 |
| 数据访问 | `dc3-common-sql` | SQL 工具 |
| 数据访问 | `dc3-common-repository` | 位号值存储抽象 |
| 通信 | `dc3-common-rabbitmq` | RabbitMQ 配置和常量 |
| 通信 | `dc3-common-mqtt` | MQTT 客户端配置 |
| 通信 | `dc3-common-facade-api` | 跨服务 facade 接口 |
| 通信 | `dc3-common-facade-grpc` | gRPC facade 实现 |
| 通信 | `dc3-common-facade-local-auth` | Auth 本地 facade |
| 通信 | `dc3-common-facade-local-manager` | Manager 本地 facade |
| 通信 | `dc3-common-facade-local-data` | Data 本地 facade |
| 领域能力 | `dc3-common-auth` | 认证、授权、租户和 Token 领域能力 |
| 领域能力 | `dc3-common-manager` | 驱动、模板、设备、位号和元数据领域能力 |
| 领域能力 | `dc3-common-data` | 位号值、命令和数据查询领域能力 |
| 领域能力 | `dc3-common-driver` | Driver SDK、注册、调度、采集和命令运行时 |
| 领域能力 | `dc3-common-agentic` | AI 会话、模型提供方、工具调用和记忆能力 |
| 网关 | `dc3-common-gateway` | Gateway 过滤器和路由辅助能力 |
| 平台支撑 | `dc3-common-log` | 日志配置 |
| 平台支撑 | `dc3-common-thread` | 线程池配置 |
| 平台支撑 | `dc3-common-quartz` | 调度基础设施 |
| 平台支撑 | `dc3-common-api` | API 工具 |
| 平台支撑 | `dc3-common-resource-registrar` | 资源注册 |
| 测试 | `dc3-common-test` | Testcontainers、gRPC、RabbitMQ 和契约测试基础设施 |
## 相关文档
- [架构总览](../architecture/)
- [模块与依赖](../architecture/modules)
- [驱动开发](../development/driver-authoring)
- [API 文档](../development/api-documentation)
---
# 告警与通知
URL: https://docs.dc3.site/zh/operation/alarms
# 告警与通知
平台把"什么时候出问题、谁该被告知"统一收敛到一张运行告警表和一条通知链路。读完这页,你能看懂五类告警来源如何汇入
`dc3_entity_alarm`、前端三个告警视图是怎么从同一张表过滤出来的,以及规则触发后通知是怎么经由邮件/短信/Webhook 发出去的。
> 你在这里:已[接入一个设备](./device-onboarding)
> 并有数据流转,想为异常配置告警与通知。要理解数据从哪来,可回看[数据平面](../architecture/data-plane)。
## 为什么是"一张表 + 一条链路"
现场会从很多角度出问题:规则命中阈值、设备/驱动心跳超时、设备主动上报故障、驱动上报异常、设备上报的事件触发了规则。如果每类来源各建一套表、各走一套通知,运维就要在多个页面之间来回对账。IoT
DC3 的选择是:**所有运行告警,无论来源,统一落在 `dc3_entity_alarm`**;用 `alarm_source_flag`(来自哪里)和
`alarm_target_type_flag`(针对什么实体)两个标志位区分,再靠索引把"驱动告警/设备告警/位号告警"这类视图快速过滤出来。
这条统一表与一条"规则 → 状态机 → 通知"的链路配合,构成告警子系统的两个支柱:前者是**事实记录**,后者是**触发与送达**。
## 五类来源如何汇入一张表
五类告警来源最终都写入 `dc3_entity_alarm`,区别只在标志位。`alarm_source_flag` 的取值来自 `AlarmSourceTypeEnum`,注意
`EVENT_REPORT=5`、`SYSTEM=4`(5 号为持久化兼容保留,枚举里排在 4 之后):
`alarm_source_flag`(来自哪里)和 `alarm_type_flag`(发生了什么)是两个独立维度,不要混淆:
- **来源 `alarm_source_flag`**:`0=RULE`、`1=STATE_TIMEOUT`、`2=DEVICE_REPORT`、`3=DRIVER_REPORT`、`4=SYSTEM`、
`5=EVENT_REPORT`。
- **类型 `alarm_type_flag`**:`0=RULE`(规则命中)、`1=OFFLINE`(心跳超时)、`2=FAULT`(设备内部故障)、`3=STATE_FLIP`(实体状态翻转)、
`4=REPORT`(外部事件上报)。
### 三个前端告警视图,同一张表
前端 Settings 下的三个告警页面查询的都是 `dc3_entity_alarm`(经 `POST /api/v3/data/dashboard/alert/page`),靠请求体里的
`source` 字符串过滤出不同实体维度:
| 视图 | 路由路径 | 过滤参数 |
|------|--------------------------|-----------------|
| 驱动告警 | `/settings/alarm/driver` | `source=driver` |
| 设备告警 | `/settings/alarm/device` | `source=device` |
| 位号告警 | `/settings/alarm/point` | `source=point` |
这套过滤之所以快,靠的是建在表上的复合索引:
`idx_entity_alarm_source_time (tenant_id, alarm_source_flag, create_time DESC)` 服务"按来源 + 时间"翻页,
`idx_entity_alarm_target (tenant_id, alarm_target_type_flag, entity_id, create_time DESC)` 服务"按实体维度"翻页。两个索引都以
`tenant_id` 打头——告警数据严格按租户隔离。
## 规则、状态机与通知的实体关系
写入 `dc3_entity_alarm` 只是"记一笔"。要让告警"反复触发不轰炸、恢复能感知、能送达到人",靠的是 `dc3_rule`(规则定义)→
`dc3_rule_state`(运行状态机)→ `dc3_notify`(通知配置)→ `dc3_notify_channel`(渠道)→ `dc3_notify_history`(送达审计)这条链路。下图是这些表与
`dc3_event_history` 的关系(这些是逻辑关联,库内通过 id 列关联,未建外键约束):
### 触发状态机:pending → firing → recovered → closed
`dc3_rule_state` 是每条规则对每个实体(由 `fingerprint` 唯一标识)的运行态,`entity_state_flag` 取值受 SQL 约束
`CHECK (entity_state_flag BETWEEN 0 AND 3)`:
- `0=pending` 待触发,`1=firing` 触发中,`2=recovered` 已恢复,`3=closed` 已关闭。
- 状态翻转时记录 `first_trigger_time` / `last_trigger_time` / `last_recover_time` / `last_notify_time` 与
`trigger_count`,并把当次告警的 `alarm_id` 回填——这让"同一异常持续触发"只累加计数、不重复刷屏,恢复也能被感知。
### 从触发到送达
规则命中后,告警写入与通知发送的次序如下(`dc3_notify_history` 的 pending 记录在事务内同步落库,随后才经 RabbitMQ
异步投递渠道并回写状态):
`dc3_notify_channel.channel_type_flag` 受约束 `CHECK (channel_type_flag BETWEEN 0 AND 2)`,即 `0=email`、`1=SMS`、
`2=webhook`;`dc3_notify_history.status_flag` 受约束 `CHECK (status_flag BETWEEN 0 AND 4)`,覆盖
pending/sent/success/failed/retry 五态,失败可重试并累加 `retry_count`。
## 事件历史 vs 运行告警:别混为一谈
`dc3_event_history` 和 `dc3_entity_alarm` 经常被一起提及,但它们是两类不同的东西。事件历史是**设备主动上报的事件原始日志**
——设备通过 `EventReportDTO` 上报,经 `EventReportReceiver` 落入 `dc3_event_history`;运行告警是**任意来源触发后产生的统一告警记录
**。两者的关系是:事件上报**可能**触发规则评估,进而产生一条 `alarm_source_flag=5` 的运行告警,但事件历史本身仍是独立的日志。
| 维度 | `dc3_event_history`(事件历史) | `dc3_entity_alarm`(运行告警) |
|------|-----------------------------------|-----------------------------------------------|
| 定义来源 | 模板里的事件定义(`dc3_event` 表) | 任意规则/状态触发产生的告警 |
| 发起方 | 设备通过 `EventReportDTO` 主动上报 | 规则引擎、状态超时、设备/驱动/事件上报 |
| 落库表 | `dc3_event_history`(原始日志) | `dc3_entity_alarm`(统一记录) |
| 状态跟踪 | `acknowledge_flag`(0 未确认 / 1 已确认) | `confirm_flag`(0/1)+ `dc3_rule_state` 生命周期 |
| 生命周期 | 单次创建,事后确认 | 关联 `dc3_rule_state`,跟踪 trigger_count、首末次时间、恢复 |
`dc3_event_history` 自己也有分级字段:`event_type_flag`(`0=info`/`1=alert`/`2=fault`/`3=lifecycle`)和 `event_level_flag`(
`0=LOW`/`1=MEDIUM`/`2=HIGH`/`3=CRITICAL`),并以 `record_id`(UUID)唯一标识一次上报。
## 实操:查询与确认
查询某租户的告警走数据中心的看板接口;下面给出真实路径与请求/响应形态,示例值标注为示例。
::: code-group
```bash [curl 查询告警]
# 经网关 (8000) 查询运行告警,按 source/类型/确认态过滤
# X-Auth-* 三件套由 POST /api/v3/auth/token/generate 登录后取得
curl -X POST http://localhost:8000/api/v3/data/dashboard/alert/page \
-H 'Content-Type: application/json' \
-H 'X-Auth-Tenant: <示例: 你的租户>' \
-H 'X-Auth-Login: <示例: 你的登录名>' \
-H 'X-Auth-Token: <示例: 登录返回的 token>' \
-d '{
"current": 1,
"size": 20,
"source": "device",
"alarmTypeFlag": 0,
"confirmFlag": 0
}'
```
```json [响应形态(示例)]
{
"code": "R200",
"data": {
"current": 1,
"size": 20,
"total": 3,
"records": [
{
"id": "示例-雪花ID",
"source": "device",
"sourceId": "示例-来源实体ID",
"pointId": "示例-位号ID",
"alarmTypeFlag": 0,
"confirmFlag": 0,
"message": "示例-告警描述",
"createTime": "2026-06-22T10:00:00"
}
]
}
}
```
:::
::: tip 三视图就是一个 source 参数
前端"驱动/设备/位号告警"三页本质是对同一接口传不同 `source`(`driver`/`device`/`point`)。你用 curl 复刻它们,只需切换这一个字段。
:::
## 约束与边界
::: warning 告警级别是 P0–P3
`alarm_level_flag` 取 `0=P0 … 3=P3`,0 为最高优先级,排序/筛选时注意方向。
:::
::: danger 告警严格按租户隔离
`dc3_entity_alarm`、`dc3_rule`、`dc3_rule_state`、`dc3_notify*` 全部带 `tenant_id`,所有索引以 `tenant_id`
打头。新增查询/缓存键时必须保留租户范围,不得跨租户读取或省略 `tenant_id`。
:::
::: info 表关联为逻辑关联,无外键约束
`rule_id`、`rule_state_id`、`alarm_id`、`notify_id`、`channel_id` 等是 id 列逻辑关联,库内未建 FK 约束。删除/清理时需在业务层保证一致性,DB
不会级联。
:::
::: info 以源码为准
本页标志位取值与约束依据 `AlarmSourceTypeEnum` / `AlarmTargetTypeEnum` 与 `03-iot-dc3-data.sql` 的 `CHECK` 约束(仓库路径
`dc3/dependencies/postgres/initdb/03-iot-dc3-data.sql`)。字段名以 DO 模型与 SQL 为准;通知触发与异步投递的具体实现以
`AlarmRuleTriggerService` 等服务类为准。
:::
## 延伸阅读
- [设备接入](./device-onboarding) — 告警的前提:先把设备接进来、有值在流转
- [数据平面](../architecture/data-plane) — 设备值如何落库,告警规则评估的数据来源
---
# 数据与命令
URL: https://docs.dc3.site/zh/operation/data-commands
# 数据与命令
设备接入后,验证只剩两件事:值能不能查到、命令能不能下到。这页把"看数据"和"下命令"连成一条用户故事——先用真实 `curl`
读到位号(Point)的最新值与历史,再下一条写命令并轮询它的执行结果,最后讲清设备离线、只读位号、写失败这些边界下系统的真实行为。
> 你在这里:已[接入第一个设备](../quickstart/first-device)、值开始进库。读完这页你能独立地查值、下命令、判断命令到底成没成。
## 两条相反的链路
数据与命令是方向相反的两条链路,但在你这一侧都收敛成 HTTP 调用,统一从网关(Gateway / `dc3-gateway`,`8000`)进。
**数据流是设备 → 你**:驱动(Driver)把一次采集封装成位号值(PointValue),经 RabbitMQ 的 `dc3.e.value` 交换机发往数据中心(
Data Center / `dc3-center-data`),落进 TimescaleDB 的 `dc3_point_value` 超表;你通过 `/api/v3/data/point_value/latest`
读最新值、`/api/v3/data/point_value/list` 读历史区间。这是**已发生事实的查询**,无副作用、可随意重试。
**命令流是你 → 设备**:你 `POST` 一条读/写命令到数据中心,它先落 `dc3_point_command_history`(状态 `PENDING`),再经
`dc3.e.point_command` 交换机路由到目标驱动;驱动对设备执行后,结果经 `dc3.e.point_command_result` 回传。命令接口*
*立即返回一个 `commandId`**,真正的成败要靠这个 ID 去轮询。这是**异步、有副作用**的写路径。
两条链路的字段、模型变换与 RabbitMQ 拓扑细节,分别在 [数据平面](../architecture/data-plane)
与 [命令平面](../architecture/command-plane) 里展开;这页只讲怎么用。
## 看数据:查最新值与历史
数据中心暴露两个读接口,都是 `POST`(请求体携带分页与过滤条件),返回 `Page`,每条值含 `deviceId`、`pointId`、
`rawValue`(原始值)、`calValue`(工程值)、`numValue`(数值投影,可空),以及 `createTime`、`operateTime`。两者都按租户(`tenantId`)
隔离,权限码 `point_value:list`。受保护接口需带鉴权头 `X-Auth-Tenant`、`X-Auth-Login`、`X-Auth-Token`
(如何取见 [API 文档](../development/api-documentation))。
`/api/v3/data/point_value/latest` 拿每个位号的当前值,`deviceId`、`pointId` 可选——只给 `deviceId` 就是这台设备所有位号的最新值。分页字段嵌在
`page` 对象里:
::: code-group
```bash [latest 最新值]
# 示例 deviceId / pointId,替换为你自己的
curl -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H 'Content-Type: application/json' \
-H 'X-Auth-Tenant: <租户>' -H 'X-Auth-Login: <账号>' -H 'X-Auth-Token: ' \
-d '{"deviceId": 1001, "page": {"current": 1, "size": 20}}'
```
```bash [list 历史区间]
# 用 rangeKey (today/24h/7d/30d) 或 createTimeFrom / rangeHours 圈定时间窗
curl -X POST http://localhost:8000/api/v3/data/point_value/list \
-H 'Content-Type: application/json' \
-H 'X-Auth-Tenant: <租户>' -H 'X-Auth-Login: <账号>' -H 'X-Auth-Token: ' \
-d '{"deviceId": 1001, "pointId": 2001,
"rangeKey": "24h",
"page": {"current": 1, "size": 100}}'
```
:::
`/api/v3/data/point_value/list` 比 `latest` 多 `createTimeFrom`、`rangeHours`、`rangeKey`(`today`/`24h`/`7d`/`30d`
)等时间过滤字段,用于翻历史。响应大致是这个形态(示例值):
```json
{
"data": {
"current": 1, "size": 20, "total": 1,
"records": [
{ "deviceId": 1001, "pointId": 2001, "rawValue": "26.5", "calValue": "26.5",
"numValue": 26.5, "createTime": "2026-06-22T11:59:58" }
]
}
}
```
::: warning 聚合时 num_value 可空
底层 `dc3_point_value.num_value` 对非数值/JSON 载荷为 `NULL`。如果你绕过 API 直接对超表做 `AVG`/`SUM` 等聚合,必须加
`num_value IS NOT NULL`,否则结果偏差。位号的原始值与计算值分别在 `raw_value`、`cal_value`(均为文本)。
:::
## 下命令:读命令、写命令与轮询结果
命令接口在数据中心,权限码 `point_command:list`。它们**不返回执行结果**,只返回一个命令 ID——执行是异步的,你拿这个 ID 去轮询历史。
`POST /api/v3/data/point_command/read` 主动触发一次读(绕过采集周期,立刻向设备要值);
`POST /api/v3/data/point_command/write` 把一个值写到可写位号。两者都接受可选的 `commandId` 用于幂等去重。
::: code-group
```bash [写命令 write]
curl -X POST http://localhost:8000/api/v3/data/point_command/write \
-H 'Content-Type: application/json' \
-H 'X-Auth-Tenant: <租户>' -H 'X-Auth-Login: <账号>' -H 'X-Auth-Token: ' \
-d '{"deviceId": 1001, "pointId": 2001, "value": "100"}'
# 响应体 data 即为 commandId(示例): "a1b2c3d4-...."
```
```bash [读命令 read]
curl -X POST http://localhost:8000/api/v3/data/point_command/read \
-H 'Content-Type: application/json' \
-H 'X-Auth-Tenant: <租户>' -H 'X-Auth-Login: <账号>' -H 'X-Auth-Token: ' \
-d '{"deviceId": 1001, "pointId": 2001}'
```
```bash [轮询结果]
# commandId 用上一步拿到的命令 ID
curl 'http://localhost:8000/api/v3/data/point_command_history/get_by_command_id?commandId=a1b2c3d4-....' \
-H 'X-Auth-Tenant: <租户>' -H 'X-Auth-Login: <账号>' -H 'X-Auth-Token: '
```
:::
轮询返回 `PointCommandHistoryVO`,关键字段 `status`(执行状态,`PointCommandStatusEnum`)、`responseValue`(结果/读回值)、
`requestValue`、`finishTime`、`expireTime`。状态从 `PENDING` 开始流转,看到 `SUCCESS` 才算成:
```json
{
"data": {
"commandId": "a1b2c3d4-....",
"deviceId": 1001,
"status": "SUCCESS",
"responseValue": "100",
"finishTime": "2026-06-22T12:00:01"
}
}
```
::: tip 命令有 10 秒默认时效
底层命令 DTO 的 `expireAt` 默认是 `下发时刻 + 10s`。设备若在这之前没被触达,驱动在消费时发现 `now > expireAt` 会把命令置为
`EXPIRED`,而不是无限等待。轮询时若长时间停在 `PENDING`/`SENT`,优先怀疑驱动或设备不在线。
:::
## 命令状态机:PENDING 之后会去哪
命令一生在 `dc3_point_command_history.status` 里逐格推进。`PENDING` 是刚落库待发布;RabbitMQ 发布确认(publisher-confirm)
回来后转 `SENT`(已进队列、等驱动);之后由驱动的执行回执决定终态。理解这张图,你就能从一个状态反推卡在哪一跳。
终态共六种,对应 `PointCommandStatusEnum` 的 `2`–`7`:`SUCCESS(2)` 成功;`FAILED(3)` 驱动明确失败;`TIMEOUT(4)` 应用层等不到回执;
`EXPIRED(5)` 超过 `expireAt` 才被消费;`DUPLICATE(7)` 被驱动去重缓存挡掉;`DEAD(6)` 被拒入死信、不再处理。`PENDING(0)` 与
`SENT(1)` 是过程态。
::: info TIMEOUT 当前无生产者
`TIMEOUT(4)` 在 `PointCommandStatusEnum` 中已预留,但当前链路尚无代码把命令置为该状态;
`SUCCESS/FAILED/EXPIRED/DUPLICATE/DEAD` 才是实际会产生的终态。状态机图中的 `TIMEOUT` 转移按此理解。
:::
## 边界:离线、只读位号与写失败
下命令前,系统会按租户一致 → 设备/位号启用 → 写命令校验 `rwFlag` →
驱动在线的顺序校验,任一不过都不会真正派发。三种最常见的"命令没成"原因要分清:
设备/驱动**离线**时,命令仍能提交并拿到 `commandId`,但因无人消费,最终多半停在 `SENT` 直至 `EXPIRED`,或在超时后变
`TIMEOUT`。设备在线与否由 `dc3_entity_state` 的租约(lease)+ RabbitMQ 心跳判定,不是你手填的。
位号的读写能力由其 `rwFlag` 决定,取值 `READ_ONLY`、`WRITE_ONLY`、`READ_WRITE`。对只读位号下写命令,会在校验阶段被直接拒绝。
::: danger 只读位号写会被拒,写失败不回显值
对 `rwFlag=READ_ONLY` 的位号调用 `/point_command/write` 会被拒绝——这是设计约束,不是临时校验。
此外,写命令**只有**在驱动的 `write()` 明确返回成功时才记 `SUCCESS`;一旦失败,结果状态为 `FAILED` 且
`responseValue=null`——**失败的写不会回显任何值**,以杜绝"看起来成功了"的假象。轮询到 `FAILED` 时不要用 `responseValue`
当作已写入的值。
:::
::: info 自定义命令是另一套
本页讲的是位号级读写命令(`dc3.e.point_command` / `PointCommandDTO`)。设备级"自定义命令"走的是独立的 `dc3.e.command` /
`CommandCallDTO` 命名空间,不要把两者的交换机或 DTO 混用。
:::
## 排查清单
按现象快速定位。表格只作速查,根因解释见上文与两条平面文档。
| 现象 | 优先排查 |
|--------------------------------|----------------------------------------------------------------------|
| 有设备但 `/point_value/latest` 返回空 | 租户/`deviceId`/`pointId` 是否对、采集周期、驱动协议日志、RabbitMQ 是否积压 |
| 历史值有缺口或延迟 | 批处理阈值(`POINT_BATCH_SPEED`/`POINT_BATCH_INTERVAL`)、RabbitMQ 积压、数据中心日志 |
| 命令长期停在 `PENDING`/`SENT` | 目标驱动是否在线、是否监听了 `dc3.q.point_command.{serviceName}` |
| 命令变 `EXPIRED` | 设备/驱动离线或响应慢,10 秒 `expireAt` 已过 |
| 写命令返回 `FAILED` | 位号 `rwFlag` 是否允许写、写入值类型/范围、协议返回码 |
## 延伸阅读
- [数据平面](../architecture/data-plane) — 一条值从设备到超表的每一跳、RabbitMQ 拓扑与模型变换
- [命令平面](../architecture/command-plane) — 命令生命周期状态机、队列 TTL/DLX 与结果回执通道
- [第一个设备](../quickstart/first-device) — 还没有可查的设备?先走完这条黄金路径
- [API 文档](../development/api-documentation) — 鉴权头怎么取、OpenAPI/Swagger 在哪
---
# 设备接入
URL: https://docs.dc3.site/zh/operation/device-onboarding
# 设备接入
把一台现场设备接入 IoT DC3,本质是五步:按协议选一个驱动 → 建模板与位号 → 建设备并绑定模板和驱动 →
为设备填位号属性的具体值 → 启用后确认设备在线、数据可查。读完这页,你能用内置的 `dc3-driver-virtual`
跑通一次完整接入,并把同样的步骤套到真实协议上。
> 你在这里:已了解 [核心概念](../introduction/concepts)(驱动/模板/设备/位号、三层配置),现在动手接入第一台设备。更快的"
> 复制粘贴"上手版见 [第一个设备](../quickstart/first-device)。
## 先决定:用哪个驱动
接入的第一个决策是**按设备说的协议挑驱动**。驱动(Driver / `dc3-driver-*`)是协议适配实例——它知道怎么和某一类设备通信,并把"
这类设备/位号需要哪些配置项"注册到管理中心。选错协议,后面的模板和位号都对不上。
平台内置 28 个驱动,覆盖工业现场总线、IoT 无线、数据库桥接和基础通信。下面这张图按协议把常见选择收敛到一个驱动模块:
::: tip 选不准时的两条经验
- **先跑虚拟驱动**:`dc3-driver-virtual` 会按配置生成合成值,不依赖任何真实设备,是验证"模板→设备→位号→数据可查"
整条链路最快的方式,也是写新驱动的模板工程。
- **数据方向决定模式**:平台主动去"读"设备(轮询采集)用 `dc3-driver-virtual` 这类常规驱动;外部系统主动往平台"推"数据,用反向监听的
`dc3-driver-listening-virtual`(TCP `6270` / UDP `6271`)。
:::
完整的 28 个驱动清单(工业/IoT/数据库/计量/仿真分类)见 [驱动开发](../development/driver-authoring) 与模块地图。下文用
`dc3-driver-virtual` 走一遍真实接入。
## 接入的数据走向
理解一台设备"接入成功"意味着什么,要先看它产生的值怎么流到可查询的地方。设备侧的原始值由驱动按协议读出、归一成 `PointValue`
,经 RabbitMQ 进数据中心(Data Center / `dc3-center-data`)落库,最终通过网关(Gateway / `dc3-gateway`,对外唯一入口,端口
`8000`)对外可查。
所以"接入成功"的判据不是"驱动启动了",而是**这条链路打通、能在数据中心查到这台设备最新的位号值**。这条数据平面的交换机、队列、TTL
等细节见 [数据与命令](./data-commands)。
## 第 0 步:起栈与驱动注册
接入前先确保依赖与中心服务就绪,并启动目标驱动:
- 已启动 PostgreSQL、RabbitMQ 与核心中心服务(鉴权/管理/数据中心)。
- 本地源码运行时,先加载环境变量:`source dc3/env/dev.env.sh`(让本地 Java 进程指向 Compose 暴露在 `localhost`
的服务,详见 [环境变量](../quickstart/environment))。
- 启动至少一个驱动,例如虚拟驱动。
```bash
java -jar dc3-driver/dc3-driver-virtual/target/dc3-driver-virtual.jar
```
驱动启动时,`DriverInitRunner` 会编排 **注册 → `initial()` → `schedule()`** 三步:通过 gRPC 向管理中心提交 `RegisterBO`
,带上驱动编码、名称、服务信息、租户,以及它声明的全部属性定义(attribute)。注册失败会按指数退避自动重试(2–30 秒,最多 30 次)。
::: danger `dc3.driver.code` 是稳定路由标识
驱动编码 `dc3.driver.code` 是消息路由与设备归属的稳定标识,**注册后不可随意更改**(改了等于换了一个驱动,已绑定的设备会失联)。每个驱动实例的
code 必须唯一且稳定。
:::
如果驱动迟迟没出现在驱动列表,按这张表排查注册失败的常见原因:
| 现象 | 处理方式 |
|----------------------------|--------------------------------------|
| 管理中心未启动 | 先启动 Manager Center,再重启驱动 |
| `CENTER_MANAGER_HOST` 指向错误 | 检查 `dc3/env/dev.env(.sh)` 或 IDE 环境变量 |
| 驱动编码重复 | 保持 `dc3.driver.code` 唯一且稳定 |
| RabbitMQ 未就绪 | 等健康检查通过后重启驱动 |
## 第 1–4 步:黄金路径接入
下面用网关 HTTP 接口走一遍。所有写接口都经网关 `:8000` 转发,受保护接口需带鉴权头 `X-Auth-Tenant` / `X-Auth-Login` /
`X-Auth-Token`(先 `POST /api/v3/auth/token/salt` 取盐,再 `POST /api/v3/auth/token/generate` 取 token,有效期 12
小时,详见黄金路径登录流程)。以下 `$TOKEN` 即登录拿到的访问令牌;示例里的 ID、名称为示例值。
### 1. 建模板(Profile)
为同类设备建一个能力模板。模板沉淀"这类设备有哪些位号、命令、事件",设备复用它即可。
```bash
curl -X POST http://localhost:8000/api/v3/manager/profile/add \
-H "X-Auth-Tenant: default" -H "X-Auth-Login: dc3" -H "X-Auth-Token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"profileName":"virtual-motor","profileShareFlag":"TENANT","enableFlag":true}'
# 响应:R.ok(SuccessCode.ADD),即 "Added successfully";add 不回传新建实体 id
# 后续步骤需要 profileId 时,调 POST /api/v3/manager/profile/list 按 profileName 查回
```
`profileShareFlag` 取 `ProfileShareTypeEnum`(`TENANT` / `DRIVER` / `USER`),决定模板的共享范围。
### 2. 在模板下建位号(Point)
位号(Point)是一个数据项。关键字段是数据类型 `pointTypeFlag` 与读写方向 `rwFlag`——某个位号能不能写**由它自己的 `rwFlag` 决定
**,不是命令表决定。可选的 `baseValue` / `multiple` 把原始值线性换算成工程值,`unit` 标单位。
```bash
curl -X POST http://localhost:8000/api/v3/manager/point/add \
-H "X-Auth-Tenant: default" -H "X-Auth-Login: dc3" -H "X-Auth-Token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"pointName":"temperature","pointTypeFlag":"DOUBLE","rwFlag":"READ_WRITE",
"profileId":"<上一步的 profileId>","valueDecimal":2,"unit":"celsius","enableFlag":true}'
# 响应:R.ok(SuccessCode.ADD);add 不回传 id,需要 pointId 时调 /api/v3/manager/point/list 按 pointName 查回
```
`pointTypeFlag` 取 `PointTypeEnum`(`STRING` / `BYTE` / `SHORT` / `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN`,共 8
种);`rwFlag` 取 `RwTypeEnum`(`READ_ONLY` / `WRITE_ONLY` / `READ_WRITE`)。
### 3. 建设备(Device)并绑定模板与驱动
设备(Device)是现场一台具体设备的平台镜像,它**绑定一个模板**(决定有哪些位号)**和一个驱动**(决定怎么通信)。
```bash
curl -X POST http://localhost:8000/api/v3/manager/device/add \
-H "X-Auth-Tenant: default" -H "X-Auth-Login: dc3" -H "X-Auth-Token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"deviceName":"motor-01","driverId":"",
"profileId":"","enableFlag":true}'
# 响应:R.ok(SuccessCode.ADD);add 不回传 id,需要 deviceId 时调 /api/v3/manager/device/list 按 deviceName 查回
```
设备创建后,驱动通过元数据事件(`DriverMetadataListener.event(...)` 收 ADD/UPDATE/DELETE)感知变更并刷新缓存,多数情况无需重启驱动。
### 4. 为设备配置位号属性(Attribute 的 Config 值)
这一步最容易混淆,必须区分两个概念:
::: info Attribute 是驱动注册的"有哪些",Config 是设备实例填的"具体值"
- **属性 Attribute**(`PointAttribute` / `DriverAttribute` 等):**驱动启动时**从自己的 `application.yml`
注册的协议层配置项——它声明"这个驱动的位号**需要**哪些配置项"(如 Modbus 的寄存器地址、virtual 的取值范围)。你不创建
attribute,它随驱动注册而来。
- **配置 Config**(`PointAttributeConfigDO` 等):**这台设备**给上述每个 attribute 填的**具体值**——如"motor-01 的
temperature 位号,寄存器地址填 40001"。这一步就是在填 Config。
三层配置(业务层 Param / 协议层 Attribute / 实例层 Config)的完整说明见 [核心概念](../introduction/concepts)。
:::
为设备上某个位号的某个属性写入实例值,调用 `POST /api/v3/manager/point_attribute_config/add`:
```bash
curl -X POST http://localhost:8000/api/v3/manager/point_attribute_config/add \
-H "X-Auth-Tenant: default" -H "X-Auth-Login: dc3" -H "X-Auth-Token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"attributeId":"<驱动注册的位号属性 id>","deviceId":"",
"pointId":"","configValue":"40001","enableFlag":true}'
# 响应:R.ok(SuccessCode.ADD);add 统一返回成功码,不回传新建记录 id
```
`attributeId` 来自驱动注册的属性列表;`configValue` 是这台设备实例填的值。每个驱动声明的属性集不同——virtual
驱动声明的是取值范围之类的合成参数,Modbus 驱动声明的是寄存器/地址之类的协议参数。
::: tip 用真实现场参数校准位号
对工业协议,重点核对:寄存器/地址/对象 ID/topic 是否正确、数据类型与字节序、倍率与单位是否匹配现场、读写方向是否与设备能力一致、采集周期是否与设备性能匹配。这些都落在
`configValue` 上。
:::
## 第 5 步:启用后确认设备在线、数据可查
接入的终点是确认链路打通。启用设备后等待一个采集周期,按"状态 → 数据 → 日志"的顺序确认。
**先看数据**——能查到最新位号值,就说明整条链路通了。经网关查这台设备的最新值:
::: code-group
```bash [curl]
curl -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H "X-Auth-Tenant: default" -H "X-Auth-Login: dc3" -H "X-Auth-Token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"deviceId":"","current":1,"size":20}'
```
```json [响应形态 (示例值)]
{
"data": {
"current": 1, "size": 20, "total": 1,
"records": [
{
"deviceId": "...", "pointId": "...", "driverId": "...", "tenantId": "...",
"rawValue": "23.71", "calValue": "23.71", "numValue": 23.71,
"hasLatestValue": true,
"createTime": "2026-06-22T08:30:00", "operateTime": "2026-06-22T08:30:00"
}
]
}
}
```
:::
`POST /api/v3/data/point_value/latest` 返回 `Page`(每条含 `deviceId` / `pointId` / `driverId` /
`tenantId` / `rawValue` 原始值 / `calValue` 工程值 / `numValue` 数值投影(可空)/ `hasLatestValue` / `createTime` /
`operateTime`,时间为本地日期时间);要按时间窗翻历史值用 `POST /api/v3/data/point_value/list`
。完整的读写命令链路见 [数据与命令](./data-commands)。
**没数据时,按这条链路反向排查**,每一跳都对应上面那张数据走向图:
1. **驱动状态**:驱动是否在线?设备健康状态是否 `ONLINE`?注意驱动上报的状态 TTL **必须大于读取周期**(如 30 秒 cron,TTL 至少
25 秒),否则设备会反复掉线。
2. **驱动日志**:有没有协议连接错误(连不上 host/port、寄存器越界、认证失败)。
3. **RabbitMQ**:队列是否有积压或绑定异常,说明驱动发出去了但数据中心没消费。
4. **数据中心**:是否收到该设备的位号值消息。
5. **租户一致性**:设备、模板、位号、属性配置的 `tenantId` 是否一致;跨租户访问会返回 404 而非数据。
6. **属性配置**:`configValue` 是否缺失或格式不符合驱动期望(如地址填了非法字符串)。
::: warning 设备一直显示离线?先查状态 TTL
最常见的"启用了却查不到值"是状态 TTL 配小了:驱动按读取周期上报心跳,TTL 短于周期就会在两次上报之间过期,设备被判离线。把
TTL 配成略大于读取周期即可。
:::
跑通虚拟驱动这一遍后,把同样的五步套到真实协议上:唯一变化的是第 0 步选的驱动模块、以及第 4 步每个驱动声明的属性集不同。
## 延伸阅读
- [第一个设备](../quickstart/first-device) — 更短的复制粘贴上手版,先跑通再回来细读
- [数据与命令](./data-commands) — 接入后如何查历史值、下发读写命令与回执
- [核心概念](../introduction/concepts) — 驱动/模板/设备/位号与三层配置(Param/Attribute/Config)的心智模型
- [驱动开发](../development/driver-authoring) — 28 个驱动的清单、SPI 契约,以及从 `dc3-driver-virtual` 写一个新协议驱动
---
# 操作手册
URL: https://docs.dc3.site/zh/operation/
# 操作手册
这页是操作手册的门户:按"接入设备 → 看数据 → 下命令 → 收告警 → AI 运营"
这条主线,告诉你每一步去哪个页面、从哪个入口动手,以及"做对了应该看到什么"。读完你就有了一条可执行的任务路线,而不是一堆零散功能。
> 你在这里:已了解[平台定位](../introduction/)与[核心概念](../introduction/concepts)
> ,准备真正动手。如果本地环境还没起来,先完成 [快速开始](../quickstart/)。
## 一条主线,五个动作
平台的日常使用其实是一条线性任务流:先让一个驱动把现场设备接进来,确认能采到位号值;再下发读/写命令并验证回执;最后让规则引擎把异常变成告警,并可选地交给
Agentic Center 做自然语言运营。每一步都依赖前一步的产物——没有在线的设备,就没有位号值;没有位号值,命令和告警都无从谈起。
图里实线是任务推进顺序,虚线指向承载该动作的文档页。前四步是平台核心功能,第五步(AI 运营)是可选的进阶能力。
## 推荐路径
按下面的顺序走一遍,你会从"理解模型"一直推进到"AI 辅助运营",每一步都有可验证的产物:
1. 先读 [核心概念](../introduction/concepts),理清驱动、模板、设备、位号与位号值之间的固定关系——这是后续所有页面术语的底座。
2. 按 [设备接入](./device-onboarding) 完成一次接入(推荐先用 `dc3-driver-virtual` 跑通完整链路,再换真实协议驱动)。
3. 按 [数据与命令](./data-commands) 验证位号值采集、历史查询,以及读/写命令的下发与回执。
4. 按 [告警与通知](./alarms) 配置规则,让设备离线、位号越限、事件上报等异常自动产生告警并通知。
5. 如需接入大模型做自然语言运营,阅读 [Agentic 中心](../ai/agentic)。
### 成功是什么样
每一步都有一个肉眼可判断的"做对了"信号,不要跳过验证就往下走:
::: tip 三个成功信号
- **设备在线**:接入后设备的状态变为在线(心跳租约未过期),而不是一直停留在未知/离线。
- **位号有值**:`POST /api/v3/data/point_value/latest` 能查到该设备位号的最新值,`calValue`/`numValue` 与 `createTime` 非空。
- **命令有回执**:下发读/写命令后,凭返回的命令 ID 查 `GET /api/v3/data/point_command_history/get_by_command_id`,`status`
为终态(SUCCESS/FAILED 等)、`responseValue` 有结果,而不是一直挂起。
:::
::: warning 写命令失败不回显
写命令一旦执行失败,命令回执里的 `responseValue` 为 `null`、不会回显设备侧的值。排查时以 `status` 为准,别把"无回显"误判成"
还没执行"。
:::
## 运行入口
平台对外只有 Gateway 一个 HTTP 入口(默认 `8000`,由 `DC3_GATEWAY_PORT` 控制),它聚合 Auth / Manager / Data / Agentic
四个中心的路径,统一做鉴权头提取与租户上下文注入。开发时也可以绕过网关直连某个中心调试,但生产链路一律走网关。
下面这张表是参考索引,具体怎么用见各自的文档页:
| 入口 | 地址 / 说明 | 用途 |
|----------------|-------------------------------------------------------------|--------------------------------------------------------------------------------------|
| Gateway API | `http://localhost:8000/api/v3/...` | 唯一对外 HTTP 入口;下文示例的 curl 都打到这里 |
| Swagger UI | `http://localhost:8000/swagger-ui.html` | 开发环境查看网关聚合后的 API(生产环境一般关闭) |
| 各中心直连调试 | Auth `8300` / Manager `8400` / Data `8500` / Agentic `8600` | 单独调试某个中心时直连其 HTTP 端口,绕过网关 |
| MCP / OAuth 入口 | `POST /mcp`、`GET /.well-known/oauth-protected-resource` | 供 AI Agent 经 OAuth 2.1 访问 MCP 工具(均在网关根路径,不经 `/api/v3`),见 [Agentic 中心](../ai/agentic) |
| Web UI | 前端源码在本仓库 `dc3-web/` 目录 | 图形化操作界面,后端接口同样通过 Gateway 访问 |
::: info Web UI 与 API 共用同一入口
图形界面位于本仓库 `dc3-web/` 目录,通过 Gateway 调用同一套 API。本手册以 API / curl 为准描述操作,UI 上的对应入口与之一一对应。
:::
## 从登录到一条命令:最小可跑示例
下面用黄金路径的真实接口,演示"拿 token → 下一条读命令"的最小闭环。登录分两步:先取盐,再用加盐后的口令换 token(有效期 12
小时);之后所有受保护接口都要带上三个鉴权头。示例值(租户、用户名、ID)仅为占位,按你的实际数据替换。
::: code-group
```bash [1. 取盐 + 换 token]
# 取登录盐(公开端点,建议 5 分钟内使用)
curl -s -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# 用加盐后的口令换 access token(12 小时有效)
curl -s -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"<上一步返回的盐>","password":"<加盐口令>"}'
```
```bash [2. 下一条读命令]
# 带上三个鉴权头,对某设备的某位号发起一次读命令
curl -s -X POST http://localhost:8000/api/v3/data/point_command/read \
-H 'Content-Type: application/json' \
-H 'X-Auth-Tenant: ' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: <上一步返回的 token>' \
-d '{"deviceId":"","pointId":""}'
# 返回值为该命令的 ID(String),用它去 point_command_history 查回执
```
:::
::: warning 命令是异步的,有 10 秒默认时效
读/写命令下发后返回的是命令 ID,执行结果是异步回写的。命令的 `expireAt` 默认是 `now+10s`——超时未被驱动消费即作废。所以"返回命令
ID"只代表已受理,要看真正结果必须凭 ID 查 `point_command_history`。详见 [数据与命令](./data-commands)。
:::
## 延伸阅读
- [核心概念](../introduction/concepts) — 驱动 / 模板 / 设备 / 位号的固定关系与三层配置,先看它再操作
- [设备接入](./device-onboarding) — 第一步:用 virtual 驱动跑通一次完整接入
- [数据与命令](./data-commands) — 第二、三步:位号值采集、历史查询与读/写命令回执
- [告警与通知](./alarms) — 第四步:规则触发告警、通知通道与确认流程
- [Agentic 中心](../ai/agentic) — 可选进阶:自然语言运营、内置工具与 MCP 接入
---
# 环境变量详解
URL: https://docs.dc3.site/zh/quickstart/environment
# 环境变量详解
IoT DC3 有两套环境变量文件,读取者完全不同:根目录 `.env` 给 Docker Compose 做插值,`dc3/env/dev.env(.sh)` 给本地 Java
进程。读完这页,你能分清哪个变量该写进哪个文件、`localhost` 端口与容器内端口为何不一样,以及生产前必须改掉的两个密钥默认值。
> 你在这里:已经[从源码本地开发](./)或用 Compose 起栈。下一步看[部署模式与镜像源](../guide/usage)了解整套栈如何拉起。
## 为什么有两套文件
同一个变量名(如 `POSTGRES_HOST`),在容器里和在你笔记本上的 Java 进程里,含义不一样。容器之间靠 Compose 网络上的服务名(
`dc3-postgres`)互访;而你本地 IDE 里跑的 Java 进程在宿主机上,只能通过 Compose **发布到宿主机的端口**(如 `localhost:35432`
)连进去。两套文件就是为这两条互不重叠的路径准备的——混用会让本地进程去连一个解析不了的容器名,或让容器去连一个它根本看不到的
`localhost`。
| 文件 | 读取者 | 用途 | 注入方式 |
|----------------------|-------------------|-----------------------------------------|------------------|
| `.env.example` | Docker Compose 模板 | 复制为根目录 `.env`,定义镜像仓库、镜像标签、发布端口 | Compose 变量插值 |
| `.env` | Docker Compose | 本机未跟踪配置,供 `dc3/docker-compose*.yml` 插值 | Compose 变量插值 |
| `dc3/env/dev.env` | IDE(EnvFile 插件) | 本地 Java 进程环境变量,**不带** `export` | IDE EnvFile 插件读取 |
| `dc3/env/dev.env.sh` | Shell | 本地 Java 进程环境变量,带 `export`,用 `source` 加载 | Shell 环境注入 |
::: warning 根 `.env` 不会注入本地 Java 进程
根目录 `.env` 只服务 Docker Compose,**不会**自动注入到本地 IDE/命令行启动的 Java 进程。本地源码运行必须用
`dc3/env/dev.env(.sh)`,让进程指向 Compose 发布在 `localhost` 上的依赖端口。在根 `.env` 里写 `POSTGRES_HOST=localhost`
也不会改变任何容器的运行时环境。
:::
## 两条互不注入的路径
下图说明两套文件如何沿各自路径生效。关键是这两条路径**互不交叉**:Compose 不读 `dev.env.sh`,本地 Java 进程也不自动读根
`.env`。
host 端口与 internal 端口的对应关系,是理解这张图的核心:
| 依赖 | host(本地进程用) | internal(容器互访用) |
|---------------|-------------------|----------------------|
| PostgreSQL | `localhost:35432` | `dc3-postgres:5432` |
| RabbitMQ AMQP | `localhost:35672` | `dc3-rabbitmq:5672` |
| EMQX MQTT | `localhost:31883` | `dc3-emqx:1883`(约定值) |
## 怎么用
### Compose 起栈
先从模板创建本地 `.env`,再用 `make`(底层是 `podman compose`)拉起:
::: code-group
```bash [make]
cp .env.example .env
make up-db && make up-optional && make up-dev
```
```bash [podman compose]
cp .env.example .env
podman compose -f dc3/docker-compose-dev.yml config --quiet
```
:::
根 `.env` 的变量用于 compose 文件插值,例如镜像与发布端口:
```yaml
image: ${DC3_IMAGE_REGISTRY:-pnoker}/dc3-gateway:${DC3_IMAGE_TAG:-2026.6}
ports:
- "${DC3_BIND_HOST:-127.0.0.1}:${DC3_GATEWAY_PORT:-8000}:8000"
```
Compose 不会把 `.env` 里每个变量都注入每个容器——只有 compose 文件通过 `environment`、`env_file` 显式引用的变量才会进入容器。
### 本地源码运行
从命令行启动 Java 进程前先 `source`:
```bash
source dc3/env/dev.env.sh
```
不加载时,本地进程会回退到容器内服务名(`dc3-postgres`、`dc3-rabbitmq`、`dc3-center-manager`)或默认端口,从而连不上本机依赖。JetBrains
IDEA 用户改用 `dc3/env/dev.env`(同样内容、无 `export`):安装 EnvFile 插件 → 在 Run Configuration 启用 EnvFile → 添加
`dc3/env/dev.env`。不要把 `.env.example` 直接当作 IDEA 环境变量文件——它是 Compose 模板,不是 Java 运行时配置。
## 按场景分组的关键变量
下面按"为某个场景实际需要"的角度分组列出关键变量。完整长表在文末 `::: details` 折叠,这里只列你会真正改动或排错时查的那些。变量的
`Scope` 三类含义:`Runtime`(本地或容器内 Java 进程都读)、`Compose only`(仅根 `.env` 给 Compose)、`Per-process`(单服务覆盖)。
### 安全密钥(Runtime)
两个密钥是平台的身份根。它们自带默认值仅为方便首次启动,绝不可带进生产。
| 变量 | 默认值 | 用途 |
|--------------------|------------------------------------------|-----------------------------------------------------|
| `DC3_SECURITY_KEY` | `dc3.security.key.2026.io.github.pnoker` | 鉴权中心生成/校验登录 Token 的签名密钥 |
| `AUTH_HMAC_SECRET` | `io.github.pnoker.dc3` | Gateway 向后端服务签名 `X-Auth-Principal` 的 HMAC-SHA256 密钥 |
::: danger 生产必须改成强随机值,且切勿提交/打印
`DC3_SECURITY_KEY` 与 `AUTH_HMAC_SECRET` 自带默认值,生产环境必须改成强随机值,切勿提交进仓库、写入日志或打印真实密钥。当
Spring profile 为 `pre`/`pro` 且 `AUTH_HMAC_SECRET` 为空或仍等于默认值 `io.github.pnoker.dc3` 时,服务会 fail-fast 抛
`IllegalStateException` 拒绝启动——这是有意为之的安全闸门,不要绕过。
:::
### PostgreSQL(Runtime)
本地进程连 `localhost:35432`;容器内连 `dc3-postgres:5432`。`POSTGRES_SCHEMA` 只在单服务进程里做 schema 覆盖(如
`dc3_manager`、`dc3_data`),不要全局设。
| 变量 | 默认值 | Scope | 用途 |
|---------------------|-------------|--------------|-------------------------------------|
| `POSTGRES_HOST` | `localhost` | Runtime | 本地用 `localhost`,容器内用 `dc3-postgres` |
| `POSTGRES_PORT` | `35432` | Runtime | host 发布端口;internal 为 `5432` |
| `POSTGRES_USERNAME` | `dc3` | Runtime | 用户名 |
| `POSTGRES_PASSWORD` | `dc3dc3dc3` | Runtime | 密码 |
| `POSTGRES_DB` | `dc3` | Runtime | 数据库名 |
| `POSTGRES_SCHEMA` | (未设) | Per-process | 单服务 schema 覆盖 |
| `DC3_POSTGRES_PORT` | `35432` | Compose only | 容器发布到宿主机的端口 |
### RabbitMQ(Runtime)
AMQP host 端口 `35672`,internal `5672`。开启 TLS 时内部端口切到 `5671`。
| 变量 | 默认值 | Scope | 用途 |
|--------------------------------|-------------|--------------|-----------------------------------|
| `RABBITMQ_HOST` | `localhost` | Runtime | 本地 `localhost`,容器内 `dc3-rabbitmq` |
| `RABBITMQ_PORT` | `35672` | Runtime | AMQP host 端口;internal `5672` |
| `RABBITMQ_USERNAME` | `dc3` | Runtime | 用户名 |
| `RABBITMQ_PASSWORD` | `dc3dc3dc3` | Runtime | 密码 |
| `RABBITMQ_VIRTUAL_HOST` | `dc3` | Runtime | virtual host |
| `RABBITMQ_SSL_ENABLED` | `false` | Runtime | 启用 TLS(true 时走 5671) |
| `DC3_RABBITMQ_PORT` | `35672` | Compose only | AMQP 发布端口 |
| `DC3_RABBITMQ_MANAGEMENT_PORT` | `15672` | Compose only | 管理界面发布端口 |
### EMQX / MQTT(Runtime)
MQTT broker host 端口 `31883`。EMQX 还发布 WebSocket、Dashboard 等多个端口,详见折叠长表。
| 变量 | 默认值 | Scope | 用途 |
|---------------------------|-------------|--------------|--------------------------------------|
| `MQTT_BROKER_HOST` | `localhost` | Runtime | broker 主机 |
| `MQTT_BROKER_PORT` | `31883` | Runtime | broker 端口(EMQX 发布;internal 约 `1883`) |
| `MQTT_USERNAME` | `dc3` | Runtime | 用户名 |
| `MQTT_PASSWORD` | `dc3dc3dc3` | Runtime | 密码 |
| `DC3_EMQX_MQTT_PORT` | `31883` | Compose only | MQTT 发布端口 |
| `DC3_EMQX_DASHBOARD_PORT` | `18083` | Compose only | Dashboard 发布端口 |
### gRPC / facade(Runtime)
中心服务之间通过 facade 互联。分布式部署默认 `DC3_FACADE_MODE=grpc`,本地进程用 `CENTER_*_HOST` 指到 `localhost`。
| 变量 | 默认值 | Scope | 用途 |
|-------------------------------|-------------|---------|---------------------------------------|
| `CENTER_AUTH_HOST` | `localhost` | Runtime | 鉴权中心主机 |
| `CENTER_MANAGER_HOST` | `localhost` | Runtime | 管理中心主机 |
| `CENTER_DATA_HOST` | `localhost` | Runtime | 数据中心主机 |
| `CENTER_AGENTIC_HOST` | `localhost` | Runtime | 智能中心主机 |
| `DC3_FACADE_MODE` | `grpc` | Runtime | facade 协议模式 |
| `DC3_FACADE_GRPC_DEADLINE_MS` | `3000` | Runtime | gRPC 单次请求 deadline,`0` 关闭客户端 deadline |
### 网关与服务端口(Compose only)
网关是唯一对外 HTTP 入口(`8000`)。各中心 HTTP/gRPC 发布端口如下;`SERVER_PORT`/`GRPC_SERVER_PORT`
仅在本地同时跑多个服务、需要错开端口时作单进程覆盖。
| 变量 | 默认值 | Scope | 用途 |
|----------------------------------|--------|--------------|-----------------------------|
| `DC3_GATEWAY_PORT` | `8000` | Compose only | 网关 HTTP 发布端口(入口) |
| `DC3_AUTH_PORT` | `8300` | Compose only | 鉴权中心 HTTP |
| `DC3_MANAGER_PORT` | `8400` | Compose only | 管理中心 HTTP |
| `DC3_DATA_PORT` | `8500` | Compose only | 数据中心 HTTP |
| `DC3_AGENTIC_PORT` | `8600` | Compose only | 智能中心 HTTP |
| `DC3_AUTH_GRPC_PORT` | `9300` | Compose only | 鉴权中心 gRPC |
| `DC3_MANAGER_GRPC_PORT` | `9400` | Compose only | 管理中心 gRPC |
| `DC3_DATA_GRPC_PORT` | `9500` | Compose only | 数据中心 gRPC |
| `DC3_LISTENING_VIRTUAL_TCP_PORT` | `6270` | Compose only | Listening Virtual 驱动 TCP 发布 |
| `DC3_LISTENING_VIRTUAL_UDP_PORT` | `6271` | Compose only | Listening Virtual 驱动 UDP 发布 |
| `SERVER_PORT` | (未设) | Per-process | 单服务 HTTP 端口覆盖 |
| `GRPC_SERVER_PORT` | (未设) | Per-process | 单中心 gRPC 端口覆盖 |
::: warning `DC3_LISTENING_VIRTUAL_*_PORT` 是宿主机发布端口
`DC3_LISTENING_VIRTUAL_TCP_PORT`/`DC3_LISTENING_VIRTUAL_UDP_PORT` 是 Compose 发布到宿主机的端口;进程内部端口用
`TCP_PORT`/`UDP_PORT`(Per-process),两者别混。
:::
### Agentic / AI(Runtime)
仅当 `dc3_model_provider` 没有配置可用提供方时,才回退到这组 `AGENTIC_FALLBACK_OPENAI_*`。会话记忆默认关闭。
| 变量 | 默认值 | 用途 |
|---------------------------------------|--------------------------------|------------------------------------------------------------------|
| `AGENTIC_FALLBACK_OPENAI_BASE_URL` | `https://api.openai.com` | fallback OpenAI 兼容 API 地址 |
| `AGENTIC_FALLBACK_OPENAI_API_KEY` | (空) | fallback API key(端点需鉴权时填) |
| `AGENTIC_FALLBACK_OPENAI_MODEL` | `gpt-4o` | fallback 模型名 |
| `AGENTIC_FALLBACK_OPENAI_TEMPERATURE` | `0.7` | 采样温度(0.0–2.0) |
| `AGENTIC_FALLBACK_OPENAI_MAX_TOKENS` | `2048` | 最大输出 token |
| `AGENTIC_MEMORY_SCHEMA_INIT` | `never` | Spring AI JDBC 记忆表初始化模式(`always`/`never`/`create_if_not_exists`) |
| `AGENTIC_MEMORY_ENABLED` | `false` | 是否启用持久化会话记忆 |
| `AGENTIC_TOOL_CALLING_ENABLED` | `true` | 是否启用工具调用 |
| `AGENTIC_MEMORY_MAX_MESSAGES` | `50` | 每个对话窗口保留的最大消息数 |
| `AGENTIC_ATTACHMENT_STORAGE_PATH` | `dc3/data/agentic/attachments` | 附件存储路径 |
::: info `AGENTIC_MEMORY_SCHEMA_INIT` 的建表绑定以代码为准
`AGENTIC_MEMORY_SCHEMA_INIT` 默认 `never`,意在控制记忆表初始化模式。但该变量经 compose 注入容器后,仓库内未见
`application*.yml` 把它绑定到 Spring AI 的 `initialize-schema`;记忆表实际由 initdb 脚本预建。是否真能通过设为 `always`
触发自动建表,请以代码为准,不要当成已接线的可用开关。
:::
::: danger 真实 API key 不进文档/日志/提交历史
`AGENTIC_FALLBACK_OPENAI_API_KEY` 等敏感值绝不能写入文档、日志或提交历史。正式提供方应配置在 `dc3_model_provider`
表,fallback 仅作兜底。
:::
::: info 表中默认值取自 `.env.example`,与代码内回退默认不一致
上表 `AGENTIC_MEMORY_ENABLED`(`false`)、`AGENTIC_ATTACHMENT_STORAGE_PATH`(`dc3/data/agentic/attachments`)取自
`.env.example`——走 `cp .env.example .env` + `source` 路径时会被显式注入这些值。但 `application-agentic.yml`
的代码内回退默认不同(未设环境变量时记忆默认开启、附件路径为 `dc3/data/upload/agentic/attachment`)。不走 `.env.example`
路径时以代码为准。
:::
### 批处理(Runtime)
MQTT 与位号值各有一组"数量阈值 + 间隔"参数,由 Quartz 定时按 `interval`(秒)把累积缓冲一次性刷出,
`speed = count / interval`。
| 变量 | 默认值 | 用途 |
|------------------------|-------|------------------|
| `MQTT_BATCH_SPEED` | `100` | MQTT 批量大小阈值(条/批) |
| `MQTT_BATCH_INTERVAL` | `5` | MQTT 批处理间隔(秒) |
| `POINT_BATCH_SPEED` | `100` | 位号值批量大小阈值 |
| `POINT_BATCH_INTERVAL` | `5` | 位号值批处理间隔(秒) |
### 镜像源(Compose only)
中国大陆网络可把 `REGISTRY` 设为 `cn` 走阿里云镜像。注意:Makefile 读 `REGISTRY`,Compose 插值读 `DC3_IMAGE_REGISTRY`
,两者各管一段。
| 变量 | 默认值 | 用途 |
|----------------------|-------------|--------------------------------------------------|
| `REGISTRY` | `auto` | Makefile 镜像源选择器(仅接受 `auto`/`global`/`cn`,其它值会报错) |
| `DC3_IMAGE_REGISTRY` | `pnoker` | 镜像命名空间 |
| `DC3_IMAGE_TAG` | `2026.6` | 所有服务/依赖镜像标签 |
| `DC3_BIND_HOST` | `127.0.0.1` | 发布端口绑定地址(`0.0.0.0` 对外) |
### 可观测性(Compose only / Runtime)
可选栈(EMQX、ELK、Prometheus、Grafana)通过 `make up-optional` 拉起,端口与 JVM 参数如下。
| 变量 | 默认值 | Scope | 用途 |
|----------------------|-------------------------|--------------|---------------------|
| `GF_SERVER_ROOT_URL` | `http://localhost:3000` | Runtime | Grafana 对外 root URL |
| `DC3_GRAFANA_PORT` | `3000` | Compose only | Grafana 发布端口 |
| `DC3_KIBANA_PORT` | `5601` | Compose only | Kibana 发布端口 |
| `DC3_ES_JAVA_OPTS` | `-Xms512m -Xmx512m` | Runtime | Elasticsearch JVM 堆 |
| `DC3_LS_JAVA_OPTS` | `-Xms256m -Xmx256m` | Runtime | Logstash JVM 堆 |
| `APM_AGENT_ENABLE` | `false` | Runtime | 是否启用 Java APM agent |
::: details 完整变量参考(折叠)
#### Security & Authentication(Runtime)
| 变量 | 默认值 | 用途 |
|--------------------|------------------------------------------|-----------------------------------|
| `DC3_SECURITY_KEY` | `dc3.security.key.2026.io.github.pnoker` | 登录 Token 签名密钥 |
| `AUTH_HMAC_SECRET` | `io.github.pnoker.dc3` | `X-Auth-Principal` HMAC-SHA256 密钥 |
#### PostgreSQL
| 变量 | 默认值 | Scope |
|---------------------|-------------|--------------|
| `POSTGRES_HOST` | `localhost` | Runtime |
| `POSTGRES_PORT` | `35432` | Runtime |
| `POSTGRES_USERNAME` | `dc3` | Runtime |
| `POSTGRES_PASSWORD` | `dc3dc3dc3` | Runtime |
| `POSTGRES_DB` | `dc3` | Runtime |
| `POSTGRES_SCHEMA` | (未设) | Per-process |
| `DC3_POSTGRES_PORT` | `35432` | Compose only |
#### RabbitMQ
| 变量 | 默认值 | Scope |
|--------------------------------------------|--------------|--------------|
| `RABBITMQ_HOST` | `localhost` | Runtime |
| `RABBITMQ_PORT` | `35672` | Runtime |
| `RABBITMQ_USERNAME` | `dc3` | Runtime |
| `RABBITMQ_PASSWORD` | `dc3dc3dc3` | Runtime |
| `RABBITMQ_VIRTUAL_HOST` | `dc3` | Runtime |
| `RABBITMQ_MQTT_EXCHANGE` | `dc3.e.mqtt` | Runtime |
| `RABBITMQ_SSL_ENABLED` | `false` | Runtime |
| `RABBITMQ_SSL_ALGORITHM` | `TLS` | Runtime |
| `RABBITMQ_SSL_VALIDATE_SERVER_CERTIFICATE` | `false` | Runtime |
| `RABBITMQ_SSL_VERIFY_HOSTNAME` | `false` | Runtime |
| `RABBITMQ_CONTAINER_PORT` | `5672` | Runtime |
| `DC3_RABBITMQ_PORT` | `35672` | Compose only |
| `DC3_RABBITMQ_TLS_PORT` | `35671` | Compose only |
| `DC3_RABBITMQ_MANAGEMENT_PORT` | `15672` | Compose only |
#### EMQX / MQTT
| 变量 | 默认值 | Scope |
|---------------------------|-------------|--------------|
| `MQTT_BROKER_HOST` | `localhost` | Runtime |
| `MQTT_BROKER_PORT` | `31883` | Runtime |
| `MQTT_USERNAME` | `dc3` | Runtime |
| `MQTT_PASSWORD` | `dc3dc3dc3` | Runtime |
| `MQTT_BATCH_SPEED` | `100` | Runtime |
| `MQTT_BATCH_INTERVAL` | `5` | Runtime |
| `DC3_EMQX_WS_PORT` | `38083` | Compose only |
| `DC3_EMQX_WSS_PORT` | `38084` | Compose only |
| `DC3_EMQX_MQTT_PORT` | `31883` | Compose only |
| `DC3_EMQX_MQTTS_PORT` | `38883` | Compose only |
| `DC3_EMQX_DASHBOARD_PORT` | `18083` | Compose only |
#### gRPC / facade
| 变量 | 默认值 | Scope |
|-------------------------------|-------------|--------------|
| `CENTER_AUTH_HOST` | `localhost` | Runtime |
| `CENTER_MANAGER_HOST` | `localhost` | Runtime |
| `CENTER_DATA_HOST` | `localhost` | Runtime |
| `CENTER_AGENTIC_HOST` | `localhost` | Runtime |
| `DC3_FACADE_MODE` | `grpc` | Runtime |
| `DC3_FACADE_GRPC_DEADLINE_MS` | `3000` | Runtime |
| `DC3_AUTH_GRPC_PORT` | `9300` | Compose only |
| `DC3_MANAGER_GRPC_PORT` | `9400` | Compose only |
| `DC3_DATA_GRPC_PORT` | `9500` | Compose only |
#### HTTP Gateway & 服务端口
| 变量 | 默认值 | Scope |
|----------------------------------|--------|--------------|
| `DC3_GATEWAY_PORT` | `8000` | Compose only |
| `DC3_AUTH_PORT` | `8300` | Compose only |
| `DC3_MANAGER_PORT` | `8400` | Compose only |
| `DC3_DATA_PORT` | `8500` | Compose only |
| `DC3_AGENTIC_PORT` | `8600` | Compose only |
| `SERVER_PORT` | (未设) | Per-process |
| `GRPC_SERVER_PORT` | (未设) | Per-process |
| `DC3_LISTENING_VIRTUAL_TCP_PORT` | `6270` | Compose only |
| `DC3_LISTENING_VIRTUAL_UDP_PORT` | `6271` | Compose only |
| `TCP_PORT` | (未设) | Per-process |
| `UDP_PORT` | (未设) | Per-process |
| `GATEWAY_ROUTE_AUTH_TOKEN_URI` | (未设) | Per-process |
| `GATEWAY_ROUTE_AUTH_URI` | (未设) | Per-process |
| `GATEWAY_ROUTE_MANAGER_URI` | (未设) | Per-process |
| `GATEWAY_ROUTE_DATA_URI` | (未设) | Per-process |
| `GATEWAY_ROUTE_AGENTIC_URI` | (未设) | Per-process |
#### Agentic / AI(Runtime)
| 变量 | 默认值 |
|---------------------------------------|--------------------------------|
| `AGENTIC_FALLBACK_OPENAI_BASE_URL` | `https://api.openai.com` |
| `AGENTIC_FALLBACK_OPENAI_API_KEY` | (空) |
| `AGENTIC_FALLBACK_OPENAI_MODEL` | `gpt-4o` |
| `AGENTIC_FALLBACK_OPENAI_TEMPERATURE` | `0.7` |
| `AGENTIC_FALLBACK_OPENAI_MAX_TOKENS` | `2048` |
| `AGENTIC_MEMORY_SCHEMA_INIT` | `never` |
| `AGENTIC_MEMORY_ENABLED` | `false` |
| `AGENTIC_MEMORY_MAX_MESSAGES` | `50` |
| `AGENTIC_TOOL_CALLING_ENABLED` | `true` |
| `AGENTIC_ATTACHMENT_STORAGE_PATH` | `dc3/data/agentic/attachments` |
#### 批处理 / 镜像 / 可观测性
| 变量 | 默认值 | Scope |
|------------------------|-------------------------|--------------|
| `POINT_BATCH_SPEED` | `100` | Runtime |
| `POINT_BATCH_INTERVAL` | `5` | Runtime |
| `REGISTRY` | `auto` | Compose only |
| `DC3_IMAGE_REGISTRY` | `pnoker` | Compose only |
| `DC3_IMAGE_TAG` | `2026.6` | Compose only |
| `DC3_LOG_MAX_SIZE` | `10M` | Compose only |
| `DC3_LOG_MAX_FILE` | `20` | Compose only |
| `DC3_BIND_HOST` | `127.0.0.1` | Compose only |
| `GF_SERVER_ROOT_URL` | `http://localhost:3000` | Runtime |
| `DC3_GRAFANA_PORT` | `3000` | Compose only |
| `DC3_KIBANA_PORT` | `5601` | Compose only |
| `DC3_ES_JAVA_OPTS` | `-Xms512m -Xmx512m` | Runtime |
| `DC3_LS_JAVA_OPTS` | `-Xms256m -Xmx256m` | Runtime |
| `APM_AGENT_ENABLE` | `false` | Runtime |
| `NODE_ENV` | `dev` | Runtime |
:::
## 约束与常见误区
- 改 `.env.example` 不会影响运行——必须先 `cp .env.example .env`。
- `dc3/env/dev.env` 与根 `.env` 用途不同,不是同一个文件,别互相复制。
- 服务发布端口统一用 `DC3_*_PORT`;进程内部仍用 `SERVER_PORT`、`GRPC_SERVER_PORT` 等 Spring Boot 原生命名。
- Per-process 变量(`POSTGRES_SCHEMA`、`SERVER_PORT`、`TCP_PORT` 等)只作单服务覆盖,不要全局滥用。
- Compose 应用栈可以用与本地源码不同的 `NODE_ENV`。
## 延伸阅读
- [从源码本地开发](./) — 起栈、登录、跑通第一个设备的完整路径
- [部署模式与镜像源](../guide/usage) — 整套栈如何拉起、`REGISTRY=cn` 怎么用
---
# 第一个设备:端到端
URL: https://docs.dc3.site/zh/quickstart/first-device
# 第一个设备:端到端
这页带你用平台自带的 **virtual(虚拟)驱动**走通一条完整链路:从登录拿
token,到建模板、建位号、建设备、配属性,再到读实时位号值、下发写命令。每一步都给可复制命令和"你应当看到",照着做即可。
> 你在这里:已经[起好依赖栈](./)、理清了[核心概念](../introduction/concepts)。读完这页,你将拥有:**一个由 virtual
驱动接入的设备,能看到它产生的实时位号值,并能对可写位号下发写命令。**
## 这条路径长什么样
整条黄金路径是一串前后依赖的 HTTP 调用,全部经过网关 `dc3-gateway`(`:8000`)这唯一入口。前两步换到 token,中间四步在管理中心(Manager
Center)建好元数据,最后几步在数据中心(Data Center)读值与下发命令。先有这张全景图,后面每一步你都知道自己走到哪了。
::: info 约定
下文所有 `id`、token、返回值都是**示例**——你环境里生成的是雪花 ID(一长串数字),请用上一步真实返回的值替换。每个写接口返回的都是平台统一信封
`{ "ok": true, "code": "...", "message": "...", "data": "..." }`。注意 `add` 类接口**只返回成功状态、不回传新建实体的 ID**
——需要 ID 时,调对应的
`list` 接口按名称查回(下文每步都会给出回查命令)。
:::
## 第 0 步:起栈
先把数据库、消息队列和开发栈拉起来。这两条命令分别启动 PostgreSQL + RabbitMQ 依赖,以及网关 + 四个中心 + 驱动的开发栈(virtual
驱动随栈一起启动)。
```bash
make up-db && make up-dev
```
**你应当看到**:`podman ps` 列出 `dc3-postgres`、`dc3-rabbitmq`、`dc3-gateway`、`dc3-center-auth/manager/data/agentic` 以及若干
`dc3-driver-*` 容器处于运行态。网关在 `http://localhost:8000` 可达。
::: tip 用 dc3 CLI 时先指向网关
如果你用 `dc3` CLI,先告诉它网关地址(只需一次):`dc3 config set gateway http://localhost:8000`。
:::
## 第 1–2 步:登录拿 token
登录分两步:先用租户 + 用户名取**盐(salt,建议 5 分钟内使用)**,再把**明文密码**连同盐一起提交换取**访问 token(12 小时有效)
**。拿到 token
后,后续所有受保护请求都要带三个鉴权头:`X-Auth-Tenant`、`X-Auth-Login`、`X-Auth-Token`。
::: code-group
```bash [curl]
# 1) 取盐
curl -s -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# 示例返回:{"ok":true,"code":"...","message":"...","data":"a1b2c3d4e5"}
# 2) 用盐把密码哈希后换 token(哈希算法见鉴权文档,此处 PASSWORD_HASH 为示例)
curl -s -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"a1b2c3d4e5","password":""}'
# 示例返回:{"ok":true,"code":"...","message":"...","data":""}
```
```bash [dc3 CLI]
# CLI 封装了取盐 + 哈希 + 换 token 的全过程
dc3 auth login --tenant default --username dc3
# 交互式输入密码;登录后 token 自动保存
# 验证
dc3 auth status
dc3 auth token --header # 打印 X-Auth-Tenant/X-Auth-Login/X-Auth-Token
```
:::
**你应当看到**:`/api/v3/auth/token/salt` 返回一个非空 salt 字符串;`/api/v3/auth/token/generate` 返回一个长 token
字符串(即上面的 ``)。CLI 路径下 `dc3 auth status` 显示已登录。
::: warning 后续请求都要带鉴权头
下文 curl 为简洁起见把三个头抽成变量,请先在 shell 里设好(值用你上一步真实拿到的):
```bash
H_TENANT='X-Auth-Tenant: default'
H_LOGIN='X-Auth-Login: dc3'
H_TOKEN='X-Auth-Token: ' # 示例
```
:::
## 第 3 步:确认 virtual 驱动已注册
virtual 驱动随 `make up-dev` 启动后,会把自己注册到管理中心。建设备时要用它的 `driverId`,所以先把它查出来。
::: code-group
```bash [curl]
curl -s -X POST http://localhost:8000/api/v3/manager/driver/list \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"page":{"current":1,"size":20}}'
```
```bash [dc3 CLI]
dc3 driver list
```
:::
**你应当看到**:列表里有一个 `driverName` 为 `Virtual Driver`(带空格,来自驱动 `application.yml` 的 `dc3.driver.name`;其
`driverCode` 是路由标识 `VirtualDriver`,模块/服务名是 `dc3-driver-virtual`——三者分属不同字段)的驱动,记下它的 `id` ——
后面记作 ``(示例:`92010100000000001`)。virtual 是**驱动编写模板**,专用于测试与新驱动起步,无需连任何真实设备即可产生数据。
## 第 4 步:加模板(Profile)
模板描述一类设备有哪些能力。这里建一个最小模板,位号挂在它下面。`profileShareFlag` 用 `TENANT`(租户内共享),`enableFlag` 用
`ENABLE`(启用)。
::: code-group
```bash [curl]
curl -s -X POST http://localhost:8000/api/v3/manager/profile/add \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"profileName":"虚拟温控模板","profileShareFlag":"TENANT","enableFlag":"ENABLE"}'
# 示例返回:{"ok":true,"code":"ADD","message":"Added successfully","data":"Added successfully"}
# add 只回成功状态、不回传 ID。下一步要用 profileId,先按名称查回:
curl -s -X POST http://localhost:8000/api/v3/manager/profile/list \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" -H 'Content-Type: application/json' \
-d '{"profileName":"虚拟温控模板","page":{"current":1,"size":1}}'
# 从 records[0].id 拿到 profileId
```
```bash [dc3 CLI]
dc3 profile create --name "虚拟温控模板"
dc3 profile list --name "虚拟温控模板" # 查回 profileId
```
:::
**你应当看到**:`add` 返回成功状态(`data` 为提示文案,不是 ID);用 `profile/list` 按 `profileName`
回查,从 `records[0].id` 拿到模板 ID,记作 ``(示例:`81010100000000001`)。
::: tip profileShareFlag 取值
`ProfileShareTypeEnum` 为 `TENANT` / `DRIVER` / `USER`,决定该模板在租户内、驱动内还是用户内共享。
:::
## 第 5 步:加位号(Point)
位号是要采集或写入的数据项。**能不能写由位号自己的 `rwFlag` 决定**——这里建一个 `READ_WRITE` 的可写位号,后面才能对它下发写命令。
`pointTypeFlag` 用 `FLOAT`,挂到上一步的模板上,并带单位 `°C`。
::: code-group
```bash [curl]
curl -s -X POST http://localhost:8000/api/v3/manager/point/add \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"pointName":"温度",
"pointTypeFlag":"FLOAT",
"rwFlag":"READ_WRITE",
"profileId":"81010100000000001",
"valueDecimal":2,
"unit":"°C",
"enableFlag":"ENABLE"
}'
# 示例返回:{"ok":true,"code":"ADD","message":"Added successfully","data":"Added successfully"}
# add 不回传 ID。下一步要用 pointId,按 pointName 查回:
curl -s -X POST http://localhost:8000/api/v3/manager/point/list \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" -H 'Content-Type: application/json' \
-d '{"pointName":"温度","page":{"current":1,"size":1}}'
# 从 records[0].id 拿到 pointId
```
```bash [dc3 CLI]
dc3 point create --name "温度" --profile-id "81010100000000001"
dc3 point list --name "温度" # 查回 pointId
```
:::
**你应当看到**:`add` 返回成功状态;用 `point/list` 按 `pointName` 回查,从 `records[0].id` 拿到位号 ID,记作
``(示例:`82010100000000001`)。
::: tip rwFlag 与 pointTypeFlag 取值
`RwTypeEnum` 为 `READ_ONLY` / `WRITE_ONLY` / `READ_WRITE`;对 `READ_ONLY` 位号下发写命令会被拒绝。`PointTypeEnum` 共 8 个值:
`STRING` / `BYTE` / `SHORT` / `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN`。位号还可带换算(`baseValue` / `multiple`
),把原始值线性变换成工程值。
:::
## 第 6 步:加设备(Device)
设备是绑定了一个模板和一个驱动的具体实例。用上面的 `` 和 `` 建设备。
::: code-group
```bash [curl]
curl -s -X POST http://localhost:8000/api/v3/manager/device/add \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"deviceName":"虚拟温控设备-01",
"driverId":"92010100000000001",
"profileId":"81010100000000001",
"enableFlag":"ENABLE"
}'
# 示例返回:{"ok":true,"code":"ADD","message":"Added successfully","data":"Added successfully"}
# add 不回传 ID。下一步要用 deviceId,按 deviceName 查回:
curl -s -X POST http://localhost:8000/api/v3/manager/device/list \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" -H 'Content-Type: application/json' \
-d '{"deviceName":"虚拟温控设备-01","page":{"current":1,"size":1}}'
# 从 records[0].id 拿到 deviceId
```
```bash [dc3 CLI]
dc3 device create --name "虚拟温控设备-01" \
--driver-id "92010100000000001" \
--profile-id "81010100000000001"
dc3 device list --name "虚拟温控设备-01" # 查回 deviceId
```
:::
**你应当看到**:`add` 返回成功状态;用 `device/list` 按 `deviceName` 回查,从 `records[0].id` 拿到设备 ID,记作
``(示例:`83010100000000001`)。
## 第 7 步:配置位号属性
驱动在启动时声明了它**有哪些**配置项(属性,Attribute);这一步是为**这台设备的这个位号**给某个属性填**具体值**
(配置,Config)。这就是把"位号"真正接到驱动采集逻辑上的那一刀。`attributeId` 来自 virtual 驱动注册的属性(可在驱动详情或属性列表里查到),
`configValue` 是给该属性的值。
::: code-group
```bash [curl]
curl -s -X POST http://localhost:8000/api/v3/manager/point_attribute_config/add \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"attributeId":"91010100000000001",
"deviceId":"83010100000000001",
"pointId":"82010100000000001",
"configValue":"25.0",
"enableFlag":"ENABLE"
}'
# 示例返回:{"ok":true,"code":"ADD","message":"Added successfully","data":"Added successfully"}
# add 不回传 ID。配置写入后即生效,无需单独回查。
```
```bash [dc3 CLI]
# CLI 暂无独立的属性配置子命令,请用上面的 curl
```
:::
**你应当看到**:`add` 返回成功状态(不回传 ID)。配置生效后,virtual 驱动开始为该位号产生值。
::: info attributeId 从哪来
`attributeId` 指向 virtual 驱动在管理中心注册的某个位号属性(`PointAttribute`
)。不同驱动声明的属性不同——这正是[核心概念](../introduction/concepts)里"协议层 Attribute vs 实例层 Config"那条区分。示例
ID 仅作占位,请用你环境里 virtual 驱动真实注册的属性 ID。
:::
## 第 8 步:读实时位号值
值开始产生后,从数据中心读最新位号值。`/point_value/latest` 顶层按 `deviceId` / `pointId` 过滤,分页参数放在嵌套的 `page`
对象(`current` / `size`)里,返回 `Page`。
::: code-group
```bash [curl]
curl -s -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"deviceId":"83010100000000001","pointId":"82010100000000001","page":{"current":1,"size":10}}'
# 示例返回(PointValueVO 形态):
# {"ok":true,"data":{"records":[
# {"deviceId":"83010100000000001","pointId":"82010100000000001",
# "rawValue":"25.0","calValue":"25.0","numValue":25.0,
# "hasLatestValue":true,"createTime":"2026-06-22T08:30:00","operateTime":"2026-06-22T08:30:00"}
# ],"total":1,"current":1,"size":10}}
```
```bash [dc3 CLI]
dc3 point read 82010100000000001
```
:::
**你应当看到**:`records` 里至少一条 `PointValueVO`,含 `rawValue`(原始值)、`calValue`(工程值,字符串)、`numValue`
(数值投影,可空)以及采集时间 `createTime`。值随 virtual 驱动持续刷新。
## 第 9 步:下发写命令
最后对这个可写位号下发一次写命令。`/point_command/write` 接 `deviceId` / `pointId` / `value`,**立即返回一个命令
ID(`commandId`)**——它只代表"命令已受理",不代表"已执行成功"。结果要拿命令 ID 去**轮询**回执接口
`/point_command_history/get_by_command_id`(`commandId` 即上一步返回的命令 ID)。
::: code-group
```bash [curl]
# 1) 下发写命令,立即拿到 commandId
curl -s -X POST http://localhost:8000/api/v3/data/point_command/write \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"deviceId":"83010100000000001","pointId":"82010100000000001","value":"26.5"}'
# 示例返回:{"ok":true,"code":"...","data":"cmd_20260622_a1b2c3d4"}
# 2) 用 commandId 轮询回执
curl -s -X GET 'http://localhost:8000/api/v3/data/point_command_history/get_by_command_id?commandId=cmd_20260622_a1b2c3d4' \
-H "$H_TENANT" -H "$H_LOGIN" -H "$H_TOKEN"
# 示例返回(PointCommandHistoryVO 形态):
# {"ok":true,"data":{
# "commandId":"cmd_20260622_a1b2c3d4","deviceId":"83010100000000001","pointId":"82010100000000001",
# "requestValue":"26.5","responseValue":"...","status":"...","finishTime":"2026-06-22T08:31:02"}}
```
```bash [dc3 CLI]
# 下发写值
dc3 point write 82010100000000001 --device-id 83010100000000001 --value 26.5
# 轮询回执
dc3 command history cmd_20260622_a1b2c3d4
```
:::
**你应当看到**:写命令立即返回 `commandId`;轮询回执直到 `status` 进入终态。到此,你已经完整走通了"读值 + 写命令"的双向链路。
::: danger 写命令的语义:异步、需轮询、失败不回显值
- 写命令是**异步**的:`/point_command/write` 立即返回 `commandId`,**不**等设备执行完。结果必须用该 ID 轮询
`/point_command_history/get_by_command_id`。
- 命令有 **TTL**:`PointCommandDTO.expireAt` 默认 `now + 10s`,超时未执行即视为过期。
- **写失败时不回显写入值**:回执的执行结果为失败时不会带回一个"已写入的值",不要把"拿到 commandId"误当成"写成功"。
- 只有 `rwFlag` 含写权限(`WRITE_ONLY` / `READ_WRITE`)的位号才接受写命令。
:::
## 延伸阅读
- [设备接入](../operation/device-onboarding) — 把这条最小路径展开成真实驱动的完整接入流程
- [数据与命令](../operation/data-commands) — 采集落库与读写命令在两条平面上的完整机制与回执语义
- [CLI 使用指南](../automation/cli) — `dc3` CLI 的完整命令面与脚本化、AI 接入用法
---
# 从源码本地开发
URL: https://docs.dc3.site/zh/quickstart/
# 从源码本地开发
这页带你把 IoT DC3 从源码跑起来:先用 Compose 起好 PostgreSQL 与 RabbitMQ,再用一组本地环境变量把 Java
进程指向本机端口,构建、启动、跑测试。读完你能在自己的机器上跑通整套中心服务,并理解为什么启动有先后、为什么本地进程不能直接吃根
`.env`。
> 你在这里:想从源码开发或调试。只想点一遍最短闭环,请看 [第一个设备:端到端](./first-device)
> ;想理解服务怎么拼起来,请看 [系统架构总览](../architecture/)。
## 先决条件
本地开发是"半容器"模式:基础设施(数据库、消息中间件)跑在容器里,中心服务以本地 Java 进程运行,方便断点调试与热重启。所以你需要一套
JDK/构建工具,外加一个容器运行时。
- **JDK 21** —— 平台强制 Java 21,低版本编译会直接失败。
- **Maven 3.9+** —— 仓库内置 `.mvn/settings.xml` 与并行构建配置;多模块打包用它。
- **pnpm** —— 前端 `dc3-web/` 与同级目录 `dc3-cli/` 都用 pnpm(不要用 npm/yarn)。仅做后端开发可跳过。
- **Podman** —— 本仓库容器操作一律用 `podman`(`make` 默认 `podman compose`)。
## 在 JetBrains IDEA 中开发
如果你用 IntelliJ IDEA(Community 或 Ultimate),以下步骤帮你从零配好项目。
### 1. 打开项目
1. **File → Open**(不是 New → Project from Existing Sources)
2. 选择仓库根目录的 `pom.xml`
3. 弹出对话框选 **Open as Project**
4. 等待 Maven 索引完成(右下角进度条,首次 2-5 分钟)
### 2. 安装 EnvFile 插件
1. **Settings → Plugins → Marketplace**,搜索 **EnvFile**,安装后重启
2. 打开每个服务的 Run Configuration,在 **EnvFile** 标签页点击 `+`,添加 `dc3/env/dev.env`
### 3. 配置运行入口
打开入口类,点击 `main` 左侧绿色按钮 → **Modify Run Configuration**,在 EnvFile 标签页添加 `dc3/env/dev.env`:
| 服务 | 入口类 | 模块 |
|------------|----------------------|---------------------------------|
| Gateway | `GatewayApplication` | `dc3-gateway` |
| Auth 中心 | `AuthApplication` | `dc3-center/dc3-center-auth` |
| Manager 中心 | `ManagerApplication` | `dc3-center/dc3-center-manager` |
| Data 中心 | `DataApplication` | `dc3-center/dc3-center-data` |
| Agentic 中心 | `AgenticApplication` | `dc3-center/dc3-center-agentic` |
### 4. 启动顺序
1. **Auth 中心**(8300)→ 2. **Manager 中心**(8400)→ 3. **Data 中心**(8500)→ 4. **Agentic 中心**(8600)→ 5. **Gateway**
(8000)
### 5. 常见问题
- **Lombok 报红**:Settings → Annotation Processors → 勾选 **Enable annotation processing**
- **Maven 索引卡住**:File → Invalidate Caches → Invalidate and Restart
- **EnvFile 未生效**:检查 Run Configuration 的 EnvFile 标签页是否已勾选 `dev.env`
- **端口占用**:`lsof -i :8000` 查看,在 Run Configuration 的 Environment variables 中覆盖 `SERVER_PORT`
## 为什么是这五步
本地起栈的最短闭环是五步,每一步都有明确产物,下一步依赖上一步的产物:先有基础设施(容器),才能加载指向它们的环境变量;先构建出
jar,才能启动开发栈;服务起来后才谈得上跑测试。
下面逐步展开,每步都给出"做什么"和"怎么验证"。
## 第一步:起基础设施
`make up-db` 用 Compose 拉起 db 栈——PostgreSQL 与 RabbitMQ。PostgreSQL 首次启动会按文件名顺序执行 initdb
脚本(扩展、common、auth、data、manager、history、agentic),把租户、用户、菜单、元数据表全部建好,所以第一次起会比后续慢。
::: code-group
```bash [全球镜像源]
make up-db
```
```bash [中国大陆镜像源]
make up-db-cn
```
:::
容器对宿主发布的端口是固定的:PostgreSQL `localhost:35432`、RabbitMQ AMQP `localhost:35672`(容器内部仍是 `5432` / `5672`
)。验证:
```bash
podman ps # 应看到 dc3-postgres、dc3-rabbitmq 在 running
podman exec dc3-postgres psql -U dc3 -d dc3 -c '\dt dc3_auth.*' # 能列出 auth 表即就绪
```
::: tip 可选可观测栈
需要 EMQX、ELK、Prometheus、Grafana 时,再执行 `make up-optional`。本地核心开发用不到,建议等中心服务稳定后再起,避免一次性拉起过多容器。
:::
## 第二步:加载本地环境变量
```bash
source dc3/env/dev.env.sh
```
这一步把数据库、RabbitMQ、MQTT、gRPC 目标主机等开发默认值导出到当前 shell。其中关键的是把 `POSTGRES_HOST=localhost`、
`POSTGRES_PORT=35432`、`RABBITMQ_HOST=localhost`、`RABBITMQ_PORT=35672`、以及
`CENTER_AUTH_HOST/MANAGER_HOST/DATA_HOST/AGENTIC_HOST=localhost` 指向本机,让本地 Java 进程能连上第一步发布出来的容器端口。
::: warning .env 只给 Docker Compose 插值,不会注入本地 Java 进程
根目录 `.env` 是 Compose 专用的——它只在 `docker compose` 解析时做变量插值(镜像仓库、镜像 tag、发布端口),**不会**自动注入到你本地起的
Java 进程。本地从源码跑,必须 `source dc3/env/dev.env.sh`;否则服务会沿用容器内 DNS 名(如 `dc3-postgres:5432`
),在本机根本解析不到,连接直接失败。
在 JetBrains IDEA 里运行时,用 EnvFile 插件加载不含 `export` 的 `dc3/env/dev.env`,或把其键值粘进运行配置的环境变量。
:::
验证:`echo $POSTGRES_PORT` 应回显 `35432`。
## 第三步:构建
```bash
make package # 等价于 mvn -s .mvn/settings.xml clean package
```
仓库已配好并行构建、强制 JDK 21/Maven 3.9+、Spring Java Format 校验。构建产物是各服务模块的可执行 jar(如
`dc3-gateway/target/dc3-gateway.jar`)。这一步只验证编译与打包,不依赖第一步的容器。
::: tip 快速编译检查
只想确认改动能编译、不想整包,用 `mvn -s .mvn/settings.xml -q -DskipTests compile`,比全量 `package` 快很多。
:::
## 第四步:起开发栈
构建出 jar 后,用 dev 栈把中心服务跑起来。`make up STACK=dev`(或简写 `make up-dev`)按依赖顺序启动
Gateway、Auth、Manager、Data、Agentic 与驱动。
::: code-group
```bash [全球镜像源]
make up-dev
```
```bash [中国大陆镜像源]
make up-dev-cn
```
:::
为什么有先后顺序?因为服务之间有依赖:
- **Auth 中心要先就绪**——它持有租户、用户、RBAC 与令牌签发逻辑,其它服务和网关的鉴权都依赖它。
- **Gateway 是唯一对外 HTTP 入口(8000)**,它聚合 Auth/Manager/Data/Agentic 的路由、抽取鉴权头、注入 principal
上下文。它要在后端中心服务可达之后才能正确转发,所以排在依赖项之后。
::: details 完整启动顺序与端口
分布式默认走 gRPC(`DC3_FACADE_MODE=grpc`,dev.env 已设)。各服务端口:
| 服务 | HTTP | gRPC |
|---------------------------------|------|------|
| Gateway / `dc3-gateway`(唯一对外入口) | 8000 | — |
| 鉴权中心 / `dc3-center-auth` | 8300 | 9300 |
| 管理中心 / `dc3-center-manager` | 8400 | 9400 |
| 数据中心 / `dc3-center-data` | 8500 | 9500 |
| 智能中心 / `dc3-center-agentic` | 8600 | — |
只有 Gateway(用户入口)与 listening-virtual 的 TCP 6270 / UDP 6271(设备入口)对宿主映射,其余后端端口都是内部端口。
:::
验证:栈起来后,对网关跑一次登录黄金路径。登录分两步——先取盐,再用盐哈希后的密码换 12 小时有效的 access token:
```bash
# 1) 取盐(公开端点,建议 5 分钟内使用)
curl -s -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}' # 返回字符串 salt(示例值)
# 2) 用 salt 哈希密码后换 token(公开端点,access token 12 小时有效)
curl -s -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"<上一步的 salt>","password":""}'
```
拿到 token 后,受保护端点都通过网关访问,并带上三个鉴权头 `X-Auth-Tenant`、`X-Auth-Login`、`X-Auth-Token`。完整的"建驱动 →
建模板 → 建设备 → 读写位号"闭环见 [第一个设备:端到端](./first-device)。
## 第五步:跑测试
```bash
make test # 单元测试套件
```
需要更高层验证时:`make test-it` 跑集成测试(需容器运行时供 Testcontainers),`make test-e2e` 跑后端 E2E。日常开发改完一处,先
`make test` 兜住单元回归即可。
## 常见坑
- **没 `source dev.env.sh` 就起服务**——最常见。本地 Java 进程拿不到 `localhost:35432` / `35672`,会去连容器内 DNS
名而连不上。每开一个新 shell 都要重新 `source`(变量只在当前 shell 生效)。
- **podman 没起**——`make up-db` 报连不上容器运行时,先确认 `podman` 守护进程/machine 已启动(macOS 上需
`podman machine start`),再用 `podman ps` 确认。
- **端口被占**——`35432` / `35672` / `8000` 等被其它进程占用时启动失败。释放占用进程,或在根 `.env` 中覆盖 Compose
发布端口(仅影响容器侧),本地进程端口用服务级环境变量覆盖。
- **首次起库很慢或表不全**——PostgreSQL 只在空卷首启时跑 initdb。若中途中断导致表不全,需重置卷重来:`make reset STACK=db`(需
`CONFIRM_RESET_VOLUMES=true`,会删数据,谨慎)。
::: danger 生产密钥必须替换
`dev.env.sh` 里的 `DC3_SECURITY_KEY` 与 `AUTH_HMAC_SECRET` 是开发默认值。在 `pre`/`pro` 环境,若 `AUTH_HMAC_SECRET`
为空或仍等于默认值 `io.github.pnoker.dc3`,Gateway 会 fail-fast 拒绝启动。本地开发无妨,上线前务必换成环境专属随机值。
:::
## 延伸阅读
- [环境变量详解](./environment) — `.env` 与 `dev.env(.sh)` 的边界、每个变量的作用域与默认值
- [第一个设备:端到端](./first-device) — 登录后从建驱动到读写位号的最短闭环
- [系统架构总览](../architecture/) — 五个中心服务如何分工、数据与命令如何流转
---
# Agentic Center: AI-Assisted Operations
URL: https://docs.dc3.site/en/ai/agentic
# Agentic Center: AI-Assisted Operations
The Agentic Center (`dc3-center-agentic`) connects an OpenAI-compatible large language model to IoT DC3's devices,
points, data, and commands. You ask questions in plain language, and the model calls the platform's built-in tools to
read metadata, query live values, and trigger device reads and writes under controlled authorization. This page covers
how that chain works, which tools exist, where conversations are stored, and which actions need a human to confirm.
> You are here: you've already [onboarded devices](../operation/device-onboarding) and
> can [query data and issue commands](../operation/data-commands). Now you want the model to help operate. Next,
> see [AI Agent / MCP Integration](./mcp) to bring external agents in.
## Why a dedicated Agentic Center
Once devices are connected and data is persisted, day-to-day operations are usually a string of small "check first, then
decide" actions: Which device went offline? How has this point trended over the last hour? Should we write a switch
back? Each step maps to an HTTP endpoint, but chaining them by hand is slow and error-prone.
The Agentic Center hands this "understand intent → pick the right tool → fetch data → answer" layer to the LLM. Built on
Spring AI's `ChatClient`, it exposes an OpenAI-compatible chat interface externally while hosting a set of *
*tenant-isolated built-in tools** internally. Within a single conversation, the model decides which tools to call and in
what order, then explains the conclusion in plain language.
AI isn't a prerequisite for device onboarding. Get the device, point, data, and command chains working end to end first,
then enable the Agentic Center — it consumes exactly the data and interfaces those chains produce.
::: warning Tool calling is on by default, but you can turn it off
Built-in tool calling is governed by `AGENTIC_TOOL_CALLING_ENABLED`, which defaults to `true`. Set it to `false` and the
model drops to pure conversation, no longer touching any device or data interface. If you only want Q&A in a restricted
environment, turn it off.
:::
## OpenAI-compatible chat entry point
The Agentic Center exposes a single core entry point shaped like OpenAI's Chat Completions, so any OpenAI client can
connect directly:
- Path: `POST /api/v3/agentic/chat/completions` through the gateway. The gateway's `StripPrefix=2` removes `/api/v3`
and forwards to the Agentic Center's `/chat/completions`.
- Two response modes: with `stream=true` it returns token-by-token over **SSE**; otherwise it returns a single **JSON**
response.
- Permission: `@PreAuthorize("@perm.can('chat', 'list')")` — the caller must carry the platform auth headers
`X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token`.
- This endpoint's own AI risk metadata is annotated as `riskLevel=MEDIUM`, `destructive=false`, `idempotent=false`,
`openWorld=true`.
Here's the shape of a non-streaming exchange. The example values are illustrative only; replace the auth headers with
the real token you get after logging in.
::: code-group
```bash [curl]
curl -X POST http://localhost:8000/api/v3/agentic/chat/completions \
-H "Content-Type: application/json" \
-H "X-Auth-Tenant: " \
-H "X-Auth-Login: " \
-H "X-Auth-Token: " \
-d '{
"model": "gpt-4o",
"stream": false,
"messages": [
{ "role": "user", "content": "What is the latest value of boiler #1 temperature point?" }
]
}'
```
```json [Response JSON shape (example)]
{
"id": "chatcmpl-...",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "The current value of boiler #1 temperature point is 86.4 °C, collected 2 minutes ago."
},
"finish_reason": "stop"
}
]
}
```
:::
To get the auth token above, first `POST /api/v3/auth/token/salt` to fetch the salt, then
`POST /api/v3/auth/token/generate` to exchange it for an access token valid for 12 hours. See the login steps
in [Your First Device: End to End](../quickstart/first-device).
## How an AI-assisted operation flows
The model doesn't answer out of thin air. During the conversation it calls built-in tools to read real data. The diagram
below shows a mixed "read + write" operation: read-type tools execute directly, while write-type (high-risk) tools pause
for human confirmation before continuing.
Key facts along the critical path:
- Tools are native Spring AI `@Tool` methods. They're registered as a `ToolCallbackProvider` by `ChatClientConfig` and
wrapped in an `AgenticToolTracingCallbackProvider` for call tracing.
- Every tool method starts with `AgenticToolContextUtil.requireTenantId(toolContext)` to extract the current tenant ID,
so all queries carry tenant scope — the model **cannot see or touch** another tenant's data.
- Write-type actions (command dispatch) go from the Agentic Center to the **Data Center**'s command plane via
`PointCommandFacade` (the gRPC implementation `PointCommandGrpcFacade`, or the local `PointCommandLocalFacade`). They
don't call the HTTP `POST /point_command/write` endpoint — that's the Data Center's separate Web/CLI-facing call
surface, terminating at the same command plane. The command itself has a 10-second TTL (`PointCommandDTO.expireAt`
defaults to `now+10s`) and won't execute once expired.
## Ten built-in tools
The platform ships **10** built-in tool classes covering the full read surface — from tenants and users down to devices
and commands — with a few that perform writes. Tool methods mainly use the verbs `lookup*` (fetch one or many by ID) and
`search*` (paginated query), plus `list*ByXxxId` (enumerate by ownership). These don't follow the REST layer's `getXxx`/
`listXxx` convention. They're named for the model's benefit, independent of the external HTTP CRUD convention.
Get a feel for what each domain can do first, then read the table. Given a question, the model decomposes it into a tool
sequence — say, "first look up which points a profile has → then read the latest values of those points → finally decide
whether to issue a command" — and orchestrates the calls automatically.
| Tool class | Domain | Representative methods | Typical use |
|------------------|-------------|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|
| `TenantTool` | Tenant | `getCurrentTenantInfo()` | Confirm the current tenant context |
| `UserTool` | User | `getCurrentUserProfile()` | Look up the current user's info |
| `DeviceTool` | Device | `lookupDeviceById()` / `searchDevices()` | Look up a device, search by criteria, check online status and latest values |
| `DriverTool` | Driver | `lookupDriverById()` / `searchDrivers()` | Check protocol driver connectivity, device online statistics |
| `ProfileTool` | Profile | `lookupProfileById()` / `searchProfiles()` | Look up profiles and their capabilities |
| `PointTool` | Point | `lookupPointById()` / `searchPoints()` | Look up points, read/write direction, list points by device/profile |
| `PointValueTool` | Point value | `getLatestPointValue()` / `getPointValueHistory()` / `readPointValue()` / `writePointValue()` | Read live values, query history curves, issue read/write commands |
| `SystemTool` | System | `getSystemHealth()` | Check platform health |
| `CommandTool` | Command | `lookupCommandById()` / `searchCommands()` | Look up custom commands, list commands by device/profile |
| `EventTool` | Event | `lookupEventById()` / `searchEvents()` | Look up events reported by devices |
::: info Risk metadata is annotated on REST endpoints, not tool methods
The `x-dc3-ai` risk metadata (`riskLevel` / `destructive` / `idempotent` / `openWorld`) is **hand-annotated** in the
Controller's `@Operation` extension (e.g. the chat endpoint of `ChatController`) for the OpenAPI / MCP catalog to
consume. The Agentic Center's 10 tool methods carry only `@AgenticToolMetadata(domain, title)` — just the two fields
`domain()` and `title()` — and no risk level. Don't treat endpoint-level risk metadata as a property of each tool
method.
:::
## Conversations live in the database, not in memory
Many AI services keep conversation context in memory and lose it the moment the process restarts. The Agentic Center
doesn't. It **persists every turn in the `dc3_agentic` schema**, and the `MessageChatMemoryRepository` adapter reads
history back from `dc3_message` by `conversation_id`. Conversations survive restarts and can be audited.
Here's how the three tables relate:
- The retrieval window size is controlled by `dc3.agentic.historyWindowSize` (default `30`): only the most recent few
turns are fed to the model, saving tokens while keeping context.
- `dc3_session.session_ext` is a JSON blob holding this conversation's model choice, temperature, `maxTokens`, and other
preferences, carried into the next turn.
- Uploaded attachments land in the directory pointed to by `AGENTIC_ATTACHMENT_STORAGE_PATH`, with metadata recorded in
`dc3_attachment`.
::: warning Memory table schema isn't auto-created by default
Persisted conversations depend on database tables. `AGENTIC_MEMORY_SCHEMA_INIT` is injected via compose / `dev.env` and
defaults to `never` — meaning it does not auto-initialize Spring AI's memory table schema. To get auto-creation, the
expected approach is to set it to `always` (or `create_if_not_exists`) once to create the tables, then set it back to
`never`. Note: the repository currently shows no `application*.yml` binding this variable to Spring AI's
`initialize-schema`, so whether it's actually wired up **should be verified against the code**. When in doubt,
initializing the memory tables by hand is safer.
:::
## Two-phase confirmation for high-risk actions
Not every tool call should complete in one shot. Read-type tools (`lookup*` / `search*` / `getLatest*`) are safe and run
directly; write-type tools are hard to roll back once they go wrong. The Agentic Center's only write tool,
`PointValueTool.writePointValue`, **never writes directly**. It goes through two-phase confirmation:
1. When the model calls `writePointValue`, the service calls `ActionService.createWritePointValueAction(...)` to
generate a pending **Action** (with `actionId` as a UUID), sets its status to `AgenticActionStatusEnum.PENDING`, and
sets the expiry to `now + 10 minutes`. The tool result carries `pendingConfirmation=true` and that `actionId` — no
command is issued.
2. Once the user has reviewed the action's contents, they confirm it with `POST /action/confirm` (or reject it with
`POST /action/reject`), passing the `action_id`. Only after confirmation does the service execute it via
`PointCommandFacade.submitWrite(...)`.
This keeps the split clean: the AI proposes, a human decides. Irreversible physical-world actions stay within human
authorization.
::: info This is Agentic's own Action mechanism, not the MCP gateway's risk gate
The confirmation flow uses `actionId` + `POST /action/confirm|reject` — **not** `CONFIRM_REQUIRED` / `confirmId`, and
not a generic risk policy gated on `riskLevel=HIGH`. The latter belongs to the [MCP gateway](./mcp)'s
`dc3_mcp_tool_confirmation` subsystem, a separate implementation from this chat chain. Don't conflate the two.
:::
::: danger A failed write command never echoes a fabricated value
A write command issued through a tool ultimately goes to the Data Center's command plane. The command carries a
10-second TTL (`PointCommandDTO.expireAt` defaults to `now+10s`), and **when a write command fails, `responseValue`
is `null` and no value is echoed** — never treat "no error" as "write succeeded."
See [Command Plane](../architecture/command-plane).
:::
## Where the model comes from: database first, env fallback
The Agentic Center supports multiple model providers. `ChatClientFactory` first reads enabled provider configuration
from the database table **`dc3_model_provider`** (`provider_type`: `0` openai-compatible / `1` anthropic, `base_url`,
`api_key`, `default_flag`, etc., tenant-isolated). Only when the table has no usable provider does it fall back to a set
of environment-variable defaults.
In other words: manage providers centrally in the database in production. The `AGENTIC_FALLBACK_*` env vars are just the
last line of defense when there's no DB configuration.
| Variable | Default | Purpose |
|---------------------------------------|--------------------------------|------------------------------------------------------------------------------|
| `AGENTIC_FALLBACK_OPENAI_BASE_URL` | `https://api.openai.com` | Fallback OpenAI-compatible API address |
| `AGENTIC_FALLBACK_OPENAI_API_KEY` | *(empty)* | Fallback API key (when the endpoint requires auth) |
| `AGENTIC_FALLBACK_OPENAI_MODEL` | `gpt-4o` | Fallback model name |
| `AGENTIC_FALLBACK_OPENAI_TEMPERATURE` | `0.7` | Sampling temperature (0.0–2.0) |
| `AGENTIC_FALLBACK_OPENAI_MAX_TOKENS` | `2048` | Maximum output tokens |
| `AGENTIC_TOOL_CALLING_ENABLED` | `true` | Whether to enable tool calling |
| `AGENTIC_MEMORY_ENABLED` | `false` | Whether to enable persisted conversation memory |
| `AGENTIC_MEMORY_MAX_MESSAGES` | `50` | Max messages in a single conversation window |
| `AGENTIC_MEMORY_SCHEMA_INIT` | `never` | Memory table schema initialization (`always`/`never`/`create_if_not_exists`) |
| `AGENTIC_ATTACHMENT_STORAGE_PATH` | `dc3/data/agentic/attachments` | Attachment storage directory |
::: tip Defaults reflect compose / `dev.env`, not the Spring bare defaults
The defaults above are the values injected by compose / `dev.env`, which **differ** from the bare Spring defaults in
`application-agentic.yml`: e.g. `AGENTIC_MEMORY_ENABLED` defaults to `true` in Spring (compose injects `false`), and
`AGENTIC_ATTACHMENT_STORAGE_PATH` defaults to `dc3/data/upload/agentic/attachment` in Spring (compose injects
`dc3/data/agentic/attachments`). When you start via compose / `make up-*`, this table applies; if you run Spring
directly in an IDE without compose, the yml bare defaults apply instead.
:::
See [Environment Variables](../quickstart/environment) for the full reference.
::: danger Never leak an API key
Never let a real `api_key` / `token` / `password` appear in docs, screenshots, logs, issues, or commits.
`dc3_model_provider.api_key` lives in the database, access-constrained by tenant isolation. The env fallback key should
likewise be injected only through local files like `dc3/env/dev.env` and never committed to the repository.
:::
## Pre-use checklist
Before enabling the Agentic Center, verify these prerequisites — otherwise the model may answer fluently while the data
underneath is wrong:
1. The Agentic Center is started and reachable through the gateway on port `8000`.
2. The Auth Center, Manager Center, and Data Center base capabilities are healthy — the tools ultimately call their
interfaces.
3. At least one device and point are producing data; otherwise value-query tools return empty.
4. The model provider (DB or env fallback) is reachable, and the API key isn't written into docs or logs.
5. Tool calling is opened only to trusted users and well-defined business scenarios. Turn it off with
`AGENTIC_TOOL_CALLING_ENABLED=false` when you don't need it.
## Further reading
- [AI Agent / MCP Integration](./mcp) — connect external AI agents to platform tools securely via OAuth 2.1 + MCP
- [Core Concepts](../introduction/concepts) — the object model of driver / profile / device / point / point value, so
you understand what the tools are querying
- [Command Plane](../architecture/command-plane) — how the read/write commands issued by tools flow, and why a failed
write echoes nothing
---
# AI
URL: https://docs.dc3.site/en/ai/
# AI
IoT DC3 plugs large language models into operations, so a model can do more than read data — it can act on devices. This
section covers two ways an LLM drives those actions; the difference is who initiates and how it's constrained.
- **Agentic Center** — a built-in, conversational AI operations assistant. Built on Spring AI with 10 `@Tool`s, it lets
the LLM query devices, read and write points, and run commands through Tool Calling. It speaks the OpenAI API, so you
can point GPT, Claude, DeepSeek, Qwen, and friends at it. Good when you want a UI-driven, multi-turn assistant.
- **MCP** — exposes platform tools safely to external AI agents. The gateway serves a JSON-RPC 2.0 MCP resource server
at `POST /mcp`; the tool catalog is aggregated from the four centers' OpenAPI (330+ tools) and gated by OAuth 2.1, a
per-connection tool whitelist, and risk tiers. Good when you run your own agent and want the model to pick which tool
to call.
> You are here: you've already [onboarded a device](../operation/device-onboarding) and want the model to query,
> analyze, even issue commands. Next, pick [Agentic Center](./agentic) or [AI Agent / MCP](./mcp). Prefer scripts over
> AI?
> See [Automation (dc3 CLI)](../automation/cli).
## Two paths, one door
The two differ less in what they can do than in who initiates and how it's bounded. Either way, the platform has a
single HTTP entry — the gateway `dc3-gateway` (`8000`). The Agentic chat and the MCP tool calls both go through it,
where the gateway injects principal context and hands off to `dc3-center-auth` for **RBAC** and **tenant isolation**.
In other words: the AI gets no more privilege than the account behind it, and cross-tenant data stays invisible (you get
a 404, not the data). The README's "tenant-level isolation across database, cache, and API paths" and "JWT + Spring
Security + RBAC" apply equally to both.
Auth differs, but the destination is the same — before any business service runs, the call clears the `@PreAuthorize`
permission point and the tenant boundary:
- **Agentic Center** acts under the logged-in user's session; Tool Calling still hits the platform's business APIs, so
permissions follow the current user.
- **MCP** uses a short-lived OAuth 2.1 JWT (15 minutes by default). The gateway re-introspects on every call, checks the
MCP connection, then runs the `tools/list` three-layer filter (RBAC ∩ connection whitelist ∩ risk policy) to decide
which tools the agent can see and invoke.
## Further reading
- [Agentic Center](./agentic) — conversational AI operations, 10 built-in tools, session persistence, high-risk
confirmation
- [AI Agent / MCP](./mcp) — OAuth 2.1 + MCP, exposing tools safely to external agents
- [Why Spring AI](./spring-ai-deep-dive) — architecture rationale, tool-calling mechanics, and roadmap
- [Automation (dc3 CLI)](../automation/cli) — drive the platform from the command line, no AI
- [Data Intelligence & AIoT](../foundations/aiot) — the big picture of IoT analytics meeting large models
---
# AI Agent / MCP Integration
URL: https://docs.dc3.site/en/ai/mcp
# AI Agent / MCP Integration
IoT DC3 turns the platform's entire HTTP surface into a single MCP (Model Context Protocol) tool catalog. An external AI
Agent authenticates through OAuth 2.1, then discovers and calls tools over the gateway's `/mcp` endpoint: read devices,
query point values, issue commands. This page covers how to get a token, how to call `/mcp`, why some tools don't show
up, and why HIGH-risk operations need a second confirmation.
> You're wiring IoT DC3 to an AI Agent. If you only want a chat box where people ask about data, see
> the [Agentic Center](./agentic). If you're driving the platform from scripts, see the [CLI Guide](../automation/cli).
## Why MCP, instead of calling HTTP directly
The naive approach is to hand-write every REST endpoint as a tool and feed it to the LLM. That breaks down fast: 300+
endpoints spread across four centers, permission and tenant checks scattered everywhere, and destructive operations
mixed in with read-only queries with no risk grading. MCP standardizes the layer. The platform **automatically** exports
its endpoints as a risk-annotated tool catalog. The Agent discovers and calls them through one JSON-RPC protocol, and
authentication, tenancy, permissions, and risk confirmation all flow through the gateway and auth center.
Three roles make up the pipeline. The **Auth Center (`dc3-center-auth`)** is the OAuth 2.1 authorization server — it
issues tokens, performs introspection, and aggregates the tool catalog. The **Gateway (`dc3-gateway`)** is the MCP
Resource Server: it hosts `POST /mcp`, re-checks permissions on every call, and forwards signed requests to the backend.
The backend's **Manager / Data / Agentic centers** are where business logic actually runs. The Agent only ever talks to
the first two.
## Where the tool catalog comes from: automatic aggregation, stable tool_id
The tool catalog is generated, not hand-written. The auth center's `McpOpenApiAggregator` pulls the OpenAPI specs of the
four centers — auth / manager / data / agentic — at runtime, joins them with `dc3_api` (`api_code` / `api_name`) and
`dc3_resource` (`resource_code` / `permission_code`), creates one tool record per endpoint, and stores them in
`dc3_mcp_tool_catalog`. The scale is roughly **330+ tools**, generated from **330+ OpenAPI operations** across the four
centers.
Each tool has a **stable `tool_id`** (equal to `dc3_api.api_code`), formatted as
`{service_name}:{HTTP_METHOD}:{api_path}`, where `service_name` is the full service name `dc3-center-` (from
`spring.application.name`):
```text
dc3-center-manager:POST:/device/add
dc3-center-data:POST:/point_command/write
dc3-center-data:POST:/point_value/latest
```
A tool's **risk level** is set by hand, one endpoint at a time — never inferred from the verb. Every endpoint declares
`riskLevel` (`LOW` / `MEDIUM` / `HIGH`) in its `@Extension(name = "x-dc3-ai")` annotation. The resource registrar
enforces during scanning that the annotation exists and `riskLevel` is valid, and reports a defect if it's missing. The
aggregator reads `riskLevel` from the annotation verbatim and only falls back to a conservative `HIGH` when the
annotation is absent. So `POST /point_command/write` is marked `HIGH` (`destructive=true`) by hand, and never downgraded
to `MEDIUM` just because it's a write.
`read_only_hint` is derived from the HTTP method (`GET` → 1, `POST` → 0). The aggregator writes these annotations —
`destructive_hint`, `idempotent_hint`, `open_world_hint` — into `dc3_mcp_tool_catalog`, sourced from the `x-dc3-ai`
OpenAPI extension on each endpoint (details at the end of this page).
::: info How the catalog refreshes: a manual endpoint only
The only way to refresh the tool catalog is an HTTP endpoint an administrator calls by hand (
`McpManagementController.refreshToolCatalog` → `OAuthMcpRuntimeServiceImpl.refreshToolCatalog`), which re-aggregates and
persists. **There is no scheduled refresh and no event-driven refresh** — no `@Scheduled` task and no post-commit
trigger such as `McpToolCatalogChangedEvent`. After adding a new endpoint, an administrator has to trigger a refresh
once before the catalog updates.
:::
## Access control: four gates decide whether a tool is visible and callable
Being in the catalog doesn't mean the Agent can use it. Whether a tool is **visible** to a given connection, and whether
it's **callable**, depends on OAuth verification first (to get the principal and scope), then on the intersection of
RBAC, the connection allowlist, and the risk policy.
`tools/list` returns the intersection of three sets: **the principal's permission codes ∩ this MCP connection's
allowlist ∩ the risk policy**. `tools/call` adds the OAuth scope and a per-call risk confirmation. So even if
`dc3-center-data:POST:/point_value/latest` is in the catalog, the Agent can't reach it when the connection's allowlist (
`dc3_mcp_connection_tool`) hasn't permitted it, the token lacks the `mcp:tools:call` scope, or the principal is missing
the matching query permission.
::: warning HIGH risk is invisible by default
HIGH-risk tools (the various `delete` operations) are hidden by default in `tools/list` and only appear when explicitly
enabled. Calling them requires the `mcp:tools:call:high` scope on top of that, plus the two-phase confirmation below.
This conservative default keeps the Agent from deleting things by accident.
:::
## OAuth 2.1 authorization server: how to obtain a token
The auth center runs a hand-written OAuth 2.1 authorization server (RS256 JWT). These are its HTTP endpoints — all
hosted by the auth center and exposed through the gateway:
| Endpoint | Method | Purpose |
|-------------------------------------------|---------------|-----------------------------------------------------------------------------|
| `/.well-known/oauth-authorization-server` | `GET` | Authorization server metadata discovery |
| `/.well-known/oauth-protected-resource` | `GET` | Protected resource metadata (RFC 9728, gateway side) |
| `/oauth2/authorize` | `GET` | Authorization code + PKCE (user login + consent + MCP connection selection) |
| `/oauth2/token` | `POST` (form) | Exchange for access_token / refresh_token |
| `/oauth2/jwks` | `GET` | Public key set (RS256, for signature verification) |
| `/oauth2/revoke` | `POST` (form) | Revoke a token (with replay detection) |
| `/oauth2/register` | `POST` (JSON) | Dynamic client registration (admin-restricted) |
Token introspection (`introspect`) is **not exposed as an HTTP endpoint**. It's an internal gRPC interface the gateway
uses, as the Resource Server, to validate the Bearer token.
**Security baseline** (all implemented): public clients are **forced to use PKCE S256**; `redirect_uri` is matched
exactly, no wildcards; refresh tokens rotate (RFC 9700 §6.3, with replay detection via `previous_refresh_token_hash`);
client secrets are stored as hashes only, never in plaintext.
**Token types and lifetimes**: the access_token is a short-lived JWT (default 15 minutes) carrying
`iss/aud/exp/nbf/sub=principal_id/principal_type/scope/tenant_id/mcp_connection_id`; the refresh_token rotates, default
30 days; the authorization_code is single-use within 5 minutes and PKCE-bound; client_credentials runs as a
SERVICE_ACCOUNT with no refresh.
::: danger OAuth 2.1 only, no long-lived tokens
The platform's MCP access **supports OAuth 2.1 only**. There is no PAT (Personal Access Token) and no long-lived static
token such as `dc3mcp_*`. Every call uses a short-lived access_token plus a rotating refresh_token. Don't try to
hard-code a "permanent MCP key" in a script — it doesn't exist.
:::
## A complete call: from obtaining a token to getting a result
The sequence below shows the whole path: the Agent gets a token, calls `/mcp`, and the gateway introspects and forwards
the signed request.
Note the gateway-to-backend hop. The gateway uses `McpGatewayClient.invokeBackend()` to go straight through an internal
WebClient (**bypassing** the gateway's own routing), building `X-Auth-Principal` and applying an HMAC signature. The
backend's `GatewayJwtConverter` verifies the signature, restores the principal, and hands off to `@PreAuthorize` for the
permission decision. The HMAC key `AUTH_HMAC_SECRET` fails fast in `pre/pro` if it's empty or equal to the default —
see [Auth · Tenancy · RBAC](../architecture/auth-rbac).
### JSON-RPC methods of `/mcp`
`POST /mcp` is JSON-RPC 2.0 over Streamable HTTP, handled by the gateway's `McpGatewayController`. Supported methods:
`initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`.
A `tools/list` request:
::: code-group
```bash [curl]
curl -X POST http://localhost:8000/mcp \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}'
```
```json [Response shape (example)]
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "data_point_value_latest",
"description": "Query the latest point value of a device",
"inputSchema": { "type": "object", "properties": { "deviceId": {"type":"string"} } }
}
]
}
}
```
:::
A `tools/call` (a low-risk query tool, with example argument values):
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "data_point_value_latest",
"arguments": { "deviceId": "1839...", "pointId": "1840...", "current": 1, "size": 10 }
}
}
```
On the way back, the gateway wraps the backend's `R` into an MCP `CallToolResult`. For the real endpoints and fields
behind a tool, see the [Agentic Center](./agentic) and each center's OpenAPI.
## HIGH risk: two-phase confirmation
For HIGH-risk tools (deletions and the like), the platform requires two-phase confirmation, so the Agent can't fire an
irreversible operation in a single reasoning step.
Phase one: the Agent calls `tools/call` without a valid confirmation. The server doesn't execute — it returns
`CONFIRM_REQUIRED` with a `confirmId` (UUID), default TTL `PT5M`.
Phase two: the Agent calls again with the `confirmId` + `idempotency_key`. The server checks that it isn't expired, the
`parameter_digest` matches the first call, the principal / connection / tool are unchanged, and it's consumed exactly
once. `status=PENDING` is the SQL-layer concurrency guard, so a replayed `confirmId` loses the race.
The confirmation ticket is stored in `dc3_mcp_tool_confirmation` (`confirm_id`, `tool_id`, `parameter_digest`,
`idempotency_key`, `status` PENDING/CONSUMED/EXPIRED, `ttl_expires`), with the TTL set by `dc3.mcp.confirm-ttl` (default
`PT5M`). Every HIGH-risk call is written to `dc3_mcp_audit_log` (`confirm_id`, `idempotency_key`, `argument_digest`,
`risk_level`, `duration_ms`, `remote_ip`, and so on).
## Constraints and boundaries (honestly labeled)
::: info MCP resources / prompts not implemented
`/mcp` implements the tool methods only (`tools/list` / `tools/call` plus `initialize` / `ping` /
`notifications/initialized`). The MCP protocol's `resources/*` and `prompts/*` capabilities are **not yet implemented
** (planned). The matching `mcp:resources:read` scope is reserved but not enabled.
:::
::: info `tools/list_changed` event push not implemented
When the tool catalog changes, the MCP `tools/list_changed` notification is **not pushed** (the design is planned; the
RabbitMQ push isn't built). Catalog refresh runs through the manual endpoint described above. Clients should *
*re-pull `tools/list` on their own schedule** and not assume the server pushes changes.
:::
Tool invocation is HTTP end to end (internal WebClient) — there is **no** gRPC tool-invocation channel, by design. The
`x-dc3-ai` metadata comes from real annotations on each endpoint:
```java
@Extension(name = "x-dc3-ai", properties = {
@ExtensionProperty(name = "riskLevel", value = "MEDIUM"), // LOW/MEDIUM/HIGH
@ExtensionProperty(name = "destructive", value = "false"), // whether it damages data/config
@ExtensionProperty(name = "idempotent", value = "false"), // whether it is safe to retry
@ExtensionProperty(name = "openWorld", value = "true") // whether it reaches the external/physical world
})
```
## Further reading
- [Agentic Center](./agentic) — the platform's built-in conversation and tool invocation; see what actually executes
behind an MCP tool
- [Auth · Tenancy · RBAC](../architecture/auth-rbac) — the principal model, HMAC signing, and permission resolution, end
to end
- [CLI Guide](../automation/cli) — drive the platform directly with the `dc3` CLI, no AI involved
---
# Why Spring AI: How DC3 Lets LLMs Operate Your Factory
URL: https://docs.dc3.site/en/ai/spring-ai-deep-dive
# Why Spring AI: How DC3 Lets LLMs Operate Your Factory
In 2025, large language models stopped being chatbots and started becoming operators. GPT-4o, Claude 4, DeepSeek,
Qwen — these models can already read sensor data, reason about equipment status, and decide whether to open a valve.
What was missing was a bridge between "the model understands" and "the model acts." That bridge is Spring AI, and
that's why IoT DC3 chose it as the foundation for the Agentic Center.
## The problem: AI wants to act, but platforms say no
Traditional IoT platforms treat data and commands as separate worlds. Data flows northbound from device to dashboard;
commands flow southbound from operator to device. These two directions rarely meet in a single API call, and never
in a single natural-language sentence.
Consider a real scenario: an operator notices a temperature anomaly on Boiler #3. In a conventional platform, the
workflow looks like this:
1. Navigate to the dashboard, find Boiler #3, note the temperature.
2. Switch to the history page, query the last 2 hours of temperature data.
3. Mentally assess: is this a trend or a spike?
4. Navigate to the command page, select "Fan Speed," enter the new value.
5. Submit, wait for confirmation, verify the temperature starts dropping.
That's five context switches across three pages. Each switch costs 15–30 seconds. Multiply by 200 devices, and
operational friction becomes the bottleneck.
With DC3's Agentic Center, the operator types one sentence:
> "Boiler #3 temperature is rising. Check the last 2 hours and slow down the exhaust fan to 60% if it's been
> climbing for more than 30 minutes."
The model decomposes this into tool calls, executes reads and a conditional write, and reports back — all in a
single conversational turn. This isn't a demo; it's running in production on DC3's Spring AI tool-calling
infrastructure.
## Why Spring AI, not LangChain or a custom solution
The team evaluated three approaches before committing to Spring AI:
| Approach | Pros | Cons | Verdict |
|------------------------------|------------------------------------------------------------------------------------|--------------------------------------------------------------------------|-------------------------------------|
| **LangChain (Python)** | Huge ecosystem, fast prototyping | Python/JVM interop overhead, separate deployment, security boundary blur | Too heavy for a JVM-native platform |
| **Custom `@Tool` framework** | Full control, zero dependency | Months of engineering, maintenance burden, no community | Reinventing the wheel |
| **Spring AI** | Native JVM, Spring Boot integration, OpenAI-compatible, type-safe tool definitions | Newer ecosystem (2024+) | ✅ Best fit |
Spring AI won on three decisive points:
### 1. Native JVM, no Python bridge
DC3 is a pure Java 21 / Spring Boot 4 platform. Every service runs on the JVM. Introducing a Python runtime for AI
would mean a separate container, a separate deployment, and a fragile network bridge between two language runtimes.
Spring AI runs in-process — the `ChatClient` is a Spring bean, tool calls are regular Java method invocations, and
authentication context flows through the same Spring Security filter chain as any other request.
```java
// A @Tool method in DC3 — plain Java, type-safe, tenant-aware
@Tool(description = "Get the latest value of a specific point")
public PointValueBO getLatestPointValue(
@ToolParam(description = "Point ID") Long pointId,
ToolContext toolContext) {
var tenantId = AgenticToolContextUtil.requireTenantId(toolContext);
return pointValueService.getLatestByPointId(pointId, tenantId);
}
```
No serialization across language boundaries. No separate authentication. No gRPC or HTTP between the AI and the
data — it's a direct Java call to the service layer, with tenant isolation enforced at the method level.
### 2. OpenAI-compatible by design
Spring AI's `ChatClient` speaks the OpenAI Chat Completions protocol. This means DC3 works with *any* model
provider that exposes an OpenAI-compatible endpoint: OpenAI (GPT-4o, GPT-5), Anthropic (Claude via compatible
proxy), DeepSeek, Qwen, Groq, Together AI, Ollama (local models), vLLM — the list is constantly growing.
For operators, this translates to freedom: start with a cloud model for convenience, switch to a self-hosted
model for data sovereignty, or run a hybrid where sensitive queries stay on-premises. The Agentic Center's
`dc3_model_provider` table lets you configure multiple providers and switch per-conversation.
### 3. Type-safe tool definitions with compile-time validation
In Python-based tool-calling frameworks, tool definitions are typically JSON schemas or decorators with string-based
descriptions. A typo in a parameter name is a runtime error. In Spring AI, `@Tool` and `@ToolParam` annotations are
verified by the Java compiler. If you rename a parameter in the method signature without updating the annotation,
the IDE flags it before the code even compiles.
This matters when you have 10 tool classes, 30+ tool methods, and a team of contributors. The compiler becomes a
safety net that Python tool-calling frameworks cannot provide.
## The architecture: how a chat message becomes a device command
Here's the full path of a typical AI-assisted operation in DC3, end to end:
Four things make this architecture production-grade:
1. **Tenant isolation at every layer.** Before any tool executes, `requireTenantId(toolContext)` extracts the
caller's tenant ID. Every database query, every cache key, every facade call carries that tenant ID. If the
model incorrectly guesses a device ID from another tenant, the query returns empty — not the wrong data.
2. **Write commands require human confirmation.** The model can *propose* a write, but it cannot execute one.
`PointValueTool.writePointValue()` generates a pending `Action` with a 10-minute expiry. Only a human
`POST /action/confirm` (or the equivalent UI button click) releases it. This is not a suggestion — it's
enforced at the service layer, not the UI.
3. **Conversations survive restarts.** Every turn — user message, tool call, tool result, assistant response —
lands in the `dc3_message` table. If the Agentic Center restarts, the `MessageChatMemoryRepository` replays
the most recent 30 turns (configurable) and the conversation resumes seamlessly. This is critical for
multi-turn diagnostic sessions that span hours.
4. **The model never sees another tenant's data.** Tenant IDs are injected by the gateway (from the JWT), not
by the model. Even if a prompt says "show me all devices," the tools' SQL queries carry `WHERE tenant_id = ?`
bound to the gateway-injected value. The model cannot bypass this — there's no API that queries across
tenants.
## The 10 built-in tools: a complete operational surface
The Agentic Center ships with 10 tool classes covering every domain object in the platform. Each tool method wraps
an existing service-layer method — there's no duplicated business logic.
| Tool Class | Domain | Key Methods | Risk |
|------------------|-------------|--------------------------------------------------------------------------------------------|------------------|
| `TenantTool` | Tenant | `getCurrentTenantInfo()` | Low |
| `UserTool` | User | `getCurrentUserProfile()` | Low |
| `DeviceTool` | Device | `lookupDeviceById()`, `searchDevices()` | Low |
| `DriverTool` | Driver | `lookupDriverById()`, `searchDrivers()` | Low |
| `ProfileTool` | Profile | `lookupProfileById()`, `searchProfiles()` | Low |
| `PointTool` | Point | `lookupPointById()`, `searchPoints()` | Low |
| `PointValueTool` | Point Value | `getLatestPointValue()`, `getPointValueHistory()`, `readPointValue()`, `writePointValue()` | **High (write)** |
| `SystemTool` | System | `getSystemHealth()` | Low |
| `CommandTool` | Command | `lookupCommandById()`, `searchCommands()` | Low |
| `EventTool` | Event | `lookupEventById()`, `searchEvents()` | Low |
The tool methods deliberately use different naming from the REST/gRPC layer. REST endpoints follow the project's
`getXxx`/`listXxx` convention; tool methods use `lookupXxx`/`searchXxx`. This separation lets the model
disambiguate between "fetch one by ID" (`lookupDeviceById`) and "paginated search" (`searchDevices`), which
is essential for the model to choose the right tool.
## Why this matters for industrial IoT
Industrial environments are the perfect use case for AI-assisted operations:
- **High cognitive load.** A factory floor operator monitors dozens of screens, hundreds of data points, and
must react to anomalies within seconds. The model can watch all of them simultaneously.
- **Structured, well-defined actions.** Unlike open-ended creative tasks, industrial operations have clear
boundaries: read a sensor, check a threshold, adjust an actuator. These map perfectly to tool-calling.
- **Auditability is non-negotiable.** Every AI-assisted action in DC3 is logged to `dc3_message` and
`dc3_action` tables. Who asked what, what the model decided, which tools it called, what the results were,
and who confirmed the write — all auditable.
- **Multi-vendor reality.** Factories have devices from Siemens, Rockwell, Mitsubishi, and a dozen other
vendors. DC3's 28 protocol drivers abstract this heterogeneity behind a uniform device model, and the AI
tools query that uniform model — the model doesn't need to know about Modbus register maps or OPC UA
node IDs.
## The roadmap: where this is going
The Agentic Center today handles descriptive and diagnostic operations: "what's the temperature," "why is it
rising," "show me the trend." The next frontier is *prescriptive* operations:
- **Anomaly-to-action pipelines.** The model detects an anomaly in the data stream, diagnoses the root cause
through tool calls, and proposes a corrective action — all before the operator opens the dashboard.
- **Scheduled health reports.** Every morning at 07:00, the Agentic Center generates a natural-language shift
report: which devices went offline overnight, which points trended abnormally, what maintenance actions
are recommended.
- **Multi-model routing.** Route simple queries to a fast, cheap model (GPT-4o mini); route complex diagnostics
to a reasoning model (GPT-5 or Claude); route sensitive on-premises queries to a local Ollama instance.
The `dc3_model_provider` table and per-conversation model selection already support this — the routing
logic is the next layer.
- **MCP for external agents.** While the Agentic Center is for human operators, the MCP endpoint (OAuth 2.1 +
JSON-RPC 2.0) lets external AI agents access the same tool surface — and the upcoming MCP ↔ Agentic bridge
will let a conversation escalate from an automated agent to a human operator seamlessly.
---
> **Next steps:** See [Agentic Center](./agentic) for the complete tool reference and configuration guide.
> See [AI Agent / MCP](./mcp) to connect external agents.
> See [Quick Start](../quickstart/first-device) to get your first device online.
---
# Authentication · Tenancy · RBAC
URL: https://docs.dc3.site/en/architecture/auth-rbac
# Authentication · Tenancy · RBAC
Only the gateway faces the outside world. But every protected call still has to answer three questions before it reaches
a backend service: who you are, which tenant you belong to, and whether you're allowed to do this. This page covers how
a login becomes a token, how the gateway signs that identity and passes it downstream, how the identity model is laid
out, and how RBAC and tenant isolation together close off both privilege escalation and cross-tenant access. By the end
you'll know the full path a request takes from `X-Auth-Token` to `@PreAuthorize`, and which settings must be in place
before you ship to production.
> You are here: you already know the tenant boundary from [Core Concepts](../introduction/concepts) and want to see how
> it's enforced at the auth layer. Read it alongside [Services & Topology](./services)
> and [Domain Model](./domain-model).
## Why authentication splits into "gateway signing + backend verification"
Center services talk to each other over gRPC facades, and each backend service (Manager, Data, Agentic) has its own HTTP
port — they just aren't exposed externally by default. That leaves a gap: any caller that can reach a backend port
directly only needs to forge a header that claims "I'm the admin of tenant A." If the backend trusts headers
unconditionally, it's been impersonated.
IoT DC3 closes that gap by separating *authenticated* identity from *trusted* identity:
- **Authentication** happens exactly once, at the gateway `dc3-gateway`. The gateway takes the `X-Auth-Tenant` /
`X-Auth-Login` / `X-Auth-Token` headers from the frontend, checks them against the auth center, and resolves the real
principal (tenant ID + identity ID).
- **Trust** travels as an HMAC-SHA256 signature. The gateway serializes the resolved identity into `X-Auth-Principal` (
JSON), signs it with a shared secret to produce `X-Auth-Sign`, and forwards both to the backend. The backend trusts an
identity header only when its signature checks out; if verification fails, the request is treated as anonymous.
So the backend never repeats the login check, and it can't be fooled by a bare forged header either — the signature can
only come from the secret shared between the gateway and the backend.
## Login and token issuance
Login is a two-step handshake: fetch a one-time salt, then hash the password with that salt to trade for a token. The
salt stops a plaintext password or a fixed hash from being replayed over the wire.
Both endpoints are public and need no authentication:
- `POST /api/v3/auth/token/salt`: send `tenant` and `name`. It first confirms the tenant exists, then returns a random
salt (a UUID); the response text suggests **use within 5 minutes** (the server does not store the salt or enforce the
timeout — "5 minutes" is only a client-side hint).
- `POST /api/v3/auth/token/generate`: send `tenant`, `name`, `salt`, and the **plaintext `password`** (the salt is NOT
mixed into the password — it is concatenated with the server-side key to sign the token). On success it returns an
access token, **valid for 12 hours** (`TOKEN_CACHE_TIMEOUT = 12` hours).
`generateToken` runs its checks in a fixed order. Any failure returns the same "no available authentication" error, so
the caller can't tell which step failed:
1. **Tenant**: `tenantCode` must resolve to an existing tenant.
2. **Credential**: locate `dc3_local_credential` by `loginName`.
3. **Membership**: the credential's `principalId` must be a member of the tenant (looked up via
`dc3_tenant_membership`).
4. **Salt**: the salt must not be empty.
5. **Password**: verification dispatches on the algorithm recorded in the stored hash (`ARGON2ID` or `BCRYPT`). The
default seed user `dc3` stores its password with BCrypt (cost=12), so the golden-path login actually runs BCrypt
verification. Only a newly encoded password prefers Argon2id (falling back to bcrypt when unavailable). A failure
records one failed login.
6. **Password expiry / forced change**: when `password_expire_time` has passed or `require_password_change=1`, it
throws "password change required" instead of issuing a token.
Only when all checks pass does it sign a JWT with `KeyUtil.generateToken(principalId, salt, tenantId)`. The token binds
to **principal_id + tenant_id**, not to the username. On logout, `(tenantCode:principalId)` goes onto a Caffeine
denylist, so later requests carrying an old token are rejected — even with a valid signature — because the token was
issued before the logout point.
```bash [curl login golden path]
# 1) Fetch salt
curl -s -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# Example response (use within 5 minutes): "a1b2c3d4-...-e5f6"
# 2) Hash password with salt, then exchange for token
curl -s -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"a1b2c3d4-...-e5f6","password":""}'
# Example response (valid for 12 hours): a JWT string
# 3) All subsequent protected requests carry the three headers
curl -s -X POST http://localhost:8000/api/v3/manager/device/add \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: ' \
-H 'Content-Type: application/json' \
-d '{"deviceName":"...","driverId":...,"profileId":...}'
```
## Gateway request and HMAC signature pass-through
Once you hold a token, every protected call sends the three headers `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` to
the gateway. The gateway's `AuthenticGatewayFilter` turns those three headers into a signed identity the backend can
trust.
On the gateway side (`AuthenticGatewayFilter`): identity resolution is a blocking gRPC call, so it runs on the
`boundedElastic` thread pool to keep the Netty event loop free. After resolving `PrincipalHeader`, it's serialized into
`X-Auth-Principal`. If HMAC is enabled, `X-Auth-Sign` is written too. **If HMAC is off, any inbound `X-Auth-Sign` header
is stripped**, so a client can't sneak a fake signature past the gateway to the downstream.
On the backend side (`GatewayJwtConverter`):
- No `X-Auth-Principal` → continue as anonymous.
- With HMAC enabled, recompute the HMAC over the principal payload with the same secret and compare against
`X-Auth-Sign` in **constant time**. Mismatch → reject.
- Once verification passes, parse the principal. If `tenantId` or `principalId` is missing, reject outright. Otherwise
load the permission set and hand it to `@PreAuthorize` for the decision.
The shared signing secret comes from `dc3.auth.hmac.secret` (or the `AUTH_HMAC_SECRET` environment variable). Its
default behavior depends on the environment — lenient in development, strict in production:
::: danger HMAC production fail-fast
In `pre` / `pro` environments, if `AUTH_HMAC_SECRET` is empty or still the default value `io.github.pnoker.dc3`, the
service **fails to start** (throws `IllegalStateException`). The decision logic lives in
`HmacAuthConfig.isProtectedEnvironment()`: it reads `spring.profiles.active` and `spring.env`, and turns on strict
validation when either matches `pre`/`pro`. Set it to a strong random value before you deploy.
:::
::: warning An empty key in dev/test only warns
When the key is empty in a non-protected environment, `HmacAuthSigner` doesn't error. It disables signing and prints a
prominent WARNING. At that point the backend **trusts `X-Auth-Principal` unconditionally**. That's fine for local
self-testing, but no externally reachable deployment should be left in this state.
:::
## Identity model: the principal is the root
Many platforms make the "user" the root object of authentication, so service accounts and system identities end up
crammed into the user table. IoT DC3 flips that: the root identity is **`dc3_principal`**, and a user is just one of its
types.
- **`dc3_principal`** is the unified identity table. `principal_type` is one of `USER` (a person), `SERVICE_ACCOUNT` (a
service account), or `SYSTEM` (a system identity).
- **Credentials attach to the principal**: `dc3_local_credential.principal_id` points at a principal, not at some
`user_id`. Password hashing defaults to Argon2id (BCRYPT is also supported). So the same identity model carries both
human and machine callers.
- **Tenant membership is explicit**: an identity's tenant isn't hardcoded on the identity. It's declared row by row in
`dc3_tenant_membership`. The unique index sits on `(tenant_id, principal_id)`, so **a USER can belong to several
tenants** (multiple rows). At login, `name + tenant` together pinpoint the membership. **SERVICE_ACCOUNT is
single-tenant by design**.
::: info External identity (identity provider) not yet implemented
The two tables `dc3_identity_provider` (external IdP configuration, e.g. OIDC/SAML) and `dc3_external_identity` (binding
of external identities to local principals) already exist in `02-iot-dc3-auth.sql`, and `principal.source_type` reserves
the `EXTERNAL` value. But the corresponding **login endpoint is not implemented and is disabled**. The only working
login path right now is the local-credentials flow above (`POST /api/v3/auth/token/salt` +
`/api/v3/auth/token/generate`).
:::
## RBAC: from identity to resource code
Once verification yields the principal, the next question is "what can it do." IoT DC3 uses the classic three-way "
subject — role — resource" binding, but deliberately splits the scope of two legs: role assignment is **per tenant**,
while resource authorization is **global**.
The chain is: `dc3_role_principal_bind` (carries `tenant_id`, so it picks the roles this principal has *within that
tenant*) → `dc3_role_resource_bind` (no `tenant_id`, so it maps roles to resources) → `dc3_resource` (a resource is a
permission code). Scoping role assignment to a tenant while keeping resources global means one role definition is reused
across tenants, and "who has this role in which tenant" never crosses wires.
A resource code is a three-segment `{spring.application.name}:{domain}:{scope}`, assembled at runtime by `@perm.can`
from the hosting service. For example, `@perm.can('device', 'add')` on `DeviceController` actually checks the string
`dc3-center-manager:device:add`, and `@perm.can('point_command', 'list')` on `PointCommandController` checks
`dc3-center-data:point_command:list`. Note that the seed data doesn't add a row for every API-level permission; the
default admin's resource code is the wildcard `*`, which covers every endpoint.
`AuthPermissionProvider` resolves permissions behind a short-lived cache:
- The cache key is **`(tenantId:principalId)`** with a TTL of **5 minutes** (`CACHE_TTL_MS = 300_000`). So after you
change an authorization, it can take up to 5 minutes to land on in-flight sessions.
- During resolution, every resource code that principal holds under that tenant is gathered into a set. When
`@PreAuthorize` decides, a hit on either a specific code or a wildcard lets the call through.
The most important part is the failure behavior — **fail-closed**:
::: danger No permission found = deny, not allow
When permission loading hits a transient failure, `GatewayJwtConverter` still builds an "authenticated but
permission-less" token (an empty authorities set). That's intentional fail-closed behavior: the caller counts as logged
in but with no permissions at all, and every `@PreAuthorize` guard returns **403** rather than dressing up a backend
hiccup as a 401 or letting the call slip through. When permissions can't be loaded, the default is no permission — never
the other way around.
:::
## Tenant isolation: controller-layer enforcement
RBAC decides "can you perform this kind of operation." Tenant isolation decides "can you touch this piece of data." The
two are orthogonal and both required — having `device:get` doesn't mean you can fetch another tenant's device. Isolation
lands at the controller layer (the database query layer does no automatic tenant pruning today; `MybatisPlusConfig`
registers only the pagination plugin):
**Controller layer `BaseController.requireTenant()`**: after looking up an entity by ID, it compares the entity's
`tenantId` against the caller's. On a mismatch (or if the entity doesn't exist) it throws `NotFoundException` and
returns **404** to the outside — deliberately "does not exist" rather than "no permission," so a cross-tenant probe
can't tell whether the resource is there. Batch queries go through `filterTenant()`, which drops any item that doesn't
belong to the current tenant.
::: warning There is no database-layer tenant safety net
Don't assume the SQL layer will fill in a missing tenant condition — the current implementation has **no** MyBatis-Plus
tenant-line interceptor; isolation relies entirely on the controller layer's `requireTenant` / `filterTenant`. When you
add a single or batch query, you must call these methods yourself to enforce the tenant scope, otherwise the query is
not pruned by tenant.
:::
```java
// Controller layer: if the entity looked up by ID does not belong to the current tenant, return 404 instead of 403
default T requireTenant(Long tenantId, T entity) {
if (Objects.isNull(entity) || !Objects.equals(tenantId, entity.getTenantId())) {
throw new NotFoundException("Resource does not exist");
}
return entity;
}
```
::: tip Preserve tenant scope when adding queries
Any new query, gRPC request, or cache key must carry tenant context: queries keep `tenantId`, cache keys include the
tenant, and cross-service fetches validate ownership first. Unless the data model explicitly defines a record as global,
don't write bypasses like `tenant_id IS NULL`.
:::
## Constraints and boundaries at a glance
The hard constraints scattered through the page, gathered in one place for a pre-deployment check:
| Item | Value / behavior | Source |
|------------------------------|-----------------------------------------------------------------------------|----------------------------------|
| Salt validity | Use within 5 minutes client-side (server does not enforce) | `POST /api/v3/auth/token/salt` |
| Token validity | 12 hours | `TOKEN_CACHE_TIMEOUT=12` hours |
| JWT binding | `principal_id` + `tenant_id` | `generateToken` |
| HMAC secret | `AUTH_HMAC_SECRET` / `dc3.auth.hmac.secret`, default `io.github.pnoker.dc3` | `HmacAuthConfig` |
| HMAC production check | empty or equal to default under `pre`/`pro` fails startup | `HmacAuthConfig` |
| Permission cache | key=`(tenantId:principalId)`, TTL 5 minutes | `AuthPermissionProvider` |
| Permission failure semantics | fail-closed -> empty permissions -> 403 | `GatewayJwtConverter` |
| Cross-tenant ID query | returns 404 (not 403) | `BaseController.requireTenant()` |
| External identity login | tables created, endpoint unimplemented/disabled | `02-iot-dc3-auth.sql` |
## Further reading
- [Services & Topology](./services) — how the gateway, the four centers, and drivers are distributed, plus ports and
startup order
- [Domain Model](./domain-model) — the DO/BO/VO layering and how `TenantOwned` and the tenant field thread through
entities
- [API Documentation](../development/api-documentation) — OpenAPI, authentication headers, and the CRUD verb convention
- [IoT Security](../foundations/security) — a systematic view of device, comms, platform and data security
---
# Command Plane: Dispatching Read/Write Commands and Their Receipts
URL: https://docs.dc3.site/en/architecture/command-plane
# Command Plane: Dispatching Read/Write Commands and Their Receipts
The data plane pulls values up from devices. The command plane runs the other way: it takes a "read this point" or "
write this value" request and drives it from the HTTP entry point through the data center, over RabbitMQ, into the
driver, out to the device, and back as a receipt. This page traces that full path and the state machine behind it —
submission, validation, persistence, dispatch, driver execution, and receipt — so you understand why submitting a
command is "take a ticket, poll for the result," and what each status means when something fails.
> You are here: you already know the collection flow from the [data plane](./data-plane). Now look at the reverse
> direction — command dispatch. A command can originate from the Web UI, the CLI, or AI (
> see [Data and Commands](../operation/data-commands)).
## Why "Asynchronous Ticket + Polling"
Dispatching a command crosses processes, crosses the network, and finally lands on a physical device. Any hop along the
way can be slow or can fail. If the HTTP request blocked until the device finished executing, gateway threads would be
tied up for a long time — and an offline device or a protocol timeout could drag the whole call chain down with it.
So the command plane splits "submission" from "result." After `POST /api/v3/data/point_command/read` and
`POST /api/v3/data/point_command/write` validate the request at the data center, persist the command as `PENDING`, and
publish it to RabbitMQ, they **return a `commandId` immediately** (a 36-character UUID). The caller takes that ticket
and polls the history endpoint to see where the command stands, whether it succeeded or failed, and what value the
device returned.
The chain starts at `PointCommandController` (`dc3-common-data`). Both endpoints require the `point_command:list`
permission. The request body supplies `deviceId` / `pointId` (write commands also need `value`), and may carry a
`commandId` to make submission idempotent — resubmitting the same `commandId` returns the existing record and never
dispatches a duplicate.
## The Journey of a Write Command
The sequence diagram below shows the happy path: the caller submits, the driver writes the value into the device and
acknowledges success, and the caller polls and gets the result.
### Submission Side: Validate, Persist, Publish
Before dispatching, `PointCommandServiceImpl` runs a sequence of checks. A failure at any step throws immediately, and
nothing is enqueued:
- **Tenant scope**: `deviceId` / `pointId` must belong to the current tenant, and the device's bound `profileId` must
match the point's `profileId`. A mismatch is rejected as an authorization violation.
- **Enabled status**: both the device and the point must have `enableFlag` enabled. A disabled device or point takes no
commands.
- **Writability (write commands only)**: the point's `rwFlag` must be `WRITE_ONLY` or `READ_WRITE`. Writing a
`READ_ONLY` point is rejected ("Point is not writable"). This matches the [Core Concepts](../introduction/concepts)
rule that "the Point itself decides read/write."
- **Driver online**: the owning driver's status is looked up in `dc3_entity_state`. Anything other than `ONLINE` is
rejected ("Driver is offline").
When validation passes, the command is written to `dc3_point_command_history` as `PENDING`, then published with
`rabbitTemplate.convertAndSend(...)` and `CorrelationData` set to `commandId` — so RabbitMQ's publisher-confirm maps
exactly to this one command. After the publish call returns, the record moves to `SENT` and `sendTime` is written.
### The Delivered Payload: PointCommandDTO
What crosses RabbitMQ is not a loose JSON string but a strongly typed record, `PointCommandDTO`. Its `payload` field is
a `sealed` interface, and every time field uses `Instant` (UTC):
```java
public record PointCommandDTO(
String commandId, // one-to-one with history record and receipt
Long tenantId, // tenant isolation
PointCommandTypeEnum type, // READ / WRITE / ...
PointCommandPayload payload, // ReadPayload | WritePayload (polymorphic)
PointCommandSourceEnum source,
Long sourceUserId,
Instant occurredAt,
Instant expireAt, // defaults to occurredAt + 10s
int schemaVersion
) { }
```
`PointCommandPayload` is a sealed interface with exactly two implementations, `ReadPayload(deviceId, pointId)` and
`WritePayload(deviceId, pointId, value)`. The driver dispatches them with a `switch` pattern match, and all branches are
exhaustively checked at compile time.
::: warning expireAt defaults to only 10 seconds
`PointCommandDTO.ofRead()` / `ofWrite()` set `expireAt` to `Instant.now().plusSeconds(10)`. If a command backs up in the
queue, or `now > expireAt` already holds when the driver picks it up, it is judged `EXPIRED` and not executed. This is
short-lived semantics designed for collection-style commands — don't treat it as a long-running task that can sit in a
queue.
:::
### Driver Side: Precheck, Dedup, Lock, Execute
The driver consumes the command queue through `PointCommandReceiver`. Once it picks up a command, the order is fixed:
1. **Basic validation**: if any of `commandId` / `tenantId` / `type` / `payload` is null, or the read/write payload is
missing a field, the message is `reject`ed directly (sent to the dead-letter queue, not requeued).
2. **expireAt precheck**: `now > expireAt` -> receipt `EXPIRED`; the device is never touched.
3. **Dedup**: `tryAcquire(commandId)` against a Caffeine dedup cache (5-minute expiry, capped at 50,000 entries). A hit
means this command already ran, so the receipt is `DUPLICATE`.
4. **Per-device serial lock**: acquire the device's `ReentrantLock` through `DeviceLockManager` (reference-counted for
creation and reclamation). This keeps multiple commands on the same device from interleaving and scrambling the
protocol timing.
5. **Read/write dispatch**: `ReadPayload` calls `driverReadService.read(...)`; `WritePayload` calls
`driverWriteService.write(...)`.
::: danger A failed write returns no value
A write command **counts as successful only when `driverWriteService.write()` returns `Boolean.TRUE`**. Then the receipt
is `SUCCESS` and carries the value just written. The moment it returns `false`, the receipt is `FAILED` with
`responseValue=null` — **no value is echoed**. That's deliberate: echoing a value on a failed write would mislead upper
layers into thinking the command landed and the device state changed — a false success. When you see `FAILED`, read it
as "this write did not take effect."
:::
## The Command Lifecycle
A command's status is defined by `PointCommandStatusEnum`. The flow from submission to terminal state is below. The
submission side owns `PENDING -> SENT`; every terminal state after that is produced by the receipt the driver emits on
consumption and written back through the result queue.
The index and meaning of each status (`PointCommandStatusEnum`, with the persisted `status` value in parentheses):
| Status | index | Meaning |
|-------------|-------|-------------------------------------------------------------------------------------|
| `PENDING` | 0 | Submitted, awaiting publish |
| `SENT` | 1 | Published to broker, awaiting driver processing |
| `SUCCESS` | 2 | Driver confirmed success |
| `FAILED` | 3 | Driver reported failure (write failure / exception after requeue) |
| `TIMEOUT` | 4 | Application-layer timeout (reserved in enum, not yet produced by the current chain) |
| `EXPIRED` | 5 | `expireAt` had already passed before execution |
| `DEAD` | 6 | Rejected into the dead-letter queue, no longer processed |
| `DUPLICATE` | 7 | Judged a duplicate by the driver's dedup cache |
`EXPIRED` is set by the driver when `now > expireAt` at consumption; `DUPLICATE` comes from a dedup-cache hit.
::: info TIMEOUT currently has no producer
`PointCommandStatusEnum` reserves `TIMEOUT(4)`, but no code in the current chain ever sets a command to this status.
`SUCCESS` / `FAILED` / `EXPIRED` / `DUPLICATE` / `DEAD` each have a clear production path; `TIMEOUT` alone is an enum
slot held for future application-layer timeout semantics, which is why the state machine annotates it with a note rather
than an active edge.
:::
A command's `type` comes from `PointCommandTypeEnum`: `READ(0)` / `READ_BATCH(1)` / `WRITE(2)` / `WRITE_BATCH(3)` /
`CONFIG(4)`. The current read/write endpoints dispatch `READ` and `WRITE`.
### The Error Path: Requeue Once, Then Record the Failure
When driver execution throws, the handling is built to stop a "poison message" from looping in the queue forever:
- **First failure (not a requeue)**: release the command's dedup hold and `nack(requeue=true)` to put the message back
on the queue for one more attempt.
- **Still failing after requeue**: don't requeue again. Emit a `FAILED` receipt directly (`errorCode=DRIVER_ERROR`) and
`ack` the message so it leaves the queue.
Each command is attempted by the driver at most twice — one chance for a transient fault to clear on its own, and a hard
stop before a perpetually failing command loops forever.
## The Command RabbitMQ Topology
The command chain uses two sets of exchanges and queues. One set carries commands from the data center to the driver;
the other carries receipts back. Command queues are partitioned by the driver's `serviceName`, with a 30-second TTL and
a dead-letter exchange. The result queue has a 60-second TTL.
The command queue `dc3.q.point_command.{serviceName}` is durable with `ttl(30000)` and dead-letters to
`dc3.e.point_command_dead`. Two paths reach the dead-letter queue: a command the driver never consumes within 30
seconds (TTL expiry), or the driver `reject`ing it (no requeue) when basic validation fails. Either way, nothing lingers
in the original queue.
Receipts travel over `dc3.e.point_command_result` (topic). The result queue `dc3.q.point_command_result` has
`ttl(60000)` and is consumed by the data center's `PointCommandResultReceiver`: it looks up the history record by
`commandId` and writes the terminal `status`, `responseValue`, `errorCode` / `errorMessage`, and `finishTime`.
## Submission and Polling: The Real Routes
Dispatching a write command and polling for its result are two independent HTTP calls. All paths forward through the
gateway (`http://localhost:8000`), and protected endpoints require the three auth headers `X-Auth-Tenant` /
`X-Auth-Login` / `X-Auth-Token`.
::: code-group
```bash [Submit write command]
# Write value 25.5 to device 1024, point 2048; returns commandId (example UUID)
curl -X POST http://localhost:8000/api/v3/data/point_command/write \
-H "X-Auth-Tenant: " \
-H "X-Auth-Login: " \
-H "X-Auth-Token: " \
-H "Content-Type: application/json" \
-d '{"deviceId": 1024, "pointId": 2048, "value": "25.5"}'
# → {"code":"...","data":"9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", ...}
```
```bash [Poll result]
# Use the commandId from previous step to query history; check status and responseValue
curl "http://localhost:8000/api/v3/data/point_command_history/get_by_command_id?commandId=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" \
-H "X-Auth-Tenant: " \
-H "X-Auth-Login: " \
-H "X-Auth-Token: "
```
:::
Polling returns a `PointCommandHistoryVO`. The query is tenant-isolated (`getByCommandId(tenantId, commandId)`), so one
tenant cannot read another tenant's command records. The command is done as soon as `status` reaches any terminal state
above. For write commands, watch `responseValue`: on `SUCCESS` it holds the echoed written value, and on `FAILED` it is
always `null` (see "A failed write returns no value" above).
::: info A separate namespace from "Custom Commands"
This page covers **point read/write** (`point_command`): exchange `dc3.e.point_command`, DTO `PointCommandDTO`, table
`dc3_point_command_history`. The platform also has a separate set of **Custom Commands** that travel over
`dc3.e.command` with DTO `CommandCallDTO`, used for device-level actions defined on a Profile. The two are structurally
similar but isolated — don't mix their routing keys, DTOs, or history tables.
:::
## Further Reading
- [Data Plane](./data-plane) — the reverse point-value collection chain: exchanges, queues, TimescaleDB persistence
- [Core Concepts and Mental Model](../introduction/concepts) — why a point's `rwFlag` decides its writability
- [Driver Development](../development/driver-authoring) — the `DriverProtocol.write()` contract returning `Boolean`, and
the command processing pipeline
- [Data and Commands](../operation/data-commands) — dispatching commands as a user, handling offline and read-only
points
---
# Data Plane: How a Point Value Lands in Storage
URL: https://docs.dc3.site/en/architecture/data-plane
# Data Plane: How a Point Value Lands in Storage
A raw register read on a device has a long way to go before it becomes a queryable value. It passes through driver
normalization, the message bus, and the data center, and only then is it written to the time-series store. This page
follows that journey end to end: the exchanges and queues on the path, how the consumer persists the message, the layers
the value moves through, and how the latest-value cache is hit on reads. By the end you'll know every hop from device to
API, and the hard constraints that apply to aggregate queries.
> You are here: you already know Point and PointValue from [Core Concepts](../introduction/concepts) and want to see the
> data flow. For the reverse read/write commands, see the [Command Plane](./command-plane).
## The Journey of a Value
The data flow is a one-way **south-to-north** link. Drivers poll their devices periodically, wrap each read of a point
into a `PointValue`, and publish it to RabbitMQ's value exchange via `DriverSenderService.pointValueSender()`. The data
center `dc3-center-data` consumes the queue, writes each value to TimescaleDB, and at the same time pushes it into a
local Caffeine cache for hot reads. The whole link is **asynchronous** — once the driver publishes, it does not wait for
the data center to confirm the write. Durable delivery on the bus plus manual ack make sure nothing is lost.
The driver's routing key is `dc3.r.value.point.` plus its own service name (`driverProperties.getService()`, e.g. the
service name you configured for a `dc3-driver-virtual` instance). The data center's queue binds with the wildcard
`dc3.r.value.point.*`, so a single queue receives values from every driver. When publishing, `pointValueSender()` fills
in `driverId` and `tenantId` from `DriverMetadata` if the message doesn't already carry them. It uses
`PointValueCorrelation` (a random UUID plus deviceId and pointId) as correlation data, paired with publisher confirms to
track delivery.
## RabbitMQ Topology: Value Exchange, Point Queue, Dead Letters
The value channel is a single **topic exchange** `dc3.e.value`, with one **durable queue** `dc3.q.value.point` bound
under it. The queue is declared in the data center's `DataTopicConfig`:
```java
QueueBuilder.durable(RabbitConstant.QUEUE_POINT_VALUE) // dc3.q.value.point
.ttl(604800000) // 7 days = 604800000 ms
.deadLetterExchange(RabbitConstant.TOPIC_EXCHANGE_POINT_VALUE_DEAD) // dc3.e.point_value_dead
.deadLetterRoutingKey("#")
.build();
```
Three things to know:
- **Durable + 7-day TTL**: the queue is `durable`, and messages are stamped `PERSISTENT` on publish (`RabbitConfig`'s
`BeforePublishPostProcessor` sets `MessageDeliveryMode.PERSISTENT` on every message). A message stays in the queue for
at most 7 days (`604800000` ms); if it isn't consumed before the timeout, it dead-letters.
- **Dead-letter fallback**: messages that time out or are `reject`ed flow to the dead-letter exchange
`dc3.e.point_value_dead` (dead-letter queue `dc3.q.point_value_dead`). They aren't dropped silently — you can inspect
them there.
- **Wildcard binding**: the queue binds to `dc3.e.value` with `dc3.r.value.point.*`, so one queue collects point values
from all driver instances.
::: info Exchange/queue names carry an environment prefix
Constants in `RabbitConstant` like `dc3.e.value` and `dc3.q.value.point` are prefixed with an environment `tag` at
assembly time. This page uses the stable suffix names with the prefix stripped, so you can find them by name in the
RabbitMQ management console.
:::
## Consumer: How PointValueReceiver Persists
The data center's `PointValueReceiver` listens on the point queue with
`@RabbitListener(queues = "#{pointValueQueue.name}")` and deserializes the payload into `PointValueBO` (JSON via
`JacksonJsonMessageConverter`). It uses **manual ack**:
- **Validation**: if `pointValueBO` is null or missing `deviceId` → `RabbitAckUtil.reject` (`basicReject` without
requeue) → dead letters.
- **Persistence**: one of two paths, picked by the inbound rate. Below `POINT_BATCH_SPEED` (default 100), it calls
`pointValueService.save(pointValueBO)` for **immediate persistence**. Above the threshold, it hands the value off to
`PointValueJob` for **batch processing**. The rate is `speed = count / interval`, where `POINT_BATCH_INTERVAL` (
default `5`, in **seconds**, Quartz `IntervalUnit.SECOND`) is the divisor — not the flush interval. `PointValueJob`
runs on a Quartz schedule and flushes the entire accumulated buffer each time it fires, regardless of buffer size.
There is no batch-size trigger and no first-in-first-flush.
- **Acknowledgment**: success → `RabbitAckUtil.ack`. A thrown exception → `RabbitAckUtil.nack(requeue=true)`, which
requeues for retry.
::: info Consumer concurrency is the default tier, not the high-throughput tier
`PointValueReceiver` doesn't specify a `containerFactory`, so it uses the default listener container factory:
`concurrentConsumers=2`, `maxConcurrentConsumers=8`, `prefetchCount=10`, `AcknowledgeMode.MANUAL`. `RabbitConfig` also
exposes a high-throughput factory, `highThroughputRabbitListenerContainerFactory` (`concurrent=4`, `max=32`,
`prefetch=100`), but no listener currently opts in. If you need higher throughput, add
`containerFactory="highThroughputRabbitListenerContainerFactory"` to the `@RabbitListener` explicitly. The code is
authoritative: `dc3-common-rabbitmq/.../RabbitConfig.java`.
:::
`pointValueService.save()` does two things. First it writes the value into the local Caffeine latest-value cache (
`PointValueLocalCache`, key = `REAL_TIME_VALUE_KEY_PREFIX + tenantId + "." + deviceId + "." + pointId`, dot-separated
and prefixed) and into the time-series store. Then it hands the value straight to the alarm engine for evaluation.
## Model Transformation: Six Faces Across the Layers
The same "point value" changes form six times along the link, and each layer owns its own shape and serialization. The
part that trips people up: **`PointValue` and `PointValueBO` are not the same class.** The first is the sending bean on
the driver side; the second is the message/business object on the consumer side. They belong to different layers.
Layer by layer:
- **`ReadPointValue`** (driver): the raw reading returned by the driver protocol layer's `read()`, carrying device and
point context.
- **`CalculatedPointValue`** (driver): the result of applying linear scaling/projection (`baseValue`/`multiple`, etc.)
to the raw value. It computes `finalValue` (the engineering-value string) and `numericValue` (the numeric projection,
which may be empty).
- **`PointValue`** (driver sending bean): `new PointValue(readPointValue)` runs `calculate()` internally, filling in
`rawValue`/`calValue`/`numValue` and `createTime` (the moment of acquisition). This is the payload published to
RabbitMQ.
- **`PointValueBO`** (message/business): the object the data center deserializes from the queue. It carries `tenantId`,
`createTime`, and `operateTime`, and is the input to persistence and alarm evaluation.
- **`PointValueDO`** (persistent): the database shape written to `dc3_point_value`, with `num_value` as a nullable
`DOUBLE`.
- **`PointValueVO`** (API): the shape returned to clients by read endpoints. It exposes `deviceId`/`pointId`/`rawValue`/
`calValue`/`numValue`/`createTime`/`operateTime`/`hasLatestValue`/`driverId`/`tenantId`.
## Storage: dc3_point_value Is a TimescaleDB Hypertable
Values land in a TimescaleDB **hypertable**, `dc3_history.dc3_point_value` (it lives in the `dc3_history` schema;
`search_path` includes `dc3_history, public`, so this page and queries often shorten it to `dc3_point_value`). It is
partitioned along two dimensions: the time dimension `create_time` with one chunk per **1 day**, and the device
dimension `device_id` with **16** hash buckets.
```sql
SELECT create_hypertable('dc3_point_value', by_range('create_time', INTERVAL '1 day'));
SELECT add_dimension('dc3_point_value', by_hash('device_id', 16));
```
To control storage and query cost, this hypertable also has two data-lifecycle policies:
```sql
-- chunks older than 7 days are compressed automatically (segmented by tenant/device/point, ordered by create_time)
ALTER TABLE dc3_point_value SET (timescaledb.compress,
compress_segmentby='tenant_id,device_id,point_id', compress_orderby='create_time DESC');
SELECT add_compression_policy('dc3_point_value', INTERVAL '7 days');
-- data older than 180 days is dropped automatically
SELECT add_retention_policy('dc3_point_value', INTERVAL '180 days');
```
::: tip Compression and retention are on by default
Compressing after 7 days cuts disk usage significantly (compressed chunks stay queryable, just write-restricted); the
180-day retention policy drops expired chunks automatically. If your workload needs a longer horizon, tune both
intervals at deployment.
:::
Key columns and indexes:
| Column | Type | Description |
|----------------|---------------------------------|-------------------------------------------------------------------------|
| `raw_value` | `TEXT NOT NULL` | Raw value read from the device |
| `cal_value` | `TEXT NOT NULL` | Value after scaling/projection |
| `num_value` | `DOUBLE PRECISION` **nullable** | Numeric projection of `cal_value`; `NULL` for non-numeric/JSON payloads |
| `create_time` | `TIMESTAMPTZ NOT NULL` | **Acquisition moment** (driver-side read) |
| `operate_time` | `TIMESTAMPTZ NOT NULL` | **Persistence moment** (data-center write) |
The primary time-series index `idx_point_value_ts_lookup` is `(tenant_id, device_id, point_id, create_time DESC)`. Both
tenant-isolated latest-value lookups and time-window scans use it. There's also a **partial index**
`idx_point_value_num_time ... WHERE num_value IS NOT NULL`, dedicated to numeric aggregation.
::: warning create_time and operate_time are two distinct moments
`create_time` is when the driver read the value; `operate_time` is when the data center wrote it to the store. The two
are kept apart on purpose — their difference is the acquisition-to-persistence pipeline latency, which dashboards use to
measure link delay. On persistence, `save()` always rewrites `operate_time` to the current time, and fills in the
current time for `create_time` only when it's missing.
:::
::: danger num_value is nullable: aggregate queries must use num_value IS NOT NULL
`dc3_point_value.num_value` is `NULL` for non-numeric or JSON payloads. Any aggregation such as `AVG`/`SUM`/`MAX`/`MIN`
**must** add `WHERE num_value IS NOT NULL`. Without it, null values from string-typed points get mixed in and skew the
result. The partial index `idx_point_value_num_time` also covers only rows where `num_value IS NOT NULL` — skip the
predicate and you miss the index too.
:::
## Message Reliability and Post-Persistence Alarms
The data plane's no-loss guarantee rests on three overlapping mechanisms, all wired up in the shared `RabbitConfig`:
- **Durable delivery**: the publish pre-processor stamps every message `PERSISTENT`, which — together with the `durable`
queue — survives a broker restart.
- **Manual ack**: the consumer only `ack`s after a successful write. On exception it `nack(requeue=true)` to retry; on
validation failure it `reject`s to dead letters. A message is never silently swallowed.
- **Publisher confirms**: `rabbitTemplate` registers a confirm callback that logs an error on NACK. The correlation data
`PointValueCorrelation` lets you map a confirmation back to the specific device and point.
Once a value is persisted, `PointValueServiceImpl.save()` calls
`alarmRuleTriggerService.processPointValue(pointValueBO)` right away, evaluating alarm rules against that value *
*synchronously** — not on a separate delayed link. For the rules, the state machine, and notification channels,
see [Alarms and Notifications](../operation/alarms).
## Reading the Latest Value: Cache First, Time-Series Store on Miss
The write path pushes the latest value into Caffeine at the same time it writes the store; the read path hits that cache
first. `POST /api/v3/data/point_value/latest` enters `PointValueServiceImpl.latest()`, which batch-queries the cache via
`pointValueLocalCacheService.selectLatestPointValue(tenantId, deviceId, pointIds)`, collects the pointIds that missed,
and then falls back to TimescaleDB in a single pass (`repositoryService.listLatestPointValues`) to fill in the rest.
The historical-range query `POST /api/v3/data/point_value/list` skips the cache. It scans the time-series store directly
via `repositoryService.listPagePointValue(query)`, filtered by `startTime`/`endTime`. Both read endpoints are guarded by
`@PreAuthorize("@perm.can('point_value', 'list')")`, return a paginated `PointValueVO`, and force tenant context through
`PointValueQuery` — you can't pull another tenant's data.
## How To: Read Latest Values and History
Both read endpoints are forwarded through the gateway `dc3-gateway` (:8000). Protected endpoints must carry the three
auth headers `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` (for the salt-fetch + token-issue flow,
see [Quick Start](../quickstart/)).
::: code-group
```bash [latest value curl]
curl -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H "X-Auth-Tenant: default" \
-H "X-Auth-Login: " \
-H "X-Auth-Token: " \
-H "Content-Type: application/json" \
-d '{"deviceId": 1024, "pointId": 2048, "current": 1, "size": 10}'
```
```bash [historical range curl]
curl -X POST http://localhost:8000/api/v3/data/point_value/list \
-H "X-Auth-Tenant: default" \
-H "X-Auth-Login: " \
-H "X-Auth-Token: " \
-H "Content-Type: application/json" \
-d '{"deviceId": 1024, "pointId": 2048, "current": 1, "size": 50,
"startTime": "2026-06-22T00:00:00", "endTime": "2026-06-22T23:59:59"}'
```
:::
The response is a paginated `PointValueVO`. Each record holds one latest (or in-range) value for a point:
```json
{
"code": "200",
"data": {
"current": 1,
"size": 10,
"total": 1,
"records": [
{
"deviceId": 1024,
"pointId": 2048,
"rawValue": "23.5",
"calValue": "23.5",
"numValue": 23.5,
"hasLatestValue": true,
"createTime": "2026-06-22T08:30:00",
"operateTime": "2026-06-22T08:30:01"
}
]
}
}
```
::: tip Field names follow the actual response
The values above are examples. `PointValueVO`'s exposed fields (`rawValue`/`calValue`/`numValue`/`createTime`/
`operateTime`, and so on) are mapped from `PointValueDO` by a MapStruct builder. For integration, defer to the actual
JSON the gateway returns.
:::
## Constraints and Boundaries
- **Aggregations must carry `num_value IS NOT NULL`**: see the danger callout above. It's a hard prerequisite for
correct numeric statistics.
- **`PointValue` ≠ `PointValueBO`**: don't swap the driver sending bean for the business object across layers — their
field sets and serialization contexts differ.
- **Consumer concurrency is the default tier**: the point queue runs on the default listener factory (prefetch=10,
concurrency 2–8). The high-throughput factory exists but isn't enabled; opt in explicitly when you need it.
- **Tenant isolation is enforced at the controller layer**: read endpoints carry tenant context via `PointValueQuery`,
and after fetching, the controller layer's `requireTenant` / `filterTenant` checks the tenant; cross-tenant access
returns no data.
- **Dead letters aren't loss**: values that time out or get rejected land in `dc3.e.point_value_dead`. When
troubleshooting, look in the dead-letter queue.
## Further Reading
- [Command Plane](./command-plane) — how the reverse read/write commands are dispatched, acknowledged, and queried for
status
- [Domain Model](./domain-model) — the DO/BO/VO layering and field details of Point / PointValue
- [Alarms and Notifications](../operation/alarms) — how alarm rules are evaluated after persistence and how
notifications are delivered
- [Time-Series & Streaming](../foundations/data-pipeline) — the general principles of time-series databases and stream
processing
---
# Domain Model: DO / BO / VO and Object Relationships
URL: https://docs.dc3.site/en/architecture/domain-model
# Domain Model: DO / BO / VO and Object Relationships
This page is for anyone writing code on the platform. It maps out how Profile, Point, Command, Event, Device, and Driver
fit together; untangles the "three-tier configuration" (Param / Attribute / Config) that trips people up most; and shows
how a value moves between the DO, BO, and VO tiers through MapStruct `*Builder`. Read it once and you'll know how to add
fields and enums correctly, and what shape a value takes anywhere in a `*Controller → *Service → *Manager` call chain.
> You are here: you've already seen the object relationships in [Core Concepts](../introduction/concepts), and now
> you're going one level deeper into fields and tiers. From here you can move on to the [Data Plane](./data-plane) (how
> point values are persisted) or [Driver Authoring](../development/driver-authoring) (turning these objects into a real
> driver).
## Everything starts with Profile
The IoT DC3 domain model has one root: the **template Profile**. A Profile is not a device — it's a capability manifest
for a class of devices. It declares which **Points** that class can read and write, which custom **Commands** it
supports, and which **Events** it reports. Put the capabilities on the template, and a device inherits the whole set by
binding to it. You don't redefine them device by device.
A **Device** is the platform-side mirror of one physical device. It makes two bindings: it binds to a Profile (which
decides its capabilities) and to a **Driver** (which decides the protocol it speaks). There's a hard constraint that
landed after Phase 1: `DeviceDO.profileId` is a **single foreign key** (one `Long`). The early many-to-many
`ProfileBind` is gone — a device binds to exactly **one template**.
::: danger A device and a template are one-to-one; stop designing for many-to-many
`dc3_device.profile_id` is a single-valued foreign key (`DeviceDO.java`). When you write queries that ask "where do this
device's capabilities come from?", frame them as "device → one Profile". Don't assume a device can carry multiple
templates.
:::
A Point is the smallest unit of data. Two flags decide what it can do:
- `pointTypeFlag` (`PointTypeEnum`) — the value's data type.
- `rwFlag` (`RwTypeEnum`) — the read/write direction. **Whether a point is writable is decided by its own `rwFlag`, not
by the command table.** Writing to a `READ_ONLY` point is rejected during command validation.
A point also carries engineering-quantity metadata: `unit`, `valueDecimal` (decimal precision, default `6`), and the
linear conversion `baseValue` / `multiple`. The driver applies these to turn a raw collected value into an engineering
value (the semantics are `engineering value = raw value × multiple + baseValue`).
::: info There are actually 8 point-type enums, not 4
For readability, `introduction/concepts` and the Add Point API table list only `STRING / INT / FLOAT / DOUBLE`. In the
source, `PointTypeEnum` actually has 8 values:
`STRING(0) / BYTE(1) / SHORT(2) / INT(3) / LONG(4) / FLOAT(5) / DOUBLE(6) / BOOLEAN(7)` (`PointTypeEnum.java`). `rwFlag`
maps to `RwTypeEnum`: `READ_ONLY(0) / WRITE_ONLY(1) / READ_WRITE(2)`. The code is the source of truth.
:::
### Domain entity relationships
The diagram pulls the root Profile, its three sub-capabilities, the device and driver bindings, and the attribute/config
relationships from the "three-tier configuration" into one picture. It's more complete than the one
in [Core Concepts](../introduction/concepts) — it also shows the protocol-tier `*Attribute` and the instance-tier
`*AttributeConfig`.
`profileShareFlag` (`ProfileShareTypeEnum`: `TENANT / DRIVER / USER`) controls a template's sharing scope. An `Event`'s
`event_type_flag` (`0=info / 1=alert / 2=fault / 3=lifecycle`) classifies the event **definition** and lives in the
`dc3_event` table (the management domain, created by `04-iot-dc3-manager.sql`). People often confuse this with alarms —
covered separately below.
## Three-tier configuration: Param, Attribute, and Config each own their slice
This is the most-misunderstood part of the domain model. The platform splits "configuration" into **three tiers with
different scopes**. Each tier answers a different question, is produced by a different person or process, and maps to a
different DO class:
- **Param (business tier)** — `CommandParamDO` / `EventParamDO`. Describes the input/output params of a command or event
in the template. It's **business semantics**, independent of any specific protocol.
- **Attribute (protocol tier)** — `DriverAttributeDO` / `PointAttributeDO` / `CommandAttributeDO` / `EventAttributeDO`.
**Registered by the driver at startup.** The driver reads its own `application.yml` and tells the management center, "
here are the config items my protocol needs." A Modbus driver, for example, declares "a point needs a register
address" — that's an Attribute. It defines **which items exist**, with no values.
- **Config (instance tier)** — `PointAttributeConfigDO` (plus `DriverAttributeConfigDO` / `CommandAttributeConfigDO` /
`EventAttributeConfigDO`). Stores the **concrete values** that **this device** fills in for those attributes. The core
fields of `PointAttributeConfigDO` are exactly `attributeId` (which attribute) + `deviceId` + `pointId` +
`configValue` (the value). So "device #3's temperature point has register address 40001" — `40001` lives here.
In one line: **Attribute says "there's a slot"; Config says "what goes in the slot."** Once that clicks, the "configure
point attributes" step in [Device Onboarding](../operation/device-onboarding) makes sense, and so does what
`POST /api/v3/manager/point_attribute_config/add` actually writes (its request fields are exactly `attributeId` /
`deviceId` / `pointId` / `configValue`).
## Three shapes of the same data: DO / BO / VO
A domain object takes three forms in the system, one per tier and per concern. Using a point as the example: `PointDO` /
`PointBO` / `PointVO`.
- **DO (`*DO`, e.g. `PointDO`) — the database shape.** It mirrors the `dc3_point` table. Flags are raw `Byte` (
`pointTypeFlag`, `rwFlag`, `enableFlag` are all `Byte`), with MyBatis-Plus annotations `@TableName` /
`@TableId(type = ASSIGN_ID)` (Snowflake ID) / `@TableLogic` (logical delete on `deleted`) / `JacksonTypeHandler` for
JSON extensions. DOs live only in the persistence tier. **Raw `Byte` flags must not leak into the business tier or
external responses.**
- **BO (`*BO`, e.g. `PointBO`) — the business shape.** The same flags become **domain enums**: `pointTypeFlag` is
`PointTypeEnum`, `rwFlag` is `RwTypeEnum`, `enableFlag` is `EnableFlagEnum`. A BO extends `BaseBO` and implements
`TenantOwned` (carrying `tenantId`, the starting point of tenant isolation). Business code and inter-Service calls
pass BOs, not VOs. Conversion fields use `BigDecimal` in the BO (`baseValue` / `multiple`) and become `Double` when
persisted to the DO — the `*Builder` handles that precision boundary too.
- **VO (`*VO`, e.g. `PointVO`) — the API shape.** Controller requests and responses use VOs. Like the BO, it uses domain
enums, unless a raw numeric value has to be kept for backward compatibility with old clients.
The diagram shows where the three tiers sit in the call chain and the conversion directions handled by the MapStruct
`*Builder`.
When `PointController` receives a `PointVO`, it calls `PointBuilder.buildBOByVO()` to get a `PointBO` and hands that to
`PointService`. The Service calls `buildDOByBO()` to get a `PointDO` and hands that to `PointManager` for persistence.
Reads go the other way: `buildBOByDO()` → `buildVOByBO()`. Raw Mapper methods like `select*` appear only in
`*ManagerImpl`. Service and Controller always use `get*` / `list*` / `add` / `update` / `delete` (see the CRUD verb
convention in the [API Documentation](../development/api-documentation)).
## Enums and JSON extensions: `@AfterMapping` is the key
MapStruct maps same-name, same-type fields automatically. It does not handle the `Byte ↔ domain enum` or
`JSON string ↔ extension object` conversions — you write those by hand in the `*Builder`'s `@AfterMapping` hooks. That's
exactly what keeps the DO/BO/VO layering from leaking.
The contract on both ends of an enum is fixed: the `Byte` stored in the DO is the `index` annotated with `@EnumValue` on
the enum. DO→BO uses `XxxEnum.ofIndex(byte)` to turn the number into an enum; BO→DO uses `enum.getIndex()` to get the
number back. The sequence below is what happens in `PointBuilder` when it reads a row of point data:
Mapped onto the real code in `PointBuilder.java`: in `buildBOByDO`, `pointTypeFlag` / `rwFlag` / `enableFlag` are marked
`@Mapping(ignore = true)` and then assigned one by one in `@AfterMapping` via
`RwTypeEnum.ofIndex(entityDO.getRwFlag())`. Going the other way, `buildDOByBO`'s `@AfterMapping` uses
`Optional.ofNullable(rwFlag).ifPresent(v -> entityDO.setRwFlag(v.getIndex()))`. Null safety is explicit — a null enum is
simply not written, and no NPE is thrown.
JSON extensions work the same way. `PointDO.pointExt` is a `JsonExt` (`content` stored as a JSON string, persisted with
`JacksonTypeHandler`), and becomes the strongly typed `PointExt` in the BO. In `@AfterMapping`, DO→BO calls
`JsonUtil.parseObject(content, PointExt.Content.class)` to deserialize, and BO→DO calls `JsonUtil.toJsonString(...)` to
serialize. Every extension object carries the `BaseExt` trio: `type` (identifies the subtype during parsing),
`version` (optimistic lock, default `1`), and `remark`.
::: tip When adding a new field with an enum or JSON extension
1. Add a `Byte` field + `@TableField` to the DO, and the matching **enum** field to the BO and VO.
2. On the `*Builder`, add `@Mapping(target = "xxx", ignore = true)` for that field in both DO↔BO directions.
3. In both `@AfterMapping` hooks, add the `ofIndex` / `getIndex` conversion. For JSON extensions, add `parseObject` /
`toJsonString`.
Skip steps 2 and 3 and MapStruct either fails to compile on a type mismatch or silently drops the value. After editing,
always run `mvn -s .mvn/settings.xml -q -DskipTests compile` as a safety net.
:::
## Enum naming: the suffix tells you the semantics
The platform's flag enums encode their semantics in their suffix. There's one convention per kind:
| Suffix | Semantics | Example |
|---------------|--------------------|-------------------------------------------------------|
| `*FlagEnum` | 0/1 toggle | `EnableFlagEnum` (`ENABLE(0)` / `DISABLE(1)`) |
| `*StatusEnum` | state machine | `PointCommandStatusEnum` (`PENDING → SENT → ...`) |
| `*TypeEnum` | classification set | `PointTypeEnum`, `RwTypeEnum`, `ProfileShareTypeEnum` |
::: warning In `EnableFlagEnum`, 0 means "enabled", not "disabled"
The index of `ENABLE` is `0` and `DISABLE` is `1` (`EnableFlagEnum.java`). It's natural to read 0 as false/off, but here
it's reversed. When you read SQL, `enable_flag = 0` means **enabled**.
:::
## dc3_event is the definition, dc3_entity_alarm is the instance
The last easy-to-confuse point — a classic "model vs. runtime instance" split in domain modeling:
- `dc3_event` (management domain, `04-iot-dc3-manager.sql`) — the event **definition**. It hangs under a Profile and
describes "what kinds of events this class of device can report", carrying `event_type_flag` (
`0=info / 1=alert / 2=fault / 3=lifecycle`). It's part of the template's capabilities, on the same level as Point and
Command.
- `dc3_entity_alarm` (data domain, `03-iot-dc3-data.sql`) — the runtime **alarm instance**. It's a record produced at
runtime by several sources — the rule engine, status timeouts, and device/driver/event reporting — distinguished by
`alarm_source_flag`.
Put another way: `dc3_event` answers "what this device **can** report"; `dc3_entity_alarm` answers "what it **is**
reporting right now". The two tables live in different schemas and different init scripts. Don't treat them as the same
thing when adding queries. For the full model of alarms and events — rules, notification channels, status tracking —
see [Alarms and Notifications](../operation/alarms).
## Further reading
- [Core Concepts](../introduction/concepts) — if you haven't read it, start with the simpler object-relationship diagram
and one-line mental model
- [Data Plane](./data-plane) — how `PointValue` moves and gets persisted, layer by layer, from `ReadPointValue` to
`PointValueDO`
- [Driver Authoring](../development/driver-authoring) — how a driver registers Attributes and turns domain objects into
a real protocol adapter
- [API Documentation](../development/api-documentation) — the `get`/`list`/`add`/`update`/`delete` verb convention and
OpenAPI
- [Alarms and Notifications](../operation/alarms) — the sources, rules, and notification path of `dc3_entity_alarm`
---
# Facade Modes: grpc and local
URL: https://docs.dc3.site/en/architecture/facade-modes
# Facade Modes: grpc and local
Inter-center calls between center services — the Data Center asking the Manager Center for devices, the Agentic Center
asking the Data Center for point values — can run one of two ways. In `grpc` mode each service runs as its own process
and calls happen cross-process. In `local` mode all centers collapse into a single process and calls are direct,
in-process method invocations. This page explains that the `dc3.facade.mode` switch controls **deployment topology**,
not transport protocol — and why business code never has to change when you flip it.
> You are here: you've read the [System Architecture Overview](./) and [Services and Topology](./services), and want to
> understand how the centers actually interconnect.
## A topology switch, not a protocol choice
First, the common misreading: `dc3.facade.mode` is not a "gRPC vs REST" transport pick. It's a choice about how many
processes the center services **run as**.
Business code never touches the other side's gRPC stubs or protobuf classes directly. It depends on a set of *
*protocol-neutral `*Facade` interfaces** — contracts defined in `dc3-common-facade-api` (16 interfaces such as
`DeviceFacade`, `PointValueFacade`, `TenantFacade`, `PermissionFacade`, and so on). Each interface has two
implementations, and one is selected and wired at startup based on the value of `dc3.facade.mode`:
- `grpc` mode wires the gRPC implementation (`dc3-common-facade-grpc`, e.g. `DeviceGrpcFacade`). It makes a
cross-process gRPC call to reach the independently running target center.
- `local` mode wires the in-process implementation (`dc3-common-facade-local-*`, e.g. `DeviceLocalFacade`). It calls the
target Service inside the same process directly, **with no network overhead**.
The Javadoc on the `DeviceFacade` interface itself says as much:
> `DeviceLocalFacade` — in-process call into `DeviceService`, selected when `dc3.facade.mode=local` (single deployment).
> `DeviceGrpcFacade` — gRPC call against Manager Center, selected when `dc3.facade.mode=grpc` (distributed deployment,
> default).
Both implementations satisfy the same interface and return the same BO/Page types, so **switching modes changes not a
single line of business code** — it only swaps the injected Bean.
## How the two modes are wired
Wiring relies on Spring Boot's `@ConditionalOnProperty`. The gRPC auto-configuration takes effect when
`dc3.facade.mode=grpc` or when the property is absent (`matchIfMissing = true`); the local auto-configuration takes
effect only when `dc3.facade.mode=local`. For any given `*Facade` interface, the switch alone decides which
implementation gets wired.
In the diagram, `9400` is the Manager Center's gRPC port (in grpc mode the Data Center reaches it across processes). In
local mode the Manager Center's `DeviceService` lives in the same JVM as the caller, `DeviceLocalFacade` makes a direct
method call, and no port is involved at all.
::: info Naming correspondence between interfaces and implementations
The interface names in `facade-api` are `*Facade` (e.g. `DeviceFacade`). The gRPC implementations consistently add the
`Grpc` infix (`DeviceGrpcFacade`); the in-process implementations add the `Local` infix (`DeviceLocalFacade`). So
`*GrpcFacade` is always the cross-process one, and `*LocalFacade` is always the in-process one.
:::
## When to use which
The choice comes down to how many processes you want the center services to run as:
| Dimension | `grpc` (default) | `local` |
|------------------|--------------------------------------------------------|-------------------------------------------------|
| Deployment shape | Each center as a separate process, distributed | All centers in one process, monolithic |
| Call style | Cross-process gRPC | In-process method call, no network overhead |
| Best for | Distributed deployment, horizontal scaling, production | Local development, small single-node, debugging |
| Typical pairing | Full compose stack (multiple services) | `dc3-center-single` monolithic service |
Use `grpc` for distributed deployment or when you need to scale services independently: each center is a standalone
Spring Boot service that can scale and restart on its own. Use `local` for local development, a small single node, or
debugging. Paired with the `dc3-center-single` monolithic service — which collapses the four centers into one — it saves
you from launching multiple processes and the network round-trips between them. Startup is faster, and breakpoints land
directly.
::: tip How to choose
- You're bringing up the whole thing locally to develop or debug, or you just want a minimal single-node runnable → use
`local`, run `dc3-center-single`.
- You're doing a distributed deployment, scaling centers independently, or this is production → use `grpc` (the
default), with each center as its own process.
- When in doubt, use the default `grpc`: it's the established value for distributed envs; only the monolithic scenario
needs an explicit switch to `local`.
:::
## Where the default lives, and who overrides whom
The default differs by **deployment shape**, and this is the part to get right — otherwise a literal value in some
`application.yml` can mislead you:
- **Distributed centers** (such as the Manager Center): `application.yml` says
`dc3.facade.mode: ${DC3_FACADE_MODE:grpc}`, and the distributed orchestration explicitly sets the environment variable
`DC3_FACADE_MODE=grpc` (see `.env.example` and `dc3/env/dev.env`). So the distributed default is `grpc`.
- **Monolithic service** `dc3-center-single`: `application.yml` defaults to
`dc3.facade.mode: ${DC3_FACADE_MODE:local}` — a monolith runs in a single process, so it naturally takes the
in-process facade.
::: warning The Auth Center's application.yml says local — don't be misled
The base `application.yml` of the Auth Center `dc3-center-auth` writes `dc3.facade.mode` directly as `local` (a local
override). But **under distributed deployment**, the orchestration-injected `DC3_FACADE_MODE=grpc` overrides it — Auth
actually runs as `grpc` in a distributed setup. To determine which mode a service actually uses, **trust the injected
environment variable**, not the literal value in some yml.
:::
Switching modes means changing only this one switch; business code and interface signatures stay put:
::: code-group
```bash [Environment variable]
# Distributed (default): each center as a separate process, cross-process gRPC
DC3_FACADE_MODE=grpc
# Monolithic: all centers in one process, in-process direct calls
DC3_FACADE_MODE=local
```
```yaml [application.yml]
dc3:
facade:
mode: ${DC3_FACADE_MODE:grpc} # distributed centers default to grpc
# mode: ${DC3_FACADE_MODE:local} # monolithic dc3-center-single defaults to local
```
:::
## Constraints and boundaries
- `grpc` and `local` are two implementations of the same set of `*Facade` interfaces, **not** two transport protocols;
`local` is not a "facade over REST" tier. The environment-variables page calls `DC3_FACADE_MODE` the "facade protocol
mode", but what it actually switches is which implementation gets wired — that is, the deployment topology. The code's
`@ConditionalOnProperty` is the source of truth.
- The default is `grpc`: the gRPC auto-configuration carries `matchIfMissing = true`, so without explicit configuration
the gRPC implementation is wired by default.
- `local` mode requires the target Service being called to live in the same process — it's designed for
collapsed-process setups like `dc3-center-single`. Setting separate multi-center processes to `local` leaves them
unable to find the peer Service.
- Switching modes doesn't change business code, but it does change the runtime topology and the failure domain. Under
`grpc`, one center crashing doesn't drag the others down. Under `local`, they share the same JVM process.
## Further reading
- [Services and Topology](./services) — the six services, their ports, gRPC ports, and startup dependency order
- [System Architecture Overview](./) — the full picture of how the gateway + four centers + drivers cooperate
---
# System Architecture Overview
URL: https://docs.dc3.site/en/architecture/
# System Architecture Overview
IoT DC3 runs the "collect → normalize → analyze → act → feedback" loop as a layered, multi-tenant microservice
architecture. A single gateway is the only northbound entry point. Four center services each own one segment of the
chain. Protocol drivers connect field devices southbound. This page starts with a layered diagram to give you the global
picture, then walks through the four key design decisions — what problem each one solves and how they work together —
and finally points to the deep-dive pages for every link in the chain.
> You are here: you have read [Platform Positioning](../introduction/) and [Core Concepts](../introduction/concepts),
> and now you're breaking the loop down into a concrete layered structure. From here you can jump into any plane (data /
> command / auth / domain model).
## Architecture at a Glance
This panorama lays out all six layers, the four center services with their ports, the message-bus exchanges, and the
optional observability stack in one view — get the whole picture first, then read the logical drill-down below. The
diagram adapts to the site's light/dark theme.
## Four-Layer Reference Architecture Mapping
The industry-standard IoT four-layer reference architecture — Perception, Network, Platform, Application — plus security
as a cross-cutting concern — every DC3 component maps onto this framework. This diagram helps you quickly see where DC3
stands on the "from sensor to AI operations" full map.
Legend colors: purple=Application · green=Platform · orange=Network · cyan=Perception · amber=Security.
| Layer | IoT Reference Responsibilities | DC3 Implementation |
|----------------------------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|
| **Application** | Operations, alarms, analytics, AIoT, and third-party system integration | Web console, public APIs, dc3 CLI, Agentic Center, MCP tools, and alarm analysis |
| **Platform** | Device management, storage, rule computation, identity, and business orchestration | Gateway, Auth / Manager / Data / Agentic center services, PostgreSQL, TimescaleDB, domain model, and command state machine |
| **Network** | Fieldbus, IoT protocols, wireless / WAN, and message transport | 28 protocol drivers, RabbitMQ async message bus, gRPC facades, and southbound read/write command channels |
| **Perception** | Sensing, auto-ID, actuators, field devices, and data sources | Profile / Device / Point normalize physical equipment, measurement points, and raw signals into semantic platform data |
| **Cross-cutting Security** | Identity, authorization, tenant isolation, trusted transport, and call integrity | JWT, RBAC, tenantId propagation, HMAC gateway signing, TLS / secret configuration, and audit logs |
Read this diagram as a **responsibility view**, not as a process deployment diagram. For example, the Gateway is both a
northbound entry point and part of platform governance; RabbitMQ supports protocol decoupling in the network layer and
load buffering in the platform layer; Agentic Center is an application-layer capability that is made safe by the
platform's auth, command, and data planes. For a systematic walkthrough of the IoT four-layer reference, see
[IoT Technology Overview](../foundations/).
## Three-Layer Structure: Access, Platform, Storage & Messaging
The platform isn't one monolith. It's a set of services split by responsibility. From the caller's perspective, there's
a single entry point — the gateway `dc3-gateway` (HTTP `8000`), the only externally exposed HTTP port. The HTTP and gRPC
ports of the other center services are reachable only on the internal network. The gateway routes requests to the four
center services, which don't call each other over HTTP but cooperate cross-process through gRPC facades.
Southbound runs to a different rhythm. Field devices are connected by protocol drivers (`dc3-driver-*`, 28 in total),
and drivers and the data center **never call each other directly**. They exchange messages asynchronously through
RabbitMQ — point values flow northbound (upstream), commands flow southbound (downstream). All persistence lands in
PostgreSQL, where time-series data (point value history) is stored in TimescaleDB hypertables.
The dashed lines are gRPC facade calls; the bidirectional solid lines are RabbitMQ exchanges. The difference between
those two connection styles is exactly what the four designs below explain. For each service's ports, startup order, and
health checks, see [Services & Topology](./services).
::: info Monolithic and Distributed Deployment Forms
The diagram shows the default distributed form (four centers as independent processes). The platform can also merge the
centers into a single process (`dc3-center-single`) running on one machine. That's just a deployment topology choice —
the business chain doesn't change. The switch is governed by `DC3_FACADE_MODE`; see [Facade Modes](./facade-modes).
:::
## gRPC Facade: How Centers Call One Another
The four center services frequently need data from each other. Before the data center dispatches a command, for
instance, it has to confirm with the management center that the device and point exist and are enabled. If business code
assembled HTTP URLs to call peers directly, service boundaries would leak transport details, and the monolithic and
distributed deployments couldn't share one codebase.
IoT DC3 solves this with **facade interfaces**. Cross-service calls program against the contract interfaces in
`dc3-common-facade-api`, so business code knows the interface, not the transport. At runtime, `DC3_FACADE_MODE` picks
the implementation behind the interface:
- `grpc` (distributed default) — the implementation comes from `dc3-common-facade-grpc`, and calls go cross-process via
gRPC to the target center.
- `local` (single process) — the implementation comes from `dc3-common-facade-local-*`, and calls are direct in-process
method invocations with no network hop.
In other words, "distributed or monolithic" is a deployment switch, not two codebases. The same business logic moves
between the two forms just by changing `DC3_FACADE_MODE`.
::: warning Read the Facade Mode Defaults Carefully
In a distributed setup each center defaults to `grpc`. The management center's `application.yml` declares
`dc3.facade.mode: ${DC3_FACADE_MODE:grpc}`, and `dc3/env/dev.env` also sets `DC3_FACADE_MODE=grpc`. The auth center's
base `application.yml` contains a line `dc3.facade.mode: local`, but that's a local override for the single-process
scenario — it does not mean the distributed default is `local`. Go by the environment variable and the manager's
declaration. For the full distinction, see [Facade Modes](./facade-modes).
:::
## RabbitMQ Async Decoupling: Why Drivers and the Data Center Aren't Directly Connected
Point values are high-frequency and bursty. A single round of collection by a Modbus driver can produce hundreds or
thousands of values in an instant. If a driver called the data center synchronously to write to the database, any
slowdown along the way would back-pressure the collection thread — drivers would drop offline and data points would be
lost. Command downlink is the same: an HTTP request shouldn't block indefinitely while waiting for a device to finish
writing registers.
So a layer of RabbitMQ sits between the drivers and the data center, decoupling "production" from "consumption" in time:
- **Upstream (data)**: drivers publish collection results to the topic exchange `dc3.e.value` with routing key
`dc3.r.value.point.{driverServiceName}`, landing in the durable queue `dc3.q.value.point` (7-day TTL, with dead-letter
exchange `dc3.e.point_value_dead`). The data center's `PointValueReceiver` consumes asynchronously and writes to the
database in batches or immediately.
- **Downstream (command)**: commands are delivered via the exchange `dc3.e.point_command` to the corresponding driver
queue (30-second TTL + dead letter). After the driver executes, it sends the result back to
`dc3.e.point_command_result` (60-second TTL), where the data center's result receiver collects it.
This way point-value writes never block collection, and command dispatch returns a `commandId` immediately for polling.
Messages use durable delivery + manual ack + publisher confirm, with failures handled via redelivery and dead-letter.
For the complete exchange, queue, and acknowledgment chain, see [Data Plane](./data-plane)
and [Command Plane](./command-plane).
::: danger A Failed Write Command Never Returns a Fabricated Value
A write command counts as successful only when the driver's `write()` returns `Boolean.TRUE`. On failure the result
`responseValue=null`, and **no** "looks-successful" value is ever filled in. That's deliberate, to keep false success
from misleading the upper layers. A command's `PointCommandDTO.expireAt` defaults to `now + 10s`, and the timeout is
judged as `EXPIRED` by the driver at consumption time.
:::
## Multi-Tenant Isolation: How tenantId Runs Through Every Layer
The platform is multi-tenant by design. Isolation is enforced at the interface layer; `tenantId` is carried along "
gateway → center service → gRPC call → cache key," and both single-ID and batch queries are checked at the controller
layer so cross-tenant access is blocked.
The enforcement points in practice:
- **Interface layer (single by ID)**: `BaseController.requireTenant()` compares the entity's `tenantId` after fetching
it, returning 404 (rather than leaking data) for cross-tenant access.
- **Interface layer (batch)**: `BaseController.filterTenant()` strips records that don't belong to the current tenant
from batch results. The database query layer does no automatic tenant pruning today (`MybatisPlusConfig` registers
only the pagination plugin); isolation is applied at the controller layer.
- **Cross-service**: gRPC facade calls carry the tenant ID where the contract supports it, and cache keys also include
tenant context.
So when you write a new query, a new gRPC call, or a new cache key, you have to preserve the tenant scope. That's a hard
requirement, not an optional optimization. For how isolation is enforced layer by layer and how it works with RBAC,
see [Auth · Tenant · RBAC](./auth-rbac).
## HMAC Gateway Signing: How the Backend Trusts Who the Caller Is
After authentication, the gateway packs the resolved identity (tenant, login name, principal) into an `X-Auth-Principal`
JSON header and forwards it to the backend center services. The backend makes authorization decisions based on it. The
question is: why should the backend trust that this header wasn't forged? Anyone who bypasses the gateway to hit an
internal port directly could construct a fake principal header.
The answer is **HMAC-SHA256 signing**. The gateway signs the principal content with the secret `AUTH_HMAC_SECRET` (
config key `dc3.auth.hmac.secret`) and puts the signature in the `X-Auth-Sign` header. The backend verifies it with the
same secret and rejects anything that fails. Only the gateway holding the secret can sign a valid request, so a forged
principal header is blocked at the backend.
::: danger The Production Secret Must Be Changed, or Startup Fails
The factory default of `AUTH_HMAC_SECRET` is `io.github.pnoker.dc3`, for development only. When the Spring profile
contains `pre` or `pro`, if the secret is empty or still equals that default, the service throws `IllegalStateException`
at startup and **fail-fast**s — refusing to go to production with a weak secret. `DC3_SECURITY_KEY` (login token
signing) must likewise be changed to an environment-specific, strong random value.
:::
## Consistency and Scalability
The four center services are themselves **stateless**. Hot data such as sessions, token denylists, and latest values
live in the Caffeine cache or the database, so requests aren't pinned to a particular instance. Each center can scale
horizontally: attach a few more instances of the same kind behind the gateway to share the load. No shared memory is
needed.
The data center's throughput bottleneck is on the consumption side, and consumption concurrency is tunable.
`PointValueReceiver` consumes `dc3.q.value.point` with a high-throughput listener container, switching between "
immediate write" and "`PointValueJob` batch write" based on the inbound rate. The batch threshold is controlled by
`POINT_BATCH_SPEED` (default 100 records) and `POINT_BATCH_INTERVAL` (default 5 s) — whichever is met first flushes to
disk. Under a collection flood, RabbitMQ absorbs the burst first, and concurrent consumption plus batch writes then
handle it.
::: info Strong Consistency and Eventual Consistency Coexist
Steps on the request path — tenant isolation, authorization decisions, the command state machine — are strongly
consistent (synchronous validation, immediate rejection). Upstream persistence of point values is eventually consistent
via MQ: a value is treated as reliably delivered once it enters the queue, while the database write and alarm evaluation
complete asynchronously. Understanding this boundary helps when troubleshooting timing issues like "the command is
acknowledged but the history query is still a beat behind."
:::
## Further Reading
- [Services & Topology](./services) — six deployable units, port allocation, startup dependencies, and health checks
- [Facade Modes](./facade-modes) — the trade-off between `grpc` and `local`, switching between monolithic and
distributed
- [Data Plane](./data-plane) — every hop a point value takes from device to TimescaleDB, and the MQ topology
- [Command Plane](./command-plane) — dispatch of read/write commands, the lifecycle state machine, and acknowledgments
- [Auth · Tenant · RBAC](./auth-rbac) — gateway signing, token issuance, permission resolution, and tenant propagation
- [Domain Model](./domain-model) — the fields of Profile / Point / Device and the DO/BO/VO layering
- [Module Map](./modules) — the Maven module structure, the 28 drivers, and their dependencies
---
# Module Map
URL: https://docs.dc3.site/en/architecture/modules
# Module Map
IoT DC3's code splits into three kinds of modules: deployment units, shared contracts, and protocol drivers. This page
covers the architecture side — which modules ship as runnable services, which shared libraries and contracts they lean
on to talk to each other, how the 28 drivers break down by protocol, and what the Driver SDK's SPI looks like. Read it
once and you'll know where any feature lives and what it depends on.
> Where this fits: you've already read the [System Architecture Overview](./) and [Services & Topology](./services). Now
> you want those same boundaries from a module and dependency angle. For a plain per-module list, see
> the [Module Inventory](../modules/).
## Three categories, three lifecycles
Don't try to hold the dozens of Maven modules in your head as a flat list. They fall into three categories, each with
its own reason for existing:
- **Deployment units** (`dc3-gateway`, `dc3-center-*`, `dc3-driver-*`) — packaged into runnable Spring Boot processes,
listed in compose files, each on its own port. This is the granularity that operations and topology care about.
- **Shared and contract libraries** (`dc3-api-*`, `dc3-common-*`) — they don't run on their own; deployment units depend
on them. They carry "how services talk to each other" (gRPC contracts, facade interfaces) and "what everyone shares" (
entities, enums, DAL, messaging config).
- **Protocol drivers** (`dc3-driver-*`) — a special kind of deployment unit. Each driver is its own process, but all of
them stand on the same SDK (`dc3-common-driver`) and fill in only the thin slice of protocol adaptation.
Dependencies run one way across these three: drivers and centers depend on shared libraries, shared libraries depend on
contract libraries, and the contract layer never depends back on business logic. Here's that dependency graph, then
we'll walk through each category.
## How modules depend on each other
The diagram drops infrastructure (PostgreSQL / RabbitMQ) and the individual common submodules to show only the skeleton
of who depends on whom. The gateway and the four centers each build on their own `dc3-common-*` domain library.
Cross-service calls all go through facade contracts, and drivers fetch metadata from the management center through
facades while exchanging values and commands with the data center over RabbitMQ.
The facade sits as a middle layer that many sides depend on. Business code only has a compile-time dependency on the
interfaces in `dc3-common-facade-api`; at runtime the `grpc` or `local` implementation gets injected, and the caller
never sees the transport. This "three-state" design is what lets IoT DC3 run as a distributed deployment or fold into a
monolith. See [Facade Modes](./facade-modes) for the details.
## Deployment units: gateway, four centers, and drivers
These are the modules that ship as running processes. Ports and what's exposed are governed by compose: only the
gateway's HTTP `8000` faces outward. The HTTP/gRPC ports of the other centers are all cluster-internal.
| Deployment unit | Role | HTTP | gRPC | External |
|----------------------|--------------------------------------------------------------------------------------|--------|--------|-----------------------|
| `dc3-gateway` | The only external HTTP entry point, authentication pass-through, MCP resource server | `8000` | — | Yes |
| `dc3-center-auth` | Authentication / tenant / RBAC / OAuth 2.1 | `8300` | `9300` | No |
| `dc3-center-manager` | Metadata management for drivers / profiles / devices / points, and the rest | `8400` | `9400` | No |
| `dc3-center-data` | Point value persistence, command dispatch and acknowledgement, alarms | `8500` | `9500` | No |
| `dc3-center-agentic` | LLM sessions, tool calls, memory | `8600` | — | No |
| `dc3-center-single` | A monolith merging auth + manager + data (local facade) | `8100` | `9100` | Depends on deployment |
| `dc3-driver-*` | Protocol adaptation (28 standalone processes) | Varies | — | A few only |
`dc3-center-single` folds the three centers into one process and uses the `local` facade for in-process direct calls — a
good fit for local development or small, resource-constrained deployments. It shares the same `dc3-common-*` domain
libraries as the distributed four-center version; the only differences are the facade implementation and the packaging.
::: info The Agentic Center has no gRPC port
`dc3-center-agentic` exposes only HTTP (`8600`) and opens no gRPC server port. It acts as a facade caller to reach the
other centers, and the other centers never call it back over gRPC.
:::
::: tip Only a few drivers expose ports externally
Most drivers are outbound by nature: they poll devices on a schedule and push values to RabbitMQ, with no need to listen
on inbound ports. The exception is reverse-ingestion drivers like `dc3-driver-listening-virtual`, which listens on TCP
`6270` / UDP `6271` so external systems can push data in. Those two ports get mapped to the host.
:::
## Shared and contract: how services talk to each other
The reason deployment units can each mind their own business yet still work together is the layer of libraries
underneath them — none of which run on their own. They answer two questions: **how to move calls across processes** (the
contract layer) and **what everyone shares** (the shared layer).
**The contract layer `dc3-api-*`** holds the protobuf / gRPC contract definitions. `dc3-api-auth`, `dc3-api-data`,
`dc3-api-driver`, and `dc3-api-manager` each describe the RPCs the corresponding center exposes. Change a proto and
you've changed the inter-service contract — that means regenerating stubs and running the contract tests.
**The facade three-state** is the part most worth understanding on this page. It splits "which service to call" away
from "what transport to use" across three modules with clean responsibilities:
| Module | Responsibility | When active |
|-----------------------------------------------|------------------------------------------------------------------------------------------|----------------------------------------------|
| `dc3-common-facade-api` | Defines the Java interfaces for cross-service calls (business code depends only on this) | Always |
| `dc3-common-facade-grpc` | The gRPC implementation of those interfaces, going through `dc3-api-*` stubs underneath | `dc3.facade.mode=grpc` (distributed default) |
| `dc3-common-facade-local-{auth,manager,data}` | The in-process direct-call implementation of those interfaces | `dc3.facade.mode=local` (monolith) |
Controllers and services only ever `@Autowired` the interfaces in `dc3-common-facade-api`. They never bind directly to
gRPC stubs or a specific service. That's exactly why [Facade Modes](./facade-modes) can switch deployment topology
without touching business code.
**The shared layer `dc3-common-*`** is the cross-service reusable infrastructure and domain libraries, grouped into four
by responsibility:
- Foundation: `dc3-common-constant` (enums and constants, like `PointCommandTypeEnum`), `dc3-common-model` (BO / VO /
DTO, like `PointCommandDTO`), `dc3-common-exception`, `dc3-common-public` (the `R` response wrapper),
`dc3-common-web`, `dc3-common-log`, `dc3-common-thread`.
- Data access: `dc3-common-dal` (MyBatis-Plus base capabilities, data-access and query wrappers),
`dc3-common-postgres` (multi-schema data source), `dc3-common-repository` (repository abstractions and point-value
domain objects, like `PointValueBO`), `dc3-common-sql`.
- Communication: `dc3-common-rabbitmq` (exchange / queue configuration, like `dc3.e.value`), `dc3-common-mqtt`.
- Domain: `dc3-common-{auth,manager,data,driver,gateway,agentic}`, each holding the business logic of one deployment
unit. The `dc3-center-manager` process, for example, is little more than the runtime shell around
`dc3-common-manager`.
::: info Runtime caching uses Caffeine, not Redis
Latest-value caching, the token denylist, permission caching, and the like all use in-process Caffeine (like
`PointValueLocalCache`). There's no dependency on standalone Redis.
:::
## Drivers grouped by protocol
The 28 drivers carry the platform's protocol breadth. Grouping them by protocol family makes the one you need easier to
find than a long flat list. Each driver is a `dc3-driver-` module. They all inherit the same SDK, and differ
only in the protocol adaptation.
| Category | Representative drivers | Notes |
|---------------------------|---------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| Industrial fieldbus / PLC | `dc3-driver-modbus-tcp`, `dc3-driver-opc-ua`, `dc3-driver-plcs7`, `dc3-driver-iec104` | The most common group on factory and power SCADA floors. Also includes modbus-rtu, opc-da, ethernet-ip, fins, melsec, bacnet-ip, sl651. |
| IoT wireless | `dc3-driver-mqtt`, `dc3-driver-coap`, `dc3-driver-lwm2m`, `dc3-driver-http` | Lightweight and constrained devices. Also includes ble, zigbee. |
| Basic communication | `dc3-driver-tcp-udp`, `dc3-driver-serial`, `dc3-driver-snmp`, `dc3-driver-can` | Raw socket, serial port, network management, in-vehicle bus. |
| Database bridging | `dc3-driver-mysql`, `dc3-driver-postgresql` | Ingest external databases as data sources. Also includes oracle, sqlserver. |
| Metering | `dc3-driver-dlms` | DLMS/COSEM smart electricity meters. |
| Simulation | `dc3-driver-virtual`, `dc3-driver-listening-virtual` | See the notes below. |
The simulation category has two members with entirely different roles. Don't confuse them:
- **`dc3-driver-virtual`** is the **driver development template**. To write a new protocol driver, you copy and rename
it; it shows the SDK's full surface (registration, scheduling, read/write, health). The "first device" walkthrough
in [Quick Start](../quickstart/) runs on the synthetic values it produces.
- **`dc3-driver-listening-virtual`** is **reverse-listening ingestion**. Instead of polling, it listens on TCP/UDP ports
and waits for external systems to push data in — for cases where the device or system reports on its own, rather than
the platform collecting.
::: danger `dc3.driver.code` is a stable registration identity — don't change it casually
Each driver registers with the management center using `dc3.driver.code` at startup. The RabbitMQ routing key for values
and commands comes from the driver service name `dc3.driver.service` (`driverProperties.getService()`) — code and
service are two independent fields on `DriverProperties`. Change code and you swap the registration identity; change
service and you swap the routing identity. Either way, in-flight messages or registration records lose their owner.
Unless you migrate accordingly, don't change these two values on a driver that's already deployed.
:::
## The Driver SDK's SPI: one aggregate interface, seven contracts
The SDK shared by all drivers lives in `dc3-common-driver`. Its extension point for driver authors is
`DriverCustomService` — an interface that **declares no methods of its own**. It just aggregates the 7 capability
interfaces a driver typically implements. The SDK injects it when it wants the union of all driver hooks. A new driver
that needs only a subset can implement the smaller individual interfaces instead.
The seven contracts each cover a segment of the driver's life. `DriverLifecycle` handles startup initialization and
schedule registration. `DriverMetadataListener.event(...)` receives metadata changes to refresh the local cache.
`DriverHealth` and `DeviceHealth` report driver-level and device-level health. `DriverProtocol` is the core read and
write — `read(...)` returns `ReadPointValue`, `write(...)` returns `Boolean`. `DriverCommand` handles custom commands.
And `DriverValidator` does validation, with a `simulate(...)` that's a **deterministic** synthetic value generator:
stable output, distinct from the random values the virtual driver produces on the fly with `ThreadLocalRandom` inside
`read()`.
::: danger A failed write command echoes no value
`DriverProtocol.write(...)` counts as success only when it returns `Boolean.TRUE`. On failure the command result's
`responseValue` is `null`, and it **echoes no written value back**. That's deliberate — it keeps a failure from looking
like a success.
:::
::: tip Health-status TTL must exceed the collection cycle
The health status a driver reports carries a TTL, and that TTL must exceed the read scheduling cycle (a 30s cron, say,
paired with a TTL ≥ 25s). Otherwise the device gets judged offline between two heartbeats and flaps.
:::
The SDK also ships runtime services for registration (`DriverRegisterService`, with exponential-backoff retry),
scheduling (`DriverScheduleService`, Quartz-driven), and sending (`DriverSenderService`, including `pointValueSender` /
`deviceStatusSender`, and the rest) that driver authors generally don't need to touch. For the full development
workflow, see [Driver Authoring](../development/driver-authoring).
## How this differs from the Module Inventory page
This page is about **architecture and dependencies**: what categories the modules fall into, who depends on whom, how
the facade three-state decouples things, how drivers are grouped, and what the SDK exposes. If what you want is a *
*per-module purpose quick reference** — a one-line description for each `dc3-common-*` / `dc3-api-*` submodule — go to
the [Module Inventory](../modules/). That page is reference material; this one is the mental model.
## Further reading
- [Module Inventory](../modules/) — a per-submodule purpose quick-reference table
- [Services & Topology](./services) — deployment-unit ports, startup order, and health checks
- [Facade Modes](./facade-modes) — how the `grpc` and `local` states switch deployment topology
- [Driver Authoring](../development/driver-authoring) — the full workflow for deriving a new protocol driver from the
virtual template
---
# Services and Topology
URL: https://docs.dc3.site/en/architecture/services
# Services and Topology
IoT DC3 isn't one big process. It's a set of independently deployable services that talk to each other over gRPC and
RabbitMQ. This page covers what those units are, how they fit together, and why they start in a fixed order. Read it
once and every `depends_on` in `docker-compose.yml` will make sense — and you'll be able to debug "why won't the gateway
come up" on your own.
> You are here: you've read the [System Architecture Overview](./) and want to map "five centers + drivers" onto actual
> processes, ports, and startup order. Next, read [Facade Modes](./facade-modes) to see how services call each other, or
> jump to the [Quick Start](../quickstart/) to get the stack running.
## Why Split Into So Many Units
Splitting the platform into a gateway, four centers, and a set of drivers isn't microservices for their own sake. These
categories have genuinely different scaling and failure boundaries. Southbound protocol drivers are many and scale per
site — a different concern from northbound metadata management. Authentication is the mandatory checkpoint on every
request, so it needs to be independent and ready first. Time-series ingestion is high-throughput and needs a dedicated
data center to absorb the RabbitMQ flood. Split apart, each unit can be scaled, restarted, and debugged on its own.
The platform has **six categories of deployable unit**, plus a `single` monolith that packs every center into one
process:
- **Gateway (`dc3-gateway`)** — the only external API entry point. It parses auth headers, injects HMAC signatures,
routes requests, and hosts the MCP resource server at `/mcp` (reached through the web frontend's reverse proxy under
the app stack — see the ports section below).
- **Auth Center (`dc3-center-auth`)** — authentication, tenancy, RBAC, and the OAuth 2.1 authorization server. No
business dependencies, so it's ready first.
- **Manager Center (`dc3-center-manager`)** — metadata for drivers, profiles, devices, points, and so on.
- **Data Center (`dc3-center-data`)** — point-value persistence, command dispatch and acknowledgment, and the alarm
engine.
- **Agentic Center (`dc3-center-agentic`)** — Spring AI conversations, tool calls, and chat persistence.
- **Protocol Drivers (`dc3-driver-*`)** — the driver catalog has 28 protocol adapters; `docker-compose.yml` ships 22 by
default (the 6 not included — `ble`/`iec104`/`lwm2m`/`sl651`/`zigbee`/`can` — can be started on demand). Southbound
they
connect to devices; northbound they're decoupled from the data center through RabbitMQ.
- **Single Monolith (`dc3-center-single`)** — folds all four centers into one process, wired in-process via
`dc3.facade.mode: local`. Good for local development and lightweight deployments (see [Facade Modes](./facade-modes)).
::: info First mention of a center gives "English name + identifier"
The rest of this page follows that glossary: "Data Center" means `dc3-center-data`, "Gateway" means `dc3-gateway`, and
the full names aren't repeated.
:::
## Who Listens on Which Port
Each unit may expose an HTTP port (an external or internal REST entry) and a gRPC port (for facade calls between
centers). **Key constraint: the gateway is the only external API entry point, but its HTTP `8000` isn't published to the
host under the app stack** — `docker-compose.yml` maps only the web frontend's `8080/8443` and the listening-virtual
driver's device inbound ports to the host. External requests go through the web frontend's nginx reverse proxy to
`dc3-gateway:8000` inside the container network. The gateway's `8000` — like the HTTP ports of the other centers — is
reachable only inside that network. Only the dev stack (`docker-compose-dev.yml`) publishes the gateway's `8000` (and
the other center ports) to the host. In production, don't map backend ports to the host.
The diagram below maps the topology by "who depends on whom being ready first." Solid arrows are `depends_on` health
dependencies, labeled with each service's HTTP / gRPC ports.
The port allocation follows a pattern: HTTP ports `83/84/85/86xx` map one-to-one with gRPC ports `93/94/95xx` for
auth/manager/data. The Agentic Center currently exposes only HTTP `8600`. The single monolith claims HTTP `8100` / gRPC
`9100` (`DC3_SINGLE_PORT` / `DC3_SINGLE_GRPC_PORT`) — they don't clash with the distributed stack's ports and can
coexist on the same machine.
The table below pins down the ports from the diagram, with their host-publishing status. Treat it as the source of truth
when writing code or configuring an nginx reverse proxy. The "Published to host" column reflects the actual `ports:`
mappings in the app stack (`docker-compose.yml`):
| Unit | HTTP | gRPC | Published to host (app stack) | Environment variable (published port) |
|-------------------------------------|-----------------|--------|----------------------------------------------------------------------------------------------------------|----------------------------------------------|
| Web frontend `dc3-web` | `8080` / `8443` | — | **Yes (the app stack's only HTTP entry; nginx reverse-proxies to the gateway)** | `DC3_WEB_HTTP_PORT` / `DC3_WEB_HTTPS_PORT` |
| Gateway `dc3-gateway` | `8000` | — | No (reachable only inside the container network; published via `DC3_GATEWAY_PORT` only in the dev stack) | `DC3_GATEWAY_PORT` |
| Auth Center `dc3-center-auth` | `8300` | `9300` | No | `DC3_AUTH_PORT` / `DC3_AUTH_GRPC_PORT` |
| Manager Center `dc3-center-manager` | `8400` | `9400` | No | `DC3_MANAGER_PORT` / `DC3_MANAGER_GRPC_PORT` |
| Data Center `dc3-center-data` | `8500` | `9500` | No | `DC3_DATA_PORT` / `DC3_DATA_GRPC_PORT` |
| Agentic Center `dc3-center-agentic` | `8600` | — | No | `DC3_AGENTIC_PORT` |
| Single monolith `dc3-center-single` | `8100` | `9100` | Depends on deployment | `DC3_SINGLE_PORT` / `DC3_SINGLE_GRPC_PORT` |
::: warning Backend HTTP ports are not published to the host in the app stack
In `docker-compose.yml` (the app stack), the gateway and auth/manager/data/agentic have **no** `ports:` mappings —
they're reachable only inside the `dc3net` container network. The only things mapped to the host are the web frontend's
`8080/8443` (`DC3_WEB_HTTP_PORT` / `DC3_WEB_HTTPS_PORT`) and the listening-virtual driver's device inbound ports, TCP
`6270` / UDP `6271` (`DC3_LISTENING_VIRTUAL_TCP_PORT` / `..._UDP_PORT`). So under the app stack, business API calls from
outside go through the web frontend's `8080`, which nginx reverse-proxies to `dc3-gateway:8000` inside the container;
the host can't reach `8000` directly. Only the dev stack (`docker-compose-dev.yml`) publishes the gateway's `8000` (and
the center ports) to the host for direct connection.
:::
## In What Order Do They Start
There's a hard readiness order between services. The gateway injects authentication into requests, so auth must be up
first. Data persists records and dispatches commands, which needs manager's metadata to exist first. Agentic reads data
and invokes commands, so auth/manager/data must all be up. This order isn't there for humans to memorize — Compose
enforces it with `depends_on: condition: service_healthy`: **a dependent starts only after its dependency's health check
passes.**
Health is checked the same way everywhere — each service's `/actuator/health/readiness` (the readiness probe). Note that
the centers' readiness paths carry a base-path prefix while the gateway's does not:
- Gateway: `http://127.0.0.1:8000/actuator/health/readiness`
- Auth Center: `http://127.0.0.1:8300/auth/actuator/health/readiness`
- Manager Center: `http://127.0.0.1:8400/manager/actuator/health/readiness`
- Data Center: `http://127.0.0.1:8500/data/actuator/health/readiness`
- Agentic Center: `http://127.0.0.1:8600/agentic/actuator/health/readiness`
The diagram below traces the full readiness timeline along the dependency chain, from infrastructure to drivers — each
hop begins only after the upstream readiness passes.
This diagram also explains a common observation: **drivers depend only on manager**, not on the gateway or the data
center. Once a driver is up, it registers itself with the Manager Center over gRPC (carrying its protocol attribute
definitions), then starts collecting on schedule and pushing point values to the data center over RabbitMQ. So drivers
can start in parallel with the gateway — they don't wait for it.
::: danger Infrastructure must be ready before any application
The centers in `docker-compose.yml` (the application stack) **do not include** PostgreSQL and RabbitMQ — those live in a
separate `docker-compose-db.yml` (the db stack), health-checked with `pg_isready` and `rabbitmq-diagnostics ping`. The
application stack assumes both are already healthy. So the correct startup sequence is **bring up the db stack first,
wait for it to be healthy, then bring up the application stack**. Skip this and auth will restart in a loop because it
can't reach the database. The matching commands follow.
:::
## Getting the Stack Running
In practice it's two steps: bring up the db stack (PostgreSQL + RabbitMQ), then the application stack. The Makefile
wraps the Compose details; run the commands from the `iot-dc3/` directory:
::: code-group
```bash [make (recommended)]
# 1. Bring up infrastructure first, wait for it to be healthy
make up-db
# 2. Bring up the application stack (build images + start in depends_on order)
make up STACK=app
# Follow logs to confirm each service's readiness passes in turn
make logs
```
```bash [podman compose (low-level)]
# db stack: postgres + rabbitmq
podman compose -f dc3/docker-compose-db.yml up -d
# application stack: gateway + four centers + drivers
podman compose -f dc3/docker-compose.yml up -d
# validate compose syntax
podman compose -f dc3/docker-compose.yml config --quiet
```
:::
After the stack is up, confirm the whole chain is ready. Under the app stack the gateway's `8000` isn't published to the
host, so connecting to `127.0.0.1:8000` from the host will fail — probe from inside the gateway container (same address
as the container healthcheck), or reach it from the host through the web frontend's `8080`:
```bash
# app stack: probe readiness inside the gateway container (expect {"status":"UP"})
podman exec dc3-gateway curl -fsS http://127.0.0.1:8000/actuator/health/readiness
# the host entry point is the web frontend's 8080 (nginx reverse-proxies to dc3-gateway:8000)
curl -fsS http://127.0.0.1:8080/
```
::: info Only the dev stack lets you connect to gateway 8000 directly from the host
If you start with `make up-dev` (the dev stack, `docker-compose-dev.yml`), the gateway's `8000` is published to the
host, so you can run `curl -fsS http://127.0.0.1:8000/actuator/health/readiness` directly.
:::
::: tip For local development, the single monolith avoids multi-process orchestration
If you just want to validate business logic quickly on your machine, there's no need to bring up six containers:
`dc3-center-single` wires the centers' capabilities in-process via `dc3.facade.mode: local`, listening on HTTP `8100` /
gRPC `9100`. The difference between distributed and monolith is only deployment topology — the business semantics are
unchanged. See [Facade Modes](./facade-modes) for details.
:::
## Constraints and Boundaries
- **The gateway is the only external API entry point, but the host entry differs by stack.** In the app stack (
`docker-compose.yml`), only the web frontend's `8080/8443` and listening-virtual's device inbound ports TCP `6270`/UDP
`6271` are mapped to the host; the gateway's `8000` isn't published, and external requests pass through the web
frontend's nginx reverse proxy to `dc3-gateway:8000`. Only the dev stack (`docker-compose-dev.yml`) publishes the
gateway's `8000` and the center ports to the host. In either stack, the remaining backend ports are reachable only
inside the container network — don't map them to the host in production.
- **Startup order is enforced by health checks, not manual sleeps.** `depends_on: condition: service_healthy` makes a
dependent wait until the dependency's readiness passes before starting — but this only covers the application stack
internally. You still have to bring up the db stack yourself first.
- **Readiness paths carry a base path.** The centers use `webflux.base-path` (e.g. auth's `/auth`), so the probe paths
carry that prefix; the gateway doesn't. Don't drop the prefix when writing monitoring or liveness scripts.
- **Distributed mode uses the gRPC facade by default.** For centers like manager, `dc3.facade.mode` defaults to
`${DC3_FACADE_MODE:grpc}`, and `dc3/env/dev.env` also sets it to `grpc`; only the single monolith's base
`application.yml` declares `local`. This is a deployment-topology choice, not a protocol choice —
see [Facade Modes](./facade-modes) for details.
## Further Reading
- [System Architecture Overview](./) — the holistic view of the closed loop and where each role fits
- [Facade Modes](./facade-modes) — how `grpc` (distributed) and `local` (monolith) switch, and why this is topology
rather than protocol
- [Quick Start](../quickstart/) — bring up the stack from scratch locally and get your first device working
- [Auth · Tenancy · RBAC](./auth-rbac) — how the gateway injects authentication headers and HMAC signatures
---
# CLI User Guide
URL: https://docs.dc3.site/en/automation/cli
# CLI User Guide
`dc3-cli` is the command-line client for IoT DC3 — a standalone TypeScript package (Node ≥ 20) that exposes the platform
through the `dc3` command. It talks to the platform entirely through the gateway at `/api/v3/*`. By the end of this page
you'll have it installed, the gateway configured, a token in hand, and you'll have read devices, read point values, and
sent commands from the shell.
> You are here: you can already get [your first device](../quickstart/first-device) working through the frontend or
> curl, and now you want to drive the platform from the command line or inside an AI agent. If you want an AI tool to
> talk
> to the platform directly, head to [AI Agent / MCP Integration](../ai/mcp).
## What it is and who it's for
`dc3-cli` is not another backend. It's an HTTP client: every request goes to the gateway address you configure, with a
common `/api/v3/*` prefix (the gateway then routes to the auth, manager, data, and agentic centers). There is no Java or
build-time coupling — install one Node package and it runs standalone.
It serves three audiences: **operations and onboarding engineers** who want to inspect devices, read values, and send
commands from the terminal; **automation authors** who fold platform operations into scripts and pipelines; and **agent
integrators** who let AI coding tools (Claude Code, Codex, Gemini CLI, and others) call the platform through the shell.
Every command supports `--format json`, so the output is reliable for programs to parse.
```bash
npm install -g dc3-cli
```
Three steps to get going: configure the gateway, log in, then use it.
```bash
dc3 config set gateway http://localhost:8000 # gateway address (example: local default port 8000)
dc3 auth login # interactive login
dc3 device list # list devices
```
## Authentication: the three-stage token and how it stays fresh
`dc3 auth login` runs a three-stage token chain built on the same pair of endpoints the
platform's [golden path](../quickstart/first-device) uses for curl login — the CLI just wires them together. First,
`POST /api/v3/auth/token/salt` exchanges a tenant name and username for a **salt**. The CLI then submits the **plaintext
password** together with the salt to `POST /api/v3/auth/token/generate`, which returns a JWT. From the JWT it parses the
embedded `iat` and `exp`, then writes `{ token, salt, tenant, username, issuedAt, expiresAt }` to `~/.dc3/tokens.json` (
file mode `0600`, one entry per profile).
Before each later API call, the CLI does two things so you almost never hit a 401:
- **Proactive renewal**: if the current token is within the renewal threshold of expiring, the CLI silently re-logs in
to get a fresh token before the call runs. The threshold comes from the profile's `renewal_threshold_hours`, which
defaults to **1 hour** — it renews once less than an hour of validity remains.
- **401 fallback**: if a 401 still slips through (clock drift, service restart, and so on), the CLI renews and then *
*retries the request once**.
Requests to protected endpoints carry the platform's standard three headers — `X-Auth-Tenant`, `X-Auth-Login`,
`X-Auth-Token` — where `X-Auth-Token` carries `{ salt, token }`.
::: warning Renewal needs the password
Both proactive renewal and the 401 retry need the CLI to fetch the password and run the salt→generate flow again. If you
used `--no-save` or `--store prompt` (nothing persisted), there is no password to fetch once the token expires. The CLI
can't renew silently in that case, so you'll need to run `dc3 auth login` again.
:::
::: danger Never print real passwords or tokens
All passwords and tokens on this page are example placeholders. Don't paste real passwords or JWTs in plaintext into
scripts, logs, or issues. `dc3 auth token` is for local troubleshooting only — the token it prints is a live login
credential.
:::
## Where credentials live: a four-tier resolution chain
The password itself never lands in `tokens.json`. It goes to a **credential storage backend**. When renewing, the CLI
looks up the password in a fixed order: OS keychain first, then the encrypted file, then an environment variable, and
finally an interactive prompt. The first tier that's available and has a value wins. Set the backend for the current
profile with `dc3 config set auth.store `.
What each of the four backends is for:
| Storage | Location | Use case |
|-------------|----------------------------------------------------------------------------------|-------------------------------------------|
| `keychain` | OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager) | Everyday use (default) |
| `encrypted` | `~/.dc3/credentials.enc`, AES-256-GCM encrypted | Fallback when the keychain is unavailable |
| `env` | Reads the `DC3_PASSWORD` environment variable | CI/CD, scripts |
| `prompt` | Nothing persisted; prompts interactively every time | Highest security, cannot auto-renew |
The encrypted-file backend uses `aes-256-gcm`, with the key derived from a machine identifier via `scrypt`. The password
is stored as `identifier → password` (`username@tenant`), never in plaintext.
```bash
# Choose the credential backend at login time
dc3 auth login --store keychain # store in the OS keychain (good for everyday use)
dc3 auth login --store env # read from DC3_PASSWORD (good for CI)
dc3 auth login --no-save # don't save the password; re-login manually when it expires
# Non-interactive login (example value; never use a real password in plaintext)
dc3 auth login --tenant default --username dc3 --password ''
dc3 auth status # check login state and remaining validity
dc3 auth token --header # print the full auth headers as JSON (for troubleshooting)
```
## Command module overview
The CLI has 14 command modules, grouped by object and scenario. Config and auth are the entry points; the metadata
modules (device/driver/point/profile/group/label) map to CRUD in the manager center; event/command/alert/dashboard map
to data and runtime state; and `chat` forwards requests to the agentic center.
| Module | Command prefix | Purpose |
|-----------|-----------------|------------------------------------------------------------------------|
| Config | `dc3 config` | Gateway address, tenant, credential backend, profile switching |
| Auth | `dc3 auth` | Login/logout, inspect login state and token |
| Device | `dc3 device` | Device CRUD, counts, online status |
| Driver | `dc3 driver` | Driver list, detail, runtime status |
| Point | `dc3 point` | Point CRUD, read latest value, history, write value |
| Profile | `dc3 profile` | Profile CRUD |
| Group | `dc3 group` | Device group management |
| Label | `dc3 label` | Label management |
| Event | `dc3 event` | Event definition CRUD, event history |
| Command | `dc3 command` | Command list, invocation, command history |
| Alert | `dc3 alert` | Alarm overview, list, acknowledge, trends, top sources |
| Dashboard | `dc3 dashboard` | Statistics, time series, topology, health, real-time stream |
| Topic | `dc3 topic` | Topic list |
| Agentic | `dc3 chat` | Converse with the agentic center (optional streaming, model selection) |
Under the hood, the `dc3` entry point parses the command line into those 14 modules, and every module shares one set of
core components: the HTTP client, config management, token management, and credential storage. The modules only describe
what to do; the gateway requests, profile resolution, renewal, and password retrieval all live in the core layer.
Global options apply to every module: `--profile ` switches the config profile; `--format json|table|yaml` picks
the output format (table by default on a TTY, json by default in a pipe); `--verbose` prints request and response
details; `--ci` turns on CI mode (no color, json output, strict exit codes).
::: details Multiple profiles side by side (dev / prod switching)
Each profile keeps its own gateway, tenant, credential backend, and token, fully isolated from the rest:
```bash
dc3 config profile use prod
dc3 config set gateway https://iot.example.com # example production address
dc3 auth login
dc3 config profile use default # switch back to local
dc3 device list
```
:::
## Hands-on: reading values, reading history, sending commands
The examples below cover common operations with real commands; all IDs and values are example placeholders. Reading the
latest value maps to the data center's `POST /api/v3/data/point_value/latest`, writing a point maps to
`POST /api/v3/data/point_command/write`, and a command receipt maps to
`GET /api/v3/data/point_command_history/get_by_command_id`.
::: code-group
```bash [dc3 CLI]
# Read a point's latest value
dc3 point read 456789 --format json
# Read a point's history
dc3 point history 456789 --device-id 123456 --count 100 --format json
# Send a write command to a writable point (the point must be WRITE_ONLY or READ_WRITE)
dc3 point write 456789 --device-id 123456 --value 25.5
# Invoke a device command
dc3 command call --device-id 123456 --command-id 789 --params '{"speed":1500}'
# Check the command execution receipt (use the recordId returned by call; example value)
dc3 command history 9a1f2c3d-0000-0000-0000-000000000000
# Device and system health
dc3 device status 123456 --format json
dc3 dashboard health --format json
```
```bash [Equivalent curl]
# The equivalent write command sent straight to the gateway (example values)
curl -X POST http://localhost:8000/api/v3/data/point_command/write \
-H 'Content-Type: application/json' \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: {"salt":"","token":""}' \
-d '{"deviceId":123456,"pointId":456789,"value":"25.5"}'
```
:::
`dc3 point write` and `dc3 command call` both end up in the command pipeline. A write command runs asynchronously: once
the gateway or data center accepts it, the call returns a command ID right away, and the actual result has to be looked
up by that ID in the command history.
::: danger Write failures aren't echoed back, and commands have a TTL
Whether a point can be written depends on its `rwFlag` — writing to a `READ_ONLY` point is rejected. If a write command
fails, the receipt's `responseValue` is `null`. A failed value never comes back as a success. The command itself has a
validity window: `PointCommandDTO.expireAt` defaults to `now + 10s`, and if no driver consumes it before the timeout
it's discarded. These rules are the same for the CLI and for direct curl.
:::
## Exit codes: how to read the result in scripts
`dc3` tells success from failure through exit codes. Success exits `0`; any error — bad arguments, unreachable gateway,
rejected auth, API error — exits `1`. The CLI catches every exception at the top level and calls `process.exit(1)`. It
doesn't break exit codes out by error category, so to tell the cause apart, read the error message on stderr or add
`--verbose`.
| Exit code | Meaning |
|-----------|----------------------------------------------------------------------------------------------|
| `0` | Success |
| `1` | Any error (invalid arguments, network unreachable, rejected authentication, API error, etc.) |
```bash
# Decide by exit code in CI: non-zero means failure; read the cause from stderr
if ! dc3 device list --ci 2>err.log; then
if grep -qE 'Authentication failed|Forbidden' err.log; then
echo "login required"
else
echo "other error"; cat err.log
fi
exit 1
fi
```
::: tip Prefer `--format json` for AI agents
When AI coding tools call the platform through the shell, always add `--format json` (or `--ci`). The output fields are
stable and parseable, and the `0`/`1` exit code lets the agent first tell whether it succeeded, then read the error on
stderr to decide whether to re-login or retry. If you want AI tools to discover and call the platform's full API surface
directly, see how to wire up the gateway MCP endpoint in [AI Agent / MCP Integration](../ai/mcp).
:::
## Further reading
- [Automation](./) — where the CLI, scripts, and MCP fit in the overall automation picture
- [AI Agent / MCP Integration](../ai/mcp) — let AI tools discover and call platform tools through the gateway's `/mcp`
- [First Device](../quickstart/first-device) — the golden path: end-to-end flow from creating a driver to reading values
---
# Automation
URL: https://docs.dc3.site/en/automation/
# Automation
Deterministic, repeatable, programmatic operations through the `dc3` CLI — no LLM involved, so results are predictable
and scriptable. Where the AI section (Agentic Center, MCP) is about "let the model decide," this section is about "let a
person or script execute."
## dc3 CLI
`dc3` is a standalone TypeScript CLI (Node ≥ 20) that talks to a running backend over the HTTP gateway, with no Java
build coupling. It wraps the three-step login, automatic token renewal, and credential storage, so what you actually run
are subcommands named by result cardinality: `dc3 device list`, `dc3 point history`, `dc3 driver add`.
It fits three audiences:
- **Local debugging** — read a point value, check a device, fire off a read command, all from the terminal.
- **Scripts and CI** — bulk-create devices, pull history on a schedule, wire platform operations into a deploy pipeline.
- **AI coding tools** — let Claude Code, Codex, Gemini CLI drive the platform through the shell; every command supports
`--format json`, so the output is safe for programs to parse.
The CLI authenticates with a login token: fetch a salt, exchange it for a 12-hour access token, then send
`X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` on every request. Like the AI section, it gets no more privilege than
the logged-in account, and cross-tenant data stays invisible.
> Want a model rather than a script at the wheel? See the [AI section](../ai/) (conversational Agentic Center + MCP for
> external agents).
## Further reading
- [CLI Guide](./cli) — full command surface, three-step login, credential backends (keychain / encrypted file / env)
- [AI](../ai/) — conversational Agentic and MCP for external agents
- [Your first device](../quickstart/first-device) — a CLI walkthrough for the first device
---
# Code of Conduct
URL: https://docs.dc3.site/en/community/code-of-conduct
This page is for everyone who participates in the IoT DC3 community — whether you file issues, review pull requests,
join
discussions, or maintain the repository. By the end you'll know what we encourage, what we don't tolerate, and how to
report a violation.
> You are here: ready to engage with the community. Before contributing code, also read the
> [Contributing Guide](./contributing).
## Our Pledge
We as contributors and maintainers pledge to make participation in the IoT DC3 community a harassment-free experience
for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience,
education, nationality, personal appearance, race, religion, sexual identity and orientation, or any other personal
characteristic.
## Expected Behavior
The following behaviors make the community better, and maintainers look for them alongside code quality during review:
- **Use welcoming and inclusive language** — especially in code review and issue discussions. Critique the work, not the
person.
- **Respect differing viewpoints and experiences** — a given problem may have multiple reasonable solutions. When
opinions diverge, focus on technical facts.
- **Accept constructive feedback gracefully** — having your pull request flagged for changes doesn't mean you did
poorly; review exists to make the project better.
- **Focus on what is best for the project and its users** — architecture decisions and API trade-offs should put the
long-term interests of platform users first, not personal preference.
- **Show empathy toward other community members** — a newcomer's question or a non-native English speaker's phrasing
both deserve a patient response.
## Unacceptable Behavior
The following behaviors are not tolerated in any community channel (GitHub Issues / PRs / Discussions, Gitee, mailing
lists, instant messaging, etc.):
- **Harassment and intimidation** — including but not limited to personal attacks, trolling, stalking, or sustained
unprovoked provocation.
- **Discriminatory language** — offensive remarks or slurs targeting any personal characteristic listed in the pledge.
- **Sexualized language or imagery** — the community is a professional setting. Sexual harassment in any form won't be
accepted.
- **Publishing private information without permission** — real names, contact details, employer, or other non-public
data, even if found through public channels, requires the person's consent before sharing.
- **Sustained disruption** — repeatedly derailing unrelated topics, spamming, or ignoring maintainer guidance.
- **Any other conduct maintainers reasonably consider inappropriate** — this is not a vague clause but a backstop for
equally harmful behavior the preceding items don't enumerate.
## Enforcement
Project maintainers are responsible for clarifying and enforcing this Code of Conduct. They may:
- **Remove, edit, or reject** comments, commits, code, issues, pull requests, wiki edits, or other contributions that
do not align with this Code of Conduct.
- **Temporarily or permanently ban** participants whose behavior is harmful to the community, with a written
explanation citing the violated clause.
Enforcement follows this escalation:
| Circumstance | Action |
|----------------------------------------------------------------------------------------|---------------------------------------------------------|
| First-time, non-malicious (e.g., poor word choice without awareness) | Private reminder + request to correct; no public naming |
| Repeat offender or clearly malicious (e.g., personal attacks, discriminatory language) | Public warning or temporary ban, depending on severity |
| Persistent or severe violation (e.g., doxxing, systematic harassment) | Permanent ban without further notice |
::: warning The Code applies to maintainers too
Maintainers who violate this Code are subject to the same enforcement, with standards that are only higher. If you
believe a maintainer's behavior is inappropriate, report it through the channels below — retaliation will not be
tolerated.
:::
## Reporting
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported through either of these channels:
1. **Open a private issue** (where the platform supports it) and @-mention a project maintainer.
2. **Email a maintainer directly** — including `Code of Conduct` in the subject line helps with prompt triage.
All reports will be reviewed and investigated **promptly and fairly**. The outcome will be shared with the reporter
(if the reporter provided contact information); privacy-sensitive details will not be disclosed publicly.
::: tip Reporting won't hurt you
We won't take adverse action against you for making a report. Retaliating against a reporter is itself unacceptable
behavior and will result in an immediate ban.
:::
## Further Reading
- [Contributing Guide](./contributing) — code and documentation submission workflow with verification steps
- [Security Policy](./security) — how to responsibly report vulnerabilities, and the production security baseline
- [Open Source & License](../introduction/license) — IoT DC3 Community Edition's AGPL v3 license
---
This Code of Conduct is adapted from
the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
---
# Contributing Guide
URL: https://docs.dc3.site/en/community/contributing
This page is for anyone getting ready to submit code, docs, or feedback to IoT DC3. By the end you'll know how to set up
a local environment, move a change from branch to PR, write commit messages that hold up in release notes, and run the
right checks before merging.
> You are here: ready to get involved. Before writing backend code, read
> the [Development Overview & Conventions](../development/) (the repository-root `AGENTS.md` is the authoritative source
> for engineering conventions); to get verification running, see [Testing](../development/testing).
## Ways to Contribute
Contributing is more than writing code. All four kinds below are welcome, and all of them matter:
- **Report reproducible bugs** — attach logs, version, configuration, and reproduction steps so maintainers don't have
to guess.
- **Propose new features** — state the target scenario, the expected behavior, and the impact on existing compatibility.
- **Improve documentation** — add examples, translations, or troubleshooting notes. Even a one-character fix is worth a
PR.
- **Submit code** — focused commits plus tests or verification notes, with every line of change traceable to a
requirement.
::: tip Open an Issue Before You Start
For larger features or anything that changes behavior, open an Issue first to align on the approach before writing code.
Small fixes can go straight to a PR.
:::
## Set Up Your Local Development Environment
The platform is a distributed service built on Java 21 / Spring Boot 4. Locally you need at least the dependency stack (
PostgreSQL + RabbitMQ) running. Get the toolchain in place first, start the dependencies, then make sure the Java
processes pick up the right runtime variables.
Supported toolchain:
- JDK 21
- Maven 3.9+
- Podman or Docker
- Make (optional, but recommended)
Start the local dependency stack from the repository root:
::: code-group
```bash [Start the dependency stack]
make up-db # PostgreSQL + RabbitMQ
make up-optional # Optional stack: EMQX / ELK / Prometheus / Grafana
```
```bash [Validate compose]
podman compose -f dc3/docker-compose-db.yml config --quiet
```
:::
When you run Java processes from source, you have to inject the runtime variables into the process yourself. The root
`.env` only serves Docker Compose and will **not** be picked up by local Java processes automatically:
::: code-group
```bash [Run Java from a shell]
source dc3/env/dev.env.sh
```
```bash [Prepare Compose interpolation]
cp .env.example .env
```
:::
::: warning `.env` and `dev.env` Are Not the Same Thing
The root `.env` (copied from `.env.example`) is only for Docker Compose variable interpolation. Running Java locally
from an IDE or CLI needs `dc3/env/dev.env` (read by the IDE EnvFile plugin) or `dc3/env/dev.env.sh` (sourced into the
shell). For the differences between the four files and how to use them with JetBrains IDEA,
see [Environment Variables Explained](../quickstart/environment).
:::
## Branches and Pull Requests
A contribution starts with a focused branch and ends with a focused PR. Keep unrelated refactors, formatting cleanups,
and behavior changes separate so review can move quickly.
- Unless a maintainer says otherwise, branch your feature or fix off the latest `main`.
- Give branches semantic names, like `feature//` or `fix//`.
- Submit PRs against the `develop` branch.
- Keep the PR focused: don't mix refactors, formatting churn, and behavior changes into one PR unless they're all
required for the same fix.
- Reference the relevant Issue in the PR description.
## Commit Messages: Conventional Commits
Commit messages are generated straight into release notes (`dc3/doc/CHANGE.md` is built from git history), so the
subject has to be specific and readable. The format is fixed:
```text
():
```
- Write the subject in **English, lowercase, imperative mood**, specific enough to belong in release notes.
- Allowed types: `feat`, `fix`, `perf`, `refactor`, `docs`, `build`, `ci`, `test`, `chore`, `style`, `security`,
`revert`.
- Add a scope for any change that isn't a tiny root-level one.
- Skip weak subjects like `update`, `fix`, `misc`, `wip`, or `.` — they make release notes unreadable.
Real examples:
```text
fix(manager): validate tenant scope for device queries
docs(env): explain JetBrains IDEA environment variables
refactor(container): deduplicate compose registry overrides
```
::: warning The Subject Goes Straight Into Release Notes
`dc3/doc/CHANGE.md` is generated from commit messages, and a weak subject makes the notes unreadable. Before you commit,
check your subject against the format and the examples above to confirm it's specific and readable.
:::
## Verification Before Merging
Before opening a PR, run the checks that match what you changed. Verification scales with the change — you don't need to
run everything every time.
::: code-group
```bash [Java / shared behavior]
mvn -s .mvn/settings.xml clean package
```
```bash [Container / compose]
podman compose -f dc3/docker-compose-db.yml config
make config STACK=db # or app/dev/optional, depending on the stack touched
```
:::
- **Docs-only changes**: at minimum, verify by hand that links, commands, and formatting are correct.
- **Container changes**: for every compose file you touched, run `make config STACK=` or
`podman compose config`.
- **More testing conventions** (unit, integration, E2E, coverage gates) are covered
in [Testing](../development/testing).
## Coding Conventions (Key Points)
The full spec in `AGENTS.md` is authoritative. This section lists only the rules contributors trip over most. None of
them are style preferences — they're hard constraints on platform correctness.
- Follow the existing package structure, naming, validation, exception, logging, and facade patterns. Don't introduce
new ones.
- **Tenant isolation is a hard requirement**: every new query, gRPC call, cache key, and data change must preserve the
`tenantId` scope.
- For grouped configuration, prefer typed configuration properties with validation over scattered `@Value` annotations.
- Behavior changes must come with tests or focused verification notes, especially for shared common modules and
cross-service contracts.
- Don't commit secrets, locally generated files, IDE metadata, or machine-specific configuration.
::: tip CRUD Verbs Follow Result Cardinality
The platform has no free naming space — the verb in a method name, HTTP path, or gRPC RPC has to reflect the result
cardinality (`get` for a single record, `list` for a collection). See
the [Development Overview & Conventions](../development/) for details.
:::
## Documentation and Translation
When you change the root README content, keep `README.md`, `README.zh.md`, `README.ja.md`, and `README.vi.md`
structurally aligned. If you can't finish the translation sync in the same PR, say so in the PR description.
## Release Notes
Before tagging a release, generate a categorized changelog from git history:
```bash
make changelog
```
By default it reads the current version from `pom.xml`, compares `HEAD` against the nearest reachable `dc3.release.*`
tag, and updates `dc3/doc/CHANGE.md`. You can override the range or version when needed:
```bash
make changelog FROM=dc3.release.20251005.00 TO=HEAD VERSION=2026.5.22
```
::: info Changelog-Only Commits
By default, release commits like "generate changelog" are skipped, so re-running after you commit `CHANGE.md` stays
stable. Set `INCLUDE_CHANGELOG_COMMITS=true` only when those commits need to appear in the release notes.
:::
## License
The IoT DC3 Community Edition is licensed under the GNU Affero General Public License v3.0 or later. The license
statements live in the repository-root `LICENSE-AGPL.txt` and `LICENSE.txt`.
## Further Reading
- [Development Overview & Conventions](../development/) — authoritative engineering conventions: CRUD verbs, layered
calls, facade boundaries
- [Testing](../development/testing) — unit, integration, E2E, and coverage conventions
- [Environment Variables Explained](../quickstart/environment) — the differences between `.env` / `dev.env` /
`dev.env.sh` and IDE usage
- [Code of Conduct](./code-of-conduct) — please read before participating in the community
- [Security Policy](./security) — how to report security vulnerabilities responsibly
---
# FAQ
URL: https://docs.dc3.site/en/community/faq
## Licensing
### What open-source license does IoT DC3 use?
IoT DC3 is released under the [AGPL-3.0](https://github.com/pnoker/iot-dc3/blob/release/LICENSE-AGPL.txt) license.
The core requirement of AGPL-3.0: if you modify the platform code and **provide it as a network service** (including
SaaS or internal systems), you must open-source the complete modified source code. Internal use without redistribution
or network service provision does not require open-sourcing.
### What does AGPL-3.0 mean for my company?
| Scenario | Must open-source? |
|-------------------------------------------------------------------------|-------------------------------------------------|
| Internal deployment, no code changes, self-use only | No |
| Internal deployment, modified code, self-use only (no network service) | No (but contributions welcome) |
| SaaS product built on DC3, sold externally | **Yes**, all modifications must be open-sourced |
| Secondary development based on DC3, distributed to customer deployments | **Yes**, all modifications must be open-sourced |
| Only calling DC3 APIs, no changes to DC3 itself | No |
### Can I develop proprietary derivatives?
If you only call DC3 APIs without modifying DC3 source code, your caller-side code can remain closed-source. Once you
modify DC3 source code and provide it as a network service, AGPL-3.0 requires you to open-source your modifications.
### Is there a commercial license?
There is currently no standalone commercial license. If your use case is compatible with AGPL-3.0, you may use it
freely. For special requirements, contact the maintainers through community channels.
---
## Pricing & Business Model
### Does IoT DC3 itself cost money?
**No.** IoT DC3 is completely free and open-source. You may freely download, use, modify, and distribute it (subject to
AGPL-3.0 terms).
### How does the project sustain itself?
IoT DC3 is currently a personal open-source project maintained by the author, operating as a community-driven effort.
Possible future commercialization paths include: technical support services, enterprise custom development, SaaS
hosting, etc. The core platform will always remain open-source.
### Do I need to pay anyone to use IoT DC3?
No. No payment to anyone is required to use IoT DC3. However, you are responsible for your own infrastructure costs (
servers, databases, etc.).
---
## Technology Choices
### Why Java instead of Go / Node.js / Python?
IoT DC3 chose the Java + Spring ecosystem for these core reasons:
1. **Industrial IoT landscape**: A large body of existing industrial systems (SCADA, MES, ERP) are Java-based. Java has
natural advantages in industrial integration.
2. **Spring ecosystem maturity**: Spring Boot / Cloud / Security / Data provide out-of-the-box capabilities for
distributed systems, security, and data access.
3. **JVM stability**: Long-running device-access services demand reliable GC and memory management. The JVM has decades
of production-proven stability.
4. **AI integration**: Spring AI enables the platform to interface with multiple LLM providers (OpenAI, Claude, local
models, etc.) through a unified paradigm.
5. **Team expertise**: The maintainer has deep experience in the Java / Spring ecosystem.
### Why PostgreSQL instead of MySQL?
1. **TimescaleDB extension**: For IoT time-series data, the TimescaleDB extension on PostgreSQL provides native
hypertable auto-partitioning, compression, and data retention policies.
2. **Apache AGE**: A graph database extension for device relationship and topology path queries.
3. **pgvector**: A vector extension providing infrastructure for AI semantic search.
4. **Richer data types**: JSONB, arrays, range types, etc.
5. **Stricter SQL standards**: More reliable in complex query and transaction scenarios.
IoT DC3 depends deeply on PostgreSQL. These three extensions (TimescaleDB + AGE + pgvector) are core to the platform's
data architecture.
### What device protocols are supported? How do I choose?
The platform includes **28 built-in driver modules**, covering:
- **Industrial Bus / PLC**: Modbus TCP/RTU, OPC UA/DA, S7 (Siemens), MELSEC, FINS (Omron), EtherNet/IP
- **SCADA / Power / Metering**: BACnet/IP, IEC 104, DLMS, SL651, SNMP
- **IoT / Wireless**: MQTT, CoAP, LwM2M, HTTP, BLE, Zigbee, CAN
- **Serial / General Network**: Serial, TCP/UDP
- **Database**: MySQL, PostgreSQL, Oracle, SQL Server
Selection tip: first identify the protocols your field devices support, then check
the [Driver Capability Matrix](../drivers/matrix) to confirm the required read / write / subscribe capabilities are met.
---
## Deployment & Operations
### Minimum hardware requirements?
**Development environment** (dependency stack only — PostgreSQL + RabbitMQ):
- CPU: 2 cores
- RAM: 4 GB
- Disk: 20 GB
**Production environment** (full stack — gateway + 4 centers + N drivers + dependencies):
- CPU: 8 cores or more
- RAM: 16 GB or more
- Disk: 100 GB SSD or more (time-series data grows continuously; plan for expansion)
### How to migrate from development to production?
1. **Security hardening**: Change default keys / passwords, enable TLS, configure firewall rules, disable debug
endpoints.
2. **Data persistence**: Ensure PostgreSQL and RabbitMQ data volumes are correctly mounted and backed up.
3. **High availability**: Configure PostgreSQL replication and RabbitMQ clustering as needed.
4. **Monitoring & alerting**: Deploy Prometheus + Grafana (included in docker-compose-optional.yml).
5. **Log collection**: Integrate with ELK (included in docker-compose-optional.yml).
6. **Environment variables**: See [Environment Variables](../quickstart/environment) and replace development values with
production values.
See [Security Policy](./security) for the production baseline checklist.
### How do I back up data?
PostgreSQL data backup:
```bash
# Full backup
podman exec dc3-postgres pg_dumpall -U dc3 > backup.sql
# Platform data only (exclude TimescaleDB time-series data)
podman exec dc3-postgres pg_dump -U dc3 \
--schema=dc3_auth --schema=dc3_manager --schema=dc3_data > backup_platform.sql
```
For production, configure pgBackRest or scheduled pg_dump tasks with offsite storage.
---
## Driver Development
### How do I develop a new driver?
1. Read the [Driver Development Guide](../development/driver-authoring).
2. Copy the closest existing driver module under `dc3-driver/` as a template.
3. Implement the `read()`, `write()`, and (optionally) `subscribe()` methods required by the Driver SDK.
4. Add the driver service configuration to `dc3/docker-compose.yml`.
5. Write documentation (follow the format of existing driver doc pages).
### Does a driver have to be written in Java?
The Driver SDK itself is in Java, but you can also implement device access in any language via **MQTT bridging** or *
*HTTP proxy**. A non-Java program publishes data to an MQTT topic → the MQTT driver subscribes → data enters the
platform pipeline. However, this approach loses the SDK's built-in state management, automatic reconnection, and health
reporting capabilities.
---
## AI Capabilities
### What can AI do?
IoT DC3's Agentic Center (based on Spring AI) gives LLMs the following capabilities:
- **Device querying**: Natural language queries for device status, point values, and historical data.
- **Command issuance**: Let the AI write parameters to devices through conversation.
- **Alarm analysis**: AI analyzes alarm history and provides root-cause inference.
- **Data insights**: Trend analysis and anomaly detection on time-series data.
AI capabilities are exposed through the MCP (Model Context Protocol) and can be called directly by tools like Claude
Desktop, VS Code, and Cursor. See the [AI Overview](../ai/).
### Which LLM providers are supported?
Through Spring AI, all major model providers are theoretically supported: OpenAI, Anthropic Claude, Google Gemini,
Alibaba Tongyi Qianwen, Baidu ERNIE Bot, local Ollama models, and more. See the [Agentic Center](../ai/agentic) for
configuration details.
---
## Community & Contribution
### How do I get help?
1. Check the [Troubleshooting Guide](../guide/troubleshooting).
2. Search [GitHub Issues](https://github.com/pnoker/iot-dc3/issues) for similar problems.
3. Not found? Open a new issue with: version number, logs, reproduction steps, and environment info.
### How can I contribute?
See the [Contributing Guide](./contributing). All forms of contribution are welcome: bug reports, documentation
improvements, code contributions, and discussions.
### Is commercial support available?
The project currently operates as a community effort with no official commercial support. For enterprise-level support
needs, contact the maintainers through community channels.
---
# Security Policy
URL: https://docs.dc3.site/en/community/security
IoT DC3 is an industrial IoT platform that connects field devices, so a single security gap can expose both data and
control at once. This page covers two things: which versions we still maintain and how to privately report a
vulnerability, and the minimum security baseline to hit before you take the platform to production.
> You already know the platform and you're ready to deploy. Before going to production, also
> read [Environment Variables](../quickstart/environment) and [Deployment Modes and Image Registries](../guide/usage).
## Supported Versions
We ship security patches only for the current mainline release. Version numbers follow a `YYYY.M.x` year-month scheme —
for example, `2026.5.x` is the May 2026 line — and the patch number `x` keeps rolling forward within a mainline. The
current line is `2026.5.x` (latest `2026.5.22`, image tag `2026.6`).
The table lists the release lines that still get security updates. Older versions are no longer back-ported; upgrade to
a supported mainline before reporting.
| Version line | Supported |
|------------------------|------------------------|
| `2026.5.x` | ✅ Supported |
| `2026.4.x` | ✅ Supported |
| `2025.x.x` and earlier | ❌ No longer maintained |
::: tip Upgrade first
Before reporting a vulnerability, confirm it still reproduces on a supported version. Many issues are already fixed on
the newer mainline, and upgrading is often the fastest fix.
:::
## Vulnerability Disclosure Process
We take security reports seriously. Once a vulnerability is confirmed, we fix it as fast as we can and publish the fix
in the release notes.
::: danger Do not disclose publicly
**Do not** post potential vulnerabilities in GitHub / Gitee issues or discussion forums. A public PoC immediately
exposes unpatched instances to attack. Use the private channels below instead.
:::
If you find a potential security vulnerability, report it through either private channel:
1. **Email**: Send a message to the project maintainers with `Security Vulnerability` in the subject so it gets triaged
first.
2. **Direct message**: Reach the maintainers directly via a Gitee or GitHub private message.
To help us reproduce and pin down the issue, include in your report: the affected version line, reproduction steps or a
minimal repro, the impact (data leak / privilege escalation / command injection, etc.), and any fix ideas you have. Once
we verify the vulnerability, we start the fix and publish the details in that version's release notes.
## Production Security Baseline
The default configuration is built for local development and **optimizes for developer convenience** — weak passwords,
cleartext ports, and development keys are all present. Tighten every one before going to production. The three items
below are the hardest constraints; for the rest, see [Environment Variables](../quickstart/environment).
### 1. Keys must be random, and pre/pro will enforce this
The platform has two keys that must never leak. Their defaults are for development only:
- `AUTH_HMAC_SECRET` — the HMAC-SHA256 key the Gateway uses to sign the `X-Auth-Principal` header when calling backend
services; default `io.github.pnoker.dc3`.
- `DC3_SECURITY_KEY` — the signing key the Auth Center (`dc3-center-auth`) uses to mint and validate login tokens;
default `dc3.security.key.2026.io.github.pnoker`.
::: danger HMAC key fails fast in pre/pro environments
When the active Spring profile (or the `spring.env` property) is `pre` or `pro`, and `AUTH_HMAC_SECRET` is empty or
still the default `io.github.pnoker.dc3`, startup throws an `IllegalStateException` and the service **will not start**.
That's intentional — failing to start is better than a production instance running on development keys. In production,
use a strong random value (for example, `openssl rand -base64 48`) and inject it through an environment variable. Never
hardcode it or write it to logs.
:::
```bash
# Generate a strong random value for each key (example output, do not copy verbatim)
openssl rand -base64 48 # → use as AUTH_HMAC_SECRET
openssl rand -base64 48 # → use as DC3_SECURITY_KEY
```
`DC3_SECURITY_KEY` has no startup fail-fast check like the HMAC key, but change it to a strong random value too — once
it leaks, an attacker can forge login tokens.
### 2. Enable TLS — don't run the message bus and broker in cleartext
The platform depends on RabbitMQ and (optionally) the EMQX MQTT broker. Both disable TLS by default, which is only safe
for local use. Turn on encryption in production or anywhere traffic crosses a network:
- RabbitMQ: set `RABBITMQ_SSL_ENABLED=true`, route connections over the TLS port (`5671`, published externally as
`DC3_RABBITMQ_TLS_PORT`, default `35671`), and optionally enable `RABBITMQ_SSL_VALIDATE_SERVER_CERTIFICATE` and
`RABBITMQ_SSL_VERIFY_HOSTNAME` (both default to `false`).
- EMQX: use the MQTT-over-TLS port (`DC3_EMQX_MQTTS_PORT`, default `38883`) and secure WebSocket (`DC3_EMQX_WSS_PORT`,
default `38084`) instead of the cleartext `31883` / `38083`.
The external HTTP entry point (the `dc3-gateway` gateway, default `8000`) should sit behind a reverse proxy or load
balancer that terminates HTTPS. Put HTTPS / SSL on every external interface and audit external calls.
### 3. Minimize exposed ports — never put field-protocol ports on the public internet
`DC3_BIND_HOST` defaults to `127.0.0.1`, so every published port binds only to the local host; you have to explicitly
set it to `0.0.0.0` to expose them to the network. In production, expose only the ports that **must** be public and keep
everything else behind the internal network or a security group.
::: danger Field-protocol ports must never face the public internet
Field-protocol ports — Modbus, raw TCP/UDP, and the various PLC gateways (for example the listening driver
`dc3-driver-listening-virtual`'s `DC3_LISTENING_VIRTUAL_TCP_PORT=6270` / `UDP=6271`) — generally have no built-in
authentication and **must never** face the public internet directly. Devices should connect over a VPN, a private
network, or through a gateway whitelist. The only thing that should face the public internet is the hardened gateway
HTTP port, behind a reverse proxy.
:::
The ideal exposed surface is a single point: the gateway. The Auth Center (`8300`), Management Center (`8400`), Data
Center (`8500`), Agentic Center (`8600`), and the gRPC ports (`9300/9400/9500`) are all internal and never published
externally. For the port list and defaults, see the "Gateway and Service Ports" and "gRPC / facade" sections
of [Environment Variables](../quickstart/environment).
### Other General Practices
- ✅ Run a supported version, and keep system dependencies and container images up to date.
- 🔑 Change every default password: replace the defaults for PostgreSQL (`POSTGRES_PASSWORD`, default `dc3dc3dc3`),
RabbitMQ (`RABBITMQ_PASSWORD`), MQTT (`MQTT_PASSWORD`), and the rest with strong random values.
- 🧩 Let only trusted devices and users connect. Apply least privilege on external interfaces and audit access. For how
tenant isolation and RBAC are implemented, see [Auth, Tenant, RBAC](../architecture/auth-rbac).
## Further Reading
- [Environment Variables](../quickstart/environment) — default values, scope, and production guidance for every
security-related variable
- [Deployment Modes and Image Registries](../guide/usage) — containerized deployment, port publishing, and image
registry selection
- [Auth, Tenant, RBAC](../architecture/auth-rbac) — how login, tenant isolation, and the permission model keep
multi-tenant data safe
---
# API Documentation
URL: https://docs.dc3.site/en/development/api-documentation
# API Documentation
IoT DC3's REST API docs are generated from code annotations, then aggregated by the gateway into a single Swagger UI. By
the end of this page you'll be able to open each center's online docs in development, walk through the login flow with
the default credentials (fetch salt → fetch token → call with auth headers), read the CRUD path conventions, and see how
the `x-dc3-ai` risk metadata on each endpoint feeds into AI/MCP tools.
> You're about to call or debug a backend API. If you need to get the environment running first,
> see [First Device](../quickstart/first-device). For the tenants and permissions behind those auth headers,
> see [Auth · Tenant · RBAC](../architecture/auth-rbac).
## Where the docs come from: annotation-generated, gateway-aggregated
There are no hand-written API spec files. Every endpoint's title, parameters, and request/response models come from
`springdoc-openapi` annotations on the Controllers (`@Tag`, `@Operation`, `@Parameter`, `@Schema`). Each center service
emits its own OpenAPI JSON at runtime on the WebFlux stack.
Four business centers expose their own docs — Auth Center (`dc3-center-auth`), Manager Center (`dc3-center-manager`),
Data Center (`dc3-center-data`), and Agentic Center (`dc3-center-agentic`). The Gateway (`dc3-gateway`) has no business
Controllers of its own; through `springdoc.swagger-ui.urls` it pulls all four documents into one Swagger UI with a
service dropdown, so only a single entry point faces outward.
Grouping works in two layers. `dc3-common-web`'s `SpringDocConfig` supplies the global metadata — title, version,
contact, license, security schemes. Each business module then declares a `GroupedOpenApi` Bean under its already-scanned
package, so only that module's Controllers are scanned. Each center service prepends its `spring.webflux.base-path` (
e.g. `/auth`, `/manager`) to the doc path, giving paths like `/auth/v3/api-docs`. The gateway's aggregation path
`/v3/api-docs/{svc}` flattens that away, so every service is reachable the same way.
::: info dc3-center-single mode
`dc3-center-single` packs multiple business modules into one process, so its Swagger UI shows several groups at once.
That's expected, not duplicated configuration.
:::
## Access entry points
In development, prefer the gateway aggregation entry point. When you're debugging a single center, you can also hit its
base-path docs directly.
| Target | URL |
|-------------------------------------|-------------------------------------------------|
| Gateway aggregated UI (recommended) | `http://:8000/swagger-ui.html` |
| Auth Center direct | `http://:8300/auth/swagger-ui.html` |
| Manager Center direct | `http://:8400/manager/swagger-ui.html` |
| Data Center direct | `http://:8500/data/swagger-ui.html` |
| Agentic Center direct | `http://:8600/agentic/swagger-ui.html` |
| Single-center OpenAPI JSON | `http://://v3/api-docs` |
## Login and authentication: fetch salt → fetch token → send X-Auth-* headers
Public endpoints like `/api/v3/auth/token/**` (fetch salt, generate token, change password) are open. Every other
business API behind the gateway requires three auth headers: `X-Auth-Tenant`, `X-Auth-Login`, and `X-Auth-Token`.
Login is a two-step handshake. First, request a random salt from the server using the username and tenant (use within 5
minutes as a guideline; the server does not enforce the timeout). Then submit the **plaintext password** together with
that
salt to exchange for a token (valid 12 hours). The salt does not participate in the password hash — it is concatenated
with
the server-side `DC3_SECURITY_KEY` to derive the JWT's HMAC-SHA256 signing key. The password itself is submitted in
plaintext
(relies on HTTPS for transport protection) and verified by the backend `PasswordUtil.verify` using Argon2id (falling
back to
BCrypt when Argon2 is unavailable).
A real call looks like this (values are illustrative only; `default`/`dc3` are the tenant and user shipped in the seed
data):
::: code-group
```bash [curl]
# 1. Fetch salt
curl -X POST http://localhost:8000/api/v3/auth/token/salt \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3"}'
# → R: data is the salt (e.g. "f3a9c1..."), use within 5 minutes
# 2. Submit the plaintext password together with the salt (HTTPS protects transport) to exchange for a token
curl -X POST http://localhost:8000/api/v3/auth/token/generate \
-H 'Content-Type: application/json' \
-d '{"tenant":"default","name":"dc3","salt":"f3a9c1...","password":""}'
# → R: data is the access token (e.g. "eyJ..."), valid 12 hours
# 3. Call a business API with the auth headers
curl -X POST http://localhost:8000/api/v3/manager/device/list \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: {"salt":"f3a9c1...","token":"eyJ..."}' \
-H 'Content-Type: application/json' \
-d '{"current":1,"size":10}'
```
```bash [dc3 CLI]
# The CLI wraps the whole process: fetch salt → exchange token → save credentials
dc3 config set gateway http://localhost:8000
dc3 auth login --tenant default --username dc3
```
:::
To debug a protected endpoint in the Swagger UI, click **Authorize** in the top-right corner and fill in the auth
headers per the table below:
| Header | Example value |
|-----------------|--------------------------------|
| `X-Auth-Tenant` | `default` |
| `X-Auth-Login` | `dc3` |
| `X-Auth-Token` | `{"salt":"...","token":"..."}` |
::: danger Never write real credentials into docs/logs/issues
Tokens, passwords, salts, and API keys must never be committed to documentation, commit history, or tickets. Hash values
and tokens in examples are replaced with placeholders.
:::
## CRUD path conventions: the verb reflects result cardinality
All business APIs follow one naming rule — the verb on the HTTP path, the Java method, the gRPC RPC, and the frontend
function all reflect the **cardinality of the returned result**. Use `getXxx` to read a single record, `listXxx` to read
a collection, and the write triad `add`/`update`/`delete` to write. The path alone tells you whether a call returns one
record or many, and whether it reads or writes.
| Action | Java method | HTTP path | gRPC RPC | Frontend function |
|---------------|----------------|-------------|-----------|-------------------|
| Single record | `getXxx(...)` | `/get_xxx` | `GetXxx` | `getXxx(...)` |
| Collection | `listXxx(...)` | `/list_xxx` | `ListXxx` | `listXxx(...)` |
| Create | `add(BO)` | `/add` | n/a | `addXxx(...)` |
| Update | `update(BO)` | `/update` | n/a | `updateXxx(...)` |
| Delete | `delete(Long)` | `/delete` | n/a | `deleteXxx(...)` |
::: tip Reserved verb semantics
`select*` is only for native MyBatis Mapper calls inside `*ManagerImpl`. `remove*` is only for Manager methods inherited
from MyBatis-Plus. Business deletion always uses `delete*`. `find*`/`query*`/`fetch*` aren't used as primary CRUD verbs.
:::
Here's a real example from Manager Center — the "add device" step of the golden path. The endpoint is
`POST /api/v3/manager/device/add`, the request body is `DeviceVO` with key fields `deviceName`, `driverId`, `profileId`,
`enableFlag`, and on success it returns `SuccessCode.ADD` ("Added successfully"). Note that `add` doesn't return the new
entity ID — if you need it afterward, query it back through `device/list` by name. The endpoint requires the
`device:add` permission. All APIs return a uniform `R` envelope with four fields: `ok`, `code`, `message`, and
`data`.
## x-dc3-ai: risk annotations for AI/MCP tools
An endpoint's `@Operation` can carry an `x-dc3-ai` OpenAPI extension that describes, through four boolean/enum
properties, what risk a call carries for an AI agent. This metadata isn't a human-facing comment — the MCP tool catalog
aggregator reads it, persists it into the `dc3_mcp_tool_catalog` table, and from there decides whether a tool is visible
to a given AI connection in `tools/list` and whether invoking it needs a second confirmation.
```java
@Extension(name = "x-dc3-ai", properties = {
@ExtensionProperty(name = "riskLevel", value = "MEDIUM"), // LOW / MEDIUM / HIGH
@ExtensionProperty(name = "destructive", value = "false"), // whether it destroys data/config
@ExtensionProperty(name = "idempotent", value = "false"), // whether it is safe to retry
@ExtensionProperty(name = "openWorld", value = "true") // whether it reaches the external/physical world
})
```
What each property means:
- `riskLevel`: `LOW`/`MEDIUM`/`HIGH`, **annotated by hand per verb-semantic convention** (not auto-derived from the HTTP
method). By convention `delete` is `HIGH`, `add`/`update` are `MEDIUM`, and `get`/`list` are `LOW`. The final value
comes from the hand-written annotation on each `@Operation`; the aggregator only falls back to `HIGH` when the
annotation is missing or invalid. `HIGH`-risk tools are hidden from AI by default — they must be explicitly enabled
and need a two-phase confirmation when invoked.
- `destructive`: whether the call destroys existing data or settings (e.g. changing a password, revoking a token).
- `idempotent`: whether repeating the call with the same parameters is safe (which decides whether it can be
auto-retried after a failure).
- `openWorld`: whether it reaches external systems or physical devices beyond the platform (e.g. dispatching a write
command).
Take Auth Center's `TokenController`. The fetch-salt endpoint is annotated
`riskLevel=LOW, destructive=false, idempotent=false, openWorld=false`, while generate-token is annotated
`riskLevel=HIGH`. Both are public endpoints hidden from the AI tool catalog (`hidden=true`), but their risk levels are
still faithfully distinguished. The Agentic Center's `POST /api/v3/agentic/chat/completions` is annotated
`riskLevel=MEDIUM, destructive=false, idempotent=false, openWorld=true`.
The aggregator also derives `read_only_hint` from the HTTP method (`GET` → 1, `POST` → 0) and persists all hint bits (
`destructive_hint`/`idempotent_hint`/`open_world_hint`/`read_only_hint`, valued 0/1) alongside `risk_level`. The tool
risk an AI agent sees through MCP is the final rendering of these annotations. For the full MCP tool exposure,
filtering, and confirmation mechanism, see [AI Agent / MCP Integration](../ai/mcp).
## Exporting OpenAPI JSON
When you need an offline contract snapshot, or input for a client code generator, export each center's OpenAPI JSON from
a running dev/test stack with one command:
```bash
make openapi
```
Override the export entry point and output directory with variables:
```bash
make openapi OPENAPI_BASE=http://localhost:8000 OPENAPI_OUT=build/openapi
```
## Documentation requirements when adding an API
When you add a backend API, documentation isn't an afterthought — the annotations *are* the doc source:
1. Add `@Tag(name = "...", description = "...")` to the Controller class.
2. Add `@Operation(summary = "...", description = "...")` to the method. The summary should follow the CRUD verb
convention (`add`/`delete`/`update`/`getXxx`/`listXxx`).
3. Add `@Parameter` for path, query, and request-body parameters.
4. Add `@Schema(description = ...)` to request/response DTO fields, with `example` and `requiredMode = REQUIRED` where
needed.
5. For endpoints callable by AI/MCP, add the `x-dc3-ai` extension reflecting the real risk.
6. When adding a new business module, add the `GroupedOpenApi` Bean, the gateway aggregation config, and the Swagger UI
group.
::: warning Annotation text must always be in English
The `summary`/`description` in annotations is user-visible code text and, per engineering rules, must be written in
English. Don't put sensitive values such as `apiKey`, `password`, `secret`, or `token` in `@Schema` `example` fields.
:::
::: danger Disable Swagger / OpenAPI exposure in production
API documentation is only available in the `dev`, `test`, and `pre` environments. In production (the `pro` profile) each
center service disables it in its own `application-pro.yml` (one each for auth/manager/data/agentic/single):
```yaml
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
```
The shared `application-web.yml` only sets springdoc's baseline paths — it isn't responsible for disabling, and its
comments say so. In production the springdoc endpoints simply don't exist, so no documentation content is ever exposed.
:::
## Further reading
- [Auth · Tenant · RBAC](../architecture/auth-rbac) — the salt/token/HMAC behind the auth headers, plus tenant isolation
and the permission model
- [First Device](../quickstart/first-device) — walk the golden path with the `dc3` CLI and actually exercise these APIs
- [AI Agent / MCP Integration](../ai/mcp) — how the `x-dc3-ai` metadata becomes MCP tool risk policy and two-phase
confirmation
- [Testing](./testing) — how API contract and integration tests verify these paths
---
# Changelog
URL: https://docs.dc3.site/en/development/changelog
# Changelog
This changelog isn't hand-written. It's generated by `make changelog`, which reads the git history and groups commits by
version following Conventional Commits. This page covers where the list comes from, how to read it, and the rules behind
the version numbers and tags, then inlines the full list.
> You are here to find what changed in a release or how this list is maintained. To write code, start with
> the [Development Overview & Conventions](./). For commit conventions, see
> the [Contributing Guide](../community/contributing).
## Where this list comes from
The platform has no hand-written `CHANGELOG`. Every change is tracked through normalized commit messages. At release
time a script walks the git history, reads the type and scope of each commit, and groups them into the versioned list
below. So **commit messages are the source data for the changelog** — a vague `update` or `fix bug` turns into a useless
line in the release notes. The commit convention is what makes this document worth reading.
The generator is `dc3/bin/changelog.py` — plain Python, no third-party deps. The Makefile target `make changelog` runs
it and writes `dc3/doc/CHANGE.md`, the same file inlined at the bottom of this page.
The flow runs one way: the commit history is the only source of data, the script's output lands in `CHANGE.md`, and this
page just inlines it for display. **Don't hand-edit entries here** — the next `make changelog` overwrites them.
::: code-group
```bash [Default (last release tag → HEAD)]
# Run inside the iot-dc3/ directory
make changelog
```
```bash [Specify range and version number]
# FROM/TO accept any git ref (tag, branch, commit); VERSION is written into the group heading
make changelog FROM=dc3.release.20251005.00 TO=HEAD VERSION=2026.5.17
```
:::
With no arguments, the generator finds the most recent `dc3.release.*` tag as the start point, uses `HEAD` as the end
point, and reads the version number from `dc3.version` in `pom.xml`. It writes the result back to `dc3/doc/CHANGE.md`,
which this page's include directive then inlines.
::: info Commit it separately when you change it
The changelog is a generated artifact. When `CHANGE.md` is regenerated and ready to commit, use
`docs(release): update generated changelog` — the fixed subject the repo reserves for changelog-only changes. The
generator recognizes and skips commits with the `docs(release):` and `chore(release):` prefixes, so "update changelog"
entries don't clutter the changelog itself.
:::
## How to read it: by version, then by category
The top level of the list is grouped by **version**, with one `### ` heading per version. Under that comes a
`_Generated on ._` line marking when the section was built, then a Summary block (commit count, per-category
counts, the busiest scopes, and a few Highlights), and finally the commits broken out by category.
Category order inside each version section is fixed, from most to least noteworthy:
| Order | Category | Source commit type |
|-------|------------------|------------------------------------------------|
| 1 | Breaking Changes | Any type with `!` (e.g. `feat!:`) |
| 2 | Security | `security` |
| 3 | Features | `feat` / `feature` |
| 4 | Bug Fixes | `fix` |
| 5 | Performance | `perf` |
| 6 | Refactoring | `refactor` |
| 7 | Documentation | `docs` / `doc` |
| 8 | Build | `build` |
| 9 | CI | `ci` |
| 10 | Tests | `test` / `tests` |
| 11 | Chores | `chore` / `style` / `revert` |
| 12 | Other Changes | Commits that don't follow Conventional Commits |
::: info Security is also promoted by keyword
The table maps type to category. On top of that, the generator promotes **any** commit to the Security category when the
keywords `security` / `vulnerability` / `cve` / `auth bypass` appear in its type, scope, or summary (case-insensitive) —
even if the type isn't `security`. That keeps security-related changes from getting buried under `fix`/`refactor`.
:::
Each entry looks like `****: ()`. The scope comes from the `()` in the commit
message, and the short hash in parentheses points at the specific commit. To see what changed in the latest version,
jump to the first `###` section at the top of the list. To compare two versions, the quickest way is to read the commit
counts and Highlights in their Summary lines.
::: tip Commit conventions drive output quality
The parsing rule is `(): `. The type has to be one of the agreed set (`feat`/
`fix`/`perf`/`refactor`/`docs`/`build`/`ci`/`test`/`chore`/`style`/`security`/`revert`); otherwise the commit lands in
Other Changes without a category. For the full convention, see the [Contributing Guide](../community/contributing).
:::
## Version number and tag rules
Version numbers in the list correspond to git tags created by `make tag` (`dc3/bin/tag.sh`), in the format
`dc3...`:
- `` comes from the current branch. The `develop` branch produces `develop` tags; `release` / `main` produce
`release` tags. Other branches can't create tags.
- `` is the current date.
- `` is a zero-padded two-digit sequence number counting how many tags of that type already exist for the day,
starting at `00`. So the first release tag of a given day is `dc3.release.20260622.00`, the second is
`dc3.release.20260622.01` (example values).
```bash
# In the iot-dc3/ directory, on the release branch
make tag
# → produces a tag like dc3.release.20260622.00 and pushes it to origin
```
By default, `make changelog` scans from the previous `dc3.release.*` tag up to `HEAD`. The normal release flow is: run
`make tag` to cut a new tag, then run `make changelog` to generate the changes for that segment, then commit the updated
`CHANGE.md`.
::: warning The tag is pushed to the remote
`make tag` ends by running `git push origin --tags`. That's an outbound operation, so confirm you're on the right branch
and that the day's sequence number is correct before running it.
:::
## Complete Changelog
The content below is inlined from `dc3/doc/CHANGE.md` and refreshed after each `make changelog`. Don't hand-edit entries
here — the next generation overwrites them.
## Further reading
- [Development Overview & Conventions](./) — the overall map and coding conventions for secondary development
- [Contributing Guide](../community/contributing) — commit message conventions, the commit-msg hook, and the
contribution workflow
---
# Driver Development
URL: https://docs.dc3.site/en/development/driver-authoring
# Driver Development
Drivers are the southbound I/O layer of IoT DC3. They bring heterogeneous protocol devices — Modbus, OPC UA, MQTT, S7,
BACnet, and more — into the platform's data and command planes through a single interface. This page walks through
deriving a new protocol driver from the `dc3-driver-virtual` template, then covers the driver lifecycle, read/write
scheduling, and the one routing identifier you must not change after going to production. By the end you'll have a
driver that registers, collects data, and accepts commands.
> You are here: you want to onboard devices speaking a protocol that no existing driver supports. If you only want to
> use an existing driver, start with the [Operation Manual](../operation/) and [Quick Start](../quickstart/). Next, see
> the [Command Plane](../architecture/command-plane) to understand how read/write commands flow back to devices.
Unless otherwise noted, run commands from the `iot-dc3` repository root.
## What a driver is: a Spring Boot service that aggregates 7 SPIs
A driver is a standalone Spring Boot service (`dc3-driver-`). It does not talk directly to the management
center or the data center. Instead it inherits the `dc3-common-driver` SDK, which handles registration, scheduling,
RabbitMQ messaging, gRPC calls, and tenant context — so **you only implement the protocol logic**.
That logic is exposed through a single entry interface: `DriverCustomService`. It declares no methods of its own; it
aggregates 7 single-responsibility SPI sub-interfaces. Implementing this one interface puts all 7 concerns in your
hands:
| SPI sub-interface | The question you must answer |
|--------------------------|-----------------------------------------------------------------------------------------------------------|
| `DriverLifecycle` | What to initialize when the process starts (`initial()`)? What to do on each custom cycle (`schedule()`)? |
| `DriverProtocol` | How to read a point from the device (`read(...)`)? How to write a point (`write(...)`)? |
| `DriverCommand` | How to run the custom commands defined in the template (`execute(...)`)? |
| `DriverMetadataListener` | How to refresh local caches when device/point metadata changes (`event(...)`)? |
| `DriverHealth` | Is the driver as a whole ONLINE / OFFLINE / FAULT / MAINTAIN? |
| `DeviceHealth` | How to determine the online state of a single device? |
| `DriverValidator` | Is the driver/point configuration valid (`validate*`)? Can it generate simulated values? |
Source entry point: `dc3-common/dc3-common-driver/.../service/DriverCustomService.java` (a single `extends` line
stitches the 7 interfaces together). The `dc3-driver-virtual` template ships runnable example implementations for all 7,
which makes it the best starting point for a new driver.
::: tip Terminology alignment
**Attribute** comes from `dc3.driver.*-attribute` in the driver's `application.yml` and defines "which configuration
items this driver has". **Config** is the concrete value a given device fills in for those attributes, stored in the
management center. The driver registers attributes at startup and retrieves a device's config values at runtime through
`Map`.
:::
## Lifecycle: register (with retry) → initial → schedule
After the driver process starts, `DriverInitRunner` (an `ApplicationRunner`) runs a fixed bootstrap sequence: it first
registers itself and all attribute definitions with the management center, then calls your `initial()` for one-time
setup once registration succeeds, and finally lets the SDK wire up scheduled tasks (read scheduling, custom scheduling,
device health checks).
Registration goes over gRPC, and the management center may not be ready when the driver starts (rolling restarts, pod
rescheduling). So registration is not one-shot: `DriverInitRunner.registerWithRetry()` retries with **capped exponential
backoff** — starting at 2 seconds, doubling each time, capped at 30 seconds, up to 30 attempts. Only if all 30 fail does
it throw and exit. Without this retry, a brief blip in the management center would drag the driver into
CrashLoopBackOff.
Source: `dc3-common/dc3-common-driver/.../init/DriverInitRunner.java` (`REGISTER_MAX_ATTEMPTS=30`,
`REGISTER_INITIAL_BACKOFF=2s`, `REGISTER_MAX_BACKOFF=30s`). `initial()` runs only once at startup — use it to build
connection pools and subscriptions. `schedule()` fires on the cron defined in `dc3.driver.schedule.custom`.
## From template to new driver: four steps
The work for a new driver concentrates in four places: copy the template, edit `pom.xml`, edit `application.yml`, and
implement `DriverCustomService`. The diagram shows the overall path, expanded step by step afterward.
### Step 1: Copy the template and rename
Name the driver module `dc3-driver-`, with the protocol name in kebab-case:
```bash
cp -r dc3-driver/dc3-driver-virtual dc3-driver/dc3-driver-knx
```
Then rename the Java package, the application class, and the custom service implementation class. The two key classes in
the template are:
| Class | Description |
|----------------------------------|---------------------------------------------------------------|
| `VirtualDriverApplication` | Spring Boot application class |
| `VirtualDriverCustomServiceImpl` | Protocol logic entry point (`implements DriverCustomService`) |
A new driver should use protocol-specific names — `KnxDriverApplication`, `KnxDriverCustomServiceImpl` — to avoid
duplicate class names across drivers. Put the application class and the implementation class under the same parent
package so component scanning picks up the `@Service`-annotated `DriverCustomService` implementation:
```java
@SpringBootApplication
public class KnxDriverApplication {
public static void main(String[] args) {
SpringApplication.run(KnxDriverApplication.class, args);
}
}
```
### Step 2: Wire into the parent POM
Register the new module in the `` of `dc3-driver/pom.xml`:
```xml
dc3-driver-knx
```
The new module's own `pom.xml` usually just inherits the driver parent module and adds the protocol library:
```xml
io.github.pnoker
dc3-driver
2026.5.22
dc3-driver-knx
jar
```
The `dc3-driver` parent module already pulls in `dc3-common-driver` (the SDK) and the Spring Boot Maven Plugin, so you
don't need to declare them again.
### Step 3: Configure `application.yml`
`dc3.driver` is the driver's most important user-visible configuration. The SDK reads it at startup and registers it
with the management center, which uses it to render the device and point configuration forms. The example below follows
the real structure of `dc3-driver-virtual`, with KNX semantics in place of virtual's values:
```yaml
dc3:
driver:
tenant: default
name: KNX Driver
code: KnxDriver # stable routing identifier, see constraints below
type: DRIVER_CLIENT
remark: @project.description@
schedule:
read: # read scheduling: periodically collect point values
enabled: true
cron: '0/30 * * * * ?' # one round every 30 seconds
custom: # custom scheduling: driver schedule() callback
enabled: true
cron: '0/5 * * * * ?'
health:
device: # device health reporting
enabled: true
cron: '0/15 * * * * ?'
timeout: 45 # device status lease TTL (seconds)
timeout-unit: SECONDS
driver-attribute: # driver-level attributes: filled once per device instance
- attribute-name: Host
attribute-code: host
attribute-type-flag: STRING
default-value: localhost
remark: KNX/IP gateway host
- attribute-name: Port
attribute-code: port
attribute-type-flag: INT
default-value: 3671
remark: KNX/IP gateway port
point-attribute: # point-level attributes: filled once per point
- attribute-name: Group Address
attribute-code: groupAddress
attribute-type-flag: STRING
default-value: 1/0/1
remark: KNX group address
spring:
application:
name: @project.artifactId@
profiles:
active:
- ${NODE_ENV:dev}
logging:
file:
name: dc3/logs/driver/knx/${spring.application.name}.log
```
What the attribute fields mean (the prose above builds the mental model; this table is a quick reference):
| Field | Description |
|-----------------------|---------------------------------------------------------------------------------------------------------------------------------|
| `attribute-name` | UI display name; driver metadata is conventionally in English |
| `attribute-code` | The stable key the protocol implementation reads, e.g. `host`, `port`, `objectType` |
| `attribute-type-flag` | Attribute type; `AttributeTypeEnum` has 8 values: `STRING` / `BYTE` / `SHORT` / `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN` |
| `default-value` | Default value |
| `remark` | Description text; English recommended |
::: warning The toggle field is named enabled
The scheduling toggle field is named `enabled`. `DriverScheduleServiceImpl` reads `getRead().getEnabled()` /
`getCustom().getEnabled()` / `device.getEnabled()`, bound to the `private Boolean enabled` inside `DriverProperties`.
Spring's relaxed binding will not map `enable` to `enabled` — they are different property names. The
`dc3-driver-virtual` template writes `enable`, which actually has no effect. For a new driver, use `enabled`. Device
health's `enabled` defaults to `false` and must be explicitly set to `true` to turn it on.
:::
The attribute registration chain: `dc3.driver` in `application.yml` → SDK parses it into a `RegisterBO` → submitted to
the management center over gRPC. The diagram shows the entity relationships of this flow:
### Step 4: Implement `DriverCustomService`
The core protocol logic lives in the `DriverCustomService` implementation. `read(...)` returns a single
`ReadPointValue`, and `write(...)` returns a `Boolean`. That is the entire protocol contract's outward commitment (
source `DriverProtocol.java`):
```java
@Slf4j
@Service
public class KnxDriverCustomServiceImpl implements DriverCustomService {
@Resource
private DriverMetadata driverMetadata;
@Resource
private DriverSenderService driverSenderService;
@Override
public void initial() {
// One-time initialization: set up the protocol stack, connection pool, subscriptions
}
@Override
public void schedule() {
// Custom periodic task, e.g. periodically report device status (with TTL)
driverMetadata.getDeviceIds().forEach(deviceId ->
driverSenderService.deviceStatusSender(
deviceId, EntityStatusEnum.ONLINE, 45, TimeUnit.SECONDS));
}
@Override
public void event(MetadataEventDTO metadataEvent) {
// React to device/point metadata changes (ADD/UPDATE/DELETE), refreshing local cache or subscriptions
}
@Override
public ReadPointValue read(Map driverConfig,
Map pointConfig,
DeviceBO device,
PointBO point) {
String host = driverConfig.get("host").getValue(String.class);
Integer port = driverConfig.get("port").getValue(Integer.class);
String groupAddress = pointConfig.get("groupAddress").getValue(String.class);
// Perform the protocol read, returning the raw string value (example value "0")
return new ReadPointValue(device, point, "0");
}
@Override
public Boolean write(Map driverConfig,
Map pointConfig,
DeviceBO device,
PointBO point,
WritePointValue writePointValue) {
// Perform the protocol write; return true only when the device confirms the write succeeded
return true;
}
}
```
::: danger Do not swallow exceptions on read/write failure
Throwing an exception from `read()` / `write()` is the SDK's agreed failure signal — the SDK logs it and acks or nacks
the command on RabbitMQ. When a write command fails, the result does not echo back the written value (
`responseValue=null`), to avoid a "false success". And a single point's read failure should not bring down the whole
collection round.
:::
## Read/write scheduling: how data goes out, how commands come in
A driver has two data flows running in opposite directions. The SDK orchestrates both; you only fill in the protocol
implementation.
**Read (outbound)**: Quartz's `DriverReadScheduleJob` fires on the cron in `dc3.driver.schedule.read`, iterates this
driver's devices from the `DriverMetadata` cache, submits a read task per device (thread pool), calls your `read()` to
get a `ReadPointValue`, and the SDK then sends it to the data center over RabbitMQ. You do **not** write the RabbitMQ or
gRPC plumbing yourself.
**Write (inbound)**: the data center dispatches read/write commands to this driver's command queue over RabbitMQ.
`PointCommandReceiver` deduplicates them, locks per device, then calls your `read()` or `write()` in turn and sends the
result back to the data center.
Inbound write handling on the driver side is not a bare `write()` call — it is a pipeline with validation,
deduplication, and locking. The diagram below expands `PointCommandReceiver`'s pipeline, including error paths:
The send side goes through `DriverSenderService` (source `DriverSenderService.java`). Common methods:
| Method | Purpose |
|-----------------------------------------------------------------------|----------------------------------------------|
| `pointValueSender(PointValue)` / `pointValueSender(List)` | Send a single or batched set of point values |
| `deviceStatusSender(deviceId, status)` | Report device status (default TTL) |
| `deviceStatusSender(deviceId, status, timeout, unit)` | Report device status with TTL |
| `driverAlarmSender(String)` | Report a driver-level alarm |
| `deviceAlarmSender(deviceId, String)` | Report a device-level alarm |
| `eventReportSender(EventReportDTO)` | Report a device event |
| `pointCommandResultSender(...)` / `commandResultSender(...)` | Acknowledge command results |
The `status` values are defined in `EntityStatusEnum`: `ONLINE(0)` / `OFFLINE(1)` / `MAINTAIN(2)` / `FAULT(3)`.
::: warning The device status TTL must be greater than the read cycle
Device status is reported as a "lease": if it is not renewed before expiry, the device is judged offline. The TTL must
be **greater than** the status-report or read cycle, otherwise the device will be judged offline between two heartbeats
and flap repeatedly. For example, with a read cron of `0/30 * * * * ?` (every 30 seconds), the TTL should be ≥ 25
seconds. The template's default device health `timeout: 45` seconds leaves plenty of margin.
:::
## Naming and routing: the identifier you must not change
Driver routing involves three identifiers. Telling them apart avoids a trap you cannot undo after going to production:
| Identifier | Source | Purpose |
|---------------------------|---------------------------------------|-----------------------------------------------------------------------------------------|
| `dc3.driver.code` | `application.yml` | Unique driver-type code; the management center uses it to identify the driver type |
| `dc3.driver.service` | Auto-derived or explicitly overridden | Driver instance routing identifier, used for the RabbitMQ command queue and routing key |
| `spring.application.name` | Maven artifactId | Log file name, Actuator metadata, etc. |
::: danger dc3.driver.code is a stable identifier; changing it requires migration
Once in production, `dc3.driver.code` must not be changed casually. It is registered with the management center as the
driverCode and binds all metadata of that driver type — changing it amounts to swapping in a new driver type, all
onboarded devices will be lost, and a data migration plan is mandatory. (The RabbitMQ command queue and routing key are
built from `dc3.driver.service`, not `code` — see the table above.)
:::
## Build, run, and smoke test
To run locally, first load the environment variables, so the local Java process points at the dependency ports Compose
publishes to localhost:
```bash
source dc3/env/dev.env.sh
```
Build the new driver and its dependencies, then run:
::: code-group
```bash [Build]
mvn -s .mvn/settings.xml clean package -pl dc3-driver/dc3-driver-knx -am
```
```bash [Run]
java -jar dc3-driver/dc3-driver-knx/target/dc3-driver-knx.jar
```
:::
In development the driver auto-registers with the management center. Check the driver logs and confirm an event like
`Driver register succeeded` appears — that means registration worked. (During retries it prints
`Driver register failed on attempt n/30, retrying...`.)
Run an end-to-end smoke test along the golden path (HTTP paths and fields come from the gateway contract; example values
are marked as examples):
1. On the management side, create the driver, profile, point, and device, and fill in config values for the driver
attributes `host`/`port` and the point attribute `groupAddress`.
2. Wait for one read cycle (30 seconds by default).
3. Fetch the latest point value to confirm that `read()`'s collection has been persisted:
```bash
# Example: deviceId/pointId are example values
curl -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: ' \
-H 'Content-Type: application/json' \
-d '{"deviceId": 1, "pointId": 1, "page": {"current": 1, "size": 10}}'
```
4. Dispatch a write command to a writable point to confirm `write()` is invoked and acknowledged:
```bash
curl -X POST http://localhost:8000/api/v3/data/point_command/write \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: ' \
-H 'Content-Type: application/json' \
-d '{"deviceId": 1, "pointId": 1, "value": "42"}'
```
The endpoint returns a `commandId`. Use it to query the command history and check the execution status (
`PointCommandHistoryVO`'s `status` takes `SUCCESS`/`FAILED` etc.; on a successful write, `responseValue` echoes the
written value):
```bash
curl -X GET 'http://localhost:8000/api/v3/data/point_command_history/get_by_command_id?commandId=' \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: '
```
For the full command lifecycle and acknowledgment semantics, see the [Command Plane](../architecture/command-plane).
::: info Where the auth headers come from
All protected endpoints require `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token`. Get the token by fetching the salt
via `POST /api/v3/auth/token/salt` and exchanging it via `POST /api/v3/auth/token/generate` (valid for 12 hours). See
the [API Documentation](./api-documentation) for details.
:::
## FAQ
| Problem | Root cause and handling |
|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Driver code conflict | Duplicate `dc3.driver.code` — keep it globally unique and stable; do not change the code of an already-deployed driver |
| `DriverCustomService` not loaded | The implementation class lacks `@Service`, or sits outside the application class's component-scan scope |
| Registration keeps retrying without success | Management center not ready or gRPC unreachable — check the `Driver register failed on attempt n/30` logs, verify `CENTER_MANAGER_HOST` and the management center's health |
| `read` returns empty or throws | Do not swallow exceptions; let the logs surface the protocol error. A single-point failure should not bring down the whole round |
| Devices frequently go offline (flap) | The status TTL is smaller than the read/report cycle — increase the TTL or shorten the schedule cycle |
| Reads happen but the data page has no value | Check RabbitMQ connectivity, data center logs, and tenant context |
| Metadata changes have no effect | Update the local protocol client, subscriptions, or cache inside `event(...)` |
| Heavy protocol dependency | Put it only in the specific driver module's `pom.xml`, not in `dc3-common-driver` |
## Further reading
- [Command Plane](../architecture/command-plane) — how read/write commands are dispatched, deduplicated, locked, and
acknowledged, and how they connect to this page's `read()`/`write()`
- [Module Map](../architecture/modules) — the full picture of the 28 driver modules and where the `dc3-common-driver`
SDK sits in the dependency tree
- [Domain Model](../architecture/domain-model) — the fields and boundaries of Profile / Point / Device and the three
layers Param/Attribute/Config
- [API Documentation](./api-documentation) — the auth flow, gateway contract, and OpenAPI
- [Troubleshooting](../guide/troubleshooting) — issues with startup dependencies, ports, and environment variables
---
# Development Overview and Conventions
URL: https://docs.dc3.site/en/development/
# Development Overview and Conventions
This page is for developers about to write backend code for IoT DC3. By the end you'll know where the authoritative
conventions live, which naming and layering rules are non-negotiable, and the path from an edit to a commit.
> You are here: ready to extend the platform. Next step depends on your goal — writing a new protocol driver,
> see [Driver Authoring](./driver-authoring); wiring up an API, see [API Documentation](./api-documentation); running
> tests, see [Testing](./testing).
## The authoritative conventions live in `AGENTS.md`
This page is an entry point and quick overview. The real conventions are in **`iot-dc3/AGENTS.md` at the repository root
** — a single source of truth shared across AI tools that covers module layering, Maven commands, the validation
workflow, and commit and changelog rules. `iot-dc3/.claude/CLAUDE.md` just delegates to it and adds nothing. Read
`AGENTS.md` end to end before you start. Where this page and `AGENTS.md` disagree, `AGENTS.md` wins.
The platform is a distributed set of services built on Java 21 / Spring Boot 4 / Spring Cloud 2025. Services coordinate
over gRPC, metadata is persisted in PostgreSQL, and asynchronous messaging runs over RabbitMQ. This stack drives three
conventions you can't work around: CRUD verbs follow the cardinality of the result, cross-service calls go through a
facade, and domain objects are layered as DO/BO/VO.
## CRUD verbs follow the "cardinality of the result"
There's no free naming space here. For every CRUD-shaped method, HTTP path, gRPC RPC, and frontend API function, **the
verb must reflect the cardinality of the returned result** — `get` for a single record, `list` for a collection. This
applies across Service interfaces, ServiceImpl, Controller, Local/gRPC Facade, the gRPC server, and the RPC names in
`.proto`, and it's enforced the same way in both the frontend and backend repos. The payoff: from a method name or a
path alone, you know whether it returns one record or a batch — no need to read the implementation.
| Action | Java method | HTTP path | gRPC RPC | Frontend function |
|---------------|----------------|-------------|-----------|-------------------|
| Single record | `getXxx(...)` | `/get_xxx` | `GetXxx` | `getXxx(...)` |
| Collection | `listXxx(...)` | `/list_xxx` | `ListXxx` | `listXxx(...)` |
| Create | `add(BO)` | `/add` | n/a | `addXxx(...)` |
| Update | `update(BO)` | `/update` | n/a | `updateXxx(...)` |
| Delete | `delete(Long)` | `/delete` | n/a | `deleteXxx(...)` |
The five base methods `add`/`delete`/`update`/`getById`/`list(Q)` come from `BaseService`. Sub-interfaces only add
`getByXxx`/`listByXxx` when they need to query by some dimension, and the verb still has to match the cardinality.
`DeviceController` is a ready-made template — its endpoints are exactly `/add`, `/delete`, `/update`, `/get_by_id` (
single record), `/list_by_ids`, `/list_by_profile_id`, `/list` (collection), with verbs strictly aligned to cardinality.
::: warning Do not misuse these three reserved verbs
- `select*` is only for raw MyBatis Mapper calls inside `*ManagerImpl`, and **never appears** on
Service/Controller/Facade.
- `remove*` is only for the Manager methods inherited from MyBatis-Plus (`removeById`, `remove(wrapper)`); business
deletion always uses `delete*`.
- `find*`, `query*`, and `fetch*` are not used as primary CRUD verbs.
:::
## Layered calls: Controller(VO) → Service(BO) → Manager(DO) / Facade (cross-service)
An incoming request passes through three layers, and each layer knows one data representation. The Controller receives
and returns **VO** (the API shape). The Service interface extends `BaseService` and **works only on BO types** —
business semantics, using domain enums like `EnableFlagEnum`. The Manager/Mapper operates on **DO** — the database
shape, with flags as `Byte`. MapStruct's `*Builder` classes handle conversion between the three representations, and a
DO flag never leaks into a business or response model.
There's a hard boundary here: **when business code needs data from another service, it doesn't touch transport details
directly — it goes through a facade interface.** Controller and Service classes don't bind to any gRPC or REST detail.
They only call the contract interfaces in `dc3-common-facade-api`, and the deployment topology decides whether the
implementation behind them is gRPC (`dc3-common-facade-grpc`) or in-process (`dc3-common-facade-local-*`). Distributed
deployments default to `grpc` (`DC3_FACADE_MODE=grpc`).
The dashed line labeled "must go through facade" is the boundary itself. The left half is the in-service VO→BO→DO
drop-through; the right half is any cross-service read or write, which must first be abstracted into a facade contract
and then have its transport picked by configuration. That makes `grpc` (distributed) and `local` (monolith) a pure
deployment-topology choice — no business code changes. For the fields of each layer's objects, enum conversion, and
`*Builder` details, see [Domain Model](../architecture/domain-model).
## The first path: from changing one endpoint to committing
Putting the three conventions together, a typical backend change looks like this. Say you want to add a "count devices
by driverId" API to device management:
::: code-group
```text [layer placement]
1. VO/BO/DO add fields in dc3-common-model or the corresponding module (if needed), keep MapStruct *Builder in sync
2. Manager call the Mapper via select* in *ManagerImpl (select* allowed only here)
3. Service add getCountByDriverId(...) to the Service interface, implement it in ServiceImpl, touch BO only
4. cross-service? if you need data from another center, go through the *Facade interface, never connect to gRPC directly
5. Controller GET /get_count_by_driver_id —— use the get verb to query a single value
```
```bash [validation]
# Fast compile check (always run after changing Java/shared behavior)
mvn -s .mvn/settings.xml -q -DskipTests compile
# Full package (choose proportional to the change before committing)
mvn -s .mvn/settings.xml clean package
# Changed DAL/SQL: integration tests that require a container runtime
make test-it
```
:::
Write the code, run validation, then commit.
## Commit conventions: Conventional Commits
Commit messages become release notes directly (`CHANGE.md` is generated from git history), so the subject has to be
specific and readable. The format is fixed:
```text
(optional-scope):
```
- Write the subject in **English, lowercase, imperative mood**, specific enough to read well in `CHANGE.md`.
- Allowed types: `feat`, `fix`, `perf`, `refactor`, `docs`, `build`, `ci`, `test`, `chore`, `style`, `security`,
`revert`.
- Prefer a scope for any non-trivial change outside the root. For breaking changes, use `!` and explain the impact in
the body.
- Skip weak subjects like `update`, `fix bug`, `change code`, `misc`, `wip`, or `.`.
Real examples:
```text
feat(agentic): add session cleanup policy
fix(manager): validate tenant scope for device queries
docs(env): explain JetBrains IDEA environment variables
```
::: warning Hard rules before committing
- AI collaboration agents **must not create a commit without explicit confirmation**. Before committing, show the
proposed commit message and the files to be included, and wait for approval.
- Don't stuff unrelated changes into one commit — split by intent (feature, fix, and refactor each get their own
commit).
- The release-notes commit is fixed as `docs(release): update generated changelog`, and `CHANGE.md` is committed
separately.
- Before committing, check your commit message against the format and examples above; non-conforming formats are
rejected by CI before merge.
:::
## Further reading
- [Domain Model](../architecture/domain-model) — fields of each DO/BO/VO layer, enum conversion, and MapStruct
`*Builder` details
- [Driver Authoring](./driver-authoring) — copy the `dc3-driver-virtual` template to extend a new protocol driver,
implementing `DriverCustomService`
- [API Documentation](./api-documentation) — how OpenAPI/Swagger is exposed, the auth header, and the export workflow
- [Testing](./testing) — conventions for unit, integration, E2E, and coverage
---
# Technology Stack
URL: https://docs.dc3.site/en/development/technology-stack
This page summarizes the main technologies IoT DC3 currently runs on and recommends for local development. Treat
`pom.xml`, `dc3-web/package.json`, and `docs/package.json` as the source of truth for exact versions; the README keeps
only a short reader-facing summary.
## Backend and Center Services
| Area | Technologies | Purpose |
|-------------------------|------------------------------------------------------|----------------------------------------------------------------------------------------------|
| Language and frameworks | Java 21 · Spring Boot 4 · Spring Cloud 2025 | Runtime foundation for the gateway, center services, and driver processes |
| AI integration | Spring AI 2.0 | Agentic Center integration with OpenAI-compatible providers, Tool Calling, and MCP workflows |
| Web and API | Spring WebFlux · Spring Security · springdoc-openapi | HTTP APIs, authentication and authorization, and aggregated API docs |
| Service collaboration | gRPC · Protobuf · Facade interfaces | Strongly typed contracts between center services |
| Build | Maven 3.9+ | Multi-module build, tests, packaging, and dependency version management |
## Data, Messaging, and Scheduling
| Area | Technologies | Purpose |
|----------------------------|------------------------------|-------------------------------------------------------------------------------------------------------|
| Primary storage | PostgreSQL | Business data, tenants, resources, device models, and runtime data |
| Time-series and extensions | TimescaleDB · AGE · pgvector | Point history, graph capabilities, and vector capabilities |
| ORM / data access | MyBatis-Plus | Persistence access and paginated queries at the DO layer |
| Message bus | RabbitMQ | Asynchronous point value reports, command dispatch, and buffering between drivers and the data center |
| Cache and scheduling | Caffeine · Quartz | In-process caching, scheduled jobs, and task orchestration |
## Frontend, Docs, and Automation
| Area | Technologies | Purpose |
|----------------------|----------------------------------------------|----------------------------------------------------------------------------|
| Web frontend | Vue 3 · TypeScript 6 · Vite 8 · Element Plus | Management console under `dc3-web/` |
| Visualization | AntV G2/G6 | Dashboard charts and relationship visualizations |
| Docs site | VitePress · Mermaid | The current `docs/` site, architecture diagrams, and flow diagrams |
| CLI automation | TypeScript · pnpm · Vitest | Sibling `dc3-cli/` project, a Gateway-oriented command-line client |
| Container deployment | Podman · Docker Compose | Local dependencies, dev stack, app stack, and optional observability stack |
## Continue Reading
- [Local Development from Source](../quickstart/) — start dependencies, load environment variables, build, and verify
- [Frontend Development](../frontend/) — running, structure, and test commands for `dc3-web/`
- [System Architecture Overview](../architecture/) — how the gateway, centers, drivers, message bus, and storage work
together
- [Module Map](../architecture/modules) — Maven modules, deployable units, and dependencies
---
# Testing
URL: https://docs.dc3.site/en/development/testing
# Testing
This page covers how the IoT DC3 backend layers its tests, when a test is required, and which command runs each layer.
By the end you'll know how to pick the right test type, run unit, integration, and E2E tests locally, and tell what kind
of test a change needs before it counts as done.
> You are here: you've read the [development overview and conventions](./), and you want to validate a change to
> mergeable quality. Testing tips specific to writing drivers are in [driver authoring](./driver-authoring).
## Why layer the tests
Heavier is not better. An assertion can catch a logic error in milliseconds — that's no reason to spin up a PostgreSQL
container to confirm it. But pure mocks can't exercise cross-service message contracts or time-series persistence, so
IoT DC3 splits testing into three layers. Lower layers are faster, more numerous, and more isolated; higher layers are
slower, fewer, and closer to the real chain:
- **Unit tests** are the most numerous and the fastest. They check isolated business logic without starting the Spring
context.
- **Integration tests** use Testcontainers to spin up real PostgreSQL/TimescaleDB, RabbitMQ, and MQTT, exercising
cross-component collaboration — DAL, gRPC, and messaging.
- **E2E tests** are the fewest. They cover end-to-end business chains (command dispatch, event routing, time-series
table operations). They're off by default and require an environment variable to run.
The rule for picking a layer: use a unit test if you can avoid external dependencies; reach for integration only when a
real container is needed to reproduce the behavior (SQL, messaging, gRPC contracts); reserve E2E for validating the
entire chain.
## The three-layer pyramid and its commands
The diagram maps the three layers, each layer's scope and startup mode, and the command that triggers it. The
executors (Surefire/Failsafe) and the gate (`DC3_E2E`) decide which layers a single `mvn` run reaches.
::: warning Integration and E2E require a container runtime
Both `make test-it` and `make test-e2e` use Testcontainers to start PostgreSQL/TimescaleDB, RabbitMQ, and other
containers at runtime, so a working container runtime (podman or Docker) must be present locally. E2E additionally boots
a full set of real dependencies on a shared Docker network. Without a container runtime these two commands fail outright
rather than skip — `make test` (pure unit) is unaffected.
:::
The table below compares each layer's goal, executor, and tech stack (reference only; the prose above is authoritative
on detail):
| Layer | Goal | Executor / Gate | Technical approach |
|-------------------|-----------------------------------------------------------|----------------------------------------------------|-------------------------------------------------------------------------------|
| Unit tests | Quickly verify isolated business logic | Surefire | JUnit 5, Mockito 5, AssertJ; Reactor `StepVerifier` for reactive verification |
| Integration tests | Verify real infrastructure and cross-module collaboration | Failsafe; `*IT.java` | Testcontainers, gRPC InProcess, RabbitMQ harness |
| E2E tests | Verify end-to-end business chains | `@EnabledIfEnvironmentVariable(named = "DC3_E2E")` | `dc3-e2e`, Testcontainers (shared Docker network) |
## How to run it locally
Backend commands go through the Makefile under `iot-dc3/`. The four most common:
```bash
make test # Unit tests (Surefire)
make test-it # Integration tests (Failsafe + Testcontainers; needs a container runtime)
make test-e2e # E2E: equivalent to DC3_E2E=true mvn -s .mvn/settings.xml -pl dc3-e2e -am -Pe2e verify
make coverage # Aggregated JaCoCo report (dc3-coverage -am verify)
```
`make test-e2e` already sets `DC3_E2E=true` and runs `-Pe2e` only on the `dc3-e2e` module, so you don't need to export
the environment variable by hand. To run a single module or a single case, call Maven directly:
```bash
# Unit tests for a specific module
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-manager
# A single test class / a single test method
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-manager -Dtest=DeviceControllerTest
mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-public -Dtest="RTest#testOkWithData"
```
After `make coverage` finishes, the aggregated report lands at:
```text
dc3-coverage/target/site/jacoco-aggregate/index.html
```
::: info Why Failsafe points to outputDirectory
The parent POM configures Failsafe with `classesDirectory=${project.build.outputDirectory}`. Spring Boot repackages a
module's artifact into an executable fat jar, which can't be loaded directly on the Failsafe classpath; pointing the
test run at the unrepackaged, ordinary compiled classes is what lets the classes under test load correctly. Keep this in
mind when writing integration tests for executable modules like drivers.
:::
## Frontend test commands
The frontend (`dc3-web/`) uses pnpm + Vitest (unit/api/component/view) and Playwright (E2E), independent of the
backend:
::: code-group
```bash [Vitest suites]
pnpm test # All Vitest suites
pnpm test:unit # tests/unit
pnpm test:api # tests/api
pnpm test:component # tests/component
pnpm test:views # tests/views
pnpm test:guard # tests/guardrails (AI coding guardrails)
pnpm test:ci # vitest run --coverage (CI gate)
```
```bash [Playwright E2E]
pnpm test:e2e # headless chromium
pnpm test:e2e:headed # Visible browser (E2E_HEADLESS=false)
```
:::
## When a test is mandatory
The core convention: **for a bug fix, first write a failing test that reproduces it, then fix.** Turn "this bug no
longer occurs" into an executable test that's red right now; the loop only closes when the fix turns it green. Other
changes get tests in proportion to risk:
| Change type | Requirement |
|-------------------------------|---------------------------------------------------------------------------------|
| Bug fix | First add a regression test that reproduces the problem, then implement the fix |
| New feature / behavior change | Add unit tests, and integration tests as risk warrants |
| Refactoring | Preserve existing coverage; add contract tests for implicit contracts |
| DAL / SQL change | Add Testcontainers tests and run `make test-it` |
| gRPC proto change | Update the server, client, and contract tests in sync |
| Docs-only / formatting change | No Java tests needed; a docs build or format check suffices |
## Reusable test infrastructure
The `dc3-common-test` module gathers the containers and base classes shared across modules, so each module doesn't build
its own. Integration tests reuse these singleton containers and harnesses directly:
| Tool | Purpose |
|--------------------------|-----------------------------------------------------------------------------------------------|
| `PgTimescaleContainer` | Singleton `timescale/timescaledb-ha:pg18` container, for database and time-series table tests |
| `RabbitContainer` | RabbitMQ container, for message publish, confirm, and consume tests |
| `MqttContainer` | MQTT container, for MQTT driver tests |
| `GrpcInProcessExtension` | JUnit 5 extension: one in-process gRPC server + managed channel per test |
| `RabbitTestHarness` | Send and receive RabbitMQ within tests, with `awaitTrue()` backed by Awaitility |
| `FixedClockConfig` | `@TestConfiguration` that pins the `Clock` bean to a deterministic instant |
Two contract-test base classes guard cross-cutting conventions:
- `EnumContractTest`: via `@TestFactory`, checks that enum `getIndex()` is unique, `ofIndex()` round-trips, and
constant names stay stable.
- `SecretFieldContractTest`: checks that sensitive fields such as `apiKey`, `password`, `secret`, and `token` don't leak
through `@ToString` or serialization.
::: tip Time, randomness, and waiting
Inject `java.time.Clock` instead of calling `LocalDateTime.now()` directly; use `FixedClockConfig` when you need fixed
time. Use Awaitility for async waits — never bare `Thread.sleep`. Use `WebTestClient` for WebFlux, not `MockMvc`; use an
in-process channel for gRPC, and don't open a real socket.
:::
## Coverage gate
`make coverage` aggregates JaCoCo data from every module, gated by the thresholds in `dc3-coverage/pom.xml`:
| Metric | Current threshold |
|-----------------|----------------------------------------|
| Line coverage | `coverage.line.minimum = 0.20` (20%) |
| Branch coverage | `coverage.branch.minimum = 0.15` (15%) |
The thresholds are deliberately modest for now, to keep progress moving while the test suite is still expanding. The
check looks only at the static minimum — any metric below the table above blocks the change, with no comparison against
a historical baseline. When raising a threshold, submit tests that support the new bar rather than just bumping the
number.
## CI workflows
PRs and pushes run tests across three GitHub Actions workflows, one-to-one with the local commands:
| Workflow | Trigger | Main task |
|------------|--------------------------------------------------|-----------------------------|
| `ci.yml` | push / PR to develop, release, main | Fast compile |
| `test.yml` | push / PR to develop, release, main | Unit, integration, coverage |
| `e2e.yml` | push to develop, release, main or manual trigger | E2E |
Before merging, confirm: the unit and integration jobs pass; coverage isn't below the `dc3-coverage/pom.xml`
thresholds (below blocks); and for behavior changes, the description states what's been verified and what risks remain.
## Further reading
- [Development overview and conventions](./) — overall conventions and command entry points for secondary development
- [Driver authoring](./driver-authoring) — how to add the protocol layer and integration tests when deriving a new
driver
- [Environment variables](../quickstart/environment) — dependency hosts and ports needed to run Java locally
---
# BACnet/IP Driver
URL: https://docs.dc3.site/en/drivers/bacnet-ip
# BACnet/IP Driver
`dc3-driver-bacnet-ip` connects BACnet/IP devices to IoT DC3. It joins the network as a local BACnet device, discovers
remote devices via broadcast, periodically reads object property values, and supports writing values to writable object
properties. After reading this you can configure local networking parameters on
a [Device](../introduction/concepts/device), locate a remote data point on a [Point](../introduction/concepts/point)
by "object type + instance number + property", and diagnose the common "can't discover the device / reading the wrong
object / can't write" problems.
> You are here: a concrete driver on the building-automation side of the network layer. For BACnet's object-property
> addressing model and its place in the four-layer architecture,
> see [Industrial Buses & Protocols](../foundations/fieldbus).
## Protocol Background
BACnet (Building Automation and Control network) is an international standard protocol for building automation (ASHRAE
135 / ISO 16484-5), in wide use since 1995 for HVAC units, fresh-air systems, lighting, elevators, chiller/boiler
plants, thermostats, and other mechanical/electrical equipment. Its goal is interoperability across building equipment
from different vendors, so it abstracts "device capability" into a unified object model rather than binding to specific
hardware.
**BACnet/IP** is the variant of BACnet that runs over Ethernet: it wraps BACnet application-layer messages in UDP,
listening on port **47808** by default (i.e. `0xBAC0`). Compared with the earlier MS/TP serial or ISO 8802-3 Ethernet
forms, BACnet/IP reuses the existing IP network directly and discovers devices within a subnet via UDP broadcast — which
also imposes one hard constraint: broadcast does not cross layer-3 routing by default (see Troubleshooting below).
In the [four-layer IoT architecture](../foundations/fieldbus), BACnet/IP belongs to the building-automation side of the
**network layer**: it defines how mechanical/electrical equipment is addressed and read/written on the network, carrying
the temperatures, states, and metering values gathered by the sensing layer up to the platform. Its communication model
is **master/slave, request-response** — this driver acts as the initiator (client), actively discovering and accessing
remote devices and polling on a cron cycle; remote devices do not report on their own.
BACnet addressing is a three-level structure, and understanding it is understanding how to configure a Point:
A physical device is identified by a unique **device instance number**; it contains a number of **objects** (such as
`ANALOG_INPUT`, `BINARY_OUTPUT`), each located by "object type + object instance number"; and each object exposes
several **properties**, the most common being `PRESENT_VALUE` (the current value). Reading or writing a point is
essentially locating the path "remote device → object → property".
- **Driver name / code**: `BACnet IP Driver` / `BacnetIpDriver`
- **Type**: `DRIVER_CLIENT` (the driver actively discovers and accesses remote devices)
## Attribute Configuration
Onboarding a BACnet/IP device requires filling in [attributes](../introduction/concepts/attribute-config) at three
levels: device-level local networking parameters (`driver-attribute`), the addressing parameters of each collected
point (`point-attribute`), and the write-command parameters of each writable point (`command-attribute`). The
attributes, types, and defaults below all come from the driver's `application.yml` (the `dc3-driver-bacnet-ip` module).
### Driver Attributes (device-level `driver-attribute`)
Driver attributes answer "what identity the driver uses, which NIC it binds, and how it broadcasts" — note that these
configure how the **local driver** joins the network, **not** the remote device address; the remote device is located by
`remoteDeviceId` on the Points below. Fill in one set per device on the [Device](../introduction/concepts/device):
| Attribute | code | Type | Default | Description |
|-------------------|--------------------|--------|-------------------|------------------------------------------|
| Local Device ID | `localDeviceId` | INT | `1001` | Local BACnet device instance number |
| Bind Address | `bindAddress` | STRING | `0.0.0.0` | Local bind address |
| Port | `port` | INT | `47808` | BACnet UDP port (default 47808 = 0xBAC0) |
| Broadcast Address | `broadcastAddress` | STRING | `255.255.255.255` | Broadcast address for device discovery |
| Timeout | `timeout` | INT | `6000` | Request timeout in milliseconds |
`localDeviceId` is the instance number the driver occupies when it joins the network as a `LocalDevice`; it must not
collide with the instance number of any existing BACnet device on the network. `bindAddress` defaults to `0.0.0.0`,
letting the system pick the NIC; specify a concrete NIC IP only when the host has multiple NICs or the broadcast cannot
reach the target subnet. `broadcastAddress` is the address used to broadcast when discovering remote devices; the
default `255.255.255.255` is a limited broadcast, and for cross-subnet onboarding you should change it to the directed
broadcast address of the target subnet. The driver caches connections by device ID (one `LocalDevice` per device), and
the underlying transport timeout takes `timeout`. Config validation (`validate`) requires `localDeviceId`,
`bindAddress`, and `port` to be non-empty.
### Point Attributes (`point-attribute`)
Point attributes answer "which property of which object on which remote device to read". Fill in one set per
collected [Point](../introduction/concepts/point):
| Attribute | code | Type | Default | Description |
|------------------|------------------|--------|-----------------|--------------------------------------|
| Remote Device ID | `remoteDeviceId` | INT | `0` | Remote BACnet device instance number |
| Object Type | `objectType` | STRING | `ANALOG_INPUT` | BACnet object type (see enum below) |
| Object Instance | `objectInstance` | INT | `0` | Object instance number |
| Property ID | `propertyId` | STRING | `PRESENT_VALUE` | Property identifier |
`remoteDeviceId` is the instance number of the remote device to read; the driver first discovers the device by broadcast
using it, then locates the object by `objectType` + `objectInstance` and fetches the property by `propertyId`. Both
`objectType` and `propertyId` are matched by their **exact uppercase enum name**, drawn from a fixed mapping table in
the code (see the pitfall below and "How it lands in IoT DC3"). Config validation (`validatePoint`) requires all four to
be non-empty.
### Write Command Attributes (`command-attribute`)
Writable points also need one set on the write command, with the same structure as the point attributes but defaulting
to a writable output object:
| Attribute | code | Type | Default | Description |
|------------------|------------------|--------|-----------------|--------------------------------------|
| Remote Device ID | `remoteDeviceId` | INT | `0` | Remote BACnet device instance number |
| Object Type | `objectType` | STRING | `ANALOG_OUTPUT` | BACnet object type to write |
| Object Instance | `objectInstance` | INT | `0` | Object instance number |
| Property ID | `propertyId` | STRING | `PRESENT_VALUE` | Property identifier |
The written value is auto-encoded by the target object type (see the "Write value encoding" tip below).
::: warning Object Type / Property ID must use the exact uppercase enum name, or it silently falls back
`objectType` and `propertyId` are matched by their exact uppercase names. A wrong or misspelled value **raises no error
** — the driver's `resolveObjectType()` / `resolvePropertyIdentifier()` **silently falls back** to `ANALOG_INPUT` /
`PRESENT_VALUE`, so you may read the value of a different object without noticing.
- `objectType` is supported in code for **10** types: `ANALOG_INPUT`, `ANALOG_OUTPUT`, `ANALOG_VALUE`, `BINARY_INPUT`,
`BINARY_OUTPUT`, `BINARY_VALUE`, `MULTI_STATE_INPUT`, `MULTI_STATE_OUTPUT`, `MULTI_STATE_VALUE`, `DEVICE` (the yml
remark lists only the first 9; `DEVICE` also works — defer to the code).
- `propertyId` supports 7: `PRESENT_VALUE`, `DESCRIPTION`, `STATUS_FLAGS`, `EVENT_STATE`, `RELIABILITY`, `UNITS`,
`OUT_OF_SERVICE`.
:::
## Troubleshooting
BACnet/IP onboarding failures cluster into three kinds: broadcast discovery, object addressing, and write-value
encoding. Work through them in order:
1. **Device stays offline / connection creation fails**. When the driver joins the network with `localDeviceId`, if that
instance number collides with an existing device, or binding the port/NIC fails, `LocalDevice.initialize()` throws
`ConnectorException` and the device stays offline. First confirm `localDeviceId` is unique on the network, port
`47808` is not occupied by another BACnet program on the same host, and `bindAddress` points to a real, usable NIC.
2. **Connected but reads keep timing out (stuck until timeout)**. The driver relies on
`getRemoteDeviceBlocking(remoteDeviceId)` to block while broadcasting for the remote device — if `remoteDeviceId` is
not found on the network it **blocks until timeout** and then throws `ReadPointException`. First check whether
`remoteDeviceId` is exactly the target device's instance number; then confirm the driver and the target device are in
the same broadcast domain (see the pitfall below).
3. **Read value is wrong / looks like a different point**. Most likely `objectType` or `propertyId` is misspelled and
triggered the silent fallback (back to `ANALOG_INPUT` / `PRESENT_VALUE`). Check the uppercase enum name character by
character and confirm it is in the supported list above.
4. **Write command fails**. First confirm the target object type is writable (`*_INPUT` objects are usually physically
read-only and cannot be written), then confirm the input value matches the encoding rules (see "Write value encoding"
below). A failed write throws `WritePointException`.
5. **Can't reach devices across subnets / on a multi-NIC host**. BACnet/IP uses UDP broadcast, which does not cross
layer-3 routing by default. See the pitfall container below.
6. **Online status flapping**. The health check defaults to once every 15 seconds (cron `0/15 * * * * ?`) with a
45-second lease timeout; the driver judges online status by `LocalDevice.isInitialized()`. Frequent online/offline
flapping usually means packet loss or unstable local-device initialization —
see [Device](../introduction/concepts/device) for the online-status mechanism.
::: tip Write value encoding is determined by the object type
The driver's `createEncodable()` decides how to encode the written value by the object type prefix: `ANALOG_*` is
written as a float (`Real`); `BINARY_*` treats `true` / `1` / `active` (case-insensitive) as "active" and everything
else as "inactive"; `MULTI_STATE_*` and `DEVICE` are written as an integer (`UnsignedInteger`); a non-numeric value that
lands on the analog branch degrades to a string (`CharacterString`). So to toggle a `BINARY_OUTPUT`, send `1` or `true`
rather than `ON`.
:::
::: warning The remote device must be discoverable via broadcast
The driver can only read and write after discovering the remote device on the network via broadcast. BACnet/IP uses UDP
broadcast, which normally cannot cross layer-3 routing — make sure the driver and the target device are in the same
broadcast domain. Across subnets, deploy a **BBMD** (BACnet/IP Broadcast Management Device) on the network and change
`broadcastAddress` to the directed broadcast address of the target subnet. An unfound `remoteDeviceId` blocks until
timeout (see Troubleshooting item 2 above).
:::
## How it lands in IoT DC3
- **`dc3.driver.code`**: `BacnetIpDriver` (type `DRIVER_CLIENT`, actively discovers and accesses remote devices). This
is a stable routing identifier — do not change it casually.
- **Read**: ✓ implemented. Discovers the device by broadcast using `remoteDeviceId`, reads the object property by
`objectType` + `objectInstance` + `propertyId`, and returns the result as a string.
- **Write**: ✓ implemented. Auto-encodes the written value by object type (see the "Write value encoding" tip above).
- **Subscribe/report**: — not supported. BACnet is a master/slave polling model; this driver only actively reads/writes
and never passively receives pushes (COV subscription is not implemented). This matches the "✓ / ✓ / —" for BACnet/IP
in the [driver capability matrix](./matrix).
- **Collection cycle**: default cron `0/30 * * * * ?` (one read round every 30 seconds), configured under
`schedule.read` in the driver's `application.yml`.
- **Health/online**: device health check defaults to cron `0/15 * * * * ?`, with a lease timeout of `45 seconds`, judged
by `LocalDevice.isInitialized()`.
::: info Implementation status: available
This driver is a **complete implementation** (not a skeleton), built on BACnet4J. `read()` / `write()` issue real BACnet
read/write requests, `health()` judges online status by the local device's initialization state, `validate()` /
`validatePoint()` perform required-field checks, and it caches the `LocalDevice` connection by device ID. Note two
behaviors that differ from intuition: (1) a misspelled `objectType` / `propertyId` **silently falls back** rather than
erroring; (2) an unfound `remoteDeviceId` **blocks until timeout** — both covered above.
:::
::: info schedule.custom is enabled but is a no-op
The driver's `application.yml` declares `schedule.custom` (cron `0/5 * * * * ?`, `enable: true`), but the `schedule()`
method is an empty implementation (no custom periodic logic). That is, this custom schedule currently produces no
collection behavior; actual collection is driven only by `schedule.read` — defer to the code.
:::
### Minimal Onboarding Example
Onboard the temperature object of a BACnet/IP device whose device instance number is `9001` on the network:
1. Create a [Device](../introduction/concepts/device) using `BACnet IP Driver`. The driver attributes can all keep their
defaults (`localDeviceId=1001`, `bindAddress=0.0.0.0`, `port=47808`, `broadcastAddress=255.255.255.255`,
`timeout=6000`); adjust `bindAddress` / `broadcastAddress` only when the host has multiple NICs or the broadcast
cannot reach the target subnet.
2. Add a temperature [Point](../introduction/concepts/point) (`READ_ONLY`) to
the [Profile](../introduction/concepts/profile) bound to the device, with point attributes `remoteDeviceId=9001`,
`objectType=ANALOG_INPUT`, `objectInstance=1`, `propertyId=PRESENT_VALUE`.
3. Start the driver, and within 30 seconds the object's `PRESENT_VALUE` appears in
the [PointValue](../introduction/concepts/point-value).
4. If the Point must be writable, configure a write [Command](../introduction/concepts/command) for it, set `objectType`
explicitly to a writable output object (e.g. `ANALOG_OUTPUT`), and send the value per the object-type rules (a number
for analog, `1`/`true` for binary).
::: tip One driver instance can serve multiple devices
A single BACnet/IP driver process can serve multiple devices, each holding its own cached `LocalDevice` by device ID,
with the target remote device distinguished by `remoteDeviceId` on the Point.
:::
## Further Reading
- [Driver overview](./index) — entry point and taxonomy for all drivers
- [Driver capability matrix](./matrix) — read/write/subscribe at a glance, including the BACnet/IP row
- [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough
- [Industrial Buses & Protocols](../foundations/fieldbus) — BACnet's object-property addressing model and its place on
the building-automation side
- [SNMP Driver](./snmp) — another active read/write network-management protocol
---
# BLE Driver
URL: https://docs.dc3.site/en/drivers/ble
`dc3-driver-ble` brings Bluetooth Low Energy (BLE) devices into IoT DC3. It acts as the BLE host (central): through a
Bluetooth adapter on the host it connects to a peripheral, periodically reads the bytes of its GATT characteristics,
parses them into a [PointValue](../introduction/concepts/point-value) according to the configured format, and supports
writing command values back to characteristics. By the end you can set `adapterName`/`deviceAddress` on
the [Device](../introduction/concepts/device), set the service/characteristic UUIDs and parse format on
each [Point](../introduction/concepts/point), and diagnose the common "device stays offline / no value read" problems.
## Protocol background
BLE (Bluetooth Low Energy) is the most common short-range wireless protocol on wearables, environmental sensors,
beacons, and portable meters—typically ten-meter range, low data rate, and extremely low power, running for months to
years on a coin cell. In the four-layer IoT reference architecture, BLE sits on the **wireless access** side of
the [network layer](../foundations/iot-protocols): it only governs "how the signal travels over the air" and does not
itself define how upper-layer messages are organized, so a BLE device usually needs a host or gateway to relay its data
onto the internet—and this driver plays exactly that central-host role.
A BLE device organizes its data as a **GATT** (Generic Attribute Profile) tree: a peripheral contains several **Services
**, each Service holds several **Characteristics**, every Characteristic is identified by a **UUID**, and a
characteristic value is just a stretch of raw bytes. To read a data point the host issues a read against the
characteristic located by "service UUID + characteristic UUID"; to write, it writes bytes into the target
characteristic. This driver maps each [Point](../introduction/concepts/point) to one characteristic on the peripheral,
reading and writing bytes against the configured UUIDs, then parsing those bytes into a PointValue per the configured
format.
::: info GATT decides "how you address a data point"
Modbus addresses with "function code + register address"; BLE addresses with "service UUID + characteristic UUID"—both
are "first connect to the device, then locate a data point inside it". Understanding this mapping keeps you from
confusing the Service with the Characteristic during configuration.
:::
For the underlying transport, this driver uses the Sputnikdev Bluetooth Manager framework with the TinyB transport,
which handles scanning, connection, and GATT read/write on top of the host's physical Bluetooth adapter (default
`hci0`).
## Attribute configuration
Onboarding a BLE device means filling [attributes](../introduction/concepts/attribute-config) at three levels:
device-level connection parameters (`driver-attribute`), the addressing and parsing parameters of each polled point (
`point-attribute`), and the write-command parameters of each writable point (`command-attribute`). The attributes,
types, and defaults below all come from the driver's `application.yml` (the `dc3-driver-ble` module).
### Driver attributes (`driver-attribute`)
Driver attributes answer "which adapter connects to which peripheral". `adapterName` names the Bluetooth adapter on the
host (usually `hci0`, `hci1` on Linux); `deviceAddress` is the peripheral's MAC address, serving as its unique
identifier. `connectionTimeout` is declared in `application.yml` but is not read by the current driver code (see the
note below). Fill one set per BLE device on the [Device](../introduction/concepts/device):
| Attribute | code | Type | Default | Description |
|--------------------|---------------------|--------|---------|----------------------------------------------------------------------------------------------------|
| Adapter Name | `adapterName` | STRING | `hci0` | Host Bluetooth adapter name |
| Device Address | `deviceAddress` | STRING | (empty) | BLE device MAC address (e.g. `AA:BB:CC:DD:EE:FF`) |
| Connection Timeout | `connectionTimeout` | INT | `10000` | Connection timeout in milliseconds; **not read by the current implementation**, see the note below |
::: info `connectionTimeout` currently has no effect
`connectionTimeout` is declared in `application.yml` and can be set on the device, but the `dc3-driver-ble` code never
reads it—link setup is done by `bluetoothManager.getCharacteristicGovernor(charUrl, true)` waiting for the governor to
become ready, without passing any timeout. Changing this value does not affect connection behavior; it is a reserved
attribute.
:::
::: tip One peripheral = one device
`deviceAddress` uniquely identifies a peripheral, so one BLE peripheral maps to
one [Device](../introduction/concepts/device) on the platform. A single adapter (`hci0`) can connect to several
peripherals at once, distinguished by each device's `deviceAddress`; the driver caches a connection controller (
governor) per `deviceId`, establishing the link on the first read or write.
:::
### Point attributes (`point-attribute`)
Point attributes answer "which characteristic on the peripheral to read, and how to parse the bytes that come back".
Fill one set per polled [Point](../introduction/concepts/point):
| Attribute | code | Type | Default | Description |
|---------------------|----------------------|--------|---------|-----------------------------------------------|
| Service UUID | `serviceUuid` | STRING | (empty) | GATT Service UUID |
| Characteristic UUID | `characteristicUuid` | STRING | (empty) | GATT Characteristic UUID |
| Read Format | `readFormat` | STRING | `UTF8` | Data format (UTF8, HEX, INT16, UINT16, FLOAT) |
| Byte Order | `byteOrder` | STRING | `BIG` | Byte order (BIG, LITTLE) |
`serviceUuid` + `characteristicUuid` together locate one characteristic on the peripheral. A characteristic read returns
raw bytes, which the driver parses per `readFormat`: `UTF8` as a string (default), `HEX` as a hex string, `INT16`/
`UINT16`/`FLOAT` as numbers. Those three numeric formats are also affected by `byteOrder` (`BIG` big-endian, `LITTLE`
little-endian); `UTF8` and `HEX` are independent of byte order.
::: tip Parse format follows the peripheral's datasheet
Which `readFormat` and `byteOrder` to use depends on what the peripheral's firmware actually puts in the
characteristic—for example, if a thermometer writes temperature as a little-endian 4-byte float into a characteristic,
set `readFormat=FLOAT` and `byteOrder=LITTLE`. A wrong format does not raise an error; it just parses the bytes into a
meaningless value, so check the peripheral's GATT spec before filling these in.
:::
### Write command attributes (`command-attribute`)
`application.yml` declares `serviceUuid`/`characteristicUuid` under `command-attribute`, but **the write path does not
read them**:
| Attribute | code | Type | Default | Description |
|---------------------|----------------------|--------|---------|--------------------------------------------------------------|
| Service UUID | `serviceUuid` | STRING | (empty) | GATT Service UUID (not consumed currently) |
| Characteristic UUID | `characteristicUuid` | STRING | (empty) | UUID of the characteristic to write (not consumed currently) |
::: warning Writes reuse the point's UUIDs; command attributes have no effect today
Like `read()`, BLE's `write()` reads `serviceUuid`/`characteristicUuid` from the point attributes (`point-attribute`)
—the point decides which characteristic is written. The driver does not override `execute()`, and `command-attribute` is
only consumed on the `execute()` path, so the write command attributes above are placeholder config that the current
write path never reads. **A writable point does not need to repeat the UUIDs on the write command**—configure them on
the point.
:::
::: warning Writes are sent as UTF-8 bytes, with no format conversion
The read path has `readFormat`/`byteOrder` to parse bytes into a value, but the write path has no symmetric inverse—the
driver encodes the command value to UTF-8 bytes and writes them straight into the characteristic. To write a number or
hex value, you must express it at the layer above as a string the target characteristic accepts (the driver will not
turn `25.5` into little-endian float bytes for you).
:::
### Polling and health
- **Poll interval**: default cron `0/30 * * * * ?`—reads all points once every 30 seconds.
- **Health / online**: the device health check defaults to cron `0/15 * * * * ?` with a `45 second` lease timeout. A
device is online when the BLE link is connected and reachable (the driver evaluates
`governor.isOnline() && governor.isConnected()`); the driver as a whole is online when the Bluetooth manager has
initialized. See [Device](../introduction/concepts/device) for the online-state mechanism.
::: info The `custom` schedule in `application.yml` is currently a no-op
Although the yml configures a `custom` cron (`0/5 * * * * ?`), the driver's `schedule()` method body is empty and runs
no custom logic. Only `read` (`0/30`) and `health` (`0/15`) actually take effect.
:::
## Troubleshooting
1. **Device stays offline (most common)**. The host has no usable Bluetooth adapter, or `adapterName` is wrong (default
`hci0`), so `governor.isOnline()` is never true and the device shows offline forever. Confirm the adapter exists and
is up on the host with `hciconfig`/`bluetoothctl`, then re-check `adapterName`.
2. **Can't reach Bluetooth from a container**. This driver relies on the host's **physical** Bluetooth adapter and the
TinyB native library. In containerized deployments, if you don't pass the host's Bluetooth capabilities through (e.g.
no D-Bus/adapter mounted, no privileges granted), driver init won't error (`withIgnoreTransportInitErrors(true)`
swallows transport-init failures) but the device never connects.
3. **Characteristic not found, read/write fails**. `serviceUuid`/`characteristicUuid` must match the GATT the peripheral
actually exposes, character for character (including short/long form and case). A wrong UUID means the characteristic
can't be located: reads throw `ReadPointException` (caught by the driver as a read failure), writes throw
`WritePointException`. Confirm the peripheral's service/characteristic UUIDs with a BLE scanning tool (
`bluetoothctl`, nRF Connect, etc.) before filling them in.
4. **Read value looks like garbage or absurd numbers**. Usually `readFormat`/`byteOrder` don't match the peripheral's
actual encoding—e.g. the peripheral uses a little-endian float but you set `UTF8`, or the byte order is reversed.
Adjust these two against the peripheral's GATT spec; when a read returns empty bytes (`data.length == 0`) the driver
returns `null` and nothing is persisted.
5. **Connection timeout / can't connect**. The current driver does not use `connectionTimeout` (see the note above), so
raising it has no effect; connection failures stem only from a weak signal, too much distance, or the peripheral's
connection being held by another host. BLE typically allows only one central connected to a peripheral at a time—make
sure no phone app or other gateway is holding the connection.
6. **Write command "has no effect"**. The command value isn't a string the target characteristic accepts (see the write
semantics above), or the characteristic isn't writable. Confirm the characteristic's GATT properties include Write,
then confirm the string sent from the layer above matches what the device expects.
## How it lands in IoT DC3
- **`dc3.driver.code`**: `BleDriver` (driver name `Bluetooth LE Driver`, type `DRIVER_CLIENT`—the driver actively
connects to the peripheral). This is a stable routing identifier and should not be changed casually.
- **Capabilities**: read ✓, write ✓, subscribe —. Consistent with the [driver capability matrix](./matrix)—BLE is
request/response active read-write: the driver polls characteristics with read and pushes commands with write, and
does not subscribe to GATT notify/indication for passive reporting.
- **Implementation status**: available. `read()`/`write()`/`initial()`/`health()` are all fully implemented, using the
Sputnikdev Bluetooth Manager + TinyB for connection and GATT read/write.
::: warning Prerequisite: a working Bluetooth adapter + TinyB native library on the host
The code is ready, but whether it runs depends on the deployment environment: the host must have a physical Bluetooth
adapter (default `hci0`) and the TinyB native library. This is a hardware/environment prerequisite beyond pure
software—neither can be missing. See troubleshooting items 1 and 2 above.
:::
### Minimal onboarding example
Bring in a BLE thermometer at MAC `AA:BB:CC:DD:EE:FF`:
1. Create a [Device](../introduction/concepts/device) with `Bluetooth LE Driver`, setting driver attributes
`adapterName=hci0` and `deviceAddress=AA:BB:CC:DD:EE:FF` (`connectionTimeout` is not read by the current
implementation, so leave it at the default).
2. Add a temperature [Point](../introduction/concepts/point) (`READ_ONLY`) to
the [Profile](../introduction/concepts/profile) bound to the device. Set the point attributes `serviceUuid` and
`characteristicUuid` to that temperature characteristic's UUIDs, with `readFormat=FLOAT` and `byteOrder=LITTLE` (per
the peripheral's datasheet).
3. Start the driver; within 30 seconds the polled reading appears in [PointValue](../introduction/concepts/point-value).
4. If the point must be writable, configure a write [command](../introduction/concepts/command) for it—the write reuses
the `serviceUuid`/`characteristicUuid` already set on the point, with no need to repeat them on the command; just
express the command value at the layer above as a string the characteristic accepts.
## Further reading
- [Driver overview](./index) — grouping and selection across all 28 drivers
- [Driver capability matrix](./matrix) — a read/write/subscribe overview of every driver
- [Device onboarding](../operation/device-onboarding) — a full onboarding walkthrough
- [IoT protocols and wireless networks](../foundations/iot-protocols) — where BLE sits on the wireless-access side of
the network layer, and its trade-offs
---
# CAN Bus Driver
URL: https://docs.dc3.site/en/drivers/can
`dc3-driver-can` connects CAN bus devices to IoT DC3: joining the bus as a node, it listens for frames matching a given
CAN ID on a SocketCAN interface and parses them into readings per each [Point](../introduction/concepts/point)
configuration, optionally sends a "request" frame before reading the "response", and writes values via command frames.
After reading this page you can configure the driver and point attributes for a CAN device and tell which behaviors are
wired up versus still skeleton.
## Protocol background
CAN (Controller Area Network) is a fieldbus used heavily in automotive and industrial automation. In
the [four-layer IoT architecture](../foundations/fieldbus) it sits at the **network layer** — it defines how devices
reliably exchange frames over a shared bus.
The biggest difference between CAN and master/slave protocols like Modbus is that CAN is a **broadcast + filter-by-ID**
publish/subscribe model:
- Nodes do not communicate point-to-point by address — they broadcast frames tagged with a **CAN ID** (identifier) onto
the bus; every node sees them, and each receiver filters by the CAN IDs it cares about.
- A single frame carries up to **8 bytes** of payload; the physical quantity you want is sliced out of those 8 bytes by
offset, length, and byte order.
- Standard frames use an **11-bit** CAN ID, extended frames a **29-bit** CAN ID; the two are distinguished by the
frame-format flag.
- There is no central poller, which suits multi-receiver, event-driven scenarios naturally: a value is sent when it
changes, and whoever cares receives it.
Typical uses include vehicle ECUs, battery management systems (BMS), servo drives, all kinds of sensor nodes, and a
growing number of industrial embedded controllers. On Linux a CAN device usually appears as a **SocketCAN** network
interface (e.g. `can0`) through which applications send and receive frames — this driver works on top of that interface.
::: info Where CAN sits in the IoT network layer
CAN solves "how nodes on the same bus exchange frames," which belongs to the fieldbus (network layer) domain. It stands
alongside Modbus, Profibus, BACnet, and others; for how these protocols trade off within the four-layer architecture,
see [Fieldbus and protocols](../foundations/fieldbus).
:::
## Attribute configuration
A CAN device is configured across three layers: device-level `driver-attribute` (interface, bitrate, frame format),
point-level `point-attribute` (CAN ID, byte slicing, optional request frame), and `command-attribute` on writable
points (write target and frame-data template). The tables below come from the driver's `application.yml`; read the prose
to understand what each does, then fill the values from the table.
### Driver attributes (device-level `driver-attribute`)
When onboarding a CAN device, fill these [attributes](../introduction/concepts/attribute-config) on
the [Device](../introduction/concepts/device). `interfaceName` points to the SocketCAN interface name on the Linux host
where the driver process runs and is required (everything else builds on it); `bitrate` and `frameFormat` describe the
bus's physical and frame characteristics and must match the device.
| Attribute | code | Type | Default | Description |
|--------------|-----------------|--------|------------|-------------------------------------------------------|
| Interface | `interfaceName` | STRING | `can0` | SocketCAN interface name the driver sends/receives on |
| Bitrate | `bitrate` | INT | `500000` | CAN bus bitrate (bps), must match the bus |
| Frame Format | `frameFormat` | STRING | `STANDARD` | Frame format: `STANDARD`(11bit) or `EXTENDED`(29bit) |
### Point attributes (`point-attribute`)
Fill these on each [Point](../introduction/concepts/point) to be read. The first five decide "which frame to match,
which bytes of the payload, and how to parse them by format and byte order"; the last two, `requestCanId`/`requestData`,
are an optional "request-then-respond" mechanism — leave them empty for purely passive listening.
| Attribute | code | Type | Default | Description |
|----------------|----------------|--------|----------|--------------------------------------------------|
| CAN ID | `canId` | STRING | (empty) | CAN identifier to match (hex, no `0x`) |
| Data Offset | `dataOffset` | INT | `0` | Starting byte offset within the frame payload |
| Data Length | `dataLength` | INT | `1` | Number of bytes to read from the offset |
| Data Format | `dataFormat` | STRING | `INT` | Parse format: `INT`/`UINT`/`HEX` |
| Byte Order | `byteOrder` | STRING | `LITTLE` | Byte order for multi-byte values (e.g. `LITTLE`) |
| Request CAN ID | `requestCanId` | STRING | (empty) | CAN ID of an optional request frame |
| Request Data | `requestData` | STRING | (empty) | Payload of the optional request frame (hex) |
::: tip Request-driven reads
Many CAN devices only answer with data after receiving a "request" frame. In the source, the driver sends a request via
`cansend` before reading (of the form `cansend can0 #`) only when **both** `requestCanId` and
`requestData` are non-empty, then listens for the response frame matching `canId`; leave both empty to passively listen
for frames the bus broadcasts periodically.
:::
::: warning dataOffset / dataLength / byteOrder are not yet used in parsing
Per the source, `read()` actually uses only `interfaceName`, `canId`, `requestCanId`, and `requestData`, returning the
payload field of the frame captured by `candump` as-is. `dataOffset`, `dataLength`, `dataFormat`, and `byteOrder` are
not yet applied on the read path to slice/convert bytes (a skeleton TODO). The config keys are in place; behavior is
subject to the eventual implementation.
:::
### Write command attributes (`command-attribute`)
`application.yml` declares `canId` and `data` under `command-attribute` (`data` defaults to `${value}`), intending the
write command to carry a frame-data template.
::: warning The `data` template does not take effect yet (skeleton TODO)
Per the source, `write()` reads `canId` and `data` only from the **point attributes** (`pointConfig`):
`canId = getConfigValue(pointConfig, "canId", "")`, `data = getConfigValue(pointConfig, "data", "")`. But
`point-attribute` has **no** `data` (it is declared only under `command-attribute`), and `command-attribute` is passed
only through `DriverCommand.execute(commandConfig, …)` — which the CAN driver does not override (it uses the default
empty implementation). So the write path never reads `command-attribute`. As a result `data` is always the empty
default, `frameData` is always empty, the `${value}` template is never rendered, and `cansend` emits an empty payload (
of the form `cansend can0 #`). Like `dataOffset`/`dataLength`, this is a skeleton TODO; dispatched write values
do not actually land in the frame payload today.
:::
| Attribute | code | Type | Default | Description |
|-----------|---------|--------|------------|-------------------------------------------------------------------------------------------------------------------------------|
| CAN ID | `canId` | STRING | (empty) | CAN identifier to write to (hex) |
| Data | `data` | STRING | `${value}` | Frame-data template (by design rendered from the command argument via `${value}`; not wired up today — see the warning above) |
### Collection and health
- **Collection cycle**: the `read` schedule defaults to cron `0/30 * * * * ?` (capture one round of frames every 30
seconds). The driver also has a `custom` schedule defaulting to cron `0/5 * * * * ?`, but the CAN driver's
`schedule()` is an empty implementation, so no custom task is attached.
- **Health / online**: device health check defaults to cron `0/15 * * * * ?` with a `45 second` lease timeout — the
driver uses `ip link show ` to determine whether the interface exists (exit code 0 means online);
see [Device](../introduction/concepts/device) for the online-state mechanism.
::: details Minimal onboarding example
Onboard a node on `can0` that periodically broadcasts temperature with CAN ID `123`:
1. Create a [Device](../introduction/concepts/device) with `CAN Bus Driver`, set driver attributes `interfaceName=can0`,
`bitrate=500000`, `frameFormat=STANDARD`.
2. Add a temperature [Point](../introduction/concepts/point) (`READ_ONLY`) to
the [Profile](../introduction/concepts/profile) bound to the device, with point attributes `canId=123`,
`dataOffset=0`, `dataLength=2`, `dataFormat=INT`, `byteOrder=LITTLE`, leaving `requestCanId`/`requestData` empty (
passive listening).
3. Start the driver; the reading appears in [PointValue](../introduction/concepts/point-value) within 30 seconds.
:::
## Troubleshooting
- **The driver must run on Linux with `can-utils` installed**. Low-level read/write relies on `candump`/`cansend`, the
health check relies on `ip link show`, and an available SocketCAN interface is required. Commands run through `sh -c`:
on macOS/Windows or without `can-utils`, `candump` produces no output, so `read()` throws a `No CAN frame received`
read exception (`ReadPointException`) via the `output.isEmpty()` check; on the write side a missing `cansend` surfaces
as a `WritePointException` through `executeCommand`'s exit code/timeout, and the device stays offline.
- **Device stays offline**. The health check is effectively the exit code of `ip link show `: a wrong
interface name, an interface that is not `up`, or a process lacking permission to access it all produce a non-zero
exit code and an offline verdict. First run `ip link show can0` manually on the host to confirm the interface exists
and is UP.
- **Getting `No CAN frame received`**. `candump` captures a single frame with `timeout 3`; if no frame matching `canId`
arrives within 3 seconds it throws a read exception. Check: wrong `canId` (case/radix), `frameFormat` not matching the
device's actual frame format (11/29-bit), or the device requiring a request frame first — for the last, configure
`requestCanId`/`requestData`.
- **CAN ID notation must be correct**. Fill `canId`/`requestCanId` in hex the way `can-utils` expects, **without a `0x`
prefix** (e.g. `123` for standard frames; the 29-bit hex literal for extended frames). A prefix or decimal value will
fail to match any frame.
- **Bitrate / frame format mismatch**. `bitrate` and `frameFormat` must match the bus and the device; a wrong bus
bitrate means the whole bus receives no frames, showing up as persistent `No CAN frame received`.
- **Write has no effect**. The write path is not wired up today: `write()` reads `data` from the point attributes, but
`data` is declared only under `command-attribute` and `execute()` is not implemented, so `data` is always empty,
`${value}` is never rendered, and `cansend` emits an empty-payload frame (`cansend can0 #`) — the device
receives no payload. This is a skeleton TODO, not a configuration issue. You can still confirm the target `canId` is
correct and the point is writable (`rwFlag`).
## How it lands in IoT DC3
- **`dc3.driver.code`**: `CanDriver` (driver name `CAN Bus Driver`, type `DRIVER_CLIENT`, actively sends/receives frames
on the bus). This is a stable routing identifier and must not be changed casually.
- **Read**: ✓ supported. `read()` captures a single frame matching `canId` via `candump` and returns its payload field,
optionally sending a request frame first via `cansend`.
- **Write**: stub / partial. `write()` already shells out to `cansend` to put a frame on the bus, but the `data`
frame-data template is not wired up today (`data` is read from `pointConfig` yet declared only under
`command-attribute`, and `execute()` is not implemented), so `${value}` is never rendered and the frame currently
emitted has an empty payload (see the "Write command attributes" warning above).
- **Subscribe**: — not supported. In this driver CAN is a request-response, scheduled active read, not a subscription
push wired into DC3. The above matches the CAN row (✓ read / — write / — subscribe) in
the [driver capability matrix](./matrix).
::: warning Implementation status: skeleton (WIP), backed by can-utils
This driver is a starting template. `read()`/`write()` use `ProcessBuilder` to shell out to the Linux `can-utils`
tools (`candump`/`cansend`), and `health()` checks the interface via `ip link show` — these call paths actually execute
on a Linux host with can-utils and a real SocketCAN interface, rather than throwing "not implemented." But the source
itself marks it a WIP skeleton, with parts still not wired up:
- **Read path**: the `dataOffset`/`dataLength`/`dataFormat`/`byteOrder` point attributes are not yet applied to byte
slicing and type conversion; `read()` returns the captured payload field as-is.
- **Write path**: the `data` frame-data template rendering is not actually wired up — `data` is read from `pointConfig`
but declared only under `command-attribute`, and `execute()` is not implemented, so `${value}` is never rendered and
the frame currently emitted has an empty payload. Like `dataOffset`, this is a skeleton TODO; **do not treat write as
able to deliver values yet.**
- A `TODO` plans to replace the per-call `ProcessBuilder` approach with native SocketCAN JNI to cut latency. Byte
parsing, write-template rendering, and native I/O integration must be completed before production use.
:::
## Further reading
- [Driver overview](./index) — navigation and categories for all drivers
- [Driver capability matrix](./matrix) — read/write/subscribe capabilities at a glance
- [Device onboarding](../operation/device-onboarding) — a full onboarding walkthrough
- [Fieldbus and protocols](../foundations/fieldbus) — the network layer where CAN sits and fieldbus selection
---
# CoAP Driver
URL: https://docs.dc3.site/en/drivers/coap
# CoAP Driver
`dc3-driver-coap` connects CoAP devices to IoT DC3. Built on Eclipse Californium, it can act as a **CoAP client** that
actively reaches devices (GET to read, PUT to write) and as a **CoAP server** that listens for telemetry the device
pushes via POST. By the end you will be able to set `deviceHost`/`devicePort` on
a [device](../introduction/concepts/device), set read/write resource paths on a [point](../introduction/concepts/point),
and diagnose the common "no value / UDP won't connect" problems.
> You are here: a concrete driver on the "light-protocol" side of the network layer. For CoAP's request/response model,
> UDP/DTLS ports, and the Observe concept at the protocol level,
> see [IoT Protocols & Wireless Networks](../foundations/iot-protocols).
## Protocol Background
CoAP (Constrained Application Protocol) is a lightweight protocol the IETF designed for low-power, low-bandwidth IoT
endpoints (RFC 7252). It keeps the familiar HTTP **request/response + methods (GET/PUT/POST/DELETE) + resource path**
model, but squeezes the message down to a few dozen bytes over **UDP** on default port `5683` (`5684` for CoAPS over
DTLS). Connectionless UDP avoids TCP handshakes and keep-alive overhead, which is extremely friendly to battery-powered
endpoints that wake up only occasionally to report; the cost is that reliability must be added back through CoAP's own
CON/NON confirmation mechanism. It is common on battery-powered sensors, embedded gateways, and NB-IoT/6LoWPAN
endpoints—anywhere power and bandwidth must be conserved.
In the [four-layer IoT architecture](../foundations/iot-protocols), CoAP is an application-layer messaging protocol of
the **network layer**: it defines "what a message looks like, how it is delivered, and how reliable it is," independent
of which wireless carries it underneath—the same CoAP message can run over Wi-Fi or over an NB-IoT cellular link. CoAP's
communication model supports both a client **actively requesting** a resource and a server **passively receiving** a
client's POST. This driver implements both sides:
Client mode is the default shape: IoT DC3's [collection schedule](../introduction/concepts/driver) sends a GET on each
point's `readPath` per cron cycle, and sends a PUT on `writePath` when a write command is issued. Server mode is the
reverse: the driver listens on a CoAP port, devices POST telemetry to the `/data` resource, and the driver parses and
forwards it. The two modes are selected by `dc3.driver.coap.mode` (see the configuration below).
## Attribute Configuration
Onboarding a CoAP device mainly involves two layers of [attributes](../introduction/concepts/attribute-config):
device-level connection parameters (`driver-attribute`) and per-point resource paths (`point-attribute`). In addition,
the driver exposes a set of process-level `dc3.driver.coap.*` Spring settings (controlling client/server mode, timeouts,
DTLS) that are not part of the device config but are tuned by operations via environment/config file. All attributes,
types, and defaults below come from the driver's `application.yml` and `CoapProperties` (the `dc3-driver-coap` module).
### Driver Attributes (device-level `driver-attribute`)
Driver attributes answer "which device to connect to." Fill in one set per CoAP device on
the [device](../introduction/concepts/device):
| Attribute | code | Type | Default | Remark |
|-------------|--------------|--------|-------------|-------------------------------------------|
| Device Host | `deviceHost` | STRING | `localhost` | CoAP device host address (IP or hostname) |
| Device Port | `devicePort` | INT | `5683` | CoAP device port (standard `5683`) |
The driver combines these two attributes into the device root address `coap://:`, then appends
the point's resource path to reach a specific resource. The driver caches one `CoapClient` per device root address (URI)
—one URI, one client—and releases the client when the device is deleted or updated. Config validation (`validate`)
requires both `deviceHost` and `devicePort` to be non-empty; missing either is an error and the device cannot start.
### Point Attributes (`point-attribute`)
Point attributes answer "which resource path on this device to read/write." Fill in one set
per [point](../introduction/concepts/point):
| Attribute | code | Type | Default | Remark |
|----------------|-----------------|--------|--------------|-----------------------------------------------------------------------|
| Read Path | `readPath` | STRING | `/sensors` | CoAP resource path to GET when collecting |
| Write Path | `writePath` | STRING | `/actuators` | CoAP resource path to PUT when writing |
| Content Format | `contentFormat` | STRING | `json` | Content format declaration: `json` / `text` / `cbor` / `octet-stream` |
::: tip Reads and writes go to separate resource paths
When collecting, the driver sends GET to `coap://:`; the response payload is
the [point value](../introduction/concepts/point-value) for that [point](../introduction/concepts/point). When issuing a
write command, it sends PUT to `` with the value as the request body. `readPath` and `writePath` are
independent—a read-only point only needs `readPath`, and leaving `writePath` empty is fine since it is never used. Point
validation (`validatePoint`) only requires `readPath` to be non-empty.
:::
CoAP has no separate `command-attribute` table—the write target of a writable point is determined by the point's own
`writePath`. When a write command is issued, the driver sends PUT directly to that path, with no extra command
attributes needed.
### Process-level Configuration (`dc3.driver.coap.*`)
This group controls the driver process as a whole (client timeouts, whether to start a server, DTLS), set via config
file or environment variables and applied to all devices. It is not listed explicitly in `application.yml`, so it
defaults entirely to the built-in defaults of `CoapProperties`:
| Setting | Default | Remark |
|-----------------------|-----------|--------------------------------------------------------------------------------------------------------------------------------------------|
| `mode` | `CLIENT` | Working mode: `CLIENT` (client only, active read/write) / `SERVER` (server only, listen for reports) / `BOTH` |
| `serverHost` | `0.0.0.0` | Server bind address (effective in `SERVER`/`BOTH` mode) |
| `serverPort` | `5683` | Server listen port (effective in `SERVER`/`BOTH` mode) |
| `secureEnabled` | `false` | Whether DTLS encryption is enabled |
| `clientTimeout` | `5000` | Client GET exchange lifetime (ms, min 100) |
| `clientAckTimeout` | `2000` | Client CON acknowledgement timeout (ms, min 100) |
| `clientMaxRetransmit` | `4` | Client max retransmissions (min 1) |
| `dtls.*` | empty | DTLS credentials: `pskIdentity` / `pskSecret` or certificate paths `trustStorePath` / `identityCertificatePath` / `identityPrivateKeyPath` |
::: info Client mode by default; server mode must be turned on explicitly
The default `mode=CLIENT` makes the driver a client that actively GET/PUTs on a cron cycle and listens on no port. To
let devices POST telemetry, set `mode` to `SERVER` or `BOTH`—the driver then starts a CoAP server and registers a
`/data` resource on `serverPort` to receive reports. `secureEnabled`/`dtls.*` are reserved settings for DTLS; the
current `CoapClientManager` and `CoapServerManager` do not yet assemble DTLS endpoints when connecting (plaintext UDP).
Treat public-internet encryption as code-driven—verify against the source.
:::
## Troubleshooting
CoAP onboarding failures cluster around three areas: the UDP link, the resource path, and the report format. Work
through them in order:
1. **UDP won't connect (no value / `statusCode=timeout`)**. CoAP defaults to **UDP 5683** (not TCP). When the device
does not respond, the client GET times out and returns `null`; `read` treats it as a failure, skips the round, and
logs `CoAP read failed ... statusCode=timeout`. First verify the link with a CoAP client (e.g.
`coap-client -m get coap://:5683`): confirm the device is online, the firewall allows **UDP** 5683,
and `deviceHost`/`devicePort` are correct, rather than suspecting a wrong path first.
2. **Connects but path not found (4.04 Not Found)**. A wrong `readPath` makes the device return `4.04`; then
`response.isSuccess()` is false and `read` likewise returns `null`. Check the resource path's case and leading `/`,
and if needed GET the device's `/.well-known/core` to see which resources it actually exposes.
3. **Responses slower than the timeout get misjudged**. The client defaults are `clientTimeout=5000ms`,
`clientAckTimeout=2000ms`, and max retransmit `4`. Devices on high-RTT links (e.g. cellular/NB-IoT) may not answer
within the default window and be judged as timeouts—raise `dc3.driver.coap.clientTimeout`/`clientAckTimeout` rather
than shortening the collection cycle.
4. **Write command returns failure**. A write is a PUT to `writePath` with the command's value as the body. On failure (
PUT timeout or a non-2.xx response) `write` returns `false` and the write is not echoed back. Confirm `writePath` is
a writable resource on the device and that the device accepts the PUT method. Note: the driver always PUTs with the
`application/json` media type, regardless of the point's `contentFormat` declaration (see the pitfall below).
5. **Server mode receives no reports**. `mode` must be `SERVER` or `BOTH`, and devices must POST to
`coap://