# 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<String>: 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<T>` 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 <script setup> import ChangelogDiagram from '../../.vitepress/theme/components/ChangelogDiagram.vue' </script> # 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. <ChangelogDiagram lang="en" /> 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 `### <version>` heading per version. Under that comes a `_Generated on <date>._` 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 `**<scope>**: <summary> (<short-hash>)`. The scope comes from the `(<scope>)` 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 `<type>(<scope>): <english imperative summary>`. 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.<type>.<YYYYMMDD>.<NN>`: - `<type>` comes from the current branch. The `develop` branch produces `develop` tags; `release` / `main` produce `release` tags. Other branches can't create tags. - `<YYYYMMDD>` is the current date. - `<NN>` 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. <!--@include: ../../dc3/doc/CHANGE.md--> ## 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 <script setup> import DriverAuthoringStateDiagram from '../../.vitepress/theme/components/DriverAuthoringStateDiagram.vue' import DriverAuthoringFlow1Diagram from '../../.vitepress/theme/components/DriverAuthoringFlow1Diagram.vue' import DriverAuthoringFlow2Diagram from '../../.vitepress/theme/components/DriverAuthoringFlow2Diagram.vue' import DriverAuthoringSeqDiagram from '../../.vitepress/theme/components/DriverAuthoringSeqDiagram.vue' import DriverAuthoringFlow3Diagram from '../../.vitepress/theme/components/DriverAuthoringFlow3Diagram.vue' </script> # 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-<protocol>`). 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<String, AttributeBO>`. ::: ## 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. <DriverAuthoringStateDiagram lang="en" /> 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. <DriverAuthoringFlow1Diagram lang="en" /> ### Step 1: Copy the template and rename Name the driver module `dc3-driver-<protocol>`, 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 `<modules>` of `dc3-driver/pom.xml`: ```xml <modules> <module>dc3-driver-knx</module> <!-- existing modules --> </modules> ``` The new module's own `pom.xml` usually just inherits the driver parent module and adds the protocol library: ```xml <parent> <groupId>io.github.pnoker</groupId> <artifactId>dc3-driver</artifactId> <version>2026.5.22</version> </parent> <artifactId>dc3-driver-knx</artifactId> <packaging>jar</packaging> <dependencies> <!-- Protocol library, e.g. calimero-core (KNX). Heavy protocol dependencies belong here only, not in dc3-common-driver --> </dependencies> ``` 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: <DriverAuthoringFlow2Diagram lang="en" /> ### 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<String, AttributeBO> driverConfig, Map<String, AttributeBO> 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<String, AttributeBO> driverConfig, Map<String, AttributeBO> 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. <DriverAuthoringSeqDiagram lang="en" /> 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: <DriverAuthoringFlow3Diagram lang="en" /> The send side goes through `DriverSenderService` (source `DriverSenderService.java`). Common methods: | Method | Purpose | |-----------------------------------------------------------------------|----------------------------------------------| | `pointValueSender(PointValue)` / `pointValueSender(List<PointValue>)` | 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: <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: <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=<commandId>' \ -H 'X-Auth-Tenant: default' \ -H 'X-Auth-Login: dc3' \ -H 'X-Auth-Token: <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/ <script setup> import DevIndexDiagram from '../../.vitepress/theme/components/DevIndexDiagram.vue' </script> # 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<B, Q>`. 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<B, Q>` 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`). <DevIndexDiagram lang="en" /> 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 <type>(optional-scope): <english imperative summary> ``` - 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 <script setup> import TestingDiagram from '../../.vitepress/theme/components/TestingDiagram.vue' </script> # 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. <TestingDiagram lang="en" /> ::: 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<E>`: 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 <script setup> import BacnetIpDiagram from '../../.vitepress/theme/components/BacnetIpDiagram.vue' </script> # 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: <BacnetIpDiagram lang="en" /> 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 <requestCanId>#<requestData>`) 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 <canId>#`). 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 <interfaceName>` 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 <interfaceName>`: 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 <canId>#`) — 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 <script setup> import CoapDiagram from '../../.vitepress/theme/components/CoapDiagram.vue' </script> # 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: <CoapDiagram lang="en" /> 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://<deviceHost>:<devicePort>`, 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://<host>:<port><readPath>`; 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 `<writePath>` 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://<host>:5683<readPath>`): 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://<driver-host>:<serverPort>/data`. The report body must be JSON that deserializes into a `PointValue` (at least `deviceId` and `pointId`); otherwise the driver logs `missingIdentity` / `parse failed` and drops it. An empty body is answered with `4.00 Bad Request`. 6. **Online status flapping**. The health check defaults to every 15 seconds with a 45-second lease timeout. Frequent online/offline flapping usually means UDP packet loss or device responses slower than the timeout—see [device](../introduction/concepts/device) for the liveness mechanism. ::: warning contentFormat is only a declaration—currently not used for parsing `contentFormat` declares the resource's content format (`json` / `text` / `cbor` / `octet-stream`), but the driver currently returns the [point value](../introduction/concepts/point-value) as the raw payload ( `response.getResponseText()`) and **does not parse by this format**; the write direction also always PUTs with the `application/json` media type and does not read this attribute. When unsure what format the device actually returns, GET the resource once manually with a CoAP client to check the content before filling this in. ::: ## How It Works in IoT DC3 - **`dc3.driver.code`**: `CoapDriver` (type `DRIVER_CLIENT`). This is a stable routing identifier—do not change it casually. - **Read**: ✓ Implemented. In client mode it sends a GET on each point's `readPath` per cron cycle and reports the response body as the [point value](../introduction/concepts/point-value). - **Write**: ✓ Implemented. It sends a PUT (`application/json`) to the point's `writePath`, triggered by a write command. - **Subscribe**: — Not counted in the "subscribe" column of the [driver capability matrix](./matrix). CoAP is shown as " ✓ / ✓ / —," meaning **active read / active write / no subscribe** under the SDK point model. CoAP's **Observe** (RFC 7641, push on resource change) is **not wired up** in this driver: only the `CoapObserveHandler` interface is defined, with no implementation or caller. - **Server-push reporting (extra capability)**: in `SERVER`/`BOTH` mode the driver starts a CoAP server that receives `PointValue` JSON the device POSTs to `/data` and forwards it. This is a separate path outside the matrix and requires explicitly setting `mode`. - **Collection cycle**: default cron `0/30 * * * * ?` (collects every 30 seconds), configured in the driver's `application.yml` under `schedule.read`. `schedule.custom` (default cron `0/5 * * * * ?`) is enabled, but `schedule()` is a no-op—it runs no periodic logic beyond point collection. - **Health / liveness**: device health check default cron `0/15 * * * * ?`, lease timeout `45 seconds`. ::: info Implementation status: available (client read/write + server reporting); Observe not implemented The driver's **client read/write** and **server-push reception** are both complete implementations (not skeletons), built on Eclipse Californium. The only piece not wired up is CoAP **Observe** subscription push: `CoapObserveHandler` is an interface only, with no implementation, so the driver cannot currently "subscribe" to a resource and have the device push on change—for event-driven reporting, use the server POST mode instead. DTLS encryption (`secureEnabled`/`dtls.*`) is reserved in config but not yet assembled at connection time; verify against the source. ::: ### Minimal Onboarding Example Onboard a CoAP sensor at `192.168.1.20:5683` whose temperature resource is at `/temp`: 1. Create a [device](../introduction/concepts/device) with `CoAP Driver`, and set the driver attributes `deviceHost=192.168.1.20`, `devicePort=5683`. 2. Add a temperature [point](../introduction/concepts/point) (`READ_ONLY`) to the [profile](../introduction/concepts/profile) bound to the device, with point attributes `readPath=/temp`, `contentFormat=json` (leave `writePath` empty). 3. Start the driver. Within 30 seconds you will see the value fetched by GET on `coap://192.168.1.20:5683/temp` in the [point value](../introduction/concepts/point-value). 4. If the point needs to be writable, configure a write [command](../introduction/concepts/command) and set `writePath`; on issue the driver PUTs to that path. ::: tip Choose server mode for device-initiated reporting If a device only wakes up occasionally to report and does not accept being polled, set `dc3.driver.coap.mode` to `SERVER` and have the device POST to the driver's `/data` resource (report body is `PointValue` JSON with `deviceId`/ `pointId`). This avoids pointless periodic GETs against a sleeping device. ::: ## Further Reading - [Drivers Overview](./index) — entry point and classification of all drivers - [Driver Capability Matrix](./matrix) — read/write/subscribe at a glance, including the CoAP row - [Device Onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [IoT Protocols & Wireless Networks](../foundations/iot-protocols) — request/response model, UDP/DTLS, and Observe for light protocols like CoAP/LwM2M - [LwM2M Driver](./lwm2m) — a driver built on top of CoAP with a device-management object model --- # DLMS/COSEM Driver URL: https://docs.dc3.site/en/drivers/dlms <script setup> import DlmsDiagram from '../../.vitepress/theme/components/DlmsDiagram.vue' </script> # DLMS/COSEM Driver `dc3-driver-dlms` connects DLMS/COSEM metering devices (electricity, water, gas, and heat meters) to IoT DC3: it targets **OBIS codes**, acts as a DLMS client to the meter, and periodically reads COSEM object attribute values. By the end of this page you will understand how DLMS/COSEM addresses data, how to fill the protocol attributes on a device and its points correctly, and exactly where this driver's implementation currently stops. > You are here: the "meter" onboarding side of field devices. To understand why metering protocols form their own world > and where OBIS codes sit in the network layer, start with [Industrial Buses & Protocols](../foundations/fieldbus). ## Protocol Background DLMS/COSEM (Device Language Message Specification / Companion Specification for Energy Metering) is the international standard protocol for utility metering of electricity, water, gas, and heat, corresponding to the IEC 62056 / EN 13757-1 standard series and maintained by the DLMS User Association. It is the de facto common language between meter-reading systems, energy-management platforms, and smart meters: a large share of smart meters, concentrators, and data terminals in Europe and China speak it. The biggest difference between DLMS/COSEM and protocols like Modbus or CIP is its **object-oriented addressing model**: - **Modbus addresses by register** — you must know which holding register a value lives in (e.g. `40001`); the address is a raw number. - **DLMS/COSEM addresses by object + OBIS code** — each readable quantity in the meter (active energy, voltage, clock, …) is modeled as a **COSEM object**, uniquely identified by a 6-field **OBIS code** (such as `1.0.1.8.0.255`); each object in turn has several numbered **attributes**, where attribute `2` is usually the "present value." Reading a quantity means "locate the object by OBIS code, fetch the value by attribute number." This "object + code" model makes metering semantics highly standardized — `1.0.1.8.0.255` means "total active energy" in any compliant electricity meter, portable across vendors; the price is that before onboarding you must look up the OBIS code and object type of each quantity. In the four-layer IoT architecture, DLMS/COSEM belongs to the metering side of the **network layer**: it solves "how a meter sends metering data out with standard semantics," sitting above the sensing layer (metering chips/transducers) and below the platform layer (IoT DC3 aggregation). The diagram below places OBIS code-based addressing within a single collection: <DlmsDiagram lang="en" /> The driver uses the **Gurux DLMS library** (`GXDLMSClient`) to build and decode DLMS frames, acting as a client over TCP or serial to connect to the meter, reading the matching attribute value by the OBIS code configured on each point and unifying it as a [PointValue](../introduction/concepts/point-value) sent upstream. ## Attribute Configuration DLMS/COSEM onboarding parameters come in two layers: **driver attributes (driver-attribute)** describe "which meter, which transport and authentication" and are filled on the [device](../introduction/concepts/device); **point attributes (point-attribute)** describe "which object, which attribute" and are filled on each [Point](../introduction/concepts/point). The defaults of all of these come from the driver's `application.yml`; for the three-layer origin see [Attributes and Config](../introduction/concepts/attribute-config). DLMS/COSEM is read-only metering semantics, so this driver provides no write commands and there are no command attributes (`command-attribute` is empty). ### Driver Attributes (device-level `driver-attribute`) When onboarding a meter, first state its transport and network/serial location on the device, then fill the client/server addresses and authentication of the DLMS session. `transportType` decides TCP versus serial and selects one of two mutually exclusive connection groups; `clientAddress` / `serverAddress` identify the two ends of the DLMS session; `authentication` / `password` decide the privilege at which the association is established. | Attribute | code | Type | Default | Description | |----------------|------------------|--------|----------------|---------------------------------------------------| | Transport Type | `transportType` | STRING | `TCP` | Transport type (TCP, SERIAL) | | Host | `host` | STRING | `localhost` | Remote device address (TCP mode) | | Port | `port` | INT | `4059` | Remote device port (TCP mode, DLMS standard 4059) | | Serial Port | `serialPort` | STRING | `/dev/ttyUSB0` | Serial port path (SERIAL mode) | | Baud Rate | `baudRate` | INT | `9600` | Baud rate (SERIAL mode) | | Client Address | `clientAddress` | INT | `16` | DLMS client address (public client=16) | | Server Address | `serverAddress` | INT | `1` | DLMS server address | | Authentication | `authentication` | STRING | `NONE` | Authentication method (NONE, LOW, HIGH) | | Password | `password` | STRING | (empty) | Authentication password (used with LOW/HIGH) | ::: tip TCP or SERIAL, pick one With `transportType=TCP`, only `host` / `port` are used; with `transportType=SERIAL`, only `serialPort` / `baudRate` are used. The other group is ignored under the current transport and need not be removed. `clientAddress` / `serverAddress` / `authentication` / `password` apply to both modes. ::: ::: tip `clientAddress=16` is the public client `clientAddress=16` is the DLMS "public client," which most meters allow to read basic metering quantities with `NONE` authentication. To read protected objects (such as load profiles or parameter configuration), switch to a higher-privilege client address and raise `authentication` to `LOW` / `HIGH` with a `password`. ::: ::: info `validate()` checks only five required fields The driver's `validate()` lists `transportType` / `host` / `port` / `clientAddress` / `serverAddress` as required; `serialPort` / `baudRate` / `authentication` / `password` are not in the required check (fill as needed). Validation only checks "whether a value is present," not whether the transport type and the filled parameters are consistent — see Troubleshooting below. ::: ### Point Attributes (`point-attribute`) Each collected point must state which COSEM object to read and which attribute of that object to fetch. The OBIS code locates "which quantity to read," and the attribute number locates "which facet of that quantity to take." | Attribute | code | Type | Default | Description | |--------------|---------------|--------|------------|--------------------------------------------------------| | Object Type | `objectType` | STRING | `REGISTER` | DLMS object type (REGISTER, CLOCK, DATA, etc.) | | Logical Name | `logicalName` | STRING | (empty) | Object logical name / OBIS code (e.g. `1.0.1.8.0.255`) | | Attribute ID | `attributeId` | INT | `2` | Attribute ID (2=Present Value) | ::: tip The OBIS code locates "which quantity to read" `logicalName` is a 6-part OBIS code uniquely identifying one metering quantity in the meter — for example `1.0.1.8.0.255` is "total active energy" and `1.0.32.7.0.255` is "phase A voltage." `objectType` tells the driver which COSEM interface class the object is (`REGISTER` metering register, `CLOCK` clock, `DATA` generic data, etc.), and attributes mean different things across interface classes. `attributeId=2` reads the object's "present value" attribute. The Point's own data type ([Point](../introduction/concepts/point) `pointTypeFlag`) must match the actual type of the object attribute. ::: ::: info `validatePoint()` checks only `objectType` Point validation lists only `objectType` as required; `logicalName` defaults to empty, but leaving it empty means no object can be located. Be sure to fill the actual OBIS code on every collected point. ::: ### Collection and Health - **Collection cycle**: default read cron `0/30 * * * * ?` (reads once every 30 seconds). - **Custom schedule**: `schedule.custom` is enabled in the yml (cron `0/5 * * * * ?`), but the current `schedule()` method body is empty and performs no custom logic. - **Health / online**: device health check default cron `0/15 * * * * ?`, lease timeout `45 seconds` — see [Device](../introduction/concepts/device) for the online-state mechanism. ## Troubleshooting DLMS/COSEM onboarding failures mostly fall into two buckets: "transport not matched" and "object not located." Work through them from the outside in. ::: warning Transport type and connection parameters do not match `host` / `port` only take effect under `transportType=TCP`, and `serialPort` / `baudRate` only under `SERIAL`. `validate()` does not catch a "transport vs parameters" mismatch — if you set `transportType` to `SERIAL` but only filled in `host`, the driver follows the serial branch and looks for `serialPort`, failing to reach the meter. When changing the transport type, remember to fill the corresponding group of attributes. ::: ::: warning TCP port or firewall: 4059 unreachable The standard port for DLMS over TCP is `4059` — different from Modbus's `502` and IEC 104's `2404`, so do not carry those over. First confirm `host:4059` is reachable from the driver host (`telnet <host> 4059` or `nc -vz <host> 4059`). Common causes: the meter/concentrator does not expose the DLMS port, the network is not routed, or a firewall blocks 4059. ::: ::: warning Serial path or baud rate mismatch In serial mode, `serialPort` (e.g. `/dev/ttyUSB0`) must be a device node that really exists on the driver host, and the current user must have read/write permission (on Linux you often need to add the user to the `dialout` group). `baudRate` must match the meter's setting (common metering rates are `300` / `9600` / `19200`); a mismatch means frames cannot be parsed correctly. ::: ::: warning OBIS code or object type mismatch, quantity not located `logicalName` must match an object that actually exists in the meter verbatim, and `objectType` must match that object's real COSEM interface class. Configuring a `CLOCK` (clock) object as `REGISTER`, or entering an OBIS code that the meter does not have, both fail the read. Before onboarding, check each quantity's code and object type against the vendor's object list (OBIS table). ::: ::: warning Insufficient authentication, protected objects refused The public client at `clientAddress=16` can usually read only basic metering quantities. When reading protected objects such as load profiles, event logs, or parameters, still using the public client or `authentication=NONE` makes the meter refuse the association or the read. Switch to a higher-privilege client address and raise `authentication` to `LOW` / `HIGH` with the correct `password`. ::: ::: info Online state is not evidence that data was collected `health()` only checks whether the driver has cached a `GXDLMSClient` object for the device — it does **no real connectivity probe** — and in the current implementation the connection cache is never populated (the transport layer is pending, see below). To judge whether data is really being collected, rely on whether the [PointValue](../introduction/concepts/point-value) updates, not on the device online state. ::: ## How It Lands in IoT DC3 - **dc3.driver.code**: `DlmsDriver` (a stable routing identifier — registration and message routing both rely on it, do not change it casually). Driver name `DLMS/COSEM Driver`, type `DRIVER_CLIENT` (the driver actively connects to the meter). - **Read**: metering semantics — it is meant to read COSEM attributes by OBIS code on the collection cycle — but the current `read()` throws `ReadPointException` directly; the collection trunk is not yet wired (see the skeleton note below). - **Write**: not provided. DLMS/COSEM is read-only metering in this driver; `command-attribute` is empty and `write()` throws `WritePointException` directly. - **Subscribe / push**: not supported. This driver actively polls on the collection cycle and does not listen for meter-initiated pushes. Aligned with the [Driver Capability Matrix](./matrix): in the matrix DLMS is marked `—` for read/write/subscribe, with the note "smart meter, transport pending." ::: warning Work in progress (skeleton) This driver is a protocol skeleton, earlier-stage than drivers whose "upper flow is wired and only framing is missing": the Gurux client (`GXDLMSClient`) can generate DLMS frames, but the **transport send/receive and HDLC handshake are not yet implemented**, so neither the read nor the write trunk is wired: - `read()` / `write()` throw "not implemented" exceptions (`ReadPointException` / `WritePointException`) to fail fast, so the SDK records the failure and applies connection backoff rather than returning a cached or fabricated value; - `health()` only checks whether the internal connection cache `clientMap` contains the device, not a real protocol probe; and since that cache is never populated in the current implementation, devices will not actually show online; - the `schedule()` method body is empty, so the `custom` scheduled task enabled in the yml runs no logic for now. Treat it as a starting template for onboarding a new meter, not a production-ready driver. The attribute tables and schedules below are taken verbatim from the real `application.yml` and are safe to fill in; but for the actual read behavior, consult the `read()` / `write()` / `initial()` source in `DlmsDriverCustomServiceImpl`. ::: The minimal path to onboard a DLMS/COSEM electricity meter (to validate the configuration flow, not for production collection): 1. Create a [device](../introduction/concepts/device) with `DLMS/COSEM Driver`, and set the driver attributes `transportType=TCP`, `host=192.168.1.20`, `port=4059`, `clientAddress=16`, `serverAddress=1`, `authentication=NONE`. 2. Add an energy [Point](../introduction/concepts/point) (`pointTypeFlag=DOUBLE`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `objectType=REGISTER`, `logicalName=1.0.1.8.0.255`, `attributeId=2`. 3. Start the driver and watch the logs; the current `read()` throws `ReadPointException` directly, so the SDK records a read failure and backs off, and no value appears in [PointValue](../introduction/concepts/point-value) for now — a real value is only collected once the transport layer is completed. For the complete onboarding procedure, see [Device Onboarding](../operation/device-onboarding). ## Further Reading - [Drivers Overview](./index) — entry point for driver categories and selection - [Driver Capability Matrix](./matrix) — read/write/subscribe capabilities and implementation status at a glance - [Device Onboarding](../operation/device-onboarding) — a complete device onboarding flow - [Industrial Buses & Protocols](../foundations/fieldbus) — the metering side of the network layer; OBIS object addressing and how it compares to other protocols --- # DL/T645 Driver URL: https://docs.dc3.site/en/drivers/dlt645 `dc3-driver-dlt645` reads electricity meters speaking the Chinese DL/T645-2007 protocol over RS485. It builds the meter request frame (68H ... 16H), verifies the response checksum, and parses the data identifier (DI0-DI3) value; writes use the DL/T645 operator codes for meter programming. ## Protocol background DL/T645-2007 is the Chinese national standard for multi-function electricity meters. Frames are delimited by 68H, carry a 6-byte BCD meter address, and end with a cumulative checksum. Each measured quantity (voltage, current, power, energy) is addressed by a 4-byte data identifier DI0-DI3. - **Driver name / code**: `DL/T645 Driver` / `Dlt645Driver` - **Type**: `DRIVER_CLIENT (opens the serial port and polls the meter)` - **Underlying library**: jSerialComm (self-built DL/T645-2007 frame encode/decode) ## Attribute configuration ### Driver attributes (device-level `driver-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Serial Port | `port` | STRING | `/dev/ttyUSB0` | Serial port device path | | Baud Rate | `baudRate` | INT | `2400` | Baud rate (DL/T645 default 2400) | | Data Bits | `dataBits` | INT | `8` | Data bits | | Stop Bits | `stopBits` | INT | `1` | Stop bits | | Parity | `parity` | INT | `2` | Parity (2=Even, DL/T645 default) | | Timeout | `timeout` | INT | `1000` | Read timeout in milliseconds | | Meter Address | `meterAddress` | STRING | `000000000000` | 12-digit BCD meter address | | Password | `password` | STRING | `00000000` | Meter password for writes | | Operator Code | `operatorCode` | STRING | `00000000` | Operator code for writes | ### Point attributes (`point-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Data Identifier | `di` | STRING | (empty) | 4-byte data identifier as 8 hex chars (DI0-DI3) | | Data Format | `dataFormat` | STRING | `FLOAT` | Data format: FLOAT, HEX, ASCII, BINARY | ## Collection and health - **Collection cycle**: default cron `0/30 * * * * ?` (one polling round over all points every 30 seconds). - **Health/online**: device health defaults to cron `0/15 * * * * ?`, lease timeout `45 seconds`. ## Capability matrix | Capability | Supported | Notes | |------------|-----------|-------| | Read | ✓ | | | Write | ✓ | | | Subscribe | — | | ::: info Implementation status: available :: The frame encoder/decoder (`Dlt645Frame`) and serial connection (`Dlt645SerialPortConnection`) are fully implemented, including address BCD encoding, checksum verification, and read/write frame building. ## Minimal onboarding example 1. Create a Device using `DL/T645 Driver`, set `port=/dev/ttyUSB0`, `baudRate=2400`, `parity=2`, `meterAddress=<your 12-digit address>`. 2. Add a Point (`READ_ONLY`) with `di=02010100` (phase A voltage) and `dataFormat=FLOAT`. 3. Start the driver; within 30 seconds the value appears in PointValue. ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough --- # DNP3 Driver URL: https://docs.dc3.site/en/drivers/dnp3 `dc3-driver-dnp3` targets DNP3 (IEEE 1815) masters for utility automation. The protocol stack is provided by the `io.stepfunc:dnp3` native FFI binding (Rust `dnp3` runtime with per-platform native libraries bundled in the jar). ## Protocol background DNP3 (IEEE 1815) is the predominant SCADA protocol in North American utilities. A master connects to an outstation, polls event classes, and synchronizes a local database. This module implements the full master path: one native runtime, TCP channel, and association per outstation; class 0/1/2/3 integrity polling through a `ReadHandler` that caches point values by index; and `DIRECT_OPERATE` commands for binary and analog outputs. - **Driver name / code**: `DNP3 Driver` / `Dnp3Driver` - **Type**: `DRIVER_CLIENT (master to an outstation)` - **Underlying library**: Step Function I/O `io.stepfunc:dnp3` (native FFI binding) ## Attribute configuration ### Driver attributes (device-level `driver-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Host | `host` | STRING | (empty) | DNP3 outstation address | | Port | `port` | INT | `20000` | DNP3 TCP service port | | Master Address | `masterAddress` | INT | `1` | Master link-layer address | | Outstation Address | `outstationAddress` | INT | `1` | Outstation link-layer address | ### Point attributes (`point-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Point Index | `pointIndex` | INT | `0` | DNP3 point index within the selected point type | | Point Type | `pointType` | STRING | `BINARY_INPUT` | BINARY_INPUT, ANALOG_INPUT, COUNTER, DOUBLE_BIT_BINARY_INPUT, BINARY_OUTPUT, or ANALOG_OUTPUT | ### Command attributes (`command-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Point Index | `pointIndex` | INT | `0` | DNP3 point index for commands | | Point Type | `pointType` | STRING | `BINARY_OUTPUT` | BINARY_OUTPUT or ANALOG_OUTPUT for commands | ## Collection and health - **Collection cycle**: default cron `0/30 * * * * ?`. - **Health/online**: device health defaults to cron `0/15 * * * * ?`, lease timeout `45 seconds`. ## Capability matrix | Capability | Supported | Notes | |------------|-----------|-------| | Read | ✓ | Integrity poll cached per point | | Write | ✓ | DIRECT_OPERATE for binary/analog outputs | | Subscribe | — | | ::: info Implementation status: available ::: ::: warning Native stack requires outstation commissioning The `io.stepfunc:dnp3` native stack (Rust `dnp3` runtime with per-platform native libraries) loads and the read/write path is implemented, but on-wire behaviour must still be commissioned against a real outstation in the target environment. ::: ## Minimal onboarding example 1. Create a Device using `DNP3 Driver`, set `host=<outstation-address>` and `port=20000`. 2. Add a Point with `pointIndex=0` and `pointType=BINARY_INPUT`. 3. Commission against a real outstation before production use. ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough --- # EtherNet/IP Driver URL: https://docs.dc3.site/en/drivers/ethernet-ip <script setup> import EthernetIpDiagram from '../../.vitepress/theme/components/EthernetIpDiagram.vue' </script> # EtherNet/IP Driver `dc3-driver-ethernet-ip` connects EtherNet/IP (CIP) Rockwell Allen-Bradley PLCs to IoT DC3: it targets **tag names**, periodically reads PLC tag values, and supports commands that write values to tags. By the end of this page you will understand how EtherNet/IP addresses data, how to fill the protocol attributes on a device and its points correctly, and exactly where this driver's implementation currently stops. > You are here: the "Rockwell PLC" onboarding side of field devices. To understand why industrial protocols are > vendor-proprietary and where CIP sits in the network layer, start > with [Industrial Buses & Protocols](../foundations/fieldbus). ## Protocol Background EtherNet/IP (Ethernet Industrial Protocol) is an industrial Ethernet protocol that carries **CIP (Common Industrial Protocol)** over standard TCP/IP. Maintained by ODVA, it is used mainly with Rockwell Allen-Bradley PLCs (such as ControlLogix / CompactLogix) and the servos, drives, and I/O modules in that ecosystem. In factory automation it belongs — alongside Siemens S7, Mitsubishi MELSEC, and Omron FINS — to the camp of vendor-led, mutually incompatible proprietary protocols: pick a PLC brand and you are largely locked into its protocol. The biggest difference between EtherNet/IP and a protocol like Modbus is the **addressing model**: - **Modbus addresses by register** — you must know which holding register a value lives in (e.g. `40001`); the address is a raw number. - **CIP addresses by tag name** — variables in the PLC project have names (e.g. `Motor_Speed`), and the driver reads/writes them directly by name through the CIP **Data Table Read/Write** services, with no concern for their physical address in controller memory. This "access by name" model keeps addresses from drifting when the PLC program changes, but it also means the tag name must match the PLC project verbatim. In the four-layer IoT architecture, EtherNet/IP belongs to the industrial wired side of the **network layer**: it solves "how a field device sends a data point out over Ethernet," sitting above the sensing layer (sensors/transducers) and below the platform layer (IoT DC3 aggregation). The diagram below places CIP name-based addressing within a single collection: <EthernetIpDiagram lang="en" /> CIP does not rely on physical addresses, so a point carries a `tagName` rather than an offset; the driver decodes the bytes the PLC returns into a concrete value according to the point's `tagType` and unifies it as a [PointValue](../introduction/concepts/point-value) sent upstream. ## Attribute Configuration EtherNet/IP onboarding parameters come in two layers: **driver attributes (driver-attribute)** describe "which PLC, which port and timeout" and are filled on the [device](../introduction/concepts/device); **point attributes ( point-attribute)** describe "which tag, decoded as which type" and are filled on each [Point](../introduction/concepts/point). Writable points add a **command attribute (command-attribute)**. The defaults of all of these come from the driver's `application.yml`; for the three-layer origin see [Attributes and Config](../introduction/concepts/attribute-config). ### Driver Attributes (device-level `driver-attribute`) When onboarding an EtherNet/IP PLC, first state its network location on the device. `host` / `port` decide where TCP connects, `slot` identifies the PLC's backplane slot (a multi-module rack needs this to locate the CPU), and `timeout` bounds how long a single request waits. | Attribute | code | Type | Default | Description | |-----------|-----------|--------|-------------|---------------------------------------------------------------| | Host | `host` | STRING | `localhost` | PLC host address (IP or hostname) | | Port | `port` | INT | `44818` | EtherNet/IP TCP port (standard 44818) | | Slot | `slot` | INT | `0` | PLC backplane slot, locating the CPU module in the rack | | Timeout | `timeout` | INT | `5000` | Request timeout (milliseconds), set as the socket `SoTimeout` | ::: info `slot` is currently only validated, not framed `validate()` lists `slot` as a required attribute, but the current connect and read/write code does not yet encode `slot` into the CIP routing path (the `ForwardOpen` connection path is still a placeholder). Single-CPU setups with the CPU in slot 0 are unaffected; precise addressing for multi-slot racks awaits completed protocol framing. ::: ### Point Attributes (`point-attribute`) Each collected point must state which tag to read and what data type that tag is in the PLC — the driver does not probe the PLC for the type; it decodes bytes strictly by the `tagType` you set. | Attribute | code | Type | Default | Description | |---------------|----------------|--------|---------|-----------------------------------------------------------------------| | Tag Name | `tagName` | STRING | (empty) | CIP tag name, e.g. `Motor_Speed`, must match the PLC project verbatim | | Tag Type | `tagType` | STRING | `DINT` | Tag data type: `BOOL` / `SINT` / `INT` / `DINT` / `REAL` / `STRING` | | Element Count | `elementCount` | INT | `1` | Number of elements to read (for array tags) | ::: tip `tagType` decides how bytes are decoded The driver parses the raw bytes the PLC returns (little-endian) into the matching type per `tagType`: `BOOL` 1 byte, `SINT` 1 byte, `INT` 2-byte integer, `DINT` 4-byte integer, `REAL` 4-byte float, `STRING` ASCII text. `tagType` must match the actual type of that tag in the PLC, otherwise parsing yields a meaningless value. The Point's own data type ([Point](../introduction/concepts/point) `pointTypeFlag`) should match it. ::: ::: warning `elementCount` is not consumed yet `buildReadTagRequest()` hardcodes the element count in the read request to `1`, so the configured `elementCount` has no effect yet. Whole-array reads await completed implementation; for now reads are single-element only. ::: ### Command Attribute (`command-attribute`) Writable points add a write-value template on the write command. | Attribute | code | Type | Default | Description | |--------------|---------------|--------|------------|---------------------------------------------------------------------------------------------------| | Send Command | `sendCommand` | STRING | `${value}` | Write-value template, intended to be rendered from the command argument and encoded per `tagType` | ::: warning The `sendCommand` template is not consumed yet `write()` takes the value passed in the command directly and encodes it per `tagType` (`encodeTagValue()`); it does not go through the `sendCommand` template. This attribute is a reserved contract for now — template substitution is still to be implemented. ::: ### Collection and Health - **Collection cycle**: default cron `0/30 * * * * ?` (reads once every 30 seconds). - **Custom schedule**: `schedule.custom` is enabled in the yml (cron `0/5 * * * * ?`), but the current `schedule()` method body is empty and performs no custom logic. - **Health / online**: device health check default cron `0/15 * * * * ?`, lease timeout `45 seconds` — see [Device](../introduction/concepts/device) for the online-state mechanism. ## Troubleshooting EtherNet/IP onboarding failures mostly fall into two buckets: "cannot connect" and "the value read is wrong." Work through them from the outside in. ::: warning Port or firewall: 44818 unreachable EtherNet/IP explicit messaging runs over TCP `44818` (implicit I/O uses UDP `2222` separately, which this driver does not touch). First confirm `host:44818` is reachable from the driver host (`telnet <host> 44818` or `nc -vz <host> 44818`). Common causes: EtherNet/IP service not enabled on the PLC, network not routed, firewall blocking 44818. A failed connect in `getConnector()` throws `ConnectorException`; the log contains `EtherNet/IP connection failed`. ::: ::: warning tagName does not exist or case mismatches CIP is addressed by name, so `tagName` must match the variable name in the PLC program **verbatim and case-sensitively **. This differs from Modbus `offset`: a wrong Modbus offset silently reads a different register, whereas a non-existent CIP tag name fails the read outright and throws `ReadPointException`. When troubleshooting, go back to the PLC project first to check the tag spelling and scope (controller-level vs program-level tags). ::: ::: warning tagType mismatches the PLC's real type, yielding garbage The driver decodes bytes strictly by the `tagType` you set and never probes the PLC for the real type. Configuring a `REAL` (float) tag as `DINT` parses the float's 4 bytes as an integer and produces a meaningless large number. Confirm the actual type of each tag in the PLC project before onboarding, and make the point's `pointTypeFlag` match. ::: ::: warning Timeout too short causes intermittent read failures `timeout` is set as the socket `SoTimeout`, default `5000` ms. Under network jitter or high PLC load, too short a timeout makes `readFully()` throw `SocketTimeoutException`, which triggers `invalidateConnector()` to drop and reconnect. The symptom is periodic read failures and a flapping device online state. Raise `timeout` moderately, but investigate network quality first. ::: ::: info Device shows online but no value is read `health()` only checks whether the cached socket is `isConnected() && !isClosed()` — it does **no real protocol probe**. So with TCP connected but the CIP session not actually established, the device may still show "online." To judge whether data is really being collected, rely on whether the [PointValue](../introduction/concepts/point-value) updates, not on the device online state alone. ::: ::: info Cannot locate the CPU in a multi-slot rack `slot` is not encoded into the CIP routing path yet. If the PLC is in a non-zero slot, or the rack holds multiple CPUs, precise addressing awaits completed protocol framing; until then, validate onboarding with a single CPU placed in slot 0. ::: ## How It Lands in IoT DC3 - **dc3.driver.code**: `EthernetIpDriver` (a stable routing identifier — registration and message routing both rely on it, do not change it casually). Driver name `EtherNet/IP Driver`, type `DRIVER_CLIENT` (the driver actively connects to the PLC). - **Read**: `read()` wires the main flow — fetch tag name, build the Read Tag request, decode the bytes. - **Write**: `write()` wires the main flow — fetch tag name/type, encode the value, build the Write Tag request. - **Subscribe / push**: not supported. EtherNet/IP explicit messaging is request-response; this driver actively polls on the collection cycle and does not listen for device-initiated pushes. Aligned with the [Driver Capability Matrix](./matrix): in the matrix EtherNet/IP is marked `—` for read/write/subscribe, with the note "Rockwell / CIP, skeleton pending." ::: warning Work in progress (skeleton) This driver is a protocol skeleton. The upper-layer read/write flow (fetching by `tagName`, encoding/decoding by `tagType`, socket connect and invalidation/reconnect) is in place, but the **CIP protocol framing is not yet complete**: - session setup `RegisterSession` and connection open `ForwardOpen` are still `TODO` placeholders; - `buildEncapsulationHeader()` writes only a 24-byte length header, not a full EtherNet/IP encapsulation frame; - `health()` only inspects socket state, not a real protocol probe; - the `elementCount` and `sendCommand` attributes are not consumed yet. Treat it as a starting template, not a production-ready driver. For the final behavior, consult the `read()` / `write()` / `initial()` source in `EthernetIpDriverCustomServiceImpl`. ::: The minimal path to onboard an Allen-Bradley PLC (to validate the flow, not for production collection): 1. Create a [device](../introduction/concepts/device) with `EtherNet/IP Driver`, and set the driver attributes `host=192.168.1.20`, `port=44818`, `slot=0`, `timeout=5000`. 2. Add a speed [Point](../introduction/concepts/point) (`pointTypeFlag=INT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `tagName=Motor_Speed`, `tagType=DINT`, `elementCount=1`. 3. Start the driver and watch the connect and collection logs; once CIP framing is complete, the collected value appears in [PointValue](../introduction/concepts/point-value) within 30 seconds. For the complete onboarding procedure, see [Device Onboarding](../operation/device-onboarding). ## Further Reading - [Drivers Overview](./index) — entry point for driver categories and selection - [Driver Capability Matrix](./matrix) — read/write/subscribe capabilities and implementation status at a glance - [Device Onboarding](../operation/device-onboarding) — a complete device onboarding flow - [Industrial Buses & Protocols](../foundations/fieldbus) — the industrial wired side of the network layer; how CIP and other proprietary protocols are positioned and addressed --- # FINS Driver URL: https://docs.dc3.site/en/drivers/fins <script setup> import FinsDiagram from '../../.vitepress/theme/components/FinsDiagram.vue' </script> # FINS Driver `dc3-driver-fins` onboards Omron PLCs into IoT DC3 over the FINS protocol: as a FINS client it actively opens a TCP connection to the PLC, periodically reads values by the memory area and word address configured on each [Point](../introduction/concepts/point), and supports commands that write values back to memory areas. By the end of this page you can onboard an Omron PLC and know exactly how far the current implementation goes. - **Driver name / code**: `Omron FINS Driver` / `FinsDriver` - **Type**: `DRIVER_CLIENT` (actively connects to the PLC) ## Protocol background FINS (Factory Interface Network Service) is the native communication protocol of Omron PLCs, widely used across the CP/CJ/CS series. It partitions PLC memory by purpose into several **Memory Areas**, each addressed in units of one " word" (16 bits); a host accesses data by sending memory read/write command frames carrying a memory-area code plus a word address. FINS can run over UDP, TCP, Ethernet, or Omron's proprietary buses; this driver uses **FINS/TCP**, prepending a 4-byte length prefix to each FINS frame. In the [four-layer IoT architecture](../foundations/fieldbus), FINS sits at the **network layer**: it defines how devices on the shop floor are addressed, how commands are encoded, and how bytes travel on the link. IoT DC3 normalizes on top of it, translating "read the word at `D100`" into a uniform [PointValue](../introduction/concepts/point-value). ::: tip A few FINS concepts first **Memory Area**: a region of PLC data partitioned by purpose——`D` (data memory, the most common), `W` (work), `H` ( holding), `C` (counter). This driver maps them to FINS area codes: `D`=0x82, `W`=0xB1, `H`=0xB0, `C`=0x83. **Word Address**: an offset within a memory area in units of one "word" (16 bits), e.g. `D100` is the 100th word of the D area. **Node/Unit number**: the source/destination addresses used to reach a PLC on the FINS network; for a single direct connection these usually stay at their defaults. ::: ### How one FINS read frame is assembled The driver uses no third-party protocol library; it assembles FINS frames byte by byte. A request to read 1 word consists of a 4-byte TCP length prefix + a 10-byte FINS header + a 2-byte command code + 4 bytes of read parameters: <FinsDiagram lang="en" /> The response frame carries a 2-byte **end code** after the FINS header and command code: non-zero means the PLC rejected or errored, and the driver raises a `ReadPointException` accordingly; when it is zero, data starts at byte 14 and is decoded per `dataType` (see the implementation-status note below). ## Attribute configuration FINS onboarding parameters fall into two groups: which PLC to connect to is set by device-level **driver attributes**; which word each point reads/writes is set by **point/command attributes**. The fields in the three tables below all come from the driver's `application.yml`, and the prose before each table explains what every attribute does and where its value comes from. ### Driver configuration (device-level `driver-attribute`) When onboarding a FINS PLC, fill these [Attributes](../introduction/concepts/attribute-config) on the [Device](../introduction/concepts/device). `host`/`port` point at the PLC; `sourceNode`/`destNode`/`sourceUnit`/ `destUnit` are the source/destination addressing bytes in the FINS header, fine at their defaults for a single direct connection; `timeout` serves as both the TCP connect timeout and the read timeout (`setSoTimeout`). | Attribute | code | Type | Default | Description | |-------------|--------------|--------|-------------|------------------------------------------------------| | Host | `host` | STRING | `127.0.0.1` | PLC host address | | Port | `port` | INT | `9600` | FINS port (standard 9600) | | Protocol | `protocol` | STRING | `TCP` | Transport protocol (the driver always uses FINS/TCP) | | Source Node | `sourceNode` | INT | `1` | FINS source node number | | Dest Node | `destNode` | INT | `2` | FINS destination node number | | Source Unit | `sourceUnit` | INT | `0` | FINS source unit number | | Dest Unit | `destUnit` | INT | `0` | FINS destination unit number | | Timeout | `timeout` | INT | `5000` | Connect / request timeout (milliseconds) | ### Point configuration (`point-attribute`) Fill the read target on each acquisition [Point](../introduction/concepts/point). `memoryArea` + `address` together locate one word (e.g. `memoryArea=D`, `address=100` corresponds to Omron's familiar `D100`); `dataType` declares the decoding; `bitPosition` is the bit offset within the word. | Attribute | code | Type | Default | Description | |--------------|---------------|--------|----------|------------------------------------------------------------------------------| | Memory Area | `memoryArea` | STRING | `D` | Memory area, `D`/`W`/`H`/`C` | | Address | `address` | INT | `0` | Word address within the memory area | | Data Type | `dataType` | STRING | `UINT16` | `INT16`/`UINT16`/`INT32`/`UINT32`/`FLOAT`/`STRING`/`BCD` | | Bit Position | `bitPosition` | INT | `0` | Bit offset within the word (unused on the current read path; treated as `0`) | ::: tip The word count read is determined by `dataType` A read fetches the word count matching `dataType`: `INT32`/`UINT32`/`FLOAT` read **2 words (4 bytes)**, other types read **1 word (2 bytes)** (see `wordCount()`). Decoding supports `INT16`/`UINT16`/`INT32`/`UINT32`/`FLOAT`/`STRING`/ `BCD`, all Big-Endian; the Point's data type ([Point](../introduction/concepts/point)'s `pointTypeFlag`) should match the `dataType` set here. ::: ### Write command configuration (`command-attribute`) Writable points additionally need the target location and write type on the write command; the field meanings are the same as in the point configuration. | Attribute | code | Type | Default | Description | |-------------|--------------|--------|----------|-------------------------------------| | Memory Area | `memoryArea` | STRING | `D` | Memory area, `D`/`W`/`H`/`C` | | Address | `address` | INT | `0` | Word address within the memory area | | Data Type | `dataType` | STRING | `UINT16` | Data type of the written value | ### Acquisition and health scheduling These cadences are fixed in the `schedule`/`health` sections of `application.yml`; you do not re-enter them on the device: - **Acquisition cycle**: default cron `0/30 * * * * ?` (reads once every 30 seconds). - **Custom task**: default cron `0/5 * * * * ?`, but the FINS driver's `schedule()` is an empty implementation——the slot is reserved and currently does nothing. - **Health / online**: the device health check defaults to cron `0/15 * * * * ?` with a lease timeout of `45 seconds`. The driver decides online status by whether the TCP connection is alive ( `socket.isConnected() && !socket.isClosed()`); a dropped connection triggers a reconnect attempt, and a failed reconnect marks the device offline. For the online state mechanism see [Device](../introduction/concepts/device). ## Troubleshooting ::: warning address is a word address, not a region-prefixed string `address` takes only the numeric offset within the memory area. To read Omron's familiar `D100`, set `memoryArea=D` and `address=100`——do **not** put `D100` as a whole into `address`. The area is specified separately by `memoryArea`. `memoryArea` only recognizes `D`/`W`/`H`/`C`; any other value is silently treated as `D` (0x82). ::: - **Cannot connect / stuck offline**: first confirm the PLC has FINS/TCP enabled on port `9600` (the driver is TCP-only and does not fall back to UDP). On a failed connection the driver logs `Driver FINS connection failed` and marks the device offline, retrying on the next health-check cycle. Check `host`, network reachability, and whether the PLC limits the number of client connections. - **Wrong value**: confirm `dataType` matches the actual type/word length of the PLC register (`INT32`/`UINT32`/`FLOAT` read 2 words) and that byte order is Big-Endian; a type mismatch decodes to a wrong number. - **Non-zero endCode**: bytes 12–13 of the response frame are the FINS end code; non-zero means the PLC rejected the request (e.g. address out of range, memory area absent, insufficient permission). The driver throws `FINS command failed, endCode=0x...`; look the code up in the FINS manual and verify `memoryArea`/`address` fall within the PLC's actual memory range. - **Float writes**: the write command parses `INT32`/`UINT32` as integers (`Integer.parseInt`) into 4 big-endian bytes, and encodes `FLOAT` via `Float.parseFloat` as a 4-byte IEEE 754 big-endian float. Make sure the value string matches `dataType` (send `12.5` for a `FLOAT`). - **Frequent timeouts**: `timeout` governs both connect and read (default 5000ms). Increase it for a jittery link or a slow PLC; note that any read/write exception actively closes and evicts that device's cached connection, which is rebuilt on next access. - **Node addressing fails**: FINS scenarios crossing gateways/routers need correct `sourceNode`/`destNode`. Note the current implementation: after GCT the driver writes `destNode`/`destUnit` and `srcNode`/`srcUnit` directly, and does not emit separate zeroed DNA/SNA network-address bytes——the header does not strictly distinguish network addresses from node numbers (the node number occupies the DNA/SNA slots from the spec). A single direct connection works with the defaults `1`/`2`; across gateways and multi-level routing this simplified header may address incorrectly. ## How It Lands in IoT DC3 - **dc3.driver.code**: `FinsDriver` (stable routing identifier used for registration and command dispatch; do not change it casually). - **Read capability**: ✓ implemented——periodic polling; word count follows `dataType`, decoding `INT16`/`UINT16`/`INT32`/`UINT32`/`FLOAT`/`STRING`/`BCD`. - **Write capability**: ✓ implemented——`INT16`/`UINT16`/`INT32`/`UINT32`/`STRING` and `FLOAT` (IEEE 754) all encode correctly. - **Subscribe/report capability**: — not provided. FINS is active-poll only; the driver does not listen for device pushes, consistent with the [Driver Capability Matrix](./matrix). ::: tip One driver instance can serve multiple PLCs A single FINS driver process can serve multiple devices, each holding its own TCP connection (cached by device ID in `clientMap`). Multiple PLCs are distinguished by their own `host` and `destNode`; when a device is deleted or updated, the driver destroys the matching connection via a metadata event. ::: ### Minimal onboarding example Onboard an Omron PLC at IP `192.168.1.20:9600` and acquire one 16-bit integer at `D100`: 1. Create a [Device](../introduction/concepts/device) with `Omron FINS Driver`, set driver attributes `host=192.168.1.20` and `port=9600`, and leave the rest (`protocol`, node/unit numbers, `timeout`) at their defaults. 2. Add a [Point](../introduction/concepts/point) (`pointTypeFlag=INT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, with point attributes `memoryArea=D`, `address=100`, `dataType=INT16`. 3. Start the driver; within 30 seconds the `D100` value appears in [PointValue](../introduction/concepts/point-value). ## Further reading - [Driver Overview](./index) — pick a protocol by category and open its driver page - [Driver Capability Matrix](./matrix) — read/write/subscribe capabilities across drivers - [Device Onboarding](../operation/device-onboarding) — a full onboarding walkthrough - [Fieldbuses & Protocols](../foundations/fieldbus) — the network layer FINS belongs to: addressing, byte order, polling model --- # HTTP Driver URL: https://docs.dc3.site/en/drivers/http `dc3-driver-http` onboards any HTTP/REST endpoint into IoT DC3 as a data source—it periodically calls REST endpoints, extracts one field from the JSON response as the [PointValue](../introduction/concepts/point-value), and supports write commands that push values via a request-body template. After reading this you can decide which devices/platforms fit it, what each attribute should hold, and where to look when a connection fails. ## Protocol background HTTP (HyperText Transfer Protocol) and the REST-style endpoints built on it are the internet's most universal request/response protocol: a client issues a **method** (`GET`/`POST`/`PUT`/`DELETE`) against a **resource path**, and the server returns a status code and a payload (usually JSON in IoT scenarios). It has simple connectionless semantics, native support in virtually every language and tool, and easy debugging—which makes it the "greatest common divisor" of system integration. In the four-layer IoT reference architecture, HTTP belongs to the **network layer**, in the **application-layer messaging protocol** family (alongside MQTT, CoAP, LwM2M)—it defines "what a message looks like and how it is delivered," not which radio the bytes travel over. But to be honest: HTTP has bulky headers, costly keep-alives, and was not designed for constrained devices, so it is **not** a good fit for high-frequency reporting from battery-powered endpoints. Its real place in IoT is **integrating with existing endpoints**—open REST APIs from third-party platforms, RESTful interfaces built into devices, and data gateways that aggregate field data into an HTTP endpoint. These "the upstream already speaks REST, I just need to poll it" cases are exactly what this driver is for. For HTTP versus MQTT/CoAP/LwM2M trade-offs, see the [IoT network layer chapter](../foundations/iot-protocols). This driver acts as an HTTP client ([Driver](../introduction/concepts/driver) type `DRIVER_CLIENT`), using Spring WebFlux `WebClient` to call endpoints by the path and method configured on each [Point](../introduction/concepts/point), then extracting one value from the JSON response. Two driver-specific concepts recur below: - **Response Path**: a simple dot-notation path locating a field in the response JSON, e.g. `$.data.temperature` picks `temperature` under the `data` object. Leave it empty to use the whole raw response as the value. - **Body Template**: the request body template used when writing; the `${value}` placeholder is replaced with the actual value from the command parameter. ## Attribute configuration Attributes are filled in two layers: **driver attributes** (`driver-attribute`, device-level, deciding which service to connect to) and **point attributes** (`point-attribute`, deciding which path each point calls, which method to use, and which field to extract—used by both read and write). `application.yml` also declares **command attributes** ( `command-attribute`), but the current `write()` does not consume them (see the Command attributes section below). For where these layers come from and how they override, see [Attributes & Config](../introduction/concepts/attribute-config). All defaults are taken from the driver's `application.yml`. ### Driver attributes (device-level `driver-attribute`) When onboarding an HTTP data source, fill in these attributes on the [Device](../introduction/concepts/device). They decide which service to connect to, the headers, and the timeout—every point under the same device shares this connection: | Attribute | code | Type | Default | Remark | |-----------|-----------|--------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Base URL | `baseUrl` | STRING | (empty) | Base URL for API requests (e.g. `https://api.example.com`) | | Method | `method` | STRING | `GET` | Declared default HTTP method, but the current implementation does not read this attribute (see the warning below); the actual method is decided by the point attribute only | | Headers | `headers` | STRING | (empty) | Custom headers as JSON (e.g. `{"Authorization":"Bearer xxx"}`) | | Timeout | `timeout` | INT | `5000` | Request timeout in milliseconds, applied as `responseTimeout` | `baseUrl` is required—the driver uses it as the `WebClient` base URL and prefixes every point path with it; without it the driver's `validate()` fails. `timeout` defaults to `5000` ms and lands on the Reactor Netty `HttpClient` `responseTimeout`. ::: warning The Headers attribute currently has no effect `headers` is declared in `application.yml`, but the current `getConnector()` only sets a fixed `Content-Type: application/json` on the `WebClient`—it does **not** read or apply the `headers` attribute. Endpoints that need `Authorization` or other custom headers cannot be onboarded by this attribute alone for now. ::: ::: warning The driver-level Method attribute currently has no effect `method` is declared as a driver-level attribute in `application.yml`, but `getConnector()` reads only `baseUrl` and `timeout`—it never reads the driver-level `method`. The method in `read()`/`write()` comes solely from the point attribute `method`, falling back to the hard-coded `GET`. So setting `method=POST` on the device has no effect—like `headers`, it is a "declared but not applied" attribute. The HTTP method is decided only by the point attribute `method`. ::: ### Point attributes (`point-attribute`) On each polled [Point](../introduction/concepts/point), fill in: which path to call, which method to use, (for writes) what body to send, and which field to extract. The HTTP method actually used by read/write comes solely from the point's `method`, falling back to the hard-coded `GET` when absent; it is independent of the driver-level `method` attribute ( which the implementation does not read, see above): | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|---------|----------------------------------------------------------------------------| | Path | `path` | STRING | (empty) | API path (e.g. `/api/v1/sensor/{id}`) | | Method | `method` | STRING | `GET` | HTTP method for this point, falling back to the hard-coded `GET` | | Body Template | `bodyTemplate` | STRING | (empty) | Request body template with `${value}` placeholder | | Response Path | `responsePath` | STRING | (empty) | Path to extract a value from the JSON response (e.g. `$.data.temperature`) | ::: tip path is appended after baseUrl The actual request URL is `baseUrl + path`. For example, with `baseUrl=https://api.example.com` and `path=/api/v1/sensor/1`, the driver requests `https://api.example.com/api/v1/sensor/1`. ::: ::: warning Response Path is simple dot-notation, not full JSONPath The driver strips the `$.` prefix and drills down field-by-field on `.` from the root object; it only supports paths like `$.a.b.c`. It does **not** support array indices (`[0]`), filters, wildcards, or other full JSONPath syntax. Reaching an array element, or a path that doesn't resolve to a field, makes the driver fall back to returning the whole raw response—usually the root cause of a PointValue that "looks wrong". When the response is itself a bare value (a plain number or string), leave `responsePath` empty and the driver uses the entire response as the value. ::: ::: warning Writing relies on the Body Template A write command does not automatically place the value into the request body. You must author a template with `${value}` in the point's `bodyTemplate` (e.g. `{"value":${value}}`) for the driver to substitute the command parameter and send it; an empty template sends an empty request body. ::: ### Command attributes (`command-attribute`) ::: warning Command attributes are not consumed by the write path `application.yml` declares `path` and `method` (`default-value: POST`) under `command-attribute`, but the SPI `write()` signature only receives `driverConfig` and `pointConfig`—it is never passed a command-attribute map. `write()` actually reads `path`/`method`/`bodyTemplate` from the point attribute (`pointConfig`), and `method` falls back to the hard-coded `GET` (not `POST`). So the `command-attribute` entries below are declared-but-inert dead config today; put the write path and method on the point attribute instead. ::: The table below shows the values declared in `application.yml` (not read by the current `write()`): | Attribute | code | Type | Default | Remark | |-----------|----------|--------|---------|-----------------------------------------------------------| | Path | `path` | STRING | (empty) | API path for the command (not read by `write()` today) | | Method | `method` | STRING | `POST` | HTTP method for the command (not read by `write()` today) | ### Polling & health These are defined under `dc3.driver.schedule` and `health` in `application.yml`; you don't fill them on the device: - **Polling interval**: the `application.yml` baseline read cron is `0/30 * * * * ?` (read once every 30 seconds); the default-active `dev` profile overrides it in `application-dev.yml` to `0/5 * * * * ?` (every 5 seconds), so the out-of-the-box polling interval is 5 seconds. - **Health check**: device health-check cron `0/15 * * * * ?` with a lease timeout of `45 seconds` —see [Device](../introduction/concepts/device) for the online-state mechanism. - **Online decision**: `health()` decides online by "whether a `WebClient` exists for the device in `clientMap`"—a connection is built on the first successful read/write, after which the device is online; on a read/write exception the driver does `clientMap.remove(deviceId)`, dropping the connection so it is rebuilt next round. ## Troubleshooting ::: warning Cannot connect / always offline First confirm `baseUrl` is reachable: run `curl <baseUrl><path>` on the driver host to verify connectivity and the port. The driver decides online by "whether a `WebClient` was built"; a first failed read/write immediately drops the connection, so a wrong `baseUrl`, an unresolvable DNS, or a firewalled target port all show up as the device staying offline. ::: ::: warning Request timeouts The default `timeout=5000` ms applies to the response timeout. A slow endpoint or network jitter triggers the timeout, fails this read/write round, and drops the connection. Measure the real round-trip with `curl -w '%{time_total}'` to tell whether the endpoint or the link is slow, then raise `timeout` accordingly. ::: ::: warning PointValue looks wrong / always the whole JSON Most likely `responsePath` didn't match. The driver only understands `$.a.b.c` dot paths; a wrong path, mismatched field-name case, or trying to reach an array element (`$.list[0].v`) all fail to resolve and make the driver **fall back to the whole raw response**. Check field names level by level against the real response; reaching an element inside an array is not possible in the current implementation. ::: ::: warning 401/403 from authenticated endpoints The current implementation does not apply the `headers` attribute (see the warning in the driver-attributes table). Endpoints requiring `Authorization`, `X-Api-Key`, etc. cannot be onboarded by device attributes alone and will be rejected with 401/403. ::: ::: warning Write command errors or has no effect Writes are rendered through the point's `bodyTemplate`: an empty template sends an **empty body**, which an endpoint expecting a JSON body will reject. Make sure `bodyTemplate` contains the `${value}` placeholder and renders to valid JSON the endpoint accepts; on failure the driver throws `WritePointException` and drops the connection. ::: ## Landing in IoT DC3 - **dc3.driver.code**: `HttpDriver` (driver name `HTTP REST Client Driver`, type `DRIVER_CLIENT`). This code is a stable routing identifier and must not be changed casually. - **Read**: ✓ implemented. Periodically calls the endpoint by the point's `path`/`method`, extracting a value via `responsePath`. - **Write**: ✓ implemented. Substitutes `${value}` in the point's `bodyTemplate` with the command parameter, then sends the request. - **Subscribe/report**: — not supported. HTTP is request/response, the driver always initiates, there is no passive push channel. These read/write capabilities match the `HTTP (HttpDriver)` row in the [driver capability matrix](./matrix). ::: info Implementation status: available `HttpDriverCustomServiceImpl` fully implements `initial()`/`read()`/`write()`/`health()`/`validate()`; it is a mature driver ready for collection. Two implementation boundaries to know: (1) `responsePath` supports only simple dot paths, not arrays/filters (above); (2) the `headers` attribute is declared but not yet applied to the connection, so custom request headers don't currently take effect. ::: ### Minimal onboarding example Onboard an endpoint that returns `{"data":{"temperature":25.6}}`: 1. Create a [Device](../introduction/concepts/device) with `HTTP REST Client Driver`, and set the driver attributes `baseUrl=https://api.example.com`, `method=GET`, `timeout=5000`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `path=/api/v1/sensor/1`, `method=GET`, `responsePath=$.data.temperature`. 3. Start the driver, and within a few seconds the extracted `25.6` shows up in the [PointValue](../introduction/concepts/point-value) (the default `dev` profile polls every 5 seconds). ## Further reading - [Driver overview](./index) — all driver groups and the selection entry point - [Driver capability matrix](./matrix) — read/write/subscribe capabilities at a glance - [Device Onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [IoT network layer chapter](../foundations/iot-protocols) — where HTTP sits among MQTT/CoAP/LwM2M and the trade-offs --- # IEC 104 Driver URL: https://docs.dc3.site/en/drivers/iec104 <script setup> import Iec104Diagram from '../../.vitepress/theme/components/Iec104Diagram.vue' </script> # IEC 104 Driver `dc3-driver-iec104` connects IEC 60870-5-104 telecontrol equipment to IoT DC3: it connects as a 104 client to substation/dispatch-automation devices, collects telemetry and status by **Information Object Address (IOA)**, and supports sending telecontrol commands. By the end of this page you will understand how 104 addresses data, how to fill the protocol attributes on a device and its points correctly, and exactly where this driver's implementation currently stops. > You are here: the "power telecontrol / SCADA" onboarding side of field devices. To understand why industrial protocols > are vendor-proprietary and where IEC 104 sits in the network layer, start > with [Industrial Buses & Protocols](../foundations/fieldbus). ## Protocol Background IEC 60870-5-104 (IEC 104 for short) is the international standard telecontrol protocol for power-system dispatch automation, carrying the IEC 60870-5-101 application layer over standard TCP/IP. It is widely used for **"four-remote" communication** — telemetry, telesignaling, telecontrol, telesetpoint — between substation integrated-automation systems, distribution terminals (DTU/FTU), RTUs, and master stations. In power dispatch it belongs — alongside building-automation BACnet and utility-metering DLMS/COSEM — to the camp of protocols standardized to industry-specific needs. IEC 104 differs from a protocol like Modbus in its addressing model; it locates and interprets a data point with two concepts: - **Information Object Address (IOA)** uniquely locates one data point in the telecontrol device (a telemetry value, a status point). - **ASDU type** describes the data semantics of the frame, e.g. `M_ME_NC_1` (short-float telemetry), `M_SP_NA_1` ( single-point status) — the same IOA with a different ASDU type yields data with a different meaning. 104 frames have no field delimiters; how many bytes the common address, cause of transmission, and information object address each occupy is agreed between the master and the telecontrol device during engineering configuration (typically `2/2/3`). This model of "split by byte width, locate by IOA" means the byte-length configuration must match the peer exactly. In the four-layer IoT architecture, IEC 104 belongs to the industrial wired side of the **network layer**: it solves " how a field telecontrol device sends a telemetry/status point out over TCP/IP and receives a telecontrol command," sitting above the sensing layer (transformers/transducers) and below the platform layer (IoT DC3 aggregation). The diagram below places the 104 client within a single collection: <Iec104Diagram lang="en" /> The driver acts as a client and actively connects to a 104 server, locating which point to read by the `ioa` configured on the point and interpreting the bytes by `asduType`, then unifies it as a [PointValue](../introduction/concepts/point-value) sent upstream. ## Attribute Configuration IEC 104 onboarding parameters come in two layers: **driver attributes (driver-attribute)** describe "which telecontrol device, which port, how many bytes per field" and are filled on the [device](../introduction/concepts/device); **point attributes (point-attribute)** describe "which IOA, interpreted as which ASDU type" and are filled on each [Point](../introduction/concepts/point). Writable points add a **command attribute (command-attribute)**. The defaults of all of these come from the driver's `application.yml`; for the three-layer origin see [Attributes and Config](../introduction/concepts/attribute-config). ### Driver Attributes (device-level `driver-attribute`) When onboarding an IEC 104 device, first state its network location and frame-field convention on the device. `host` / `port` decide where TCP connects; `asduAddress` (common address, a.k.a. station address) distinguishes multiple logical stations under the same connection; `cotLength` / `caLength` / `ioaLength` are the byte widths of the cause-of-transmission, common-address, and information-object-address fields in a 104 frame; `connectTimeout` bounds how long the connect waits. | Attribute | code | Type | Default | Description | |-----------------|------------------|--------|-------------|------------------------------------------------------------------| | Host | `host` | STRING | `localhost` | 104 server IP (telecontrol device address) | | Port | `port` | INT | `2404` | 104 TCP port (standard 2404) | | ASDU Address | `asduAddress` | INT | `1` | Common address (station address), distinguishes logical stations | | COT Length | `cotLength` | INT | `2` | Cause-of-transmission field byte count | | CA Length | `caLength` | INT | `2` | Common-address field byte count | | IOA Length | `ioaLength` | INT | `3` | Information-object-address field byte count | | Connect Timeout | `connectTimeout` | INT | `10000` | Connect timeout (milliseconds) | ::: tip COT/CA/IOA lengths are a station-wide convention and must match the peer `cotLength` / `caLength` / `ioaLength` are the byte widths of the respective fields in 104 frames, agreed between the master and the telecontrol device during engineering configuration (typically `2/2/3`). These three must match the peer exactly, otherwise the frame is split on the wrong byte boundaries and addresses are read off misaligned bytes. `asduAddress` (common address) distinguishes multiple logical stations under the same `host:port` connection. ::: ::: info `host` / `port` / `asduAddress` are validated as required `validate()` lists `host`, `port`, and `asduAddress` as required; missing any one reports an `ERROR` at validation and the device config does not pass. The remaining byte-length attributes have defaults and use the table values above when omitted. ::: ### Point Attributes (`point-attribute`) Each collected point must state which information object to read and which ASDU type to interpret it with — the driver does not probe the device for the type; it interprets bytes strictly by the `asduType` you set. | Attribute | code | Type | Default | Description | |-----------|------------|--------|-------------|-------------------------------------------------------------| | IOA | `ioa` | INT | `0` | Information object address, uniquely locates one data point | | ASDU Type | `asduType` | STRING | `M_ME_NC_1` | ASDU type identifier, defines the data semantics | ::: tip IOA locates "which point to read", ASDU type defines the data semantics `ioa` is the information object address, uniquely identifying one data point in the telecontrol device. `asduType` indicates the point's data type, defaulting to `M_ME_NC_1` (short-float telemetry); status points commonly use `M_SP_NA_1` (single-point status). The Point's own data type ([Point](../introduction/concepts/point) `pointTypeFlag`) should match the actual data carried by the ASDU type. `validatePoint()` lists `ioa` as required. ::: ### Command Attribute (`command-attribute`) Writable points (issuing telecontrol) add a send template on the write command. | Attribute | code | Type | Default | Description | |--------------|---------------|--------|------------|--------------------------------------------------------| | Send Command | `sendCommand` | STRING | `${value}` | Send-command template, rendered from command arguments | ::: tip sendCommand is a template with parameter placeholders `sendCommand` uses `${paramName}` placeholders; during `execute()` the driver substitutes the command arguments (e.g. `${value}`) one by one, and the default `${value}` simply uses the command value as the control value. Note: `execute()` currently only **renders the template and returns it** under the `sendCommand` key of the result map — it does not actually send the telecontrol frame to the 104 server (see implementation status below). ::: ### Collection and Health - **Collection cycle**: default read cron `0/30 * * * * ?` (reads once every 30 seconds). - **Custom schedule**: `schedule.custom` is enabled in the yml (cron `0/5 * * * * ?`), but the current `schedule()` method body is empty and performs no custom logic. - **Health / online**: device health check default cron `0/15 * * * * ?`, lease timeout `45 seconds` — see [Device](../introduction/concepts/device) for the online-state mechanism. ## Troubleshooting IEC 104 onboarding failures mostly fall into two buckets: "cannot connect" and "frame parsing is misaligned." Work through them from the outside in. Note: this driver's protocol read/write is currently a skeleton (see the next section), so the checks below target onboarding parameters and network reachability, not a running state that already collects values. ::: warning Port or firewall: 2404 unreachable 104 runs over TCP `2404` by standard — different from Modbus `502`, EtherNet/IP `44818`, and DLMS `4059`, so do not mix them up. First confirm `host:2404` is reachable from the driver host (`telnet <host> 2404` or `nc -vz <host> 2404`). Common causes: the 104 service not enabled on the telecontrol device, network not routed, firewall blocking 2404. The connect timeout is controlled by `connectTimeout` (default `10000` ms). ::: ::: warning Mismatched COT/CA/IOA lengths = whole-frame parse misalignment 104 frames have no field delimiters and are split purely by the agreed byte widths. If `cotLength` / `caLength` / `ioaLength` differ from the peer, addresses get read off the wrong bytes, so you collect the wrong point or parsing fails outright. Before onboarding, confirm the peer's `2/2/3` (or other) configuration with operations and fill all three in exactly. ::: ::: warning Wrong common address (asduAddress) connects to the wrong logical station One RTU may host several logical stations distinguished by `asduAddress` (common address). When several devices share the same `host:port`, each one's `asduAddress` decides which station to connect to / read. If `asduAddress` is wrong, the expected points will not be found in the peer's interrogation response. Confirm each logical station's common address with operations before onboarding. ::: ::: warning ASDU type does not match the point's real type The driver interprets bytes strictly by the `asduType` set on the point. Configuring a short-float telemetry point ( `M_ME_NC_1`) as single-point status (`M_SP_NA_1`) makes the decoded value meaningless. Confirm the ASDU type of each IOA before onboarding, and make the point's `pointTypeFlag` match (telemetry is usually `FLOAT`, status is usually `BOOLEAN`). ::: ::: info Device online state does not mean a value has been collected Device online/offline is maintained by the lease mechanism (default `45 seconds` timeout) and reflects the heartbeat between the driver and the platform, not that data was actually read over the 104 link. To judge whether a value is being collected, rely on whether the [PointValue](../introduction/concepts/point-value) updates. With the protocol read/write not yet implemented (see the next section), no PointValue will update. ::: ## How It Lands in IoT DC3 - **dc3.driver.code**: `Iec104Driver` (a stable routing identifier — registration and message routing both rely on it, do not change it casually). Driver name `IEC 104 Driver`, type `DRIVER_CLIENT` (the driver actively connects to the telecontrol device). - **Read**: `read()` is **not implemented** — it throws `ReadPointException` to fail fast (so the SDK records the failure and applies backoff, rather than echoing a cached value or faking success). - **Write**: `write()` is **not implemented** — it throws `WritePointException` to fail fast. - **Telecontrol command**: `execute()` is **partially implemented** — it only renders the `sendCommand` template from arguments and returns the result; it does not yet send the telecontrol frame to the 104 server. - **Subscribe / push**: not applicable. This driver follows a client model and reads actively on the collection cycle; it does not listen for device-initiated pushes. Aligned with the [Driver Capability Matrix](./matrix): in the matrix IEC 104 is marked `—` for read/write/subscribe, with the note "Power SCADA, skeleton pending." ::: warning Work in progress (skeleton) This driver is a protocol template skeleton: the attribute tables, collection cycle, and IOA/ASDU addressing semantics are in place and safe to fill in, but the **104 protocol-layer I/O is not yet implemented**: - `read()` / `write()` throw a "not implemented" exception to fail fast, performing no IOA read or telecontrol send; - `execute()` only renders the `sendCommand` template and returns — it does not actually send a frame; - `initial()`, `schedule()`, and `event()` have empty bodies, with no custom init/schedule/metadata-event logic; - the only implemented logic is configuration validation (`validate()` / `validatePoint()`) and command-template rendering. Treat it as a starting template for onboarding a 104 device, not a production-ready driver. For the final behavior, consult the `read()` / `write()` / `initial()` source in `Iec104DriverCustomServiceImpl`. ::: The minimal path to onboard a telecontrol device (to validate the config flow, not for production collection): 1. Create a [device](../introduction/concepts/device) with `IEC 104 Driver`, and set the driver attributes `host=192.168.1.30`, `port=2404`, `asduAddress=1` (leave `cotLength` / `caLength` / `ioaLength` at the defaults `2/2/3`, as long as they match the peer's convention). 2. Add a telemetry [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `ioa=16385`, `asduType=M_ME_NC_1`. 3. Start the driver and watch the connect and validation logs. Until the protocol layer is completed, the 30-second read round fails fast (with exception backoff); once completed, the collected value appears in [PointValue](../introduction/concepts/point-value). For the complete onboarding procedure, see [Device Onboarding](../operation/device-onboarding). ## Further Reading - [Drivers Overview](./index) — entry point for driver categories and selection - [Driver Capability Matrix](./matrix) — read/write/subscribe capabilities and implementation status at a glance - [Device Onboarding](../operation/device-onboarding) — a complete device onboarding flow - [Industrial Buses & Protocols](../foundations/fieldbus) — the industrial wired side of the network layer; how IEC 104 and other industry-standard protocols are positioned and addressed --- # IEC 61850 Driver URL: https://docs.dc3.site/en/drivers/iec61850 `dc3-driver-iec61850` acts as an IEC 61850 MMS client. It maintains one MMS association per IED device and reads/writes data attributes addressed by an object reference and functional constraint (e.g. `S1MMXU1.TotW.actVal` / `MX`). ## Protocol background IEC 61850 is the standard for substation automation. Data objects are organized in a server model by logical device, logical node, and data object; the MMS protocol retrieves their values. This driver resolves an object reference and functional constraint, fetches the data values, and returns the first basic data attribute value. - **Driver name / code**: `IEC 61850 Driver` / `Iec61850Driver` - **Type**: `DRIVER_CLIENT (MMS client to an IED server)` - **Underlying library**: OpenMUC `openiec61850` ## Attribute configuration ### Driver attributes (device-level `driver-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Host | `host` | STRING | (empty) | IEC 61850 server (IED) address | | Port | `port` | INT | `102` | MMS service port | ### Point attributes (`point-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Object Reference | `objectReference` | STRING | (empty) | Data object reference, e.g. S1MMXU1.TotW.actVal | | Functional Constraint | `functionalConstraint` | STRING | `MX` | MX, ST, CO, SP, SE | ### Command attributes (`command-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Object Reference | `objectReference` | STRING | (empty) | Data object reference for commands | ## Collection and health - **Collection cycle**: default cron `0/30 * * * * ?`. - **Health/online**: device health defaults to cron `0/15 * * * * ?`, lease timeout `45 seconds`. ## Capability matrix | Capability | Supported | Notes | |------------|-----------|-------| | Read | ✓ | | | Write | ✓ (boolean/int/float/visible-string by data type) | | | Subscribe | — | | ::: info Implementation status: available :: The server model is retrieved once per association and cached; reads call `getDataValues` and take the first `BasicDataAttribute` value via `getValueString()`. ## Minimal onboarding example 1. Create a Device using `IEC 61850 Driver`, set `host=<ied-address>` and `port=102`. 2. Add a Point (`READ_ONLY`) with `objectReference=S1MMXU1.TotW.actVal` and `functionalConstraint=MX`. 3. Start the driver; the measurement is polled on the collection cycle. ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough --- # Drivers URL: https://docs.dc3.site/en/drivers/ > IoT DC3 ships **36 protocol drivers** covering industrial buses, PLC/SCADA, IoT, databases, and virtual testing. Each > driver is a standalone service (`dc3-driver-*`) that registers itself and > the [config attributes](../introduction/concepts/attribute-config) it accepts with the manager on startup, then > reads [points](../introduction/concepts/point) and writes via [commands](../introduction/concepts/command). For the general onboarding flow see [Device Onboarding](../operation/device-onboarding); for the driver model see the [Driver](../introduction/concepts/driver) concept. Pick your protocol by category below: ## Industrial Bus / PLC / SCADA | Driver | Protocol | Notes | |------------------------------|-------------------|---------------------------------| | [Modbus TCP](./modbus-tcp) | Modbus TCP | Ethernet Modbus master | | [Modbus RTU](./modbus-rtu) | Modbus RTU | Serial Modbus master | | [OPC UA](./opc-ua) | OPC UA | OPC Unified Architecture client | | [OPC DA](./opc-da) | OPC DA | Classic OPC Data Access | | [S7](./plcs7) | Siemens S7 | Siemens PLC | | [MELSEC](./melsec) | Mitsubishi MELSEC | Mitsubishi PLC | | [FINS](./fins) | Omron FINS | Omron PLC | | [EtherNet/IP](./ethernet-ip) | EtherNet/IP (CIP) | Rockwell / CIP | | [BACnet/IP](./bacnet-ip) | BACnet/IP | Building automation | | [IEC 104](./iec104) | IEC 60870-5-104 | Power SCADA | | [DLMS](./dlms) | DLMS / COSEM | Smart meters | | [SL651](./sl651) | SL651 | Hydrology monitoring | | [SNMP](./snmp) | SNMP | Network device monitoring | | [DL/T645](./dlt645) | DL/T645-2007 | Electricity meter protocol | | [DNP3](./dnp3) | DNP3 (IEEE 1815) | Utility automation | | [IEC 61850](./iec61850) | IEC 61850 (MMS) | Substation automation | | [KNX](./knx) | KNX | Building automation bus | | [M-Bus](./mbus) | M-Bus (EN 13757) | Metering bus | ## IoT / Wireless | Driver | Protocol | Notes | |--------------------|--------------|---------------------------------| | [MQTT](./mqtt) | MQTT | IoT message bus | | [CoAP](./coap) | CoAP | RESTful for constrained devices | | [LwM2M](./lwm2m) | LwM2M | Lightweight device management | | [HTTP](./http) | HTTP | Generic HTTP polling | | [BLE](./ble) | Bluetooth LE | Low-energy Bluetooth | | [Zigbee](./zigbee) | Zigbee | Short-range wireless | | [CAN](./can) | CAN | Controller Area Network | | [LoRaWAN](./lorawan) | LoRaWAN | ChirpStack MQTT uplink ingest | | [Kafka](./kafka) | Apache Kafka | Streaming data source | ## Serial / Generic Network | Driver | Protocol | Notes | |----------------------|-----------|--------------------------| | [Serial](./serial) | Serial | Generic serial port | | [TCP/UDP](./tcp-udp) | TCP / UDP | Generic socket ingestion | ## Database | Driver | Source | Notes | |----------------------------|------------|-------------------------| | [MySQL](./mysql) | MySQL | Read points from tables | | [PostgreSQL](./postgresql) | PostgreSQL | Read points from tables | | [Oracle](./oracle) | Oracle | Read points from tables | | [SQL Server](./sqlserver) | SQL Server | Read points from tables | | [Redis](./redis) | Redis | Read points from keys | ## Virtual / Testing | Driver | Notes | |------------------------------------------|--------------------------------------------------------------------------| | [Virtual](./virtual) | Generate simulated data with no real device — for demos and load testing | | [Listening Virtual](./listening-virtual) | Listen on a port for device pushes — for integration testing | ## Further Reading - [Driver](../introduction/concepts/driver) — the general driver model and registration - [Attribute & Config](../introduction/concepts/attribute-config) — the three layers of driver / point / command attributes - [Device Onboarding](../operation/device-onboarding) — a full onboarding walkthrough - [Module Map](../architecture/modules) — where drivers sit in the overall architecture - [Industrial Buses & Protocols](../foundations/fieldbus) · [IoT Protocols & Wireless](../foundations/iot-protocols) — the systematic knowledge behind the protocols --- # Kafka Driver URL: https://docs.dc3.site/en/drivers/kafka `dc3-driver-kafka` treats Apache Kafka as a streaming data source. Inbound messages are consumed asynchronously and cached by message key; a point read returns the latest cached value, and a point write produces a message to the configured topic. ## Protocol background Kafka is a distributed publish/subscribe stream. Values arrive asynchronously, so this driver caches the latest message per key (or per topic when the message has no key) and serves reads from that cache while writing produces messages. - **Driver name / code**: `Kafka Driver` / `KafkaDriver` - **Type**: `DRIVER_SERVER (passively consumes messages and produces writes)` - **Underlying library**: Spring Kafka (`KafkaTemplate` + `@KafkaListener`) ## Attribute configuration ### Driver attributes (device-level `driver-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Topic | `topic` | STRING | `dc3-driver-kafka` | Default topic for produce/consume | ### Point attributes (`point-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Topic | `topic` | STRING | (empty) | Override topic for this point | | Key | `key` | STRING | (empty) | Message key used for produce and cache lookup | ### Command attributes (`command-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Topic | `topic` | STRING | (empty) | Override topic for this command | ## Collection and health - **Collection cycle**: default cron `0/30 * * * * ?` (one polling round over all points every 30 seconds). - **Health/online**: device health defaults to cron `0/15 * * * * ?`, lease timeout `45 seconds`. ## Capability matrix | Capability | Supported | Notes | |------------|-----------|-------| | Read | ✓ (latest cached message) | | | Write | ✓ | | | Subscribe | ✓ | | ::: info Implementation status: usable :: Broker connection is configured through Spring Boot `spring.kafka.*` (`KAFKA_BOOTSTRAP_SERVERS`); the consumer group id defaults to `dc3-driver-kafka-group`. ## Minimal onboarding example 1. Create a Device using `Kafka Driver`. 2. Add a Point (`READ_ONLY`) with `key=sensor-1` to read the latest message for that key. 3. Start the driver; messages are consumed from the topic and cached for reads. ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough --- # KNX Driver URL: https://docs.dc3.site/en/drivers/knx `dc3-driver-knx` connects to KNX (ISO/IEC 14543-3) installations through a KNX IP gateway. It maintains one tunneling link per gateway device and reads/writes group addresses as boolean, unsigned, float, or control values. ## Protocol background KNX is the standard for home and building automation. Group addresses link sensors and actuators; an IP gateway exposes the bus over KNXnet/IP tunneling. This driver reads group values by datapoint type and writes them back through the gateway. - **Driver name / code**: `KNX Driver` / `KnxDriver` - **Type**: `DRIVER_CLIENT (tunneling to a KNX IP gateway)` - **Underlying library**: Calimero (`calimero-core`) ## Attribute configuration ### Driver attributes (device-level `driver-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Remote Host | `remoteHost` | STRING | (empty) | KNX IP gateway address | | Remote Port | `remotePort` | INT | `3671` | KNX IP gateway port | | Local Host | `localHost` | STRING | (empty) | Local bind address (optional) | | Use NAT | `useNat` | BOOLEAN | `false` | Enable NAT mode for tunneling | | Device Address | `deviceAddress` | STRING | `0.0.0` | Local KNX individual address | ### Point attributes (`point-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Group Address | `groupAddress` | STRING | (empty) | KNX group address, e.g. 1/2/3 | | Data Type | `dataType` | STRING | `BOOL` | BOOL, UINT, FLOAT, or CONTROL | | DPT | `dpt` | STRING | (empty) | Datapoint type for UINT reads/writes, e.g. 5.001 | ### Command attributes (`command-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Group Address | `groupAddress` | STRING | (empty) | KNX group address for commands | ## Collection and health - **Collection cycle**: default cron `0/30 * * * * ?`. - **Health/online**: device health defaults to cron `0/15 * * * * ?`, lease timeout `45 seconds`. ## Capability matrix | Capability | Supported | Notes | |------------|-----------|-------| | Read | ✓ | | | Write | ✓ | | | Subscribe | — | | ::: info Implementation status: available :: `KnxDriverCustomServiceImpl` caches a `KNXNetworkLink` + `ProcessCommunicator` per device, and a device metadata UPDATE/DELETE event closes the link. ## Minimal onboarding example 1. Create a Device using `KNX Driver`, set `remoteHost=<gateway-ip>` and `deviceAddress=1.1.0`. 2. Add a Point (`READ_ONLY`) with `groupAddress=1/2/3` and `dataType=BOOL`. 3. Start the driver; the group value is polled on the collection cycle. ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough --- # Listening Virtual Driver URL: https://docs.dc3.site/en/drivers/listening-virtual <script setup> import ListeningVirtualDiagram from '../../.vitepress/theme/components/ListeningVirtualDiagram.vue' </script> # Listening Virtual Driver > `dc3-driver-listening-virtual` onboards TCP/UDP devices that push data into IoT DC3 on their own. The driver opens a > listening port, waits for devices to connect, then slices and parses values out of the pushed byte stream according to > each [point](../introduction/concepts/point)'s configuration. After this page you can configure your first listening > point and have a GPS/BeiDou-class terminal push binary frames into the platform. Some field devices don't wait to be polled; they periodically push data out by themselves: GPS trackers, environmental monitoring boxes, and assorted sensors speaking proprietary binary frames. The common pattern is to connect to a fixed IP:port and send a byte stream. This driver is built for exactly that case—it is a **listening (passive) driver** that starts both a TCP and a UDP listening port; the device connects in and pushes data, and the driver parses the byte stream into [point values](../introduction/concepts/point-value). It never actively "reads" a device. ## Protocol Background This is a **virtual / testing driver** with no real standard protocol layer—the packet format is a minimal binary convention defined by the driver itself, meant to demonstrate and exercise the "device-initiated push" path end to end. In the four-layer IoT architecture it sits at the **network layer**: TCP/UDP carries the device-to-platform byte stream, which the platform receives passively. In a real project you can use it as a template and drop in your own parsing logic for a proprietary reporting protocol. It fits: - GPS/BeiDou terminals with their own reporting logic, and sensor gateways that push binary frames; - any proprietary TCP/UDP protocol where the client connects to a passive server; - validating the full "push → parse → store" path when no real hardware is available. Before you start, two driver-specific concepts come first, since the config tables use them repeatedly: - **Keyword**: the single byte right after the device name in the pushed packet, written in hexadecimal (e.g. `62`). One device can use different keywords to distinguish packet types; the driver uses it to decide which point a given frame should be parsed into. - **Byte range (Start / End)**: the byte offsets at which data is sliced out of the packet. `start` is the start offset (inclusive), `end` is the end offset (exclusive); fixed-length numeric points only read a fixed number of bytes from `start`, while the `coordinate` string point uses the whole `start..end` range. Every packet has a fixed structure: a 22-byte device name + a 1-byte keyword + a variable-length payload. <ListeningVirtualDiagram lang="en" /> - The first 22 bytes are the device name; the driver parses it into a [device](../introduction/concepts/device) ID via `Long.parseLong` (it must map one-to-one to a device on the platform). - The 23rd byte (offset 22) is the keyword, compared byte-for-byte against the point's `key`. - The rest is the payload; each point slices a segment from the packet per its own `start`/`end`, and the **parse type is decided by the point name (pointName)**, not by the `type` attribute. ## Attribute Configuration This driver **declares no device-level `driver-attribute`**—the listening ports are process-level configuration, and all onboarding detail lives on the [point](../introduction/concepts/point). **Driver name / code / type** (from `application.yml`): - Driver name / code: `Listening Virtual TCP/UDP Driver` / `ListeningVirtualDriver` - Type: `DRIVER_SERVER` (the driver acts as the listening server, passively receiving pushed data) **Listening ports** are process-level configuration, not set on the point: TCP defaults to `6270`, overridable via the `TCP_PORT` environment variable; UDP defaults to `6271`, overridable via `UDP_PORT`. At startup `initial()` launches one thread per port to listen; packets received on either port go through the same parsing logic. ### Point Attributes (`point-attribute`) Each collected [point](../introduction/concepts/point) carries these four [attributes](../introduction/concepts/attribute-config), telling the driver which keyword to match and which segment of the packet to slice (the parse type is decided by the point name, see the note below): | Attribute | code | Type | Default | Remark | |------------|---------|--------|----------|------------------------------------------------------------------------------| | Keyword | `key` | STRING | `62` | Packet identification keyword, hexadecimal | | Start Byte | `start` | INT | `0` | Inclusive start byte offset | | End Byte | `end` | INT | `8` | Exclusive end byte offset | | Type | `type` | STRING | `string` | Required attribute; presence-checked only, not used to choose the parse type | ::: tip key is hexadecimal, compared byte-for-byte with the packet keyword `key` holds the hexadecimal value of the packet's 23rd byte (e.g. the default `62`). When a frame arrives, the driver only parses points whose keyword matches; points whose keyword differs produce no value for that frame. Different points on the same device may share one `key` (extracting several fields from one frame) or use different keys (each frame handled separately). ::: ::: warning The parse type is decided by the point name, not the `type` attribute The driver chooses how to parse based on the **point name (pointName)** (see `NettyServerHandler.readConfiguredValue`), and only these 6 names are supported: `altitude`→float (4 bytes), `speed`→double (8 bytes), `level`→long (8 bytes), `direction`→int (4 bytes), `locked`→boolean (1 byte), `coordinate`→string (by `start..end`). The point name must be one of these, otherwise the point parses to an empty string for that frame and collects no data. The `type` attribute is only presence-checked in `validatePoint` (one of the four required); it is never read during parsing. ::: ## Troubleshooting When onboarding this driver, almost every "no value collected" case maps to one of the items below. When a frame is dropped the driver only `warn`s in its log—it does not return an error to the device—so check the driver log first. ::: warning The first 22 bytes of the packet must be the platform's numeric device ID The driver parses the device ID from the first 22 bytes via `Long.parseLong` to match a [device](../introduction/concepts/device) on the platform. When the device pushes data, it **must place the corresponding numeric device ID into these 22 bytes**—if no number can be parsed (`deviceIdInvalid`) or no device matches (`deviceMissing`), the frame is silently dropped without an error to the device. Create the device on the platform first to get its ID, then configure it into the device firmware. ::: ::: warning start/end offsets are relative to the whole frame, not the payload A point's `start`/`end` are byte offsets into the **whole frame**. The first 23 bytes are taken by the device name (22) and keyword (1), so the first payload byte is at offset `23`, not `0`. To read from the start of the payload, `start` must count from `23`; the default `start=0` lands inside the device name and reads the wrong value. ::: - **Keyword mismatch**: a point's `key` must exactly equal the hexadecimal of the packet's 23rd byte (e.g. `62`). Points whose `key` does not match are silently skipped for that frame; first confirm which byte the device actually sends. - **Byte order**: fixed-length numbers are read with Netty `ByteBuf`'s `getFloat/getDouble/getLong/getInt`, all * *big-endian**. If the device packs little-endian, the parsed numbers come out garbled—pack big-endian on the device side or adjust the parsing logic yourself. - **Packet too short / offset out of bounds**: a frame shorter than 23 bytes (`payloadTooShort`), or a point whose `start+length` exceeds the actual packet length (`payloadOutOfBounds`), yields no value for that point on that frame. Note UDP datagrams are not fragmented while TCP may coalesce/split—this driver parses the received `ByteBuf` as-is and does no framing. - **The device stays online regardless of whether data is pushed**: this driver **implements no protocol-level health decision** (it does not override `health()`), so the SDK's default reports the device as online unconditionally every `0/15 * * * * ?` and renews a `45`-second lease TTL. That means the device is **not** marked offline for "nothing pushed before the timeout"—online state is independent of data push. If you need "offline when nothing is pushed", override `health()` in the driver and return OFFLINE based on the last push time. See [device](../introduction/concepts/device) for the online-state mechanism. ## Landing It in IoT DC3 - **dc3.driver.code**: `ListeningVirtualDriver` (routing identifier, stable—do not change casually). - **Read / write / subscribe capability** (aligned with the [driver capability matrix](./matrix)): - **Read**: `—`, no active polling. `schedule.read.enable=false`, and `read()` returns `null` directly; data is entirely push-triggered by the device. The config enables a `0/5 * * * * ?` `schedule.custom` callback, but the driver's `schedule()` is an empty implementation that does nothing (no periodic self-upkeep logic). - **Write**: `✓`, `write()` is implemented. Any successfully parsed frame (TCP or UDP) registers that device's most recent `Channel` into `DEVICE_CHANNEL_MAP` (registration happens in `NettyServerHandler.read()`, shared by both TCP and UDP); on a write command it looks up the active channel by `deviceId` and writes the value bytes back to the device (5-second flush timeout). If the channel is missing or inactive the write fails and returns `false`. Note: if the device's most recently registered channel is a connectionless UDP channel, the write-back usually fails—write-back relies on the connection-oriented TCP channel. - **Subscribe / report**: `✓`, this is the driver's primary capability—the device connects in and the driver receives pushes passively. ::: info This is a virtual / testing driver with a self-defined packet format The driver as a whole works (listen, parse, write-back are all implemented), but it has no standard protocol layer: the packet format (22+1+payload), the 6 fixed point names, and the big-endian numeric parsing are demonstration conventions defined by the driver itself. To onboard a proprietary protocol in a real project, replace the parsing logic in `NettyServerHandler` with your protocol's rules and treat this as an implementation template for a passive listening driver. ::: ### Minimal Onboarding Example Onboard a GPS terminal that pushes data over TCP: 1. Create a [device](../introduction/concepts/device) with `Listening Virtual TCP/UDP Driver` (this driver has no driver attributes, so the device needs no connection parameters), and note the numeric device ID the platform assigns. 2. Add a string [point](../introduction/concepts/point) to the [profile](../introduction/concepts/profile) bound to the device; the **point name must be one of the supported names** (here `coordinate`, which parses as a string), with point attributes `key=62`, `start=23`, `end=31`, `type=string`—i.e. match packets with keyword `62` and take 8 bytes from the start of the payload as a string. If the point name is not one of `altitude/speed/level/direction/locked/coordinate`, the driver collects no value. 3. Start the driver; have the device push "22-byte numeric device ID + 1-byte `0x62` + payload" to the driver's TCP port `6270`, and within seconds the parsed result appears in [point values](../introduction/concepts/point-value). ## Further Reading - [Drivers Overview](./index) — what a driver is, registration and lifecycle, the three-tier origin of config - [Driver Capability Matrix](./matrix) — read/write/subscribe across 28 drivers, to confirm this driver's positioning - [Device Onboarding](../operation/device-onboarding) — a full device onboarding walkthrough --- # LoRaWAN Driver URL: https://docs.dc3.site/en/drivers/lorawan `dc3-driver-lorawan` ingests LoRaWAN uplinks by subscribing to ChirpStack MQTT topics (`application/+/device/+/event/up`). It decodes the JSON payload, caches the latest FRMPayload (base64) and Cayenne LPP object fields per DevEUI, and publishes downlink commands to the ChirpStack `command/down` topic. ## Protocol background LoRaWAN devices send uplinks through a gateway to a network server (ChirpStack). ChirpStack exposes uplinks over MQTT; this driver subscribes to those topics, matching points by DevEUI. A point field selects a Cayenne LPP object key; an empty field returns the raw base64 payload. - **Driver name / code**: `LoRaWAN Driver` / `LorawanDriver` - **Type**: `DRIVER_SERVER (subscribes to ChirpStack MQTT uplinks)` - **Underlying library**: Eclipse Paho MQTT v3 + Jackson ## Attribute configuration ### Driver attributes (device-level `driver-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Application ID | `applicationId` | STRING | (empty) | ChirpStack application ID for downlink commands | | Broker URI | `brokerUri` | STRING | `tcp://dc3-mqtt:1883` | MQTT broker URI for ChirpStack events | | Subscribe Topic | `topic` | STRING | `application/+/device/+/event/up` | MQTT uplink topic filter | | Username | `username` | STRING | (empty) | MQTT broker username (optional) | | Password | `password` | STRING | (empty) | MQTT broker password (optional) | ### Point attributes (`point-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | DevEUI | `devEui` | STRING | (empty) | LoRaWAN device EUI (16 hex characters) | | Field | `field` | STRING | (empty) | Cayenne LPP object field; empty returns raw base64 | ### Command attributes (`command-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | DevEUI | `devEui` | STRING | (empty) | LoRaWAN device EUI for downlink | ## Collection and health - **Collection cycle**: default cron `0/30 * * * * ?`. - **Health/online**: device health defaults to cron `0/15 * * * * ?`, lease timeout `45 seconds`. ## Capability matrix | Capability | Supported | Notes | |------------|-----------|-------| | Read | ✓ (latest cached uplink) | | | Write | ✓ (downlink publish) | | | Subscribe | ✓ | | ::: info Implementation status: usable :: The MQTT connection is established lazily on first read/write, so startup tolerates a temporarily unreachable broker; `messageArrived` parses `deviceInfo.devEui`, `data`, and the Cayenne LPP `object`. ## Minimal onboarding example 1. Create a Device using `LoRaWAN Driver`, set `applicationId=<your-app-id>` and `brokerUri` to your ChirpStack MQTT broker. 2. Add a Point (`READ_ONLY`) with `devEui=<device-eui>` and `field=temperature`. 3. Start the driver; the next uplink is cached and served as the point value. ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough --- # LwM2M Driver URL: https://docs.dc3.site/en/drivers/lwm2m <script setup> import Lwm2mDiagram from '../../.vitepress/theme/components/Lwm2mDiagram.vue' </script> # LwM2M Driver `dc3-driver-lwm2m` embeds an Eclipse Leshan LwM2M server: devices act as clients and register with their own `endpoint` name, and the driver then reads/writes resources on that endpoint by the three-part `Object / Object Instance / Resource` path configured on each point. This page explains which protocol it speaks, which attributes to fill in, how to troubleshoot a failed connection, and its real implementation status in IoT DC3. ## Protocol Background LwM2M (Lightweight M2M) is an OMA-defined protocol for IoT **device management plus data collection**. It does not start from scratch—it is **built on top of CoAP**, running over UDP (default `5683`, `5684` for DTLS/CoAPS encryption) and supplying the "device management" layer that CoAP lacks. In the [four-layer IoT reference architecture](../foundations/iot-protocols), it sits—like CoAP and MQTT—among the * *network layer's application messaging protocols**: defining "what a message looks like, how it is delivered, and how reliable it is," independent of whether Wi-Fi or NB-IoT carries it underneath. The core of LwM2M is abstracting device capabilities into an **object tree**: - **Object** (e.g. `3303` = Temperature)—a class of capability; - **Object Instance**—multiple instances of the same capability (e.g. several temperature sensors on one device); - **Resource** (e.g. `5700` = Sensor Value)—a specific readable/writable item within an instance. Accessing a specific value means giving the path `/<objectId>/<objectInstanceId>/<resourceId>`. Firmware upgrade, remote configuration, and subscription reporting are all standardized into this object model, which is why LwM2M is common on * *carrier-grade endpoints that need remote operation**: NB-IoT modules, smart meters, remote environmental sensors—anywhere both remote management and low power consumption are needed. Unlike drivers such as Modbus or CoAP that **actively connect to devices**, this driver works the other way around—it embeds a **LwM2M server**: <Lwm2mDiagram lang="en" /> The device first registers to this server with its own endpoint name; only after a successful registration can the driver issue reads/writes to it by the point's path. Whether a device is online depends on whether its endpoint is still in the registry. ## Attribute Configuration LwM2M attributes come in two layers: **driver attributes** are set on the [device](../introduction/concepts/device) and describe "where the server listens, which endpoint to match, whether to encrypt"; **point attributes** are set on each [point](../introduction/concepts/point) and describe "which resource path in the object tree this point maps to." Both originate from the driver's `application.yml` `driver-attribute` / `point-attribute` declarations; at onboarding you [fill a concrete value](../introduction/concepts/attribute-config) for each attribute on the device instance. ### Driver attributes (device-level `driver-attribute`) `endpoint` is the key that maps this DC3 device to a registered LwM2M client—it must match the endpoint name the device reports at registration **exactly**, otherwise they will not match and the device stays offline. `serverHost` / `serverPort` / `securePort` declare the server's bind address and ports; `securityMode` decides plaintext versus PSK encryption, with `pskIdentity` and `pskKey` added when PSK is enabled. | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|-----------|-----------------------------------------------| | Endpoint | `endpoint` | STRING | (empty) | LwM2M device endpoint name | | Server Host | `serverHost` | STRING | `0.0.0.0` | Server bind address | | Server Port | `serverPort` | INT | `5683` | CoAP port | | Secure Port | `securePort` | INT | `5684` | CoAPS/DTLS port | | Security Mode | `securityMode` | STRING | `NOSEC` | Security mode: NOSEC, PSK | | PSK Identity | `pskIdentity` | STRING | (empty) | PSK identity (when `securityMode=PSK`) | | PSK Key | `pskKey` | STRING | (empty) | HEX-encoded PSK key (when `securityMode=PSK`) | ::: warning serverHost / serverPort / securePort are not actually applied yet When the driver starts the embedded server it uses the **default binding** of `new LeshanServerBuilder().build()` (which happens to be `5683`/`5684`); it does not feed the `serverHost`/`serverPort`/`securePort` or PSK values above into Leshan. In other words these items are **declared but not wired**: filling them only lands on the default ports and a plaintext link. To bring the link up, use the default plaintext `5683` port; changing the port or enabling PSK still needs to be wired into the driver (see Implementation status below). ::: ### Point attributes (`point-attribute`) Fill in one LwM2M resource path on each point. The driver assembles `objectId` / `objectInstanceId` / `resourceId` into `/<objectId>/<objectInstanceId>/<resourceId>`, issues a read to the device endpoint, and the returned value is the [point value](../introduction/concepts/point-value) for that point. | Attribute | code | Type | Default | Remark | |--------------------|--------------------|--------|---------|----------------------------------------------| | Object ID | `objectId` | INT | `0` | LwM2M Object ID (e.g. `3303`=Temperature) | | Object Instance ID | `objectInstanceId` | INT | `0` | LwM2M Object Instance ID | | Resource ID | `resourceId` | INT | `0` | LwM2M Resource ID (e.g. `5700`=Sensor Value) | | Observe | `observe` | STRING | `false` | Enable LwM2M Observe: true, false | ::: tip The three-part path decides which resource to read The point's data type ([Point](../introduction/concepts/point)'s `pointTypeFlag`) must match the actual data type of that Resource. LwM2M has no separate `command-attribute` table (`command-attribute: [ ]` is empty in the yml)—the write target of a writable point is simply its own three-part path: when a write command is issued, the driver sends a `WriteRequest` directly to `/<objectId>/<objectInstanceId>/<resourceId>`, with no extra command attributes needed. ::: ::: warning The observe attribute has no effect yet At the protocol level `observe=true` means "enable LwM2M Observe (subscription-style reporting) on the resource, so the device pushes proactively on value change." But this driver **does not register any Observe and does not consume the attribute**—neither `read()` nor `write()` reads `observe`. Point values can currently only be obtained via the default 30-second active read; setting `observe=true` will not trigger subscription pushes (see Implementation status below). ::: ### Collection and health cadence The following cycles come from `application.yml`'s `dc3.driver.schedule` / `health`: - **Collection cycle**: default cron `0/30 * * * * ?`, issuing one read to each point's resource path every 30 seconds. - **Custom job**: a built-in custom schedule, default cron `0/5 * * * * ?`, every 5 seconds, reserved for the driver's own periodic logic (the current `schedule()` is an empty implementation). - **Health / liveness**: device health check default cron `0/15 * * * * ?`, lease timeout `45` seconds. Whether a device is online depends on whether its endpoint is still registered on the embedded server. ## Troubleshooting | Symptom | Likely cause | Where to look | |---------------------------------------------|-----------------------------------------------------------------|-------------------------------------------------------| | Device stays offline, reads return nothing | `endpoint` differs from what the device reports at registration | They must match exactly—see the first note below | | Device cannot reach the server | UDP `5683` blocked by a firewall, or broken NAT mapping | Verify the UDP link before the app config | | Changing `serverPort` still listens on 5683 | Port config is not fed to Leshan yet | Port override is not implemented—use the default port | | Registration fails after enabling PSK | PSK config not applied yet, or the device forces DTLS | Bring the link up with `NOSEC` plaintext first | | `observe=true` receives no pushes | Observe auto-forwarding is not implemented | Rely on the default 30-second active read | ::: warning The endpoint name must match the device's registration exactly Device liveness is judged by matching the `endpoint` name in the embedded server's registry ( `isDeviceRegistered(endpoint)`). If the endpoint the device actually registers with (commonly `urn:imei:<IMEI>` or a vendor-defined string) and the `endpoint` filled on the device differ by even one character, they will not match: the device stays offline and reads return nothing. Before onboarding, confirm exactly what endpoint name the device firmware uses to register. ::: ::: tip For UDP protocols, check the link first LwM2M runs over CoAP over UDP; a failure to connect is usually the UDP port (`5683`/`5684`) being blocked by a firewall or a broken NAT mapping, rather than a wrong application config—verify the link first, then check the endpoint and point paths. When the device is on the public internet/cellular, remember to allow the corresponding UDP ports. ::: ::: warning Changing ports and enabling PSK are not yet available The driver currently does not consume the `serverPort`/`securePort`/`securityMode`/PSK config; the embedded server is fixed to Leshan defaults (plaintext `5683` / DTLS `5684`). So changing the listening port or using a PSK handshake is not possible today—use the default `NOSEC` plaintext `5683` port to get the link working first. To support encryption, these settings must first be wired into `LeshanServerBuilder` inside `Lwm2mServerManager`. ::: ## How It Lands in IoT DC3 - **`dc3.driver.code`**: `Lwm2mDriver` (a stable routing identifier consistent with the [driver capability matrix](./matrix); do not change it casually). - **Driver name / type**: `LwM2M Driver` / `DRIVER_CLIENT`. - **Read (implemented)**: `read()` calls `Lwm2mServerManager.read()`, sending `ReadRequest(objectId, objectInstanceId, resourceId)` to the registered device and using the returned content as the point value—this is real protocol I/O, not a stub. - **Write (implemented)**: `write()` calls `Lwm2mServerManager.write()`, sending a `WriteRequest` to the same three-part path; success returns `true`, failure/timeout returns `false`. - **Subscribe (not implemented)**: the [driver capability matrix](./matrix) marks LwM2M as read/write/subscribe-complete, but **Observe-based reporting is not landed yet**— `Lwm2mObservationHandler.onObservation()` only logs and carries a `TODO`; the driver neither registers Observe nor maintains the endpoint→deviceId / resource-path→pointId mapping needed to forward observed values. ::: warning Implementation status: read/write usable, subscribe and server config incomplete The class doc of `Lwm2mDriverCustomServiceImpl` still marks it a "work-in-progress skeleton," but **read and write are wired to real Leshan I/O and work against a registered device**. Three things remain unfinished: ① auto-forwarding of Observe-subscribed values; ② feeding `serverHost`/`serverPort`/`securePort` config into Leshan; ③ the PSK-encrypted link. Treat it as a "read/write runnable, subscribe/encryption pending" onboarding starting point, and validate read/write over a plaintext link with the example below first. ::: ::: details Minimal onboarding example: read back a temperature point Onboard a LwM2M sensor whose endpoint name is `urn:imei:860000000000001` and whose temperature resource is at `/3303/0/5700`: 1. Create a [device](../introduction/concepts/device) with `LwM2M Driver`, and set the driver attributes `endpoint=urn:imei:860000000000001`, `securityMode=NOSEC` (ports are currently fixed to the default `5683`, neither needed nor changeable). 2. Have the LwM2M client register to this service's port `5683` (plaintext) using the **same** endpoint name. 3. Add a temperature [point](../introduction/concepts/point) (`READ_ONLY`) to the [profile](../introduction/concepts/profile) bound to the device, with point attributes `objectId=3303`, `objectInstanceId=0`, `resourceId=5700`. 4. Start the driver. Once the device registers successfully, within 30 seconds you will see the temperature value read back in the [point value](../introduction/concepts/point-value). ::: ## Further Reading - [Drivers Overview](./index) — the full landscape and grouping of the 28 drivers - [Driver Capability Matrix](./matrix) — a quick reference for each driver's read/write/subscribe support - [Device Onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [IoT Protocols & Wireless Networks](../foundations/iot-protocols) — LwM2M's place in the network layer and its trade-offs vs CoAP/MQTT --- # Driver Capability Matrix URL: https://docs.dc3.site/en/drivers/matrix This page gives an at-a-glance view of all **36 drivers** in IoT DC3 — their protocol category, read / write / subscribe capabilities, and implementation status — so you can match a protocol to your needs fast. Each row links to that driver's own page, where attributes, polling cadence, and a minimal onboarding example are spelled out. Read / write / subscribe reflect each driver's actual current implementation: "✓" means the capability is in place, "—" means the driver does not implement that direction, deliberately does not offer it by design, or is still a skeleton — code is the source of truth (see the note after the tables). Subscribe / report means the driver passively receives device pushes (listens on a port, network callbacks, device registration, etc.), as opposed to periodic polling. The "Status" column summarizes each driver's overall maturity: - **Complete**: read / write / subscribe are fully implemented per the protocol design and ready for production onboarding. - **Usable**: the core path works, but there are local gaps (e.g. some data types, Observe, or health hooks); see the Notes and the driver page. - **Skeleton**: protocol framing or transport is not yet implemented; currently serves as a structural reference only and cannot collect from real devices. ## Industrial Bus / PLC These drivers act as masters (clients) that actively connect to the device, poll values per [Point](../introduction/concepts/point) and write via [commands](../introduction/concepts/command); they do not listen for pushes. `ethernet-ip` is currently a protocol skeleton — CIP framing is not yet complete. | Driver (dc3.driver.code) | Category | Read | Write | Subscribe/Report | Status | Notes | |---------------------------------------------------|--------------------|------|-------|------------------|----------|------------------------------------------------------------| | [Modbus TCP](./modbus-tcp) (`ModbusTcpDriver`) | Industrial Bus/PLC | ✓ | ✓ | — | Complete | Ethernet Modbus master | | [Modbus RTU](./modbus-rtu) (`ModbusRtuDriver`) | Industrial Bus/PLC | ✓ | ✓ | — | Complete | Serial Modbus master | | [OPC UA](./opc-ua) (`OpcUaDriver`) | Industrial Bus/PLC | ✓ | ✓ | — | Complete | OPC Unified Architecture client | | [OPC DA](./opc-da) (`OpcDaDriver`) | Industrial Bus/PLC | ✓ | ✓ | — | Complete | Classic OPC Data Access (DCOM) | | [S7](./plcs7) (`PlcS7Driver`) | Industrial Bus/PLC | ✓ | ✓ | — | Complete | Siemens PLC | | [MELSEC](./melsec) (`MelsecDriver`) | Industrial Bus/PLC | ✓ | ✓ | — | Complete | Mitsubishi PLC (MC protocol) | | [FINS](./fins) (`FinsDriver`) | Industrial Bus/PLC | ✓ | ✓ | — | Usable | Omron PLC, supports 16/32-bit ints, float, string, and BCD | | [EtherNet/IP](./ethernet-ip) (`EthernetIpDriver`) | Industrial Bus/PLC | — | — | — | Skeleton | Rockwell / CIP, framing pending | ## SCADA / Power / Metering Building, power, and metering protocols. `bacnet-ip` and `snmp` read and write actively; `sl651` is hydrology telemetry — it opens a TCP server and passively receives reports, hence subscribe only; `iec104` and `dlms` are currently skeletons. | Driver (dc3.driver.code) | Category | Read | Write | Subscribe/Report | Status | Notes | |---------------------------------------------|----------------------|------|-------|------------------|----------|----------------------------------------| | [BACnet/IP](./bacnet-ip) (`BacnetIpDriver`) | SCADA/Power/Metering | ✓ | ✓ | — | Complete | Building automation | | [IEC 104](./iec104) (`Iec104Driver`) | SCADA/Power/Metering | — | — | — | Skeleton | Power SCADA, protocol layer pending | | [DLMS](./dlms) (`DlmsDriver`) | SCADA/Power/Metering | — | — | — | Skeleton | Smart meters, transport pending | | [SL651](./sl651) (`Sl651Driver`) | SCADA/Power/Metering | — | — | ✓ | Complete | Hydrology telemetry, TCP server ingest | | [SNMP](./snmp) (`SnmpDriver`) | SCADA/Power/Metering | ✓ | ✓ | — | Complete | Network device monitoring | | [DL/T645](./dlt645) (`Dlt645Driver`) | SCADA/Power/Metering | ✓ | ✓ | — | Complete | Electricity meter (DL/T645-2007) | | [DNP3](./dnp3) (`Dnp3Driver`) | SCADA/Power/Metering | ✓ | ✓ | — | Available | Utility automation, native stack implemented | | [IEC 61850](./iec61850) (`Iec61850Driver`) | SCADA/Power/Metering | ✓ | ✓ | — | Complete | Substation automation (MMS client) | | [KNX](./knx) (`KnxDriver`) | SCADA/Power/Metering | ✓ | ✓ | — | Complete | Building automation bus (Calimero) | | [M-Bus](./mbus) (`MbusDriver`) | SCADA/Power/Metering | ✓ | ✓ | — | Complete | Metering bus (EN 13757), self-built framing | ## IoT / Wireless IoT and wireless drivers. `mqtt` is publish/subscribe — values arrive passively via subscription (no active read), commands can be sent; `lwm2m` (embedded server, receives device registration and notifications) has read and write in place but its Observe/subscribe is not yet implemented; `coap`, `http`, `ble`, and `can` are request-response active read/write (`coap` Observe not implemented); `can` and `zigbee` are currently skeletons — `zigbee` only listens for coordinator network state (not node join or attribute reports), and `can` is backed by can-utils. | Driver (dc3.driver.code) | Category | Read | Write | Subscribe/Report | Status | Notes | |-------------------------------------|--------------|------|-------|------------------|----------|--------------------------------------------------------------------------| | [MQTT](./mqtt) (`MqttDriver`) | IoT/Wireless | — | ✓ | ✓ | Usable | Publish/subscribe, values via subscription; `initial()` hook is skeleton | | [CoAP](./coap) (`CoapDriver`) | IoT/Wireless | ✓ | ✓ | — | Usable | RESTful for constrained devices, Observe not implemented | | [LwM2M](./lwm2m) (`Lwm2mDriver`) | IoT/Wireless | ✓ | ✓ | — | Usable | Embedded server, read/write ready, Observe not implemented | | [HTTP](./http) (`HttpDriver`) | IoT/Wireless | ✓ | ✓ | — | Complete | Generic HTTP polling | | [BLE](./ble) (`BleDriver`) | IoT/Wireless | ✓ | ✓ | — | Complete | Bluetooth Low Energy GATT | | [Zigbee](./zigbee) (`ZigbeeDriver`) | IoT/Wireless | ✓ | ✓ | — | Skeleton | Skeleton; subscribe (join/reports) not implemented | | [CAN](./can) (`CanDriver`) | IoT/Wireless | ✓ | — | — | Skeleton | Controller Area Network, backed by can-utils | | [LoRaWAN](./lorawan) (`LorawanDriver`) | IoT/Wireless | ✓ | ✓ | ✓ | Usable | ChirpStack MQTT uplink ingest, Cayenne LPP, downlink publish | | [Kafka](./kafka) (`KafkaDriver`) | IoT/Wireless | ✓ | ✓ | ✓ | Usable | Streaming source, consume + produce | ## Serial / Generic Network Generic pass-through drivers that frame requests from a command template, sending and receiving actively without listening. | Driver (dc3.driver.code) | Category | Read | Write | Subscribe/Report | Status | Notes | |---------------------------------------------|------------------------|------|-------|------------------|----------|-----------------------------| | [Serial](./serial) (`SerialDriver`) | Serial/Generic Network | ✓ | ✓ | — | Complete | Generic serial pass-through | | [TCP/UDP](./tcp-udp) (`TcpUdpDriver`) (raw) | Serial/Generic Network | ✓ | ✓ | — | Complete | Generic socket pass-through | ## Database Treat a table as a data source: reads go through `executeQuery`, writes through `executeUpdate`, driven by the SQL template on each [Point](../introduction/concepts/point); no change subscription. | Driver (dc3.driver.code) | Category | Read | Write | Subscribe/Report | Status | Notes | |-------------------------------------------------|----------|------|-------|------------------|----------|-------------------------| | [MySQL](./mysql) (`MysqlDriver`) | Database | ✓ | ✓ | — | Complete | Read points from tables | | [PostgreSQL](./postgresql) (`PostgresqlDriver`) | Database | ✓ | ✓ | — | Complete | Read points from tables | | [Oracle](./oracle) (`OracleDriver`) | Database | ✓ | ✓ | — | Complete | Read points from tables | | [SQL Server](./sqlserver) (`SqlserverDriver`) | Database | ✓ | ✓ | — | Complete | Read points from tables | | [Redis](./redis) (`RedisDriver`) | Database | ✓ | ✓ | — | Complete | Read/write STRING and HASH keys | ## Virtual / Testing Two drivers with no real device: `virtual` generates simulated read values by point type (write is a placeholder, nothing hits a device); `listening-virtual` runs in reverse, opening TCP/UDP servers to receive external pushes and able to write back to a device over the connection channel. | Driver (dc3.driver.code) | Category | Read | Write | Subscribe/Report | Status | Notes | |---------------------------------------------------------------------|-----------------|------|-------|------------------|----------|--------------------------------------------------| | [Virtual](./virtual) (`VirtualDriver`) | Virtual/Testing | ✓ | — | — | Usable | Generates simulated data, write is a placeholder | | [Listening Virtual](./listening-virtual) (`ListeningVirtualDriver`) | Virtual/Testing | — | ✓ | ✓ | Complete | TCP/UDP server ingest, can write back | ::: info Capability marks are code-driven The "✓ / —" in these tables reflect what each driver's `*DriverCustomServiceImpl` actually implements today: a "—" may mean the protocol direction simply is not offered (e.g. `virtual` write, `mqtt` active read), or that the skeleton is not yet filled in (e.g. `ethernet-ip`, `iec104`, `dlms`, `can`). The "Status" column is an overall maturity summary; finer data-type or sub-capability gaps are flagged on each driver page via `::: warning` / `::: info`. For the final behavior, consult that module's `read()` / `write()` / `initial()` source; this table is kept in sync as drivers evolve. ::: ## Further Reading - [Drivers Overview](./index) — pick a protocol by category and open its page - [Custom Driver](../development/driver-authoring) — implement your own protocol driver from the `virtual` template --- # M-Bus Driver URL: https://docs.dc3.site/en/drivers/mbus `dc3-driver-mbus` reads heat, water, and gas meters over the M-Bus (Meter-Bus, EN 13757) wired bus. It builds REQ_UD2 requests, SND_NKE/SND_UD frames, verifies the response checksum, and decodes the data records into values. ## Protocol background M-Bus (EN 13757) is the European standard for remote meter reading. A master sends short frames to address a meter by primary address, and the meter replies with a long frame containing a variable-length data record (DIF/VIF/data) that carries the measured quantity. - **Driver name / code**: `M-Bus Driver` / `MbusDriver` - **Type**: `DRIVER_CLIENT (opens the serial port and polls the meter)` - **Underlying library**: jSerialComm (self-built EN 13757-2/3 frame encode/decode — no native jrxtx dependency) ## Attribute configuration ### Driver attributes (device-level `driver-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Serial Port | `port` | STRING | `/dev/ttyUSB0` | Serial port device path | | Baud Rate | `baudRate` | INT | `2400` | Baud rate (M-Bus default 2400) | | Data Bits | `dataBits` | INT | `8` | Data bits | | Stop Bits | `stopBits` | INT | `1` | Stop bits | | Parity | `parity` | INT | `2` | Parity (2=Even) | | Timeout | `timeout` | INT | `1000` | Read timeout in milliseconds | | Primary Address | `primaryAddress` | INT | `0` | M-Bus primary address (0-250) | ### Point attributes (`point-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Record Index | `recordIndex` | INT | `0` | 0-based index of the data record to read | | Data Format | `dataFormat` | STRING | `FLOAT` | Data format: FLOAT, HEX, ASCII | ## Collection and health - **Collection cycle**: default cron `0/30 * * * * ?` (one polling round over all points every 30 seconds). - **Health/online**: device health defaults to cron `0/15 * * * * ?`, lease timeout `45 seconds`. ## Capability matrix | Capability | Supported | Notes | |------------|-----------|-------| | Read | ✓ | | | Write | ✓ (SND_NKE reset and SND_UD select frames) | | | Subscribe | — | | ::: info Implementation status: available :: Frames are self-built (no native jrxtx dependency); `MbusFrame` handles REQ_UD2/SND_NKE/SND_UD building, checksum verification, and DIF/VIF record parsing. ## Minimal onboarding example 1. Create a Device using `M-Bus Driver`, set `port=/dev/ttyUSB0`, `baudRate=2400`, `parity=2`, `primaryAddress=0`. 2. Add a Point (`READ_ONLY`) with `recordIndex=0` and `dataFormat=FLOAT`. 3. Start the driver; within 30 seconds the value appears in PointValue. ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough --- # Melsec Driver URL: https://docs.dc3.site/en/drivers/melsec `dc3-driver-melsec` onboards Mitsubishi PLCs into IoT DC3 over the MC protocol: it acts as an MC client, actively connects to the PLC, periodically reads by the device address configured on each [Point](../introduction/concepts/point), and supports commands that write values back to devices. After reading this page you can fill in the driver / point attributes, onboard a Mitsubishi PLC, and know where to look when it won't connect. ## Protocol background The MC protocol (MELSEC Communication) is the native communication protocol of Mitsubishi Electric PLCs, widely used across the A, QnA, Q/L, and iQ-R series. On the plant floor a Mitsubishi PLC exposes an MC server port through its Ethernet module or built-in CPU port; an upstream system connects as an MC client and reads/writes data units in PLC memory by their **device address** (e.g. `D100`, `M0`) — data registers hold process parameters, internal relays hold logic state, input/output relays map to field IO. In the four-layer IoT architecture the MC protocol sits on the wired-industrial side of the **network layer**: it is the "last mile" language contract between a PLC and upstream systems, governing how bytes are laid out, how devices are addressed, and the request-response timing of each exchange. Like Siemens S7 and Omron FINS, it is a vendor-proprietary master/slave protocol — the PLC never reports unprompted; the master must ask. To see where it sits in the protocol landscape and why PLC vendors' protocols are mutually incompatible, read [IoT network layer: industrial buses and protocols](../foundations/fieldbus). The driver is built on the `McPLC` of the `iot-communication` protocol library, automatically selecting the matching word width and codec by the point's data type when reading or writing. - **Driver name / code**: `Mitsubishi Melsec Driver` / `MelsecDriver` - **Type**: `DRIVER_CLIENT` (actively connects to the PLC) ::: tip A few MC concepts first **Device (also called memory address)**: a data unit in a Mitsubishi PLC partitioned by purpose — e.g. `D` (data register, the most common), `M` (internal relay), `X` (input relay), `W` (link register). **Device address**: a complete address string made of an area prefix plus a number, e.g. `D100`, `M0`, `X10`, `W200` — the area and number are written together, not split into two fields. **PLC Series**: the MC frame format differs slightly between series, so choose `A` / `QnA` / `Q_L` / `IQ_R` to match the actual PLC. ::: ## Attribute configuration Melsec driver configuration has two layers: **driver attributes** describe "which PLC to connect to" (device-level, one set per device), and **point attributes** describe "which device to read" (point-level, one set per point). Both layers are declared in the driver's `application.yml`; you fill the values in the console at onboarding time. ### Driver attributes (device-level `driver-attribute`) `host` / `port` decide which PLC's MC server port the TCP connection targets; `series` decides which framing the MC frame uses and must match the real PLC series. When onboarding a Melsec PLC, fill these [Attributes](../introduction/concepts/attribute-config) on the [Device](../introduction/concepts/device): | Attribute | code | Type | Default | Description | |------------|----------|--------|----------------|------------------------------------| | Host | `host` | STRING | `192.168.0.20` | PLC host address (Ip) | | Port | `port` | INT | `6000` | MC service port | | PLC Series | `series` | STRING | `QnA` | PLC series, `A`/`QnA`/`Q_L`/`IQ_R` | ### Point attributes (`point-attribute`) `address` specifies the device to read/write (whole-string notation, area + number written together); `length` is only used for the string type — the byte length of the string to read. Fill these on each [Point](../introduction/concepts/point): | Attribute | code | Type | Default | Description | |----------------|-----------|--------|---------|----------------------------------------------------| | Device Address | `address` | STRING | `D100` | Device address (`D100`, `M0`, `X10`, `W200`, etc.) | | String Length | `length` | INT | `0` | String read length (`0` for non-string types) | ::: tip The data type decides how many words to read and how to decode them The driver picks the read/write width and codec automatically from the point's data type ([Point](../introduction/concepts/point)'s `pointTypeFlag`): `BOOLEAN` reads/writes a bit, `BYTE` reads/writes 8 bits, `SHORT` reads/writes 16 bits (int16), `INT`/`FLOAT` read/write 32 bits, `LONG`/`DOUBLE` read/write 64 bits, `STRING` reads/writes a string. Only the `STRING` type uses `length` (the number of bytes to read; **the driver falls back to 64 when it is 0 or empty**); for non-string points keep `length=0` and the driver ignores it. ::: ### Write command: reuses the point `address`, no separate attribute This driver supports writing values to a point (numeric, boolean, and string alike), but there is **no separate `command-attribute`** — a write command reuses the `address` already configured on the point, the target device is the point's `address`, and the word width of the written value is determined by the data type of the value being issued. So a writable point needs no extra configuration; just mark the point as writable in the [Profile](../introduction/concepts/profile). ### Acquisition and health scheduling These crons come from the `schedule` / `health` sections of `application.yml` and govern the acquisition cadence and online detection: - **Acquisition cycle**: default cron `0/30 * * * * ?` (one read round every 30 seconds). - **Custom task**: default cron `0/5 * * * * ?` (the Melsec driver currently has no custom task; `schedule()` is an empty implementation and the schedule slot is reserved). - **Health / online**: device health check default cron `0/15 * * * * ?`, lease timeout `45 seconds` — the driver judges online status by whether the TCP connection is alive; on a read/write exception it proactively disconnects and evicts that connection from the cache, and the next acquisition round reconnects automatically. See [Device](../introduction/concepts/device) for the online-status mechanism. ## Troubleshooting ::: warning Write `address` as the full device address — don't split area and number Fill `address` with Mitsubishi's whole-string notation, e.g. `D100`, `M0`, `X10`, `W200` — the area prefix and the number go in the same string. This differs from protocols configured as "area + numeric offset" in two fields (such as [FINS](./fins)); **do not** split the area and number into two entries, or `McPLC` parses an invalid address and the read/write fails. ::: ::: warning `series` must match the real PLC series `series` accepts only `A` / `QnA` / `Q_L` / `IQ_R`, which determines the MC frame format. A wrong or unrecognized value makes the driver log `Unknown series ... fallback to QnA` and fall back to `QnA` — which may read incorrect data or error outright on other-series PLCs. Confirm the series against the actual model before onboarding. ::: ::: warning When it won't connect / read-write fails, check the PLC-side MC service first The driver opens a TCP connection directly to `host:port`; a failed connect throws `Driver connection failed`. Common causes: the PLC Ethernet module has no MC service enabled, the port differs from `port` (many models do not default to `6000`), PLC-side IP filtering or an exhausted connection limit, or a firewall block. Confirm the port is reachable with `telnet host port`, then verify the MC service port and protocol (TCP) in the PLC project. ::: ::: warning If strings or byte order read back wrong, check the type and length A garbled or truncated string usually means `length` does not match the PLC-side string byte count (with 0 left in, the driver reads 64). An off numeric value usually means the point's `pointTypeFlag` does not match the word width actually stored in the device — configuring a 32-bit float device as `SHORT` only reads the low 16 bits. ::: ::: tip One driver instance can serve multiple PLCs, with connections cached per device A single Melsec driver process can serve multiple devices, each maintaining its own `McPLC` connection (cached by device ID in `connectMap`) and serializing read/write with its own `ReentrantLock`. Multiple PLCs are distinguished by their respective `host`. When a device is updated or deleted, the driver receives a metadata event and closes and evicts the corresponding connection. ::: ## How it lands in IoT DC3 ::: info Implementation status: available Both the read and write paths of the Melsec driver are fully implemented — `read()` / `write()` call real MC reads/writes through `iot-communication`'s `McPLC` (`readInt16` / `writeInt16` / `readString`, etc.), selecting the correct word width by data type. This is an available driver, and its behavior matches the `application.yml` declaration. ::: - **dc3.driver.code**: `MelsecDriver` — the driver's stable routing identifier inside the platform; device-to-driver binding and message dispatch address it by this code, so don't change it casually. - **Read capability**: ✓ supported. Reads periodically on the acquisition cycle, covering all of `BOOLEAN`/`BYTE`/ `SHORT`/`INT`/`LONG`/`FLOAT`/`DOUBLE`/`STRING`, consistent with MELSEC's "read ✓" in the [driver capability matrix](./matrix). - **Write capability**: ✓ supported. On a write command it writes the matching word width by the value's data type, consistent with the matrix's "write ✓". - **Subscribe capability**: — not supported. MC is a master/slave polling model; this driver polls on the acquisition cycle and offers no PLC-pushed subscription, consistent with the matrix's "subscribe —". **Minimal onboarding example**: onboard a QnA-series Mitsubishi PLC at IP `192.168.0.30:6000` and acquire a 16-bit integer from `D100`: 1. Create a [Device](../introduction/concepts/device) using `Mitsubishi Melsec Driver`, with driver attributes `host=192.168.0.30`, `port=6000`, `series=QnA`. 2. Add a [Point](../introduction/concepts/point) (`pointTypeFlag=SHORT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, with point attributes `address=D100`, `length=0`. 3. Start the driver, and within 30 seconds the acquired value of `D100` appears in [PointValue](../introduction/concepts/point-value). For the full onboarding flow (modeling, binding, dispatch) see [Device onboarding](../operation/device-onboarding). ## Further reading - [Driver overview](./index) — entry point to all drivers and their categories - [Driver capability matrix](./matrix) — a quick read/write/subscribe lookup per driver - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Industrial buses and protocols](../foundations/fieldbus) — where the MC protocol sits in the network-layer protocol landscape - [FINS Driver](./fins) — a TCP industrial protocol for Omron PLCs, addressed by "area + offset" in two fields --- # Modbus RTU Driver URL: https://docs.dc3.site/en/drivers/modbus-rtu `dc3-driver-modbus-rtu` connects Modbus RTU slave devices to IoT DC3: acting as the master, it reaches slaves on an RS-485/RS-232 bus over a single serial port, periodically reads coil/register values, and supports commands that write to coils and holding registers. By the end of this page you can configure the serial parameters, function codes, and addresses for a serial Modbus device, and know where to look when it won't connect. ## Protocol background Modbus is an industrial protocol born in 1979 for serial PLC communication, and it remains one of the most common protocols on the plant floor — heavily used by PLCs, energy meters, VFDs, temperature controllers, and sensors. **Modbus RTU** is its serial-link variant: messages travel as compact binary frames over an RS-485/RS-232 bus, with a CRC to guarantee integrity. It shares the exact same function codes and address model as the [Modbus TCP Driver](./modbus-tcp) — reads use `01/02/03/04`, writes use `05/06/15/16`, and addresses are 0-based offsets — the only difference being the physical layer: RTU runs over a serial port (baud rate, data bits, parity, stop bits) rather than IP/port. In the four-layer IoT architecture, Modbus RTU sits at the **network layer**: it answers "what signaling and what rules to exchange bytes over" between field devices and the polling master. It is a textbook **master/slave request-response** protocol — the master polls slaves and waits for replies, slaves never speak unprompted; one RS-485 bus can carry multiple slaves, distinguished by unit ID (`slaveId`). The protocol itself often just moves bytes, and **how those bytes are interpreted is decided by configuration** (16-bit int vs 32-bit float, low byte first vs high byte first) — the most common pitfall on the floor. For the general background on addressing, byte order, and the polling model, see [IoT network layer: Industrial Buses & Protocols](../foundations/fieldbus). - **Driver name / code**: `Modbus RTU Driver` / `ModbusRtuDriver` - **Type**: `DRIVER_CLIENT` (actively connects to slaves) - **Underlying libraries**: modbus4j + jSerialComm (one dedicated serial connection per device) ## Attribute configuration Modbus RTU configuration spans three layers: **driver attributes** (`driver-attribute`, device-level) describe how this serial port is opened; **point attributes** (`point-attribute`) describe which slave and register each collected point reads; **command attributes** (`command-attribute`) describe where a writable point writes. The defaults and descriptions in the three tables below all come from the driver's `application.yml`; for where attributes originate across the three layers, see [Attributes & config](../introduction/concepts/attribute-config). ### Driver attributes (device-level `driver-attribute`) When onboarding a Modbus RTU device, set these five serial parameters on the [Device](../introduction/concepts/device). They are passed verbatim to jSerialComm's `setComPortParameters` to open the port, so they **must match the slave's serial settings one by one** — unlike TCP, RTU has no handshake negotiation, and a mismatch just yields garbage or timeouts: | Attribute | code | Type | Default | Description | |-----------|------------|--------|----------------|-------------------------------------------------| | Port | `port` | STRING | `/dev/ttyUSB0` | Serial port name (e.g. /dev/ttyUSB0, COM3) | | Baud Rate | `baudRate` | INT | `9600` | Serial baud rate (e.g. 9600, 19200, 115200) | | Data Bits | `dataBits` | INT | `8` | Data bits (7 or 8) | | Stop Bits | `stopBits` | INT | `1` | Stop bits (1 or 2) | | Parity | `parity` | INT | `0` | Parity (0=None, 1=Odd, 2=Even, 3=Mark, 4=Space) | ### Point attributes (`point-attribute`) Set three attributes on each collected [Point](../introduction/concepts/point), pinning down "which slave's which address, read with which function code": | Attribute | code | Type | Default | Description | |---------------|----------------|------|---------|----------------------------------------| | Slave ID | `slaveId` | INT | `1` | Modbus slave unit ID | | Function Code | `functionCode` | INT | `1` | Read function code `[1, 2, 3, 4]` | | Offset | `offset` | INT | `0` | Register/coil address offset (0-based) | ::: tip The function code decides what is read; the Point type decides how bytes are assembled Reading supports four function codes: `01` (coil) / `02` (discrete input) / `03` (holding register) / `04` (input register). For register reads (`03`/`04`), the driver uses the Point's data type ([Point](../introduction/concepts/point)'s `pointTypeFlag`) to decide how many registers to take and how to interpret them: `LONG`→4-byte signed int, `FLOAT`→4-byte float, `DOUBLE`→8-byte float, everything else 2-byte signed int. Get the Point type wrong and a float reads back as a meaningless large number. ::: ### Command attributes (`command-attribute`) Writable Points also need four attributes on the write command. `valueTemplate` is the value template, rendered with command params before the write is issued: | Attribute | code | Type | Default | Description | |----------------|-----------------|--------|------------|------------------------------------------------------------------------------------------------------| | Slave ID | `slaveId` | INT | `1` | Modbus slave unit ID | | Function Code | `functionCode` | INT | `6` | Write function code (the driver actually handles only `1` write coil and `3` write holding register) | | Offset | `offset` | INT | `0` | Register/coil address offset (0-based) | | Value Template | `valueTemplate` | STRING | `${value}` | Value template rendered with command params | ### Collection & health - **Collection cycle**: default cron `0/30 * * * * ?` (reads all points once every 30 seconds). - **Health / online**: device health check default cron `0/15 * * * * ?`, lease timeout `45 seconds` — the driver decides online/offline from whether the serial connection is initialized; see [Device](../introduction/concepts/device) for the online-state mechanism. The minimal onboarding path: create a [Device](../introduction/concepts/device) with `Modbus RTU Driver` and set driver attributes `port=/dev/ttyUSB0`, `baudRate=9600`, `dataBits=8`, `stopBits=1`, `parity=0`; on the bound [Profile](../introduction/concepts/profile), add a temperature [Point](../introduction/concepts/point) ( `pointTypeFlag=FLOAT`, `READ_ONLY`) with point attributes `slaveId=1`, `functionCode=3`, `offset=0`; start the driver, and within 30 seconds the collected value shows up in [PointValue](../introduction/concepts/point-value). ## Troubleshooting ::: warning One wrong serial parameter and nothing connects All five of `port`, `baudRate`, `dataBits`, `stopBits`, `parity` must match the slave's serial settings one by one. RTU has no handshake negotiation, so any mismatch yields garbage or timeouts rather than a clear error. Confirm cabling and baud rate with a multimeter / serial terminal first, then check parity (many meters default to even parity `2`, not the default no-parity `0`). ::: ::: warning Three consecutive failures trigger a 60-second backoff; the device is offline throughout The driver tracks consecutive failures per device: **after 3 consecutive connection failures it enters a 60-second backoff** (`FAILURE_BACKOFF_THRESHOLD=3`, `FAILURE_BACKOFF_MS=60000`), during which the health check reports offline outright and stops attempting connections. So even after you fix the serial parameters or cabling, you must wait out the backoff before it reconnects — don't keep tweaking config inside the backoff window and conclude it "still won't connect." ::: ::: warning offset is a 0-based protocol address, not 40001 `offset` is the protocol-level 0-based offset. To read "holding register 40001" in the conventional Modbus notation, set `functionCode=3`, `offset=0` (the 2nd holding register is `offset=1`, and so on). Putting `40001` straight into `offset` reads the wrong address or goes out of range. ::: ::: warning Byte order: a 32-bit float read as a huge number usually means swapped register order A 32-bit `FLOAT`/`LONG` spans two 16-bit registers, and field devices use one of four register orderings ( ABCD/CDAB/BADC/DCBA). This driver interprets registers in modbus4j's default order; if the float reads back as a meaningless large number, the slave is most likely using the opposite register order — adjust the byte-order setting on the slave side, or switch to an integer type and convert from the raw register value in an upper layer. ::: ::: tip Multiple slaves share one serial port, addressed by slaveId An RS-485 bus can carry multiple slaves; they share the same `port` and are distinguished by the Point's `slaveId`. The driver keeps one connection per [Device](../introduction/concepts/device) ID (`connectMap`), so slaves on the same physical bus should be created as devices pointing at the **same `port`** and addressed via `slaveId` — do not assign a different serial port per slave. Note that a serial port is an exclusive resource: make sure that `port` is not held by another process (and on Linux the running user needs read/write access to `/dev/ttyUSB*`). ::: ## How it lands in IoT DC3 Whatever the underlying protocol, everything converges on the platform into a single [Point](../introduction/concepts/point) and its [PointValue](../introduction/concepts/point-value). The Modbus RTU driver registers with `dc3.driver.code = ModbusRtuDriver`, the stable routing identifier the platform uses to dispatch read/write commands to this driver. Per the [driver capability matrix](./matrix), this driver's capabilities are: | Capability | Supported | Implementation notes | |------------|-----------|---------------------------------------------------------------------------------------------------| | Read | ✓ | Function codes `01/02/03/04`, covering coils, discrete inputs, holding registers, input registers | | Write | ✓ | **Only** `01` (write coil) and `03` (write holding register) | | Subscribe | — | Master/slave polling protocol; no device-initiated reports, the collection cycle reads on a timer | ::: info Implementation status: available `ModbusRtuDriverCustomServiceImpl`'s `read()`/`write()`/`health()` and connection management are fully implemented (on modbus4j + jSerialComm) — not a skeleton. The read path supports all four read function codes; connection handling, backoff, health detection, and metadata events (destroying the stale connection when a device is updated or deleted) are all in place. ::: ::: warning Write commands only support coils and holding registers The command attribute `functionCode` defaults to `6`, but `write()` actually handles only `01` (write coil) and `03` ( write holding register) — codes like `15`/`16` fall into the `default` branch and return `false`, so the write fails silently. For writable Points, set `functionCode=1` for coil writes and `functionCode=3` for holding-register writes. ::: ## Further reading - [Drivers overview](./index) — all protocol drivers and the entry point for selection - [Driver capability matrix](./matrix) — quick reference for read/write/subscribe per driver - [Device onboarding](../operation/device-onboarding) — a full onboarding walkthrough - [Industrial Buses & Protocols](../foundations/fieldbus) — network layer: addressing, byte order, and the polling model - [Modbus TCP Driver](./modbus-tcp) — the Ethernet flavor of Modbus, with the same function codes and address model --- # Modbus TCP Driver URL: https://docs.dc3.site/en/drivers/modbus-tcp <script setup> import ModbusTcpDiagram from '../../.vitepress/theme/components/ModbusTcpDiagram.vue' </script> # Modbus TCP Driver `dc3-driver-modbus-tcp` connects Modbus TCP slave devices to IoT DC3. It acts as the Modbus master (client), periodically reads coils/registers over Ethernet, and supports writing values to coils and holding registers. By the end you can set `host`/`port` on a [Device](../introduction/concepts/device), set function codes and addresses on a [Point](../introduction/concepts/point), and troubleshoot the common "no value read / write won't go through" problems. > You are here: a concrete driver on the "industrial wired" side of the network layer. For the protocol-level addressing > model, byte order, and function-code concepts, see [Industrial Buses & Protocols](../foundations/fieldbus). ## Protocol background Modbus dates back to 1979, originally a master/slave protocol Modicon designed for PLC serial communication, and it is still one of the most common protocols on the industrial floor — heavily used by PLCs, power meters, VFDs, and sensor gateways. It is simple, open, and publicly documented, which is why every vendor implements it. **Modbus TCP** is the Ethernet encapsulation of Modbus: it wraps the Modbus application-layer message (PDU) that originally ran over an RS-485 serial line into TCP/IP, listening on port **502** by default. Compared with the serial [Modbus RTU](./modbus-rtu), the TCP variant drops the CRC check (TCP guarantees integrity) and prepends a 7-byte MBAP header for transaction identification. A single Ethernet segment can host many slaves, and a Modbus TCP gateway can bridge to multiple serial slaves behind it. In the [four-layer IoT architecture](../foundations/fieldbus), Modbus TCP belongs to the **network layer**, industrial-wired side: it defines how a field device is addressed and read/written over the network, moving the physical quantities sampled at the perception layer up to the platform. Its communication model is the classic **master/slave, request/response** — a slave stays silent until polled, so the IoT DC3 driver, acting as master, polls on a cron cycle. Modbus data is organized into four register spaces, distinguished on read by the **function code**: <ModbusTcpDiagram lang="en" /> Coils and discrete inputs are single bits (digital), while holding and input registers are 16-bit words (analog). A 32-bit `FLOAT` or `LONG` occupies two consecutive registers, and a 64-bit `DOUBLE` occupies four — the driver assembles multi-register quantities automatically based on the Point's data type. ## Attribute configuration Onboarding a Modbus TCP device means filling [attributes](../introduction/concepts/attribute-config) at three levels: device-level connection parameters (`driver-attribute`), per-collected-point addressing parameters (`point-attribute`), and per-writable-point write-command parameters (`command-attribute`). The attributes, types, and defaults below all come from the driver's `application.yml` (the `dc3-driver-modbus-tcp` module). ### Driver attributes (device-level `driver-attribute`) Driver attributes answer "which slave do I connect to". Fill one set on each Modbus TCP [Device](../introduction/concepts/device): | Attribute | code | Type | Default | Description | |-----------|--------|--------|-------------|--------------------------------| | Host | `host` | STRING | `localhost` | Modbus slave IP or hostname | | Port | `port` | INT | `502` | Modbus TCP port (standard 502) | `host` + `port` uniquely identify a TCP connection. The driver caches one connection per device ID (one `ModbusMaster` per device), and the socket timeout is fixed at **5 seconds**. During config validation, `port` must fall within `1–65535`. ### Point attributes (`point-attribute`) Point attributes answer "which data point on this slave do I read". Fill one set on each collected [Point](../introduction/concepts/point): | Attribute | code | Type | Default | Description | |---------------|----------------|------|---------|---------------------------------------------------| | Slave ID | `slaveId` | INT | `1` | Modbus slave unit ID | | Function Code | `functionCode` | INT | `1` | Read function code, only `[1, 2, 3, 4]` supported | | Offset | `offset` | INT | `0` | Register/coil address offset (0-based) | `slaveId` distinguishes multiple slaves behind the same IP (gateway). `functionCode` decides which register space to read, and validation forces it into `1–4`: ::: tip The function code decides which register space gets read Reading uses `01` (coils) / `02` (discrete inputs) / `03` (holding registers) / `04` (input registers). The Point's data type (the `pointTypeFlag` of the [Point](../introduction/concepts/point)) must match the data width returned by the function code — the driver maps types to Modbus data widths: `LONG`→4-byte int, `FLOAT`→4-byte float, `DOUBLE`→8-byte float, others (e.g. `INT`)→2-byte int. Multi-register quantities are assembled across registers automatically per Point type. ::: ### Write command attributes (`command-attribute`) A writable Point also needs one set on the write command, answering "which address to write and what value": | Attribute | code | Type | Default | Description | |----------------|-----------------|--------|------------|---------------------------------------------------------------------------------------| | Slave ID | `slaveId` | INT | `1` | Slave unit ID | | Function Code | `functionCode` | INT | `6` | Write function code (yml lists `[5, 6, 15, 16]`, but see implementation status below) | | Offset | `offset` | INT | `0` | Address offset (0-based) | | Value Template | `valueTemplate` | STRING | `${value}` | Value template, rendered with command params | `valueTemplate` defaults to `${value}`, meaning the value passed in the command is written as-is; change the template when a transform is needed (such as multiply-by-factor or add-offset). How the value is encoded into registers is determined by the Point's `pointTypeFlag`. ::: warning Write function code: yml lists 4, the implementation honors only 2 `application.yml` annotates the write function codes as `[5, 6, 15, 16]` with default `6`, but the current `ModbusTcpDriverCustomServiceImpl.writeValue()` handles only **`functionCode=1` (write single coil)** and * *`functionCode=3` (write single holding register)**. Any other code (including the default `6`) falls to the `default` branch and returns `false` (write failed). So set the command's `functionCode` explicitly to `3` to write a register or `1` to write a coil — do not keep the default `6`. The semantics of FC05/06/15/16 are not yet implemented in code — the code is the source of truth. ::: ## Troubleshooting Most Modbus TCP onboarding failures cluster into three classes: connection, addressing, and byte order. Work through them in this order: 1. **Port/connection unreachable (device stays offline)**. First confirm the slave IP and port 502 are reachable: `telnet <host> 502` or `nc -vz <host> 502`. The driver's socket timeout is 5 seconds; a failed connect throws `ConnectorException`. Note: after **3** consecutive connection failures the driver enters a **60-second backoff**, during which the health check reports offline immediately without retrying — once the network is fixed it recovers automatically within at most one backoff window. 2. **Connects but reads nothing / errors**. Check that the Point's `slaveId` is correct (mismatches are common with multiple slaves behind a gateway) and that `functionCode` matches the actual register type at that address (using `03` for holding registers against a read-only discrete input throws an exception). A failed read throws `ReadPointException`, invalidates that device's connection, and reconnects on the next cycle. 3. **`offset` filled in as an address like 40001**. This is the most frequent mistake — see the pitfall container below. `offset` is a 0-based protocol offset, not the PLC-conventional 4xxxx numbering. 4. **Wrong value / bytes swapped (byte order)**. 32/64-bit values span multiple registers, and devices differ in their word-order/byte-order conventions. The driver reads with the default word order of the underlying modbus4j — if a float comes out clearly garbled (e.g. `25.0` read as an astronomical number), the device-side word order usually differs from the default. The driver attributes do not currently expose a word-order switch, so for such devices adjust the register mapping on the device side, or read as integers and convert yourself. 5. **Write command returns failure**. First confirm `functionCode` is `1` or `3` per the "write function code" warning above; if it still fails, check the target is a writable space (discrete inputs `02` and input registers `04` are physically read-only and cannot be written). A failed write throws `WritePointException` and invalidates the connection. 6. **Flapping online status**. The health check runs every 15 seconds by default, with a 45-second lease timeout. If a device flaps between online/offline, it is usually packet loss or a slave responding slower than the 5-second timeout — see [Device](../introduction/concepts/device) for the online-status mechanism. ::: warning offset is a 0-based protocol address, not 40001 `offset` is the protocol-layer 0-based offset. To read "holding register 40001" in the conventional Modbus notation, set `functionCode=3` and `offset=0` (the 2nd holding register is `offset=1`, and so on). Putting `40001` directly into `offset` reads the wrong address or errors out of range. ::: ## How it lands in IoT DC3 - **`dc3.driver.code`**: `ModbusTcpDriver` (type `DRIVER_CLIENT`, actively connects to the slave). This is a stable routing identifier — do not change it casually. - **Read**: ✓ implemented. Supports function codes `1/2/3/4` (coils/discrete inputs/holding registers/input registers), assembling multi-register quantities by Point type. - **Write**: ✓ implemented, but **only** `functionCode=1` (write coil) and `functionCode=3` (write holding register); other codes return failure (see the write function-code warning above). - **Subscribe/report**: — not supported. Modbus is a master/slave polling model; the driver only actively reads/writes and never passively receives pushes. This matches the "✓ / ✓ / —" for Modbus TCP 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`; the `custom` schedule is disabled by default. - **Health/online**: device health check defaults to cron `0/15 * * * * ?`, with a lease timeout of `45 seconds`. ::: info Implementation status: available This driver is a **complete implementation** (not a skeleton), built on modbus4j. The read path covers all four register spaces, the write path covers coils and holding registers, and it includes connection caching and failure backoff. The only caveat is that the yml annotation for write function codes (`[5,6,15,16]`) is broader than the code (only `1/3`) — configure explicitly per the warning above and it works. ::: ### Minimal onboarding example Onboard a Modbus slave at IP `192.168.1.10:502`: 1. Create a [Device](../introduction/concepts/device) using `Modbus TCP Driver`, and set the driver attributes `host=192.168.1.10` and `port=502`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `slaveId=1`, `functionCode=3`, `offset=0`. 3. Start the driver, and within 30 seconds you will see the collected value in [PointValue](../introduction/concepts/point-value). 4. If the Point must be writable, configure a write [Command](../introduction/concepts/command) for it and set `functionCode` explicitly to `3` (write holding register). ::: tip One driver instance can serve multiple slaves A single Modbus TCP driver process can serve multiple devices. When several slaves hang off the same gateway under different unit IDs, `host` is the same and they are distinguished by the Point's `slaveId`; devices at different IPs each hold their own cached connection. ::: ## Further reading - [Driver overview](./index) — entry point and taxonomy for all drivers - [Driver capability matrix](./matrix) — read/write/subscribe at a glance, including the Modbus TCP row - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Industrial Buses & Protocols](../foundations/fieldbus) — the addressing model and byte-order principles behind Modbus and friends - [Modbus RTU Driver](./modbus-rtu) — the serial version of Modbus --- # MQTT Driver URL: https://docs.dc3.site/en/drivers/mqtt <script setup> import MqttDiagram from '../../.vitepress/theme/components/MqttDiagram.vue' </script> # MQTT Driver > **`dc3-driver-mqtt` onboards MQTT devices into IoT DC3**—the driver acts as a server, stays subscribed to MQTT topics, > passively receives payloads that devices publish, parses them > into [PointValues](../introduction/concepts/point-value), > and supports downstream write commands by publishing payloads to a command topic. This page explains which broker it > consumes, how to fill in the point / command / event attributes, where to look when values never arrive, and which > driver type it is and how far it is implemented. When you finish, you can onboard a device that reports to its own topic and can be commanded, and know where to look when the link goes quiet. ## Protocol background MQTT (Message Queuing Telemetry Transport) is the de-facto lightweight **publish/subscribe** message bus of IoT. It runs over TCP, default port `1883` (TLS `8883`). Its semantics are the opposite of the fieldbus "master polls every device" model: devices are not polled—they actively **publish** data to a **topic**, and the platform **subscribes** to those topics to receive the reports. Publisher and subscriber are decoupled by a **broker** (a message relay server such as EMQX, Mosquitto, or the RabbitMQ MQTT plugin)—neither needs the other's address, nor must they be online at the same time. That is what makes the model power-efficient and horizontally scalable for fleets of low-power, wide-area devices. In the [four-layer IoT reference architecture](../foundations/iot-protocols), MQTT belongs to the **network layer**, under "application-layer messaging protocols": it defines what a message looks like, how it is delivered, and how reliable that delivery is, orthogonal to whether the underlying wireless is Wi-Fi or NB-IoT. For the trade-offs between MQTT and CoAP/LwM2M/HTTP, see the [network-layer chapter](../foundations/iot-protocols). A few MQTT concepts this driver relies on repeatedly: - **Topic**: the logical address of a message, e.g. `device/1001/up`. Publishers send to a topic; subscribers receive by topic. Subscriptions may use the wildcards `+` (one level) and `#` (any trailing levels). - **QoS (Quality of Service)**: the delivery guarantee—`0` = at most once, `1` = at least once, `2` = exactly once. Higher levels are more reliable but costlier; both publisher and subscriber declare their own, and the weaker side prevails. - **JSON path (Path)**: a dot-notation path to locate a field in a reported payload, e.g. `$.payload` picks `payload` under the root, `$.eventCode` picks the event-code field. Unlike Modbus or HTTP drivers that actively connect to devices, this driver is a * *[Driver](../introduction/concepts/driver) of type `DRIVER_SERVER`**: it does not actively "read" devices but stays subscribed and waits for devices to push data up. It therefore has **no device-level `driver-attribute` table**—which broker to connect to is deployment-level config (below), not filled per device. ## Attribute configuration MQTT driver config splits into two layers: the **broker connection** is deployment-level (the whole driver connects to one broker), while the **point / command / event attributes** are device/point-level (deciding which topic each point writes to and which topic events come from). ### Broker connection (deployment-level, env vars) The driver connects to the broker via a `dc3.driver.mqtt.*` block whose values come from deployment environment variables. Key items: | Setting | Env var | Default | Purpose | |---------------------|-------------------------------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------| | Broker address | `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT` | `dc3-rabbitmq` / `1883` (dev profile `2883`) | Broker host and port, assembled into the connection URL (plaintext `tcp://host:port` by default; TLS uses `ssl://host:8883` as a production option) | | Username / password | `MQTT_USERNAME` / `MQTT_PASSWORD` | `dc3` / empty (the docker-compose stack injects `dc3dc3dc3`) | Auth credentials (auth types: `NONE` / `USERNAME` / `CLIENT_ID` / `X509`); the password falls back to empty in app config and is injected by the deployment | | Keep-alive | no env binding (`dc3.driver.mqtt.keep-alive`) | `15` (s) | Client heartbeat interval, hard-coded default | | Completion timeout | no env binding (`dc3.driver.mqtt.completion-timeout`) | `3000` (ms) | Wait timeout for a publish operation, hard-coded default | | Batch thresholds | `MQTT_BATCH_SPEED` / `MQTT_BATCH_INTERVAL` | `100` / `5` | Ingest batching: flush at 100 messages or 5 s, whichever comes first | ::: info The broker defaults to the RabbitMQ MQTT plugin The default MQTT broker is the **RabbitMQ MQTT plugin** (`dc3-rabbitmq`), addressed via `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT`; the docker-compose stack injects `dc3-rabbitmq:1883` (the dev profile YAML falls back to port `2883`). **EMQX** is the optional broker in `docker-compose-optional.yml` (host-mapped port `31883`), not the default. The RabbitMQ MQTT plugin and the platform's internal **RabbitMQ AMQP** (with a separate `dc3.e.mqtt` bridge exchange) used for inter-service messaging are two protocols on the same broker—when onboarding MQTT devices, point those two env vars at your actual MQTT broker. Across the public internet, enable TLS (`8883` / X509 certificates). ::: ### Point configuration (`point-attribute`) On each [Point](../introduction/concepts/point), fill in the point's **command target topic** and delivery quality. Acquisition is passive via subscription, so point attributes only concern downstream write commands: | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|----------------|-----------------------------------------------------------------------| | Command Topic | `commandTopic` | STRING | `commandTopic` | MQTT topic used by the point or device to receive downstream commands | | Command QoS | `commandQos` | INT | `2` | QoS level for the downstream command topic | When dispatching a write command, the driver takes `commandTopic` from the point attributes and publishes the value to it at `commandQos` (falling back to the default QoS if it is missing or an error occurs). ### Write command configuration (`command-attribute`) A writable point fills in, on its write command, which topic to publish to, which QoS to use, and what the payload looks like: | Attribute | code | Type | Default | Remark | |------------------|-------------------|--------|----------------|--------------------------------------------------------------| | Command Topic | `commandTopic` | STRING | `commandTopic` | MQTT topic used by the command to publish downstream payload | | Command QoS | `commandQos` | INT | `2` | QoS level for the command publish topic | | Payload Template | `payloadTemplate` | STRING | `{}` | Payload template rendered with command params | When executing a command, the driver substitutes the `${xxx}` placeholders in `payloadTemplate` with the command parameters (plus context such as `deviceId` / `deviceCode` / `deviceName` / `commandId` / `commandCode` / `commandName`), publishes the rendered payload to `commandTopic` at `commandQos`, and returns `topic` / `qos` / `payload` as the execution result. ### Event configuration (`event-attribute`) When a device reports an event, the driver extracts the "event code" and "event payload" from the subscribed message by topic and path: | Attribute | code | Type | Default | Remark | |-----------------|-----------------|--------|---------------|-------------------------------------------------------------------------| | Source Topic | `sourceTopic` | STRING | `eventTopic` | MQTT topic used to receive event payload (supports `+` / `#` wildcards) | | Event Code Path | `eventCodePath` | STRING | `$.eventCode` | JSON path used to resolve event code | | Payload Path | `payloadPath` | STRING | `$.payload` | JSON path used to resolve event payload | When an arriving topic matches `sourceTopic` (exact or wildcard), the driver resolves the event code via `eventCodePath` and the payload via `payloadPath`, and for device events whose code matches and that are enabled, assembles an event report and sends it to the Data Center. ## How data is received In MQTT, a "read" is not an outbound request but a callback fired when a subscribed message arrives. The diagram below traces a reported value from device to platform—both device and driver only talk to the broker: <MqttDiagram lang="en" /> For a payload to become a [PointValue](../introduction/concepts/point-value), it must resolve a `deviceId` and a `pointId` (otherwise that message is skipped); a parse failure only logs a warn and does not affect other messages. Batch messages go through `receiveValues()` and are sent together, flushed once a batch threshold (`MQTT_BATCH_SPEED` / `MQTT_BATCH_INTERVAL`) is hit. ## Troubleshooting ::: warning It is a server—it will not "connect" to devices `DRIVER_SERVER` means the driver waits for devices to push data, rather than actively polling. If [PointValues](../introduction/concepts/point-value) never arrive, **first confirm the device is actually publishing to the subscribed topic** and that the topic strings match exactly on both sides (including case and the level separators `/`)—rather than checking the driver's "acquisition interval", since scheduled reads are off by default here (`schedule.read.enable=false`). ::: - **Broker unreachable**: check that `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT` point at a real MQTT broker (default `dc3-rabbitmq:1883`) and that `MQTT_USERNAME` / `MQTT_PASSWORD` match the broker account; for public/TLS setups confirm the port is `8883` and the certificate matches. - **Messages arrive but no point values**: a payload must resolve a `deviceId` and a `pointId`, or it is silently skipped. Look for the `MQTT point value parse failed` warn in the driver log—usually the reported JSON lacks those two fields or is not valid JSON. - **QoS mismatch causes missed/duplicated messages**: align QoS on the publishing side and the device's subscribing side. When `commandQos` is missing or errors, the driver **falls back to the default QoS** so the command is still sent, but mismatched levels can still downgrade—use `1` or `2` on both sides for reliable dispatch. - **Empty payload or unsubstituted placeholders**: a command does not auto-insert the value; author a template with placeholders such as `${value}` in `payloadTemplate` (e.g. `{"value":${value}}`). An empty template sends the empty object `{}`. Placeholder names must match the command parameter keys to be substituted. - **Events not received**: confirm the arriving topic matches `sourceTopic` (the `+` wildcard matches exactly one level; `#` only at the end), that the code resolved by `eventCodePath` matches the device event's `eventCode`, and that the event is enabled. - **Device shows "online" but no data**: MQTT is passive push, so "nothing for a while" does not mean the link is down. Online detection uses lease/keep-alive, not an acquisition interval—health check defaults to cron `0/15 * * * * ?` with a `45 second` lease timeout; see [Device](../introduction/concepts/device) for the mechanism. ## How it lands in IoT DC3 - **Driver name / code**: `MQTT Driver` / `MqttDriver` - **Type**: `DRIVER_SERVER` (the driver acts as a server and passively receives device reports) - **Capabilities** (consistent with the [driver capability matrix](./matrix)): read `—`, write `✓`, subscribe/report `✓` —values arrive passively via subscription, with no active read; commands can be dispatched and events reported. ::: info Implementation status: ingestion, command dispatch, and health check are implemented; `initial()` is a skeleton Per the `MqttDriverCustomServiceImpl` and `MqttReceiveServiceImpl` source: **data reception** (parse to point value and forward, event report with topic matching), **write commands** (`write()` / `execute()` publishing the payload, QoS fallback, template rendering), and the **`health()` check** (listening to `MqttSubscribedEvent` / `MqttConnectionFailedEvent` to reflect the broker connection state in real time) are implemented; `read()` returns `null` by pub/sub semantics (data arrives passively via subscription—not a defect). The only reference stub left is `initial()`, an empty initialization template. ::: Minimal onboarding example—onboard a device that reports to `device/1001/up` and receives commands on `device/1001/down`: 1. At deployment, point `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT` at your broker (default `dc3-rabbitmq:1883`, the RabbitMQ MQTT plugin), and create a [Device](../introduction/concepts/device) with `MQTT Driver` (this driver has no driver attributes to fill). 2. Add a writable [Point](../introduction/concepts/point) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `commandTopic=device/1001/down`, `commandQos=1`. 3. Once the device publishes data to the subscribed topic, the [PointValue](../introduction/concepts/point-value) is received passively; when a write command is dispatched, the driver renders the payload per `payloadTemplate` and publishes it to `device/1001/down`. For the full flow, see [Device Onboarding](../operation/device-onboarding). ## Further reading - [Drivers overview](./index) — the full landscape and categories of the 28 drivers - [Driver capability matrix](./matrix) — read/write/subscribe capabilities at a glance - [Device Onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Network layer: IoT protocols](../foundations/iot-protocols) — trade-offs between MQTT and CoAP/LwM2M/HTTP - [CoAP Driver](./coap) — a lightweight request/response protocol for constrained endpoints --- # MySQL Driver URL: https://docs.dc3.site/en/drivers/mysql <script setup> import MysqlDiagram from '../../.vitepress/theme/components/MysqlDiagram.vue' </script> # MySQL Driver `dc3-driver-mysql` onboards a MySQL database into IoT DC3 as a data source: acting as a database client, it runs a `SELECT` on each polling cycle and uses the queried value as the reading, and supports writing values into the database via the `UPDATE`/`INSERT` write query configured on the point. After reading this you can set the connection parameters on a [Device](../introduction/concepts/device), the read/write SQL on each [Point](../introduction/concepts/point), and pinpoint common "can't connect / no value / write fails" problems. > You are here: a driver that onboards an existing database as a data source. Not all data comes from a fieldbus > device—much business data, historical data, and third-party results simply live in a MySQL table. ## Protocol background MySQL is the world's most widely used open-source relational database, born in 1995, using SQL as its query language and organizing data into tables/rows/columns. In IoT scenarios it is often not a "field device" but a hub where data converges: business systems such as MES/ERP, third-party platforms, and historical archives all tend to drop their results into a MySQL table for downstream consumption. Onboard such a table as a data source, and the platform can poll its columns into [PointValues](../introduction/concepts/point-value) just like it polls a real device. This driver acts as a database client ([Driver](../introduction/concepts/driver) type `DRIVER_CLIENT`), connecting to a MySQL database over JDBC (`mysql-connector-j`, driver class `com.mysql.cj.jdbc.Driver`) and reading/writing values by the SQL configured on each [Point](../introduction/concepts/point). Its communication model is classic * *request-response**—the driver, as a client, actively issues queries; the database never pushes, so collection is driven by cron polling. The shared logic for JDBC connections, connection pooling, and SQL execution lives in the abstract base class `AbstractJdbcDriverCustomService` (`dc3-common-sql` module), reused by all four database drivers (MySQL, PostgreSQL, Oracle, SQL Server), each of which only supplies JDBC URL construction and the driver class name. Seen through the [IoT data pipeline](../foundations/data-pipeline), database drivers sit at the entry point of "moving externally-structured data into the platform": the sensing layer and fieldbus protocols digitize physical quantities, while the MySQL driver onboards results already accumulated in a database into the same pipeline—ultimately stored, queried, and consumed by alarms and AI just like values collected from real devices. Two driver-specific concepts that the configuration tables below rely on: <MysqlDiagram lang="en" /> - **Read Query**: a `SELECT` configured on the point; the driver runs it on each polling cycle and takes the **first column of the first row** of the result as the point's value. - **Write Query**: an `UPDATE`/`INSERT` configured on the point, using a single `?` placeholder for the value to write—when a write command fires, the command parameter is bound via prepared-statement parameter binding. ## Attribute configuration Onboarding a MySQL database requires filling in [attributes](../introduction/concepts/attribute-config) at three levels: device-level connection parameters (`driver-attribute`), each polled point's read/write SQL (`point-attribute`), and one reserved attribute on the write command (`command-attribute`). The attributes, types, and defaults below are taken from the driver's `application.yml` (`dc3-driver-mysql` module). ### Driver attributes (device-level `driver-attribute`) Driver attributes answer "which database to connect to, which account to use, and the query timeout". Fill in one set per MySQL database on the [Device](../introduction/concepts/device): | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|-------------|------------------------------| | Host | `host` | STRING | `localhost` | MySQL host IP or hostname | | Port | `port` | INT | `3306` | MySQL port (standard 3306) | | Database | `database` | STRING | (empty) | MySQL database name | | Username | `username` | STRING | `root` | MySQL username | | Password | `password` | STRING | (empty) | MySQL password | | Query Timeout | `queryTimeout` | INT | `30` | SQL query timeout in seconds | The driver builds the JDBC URL from `host`, `port`, and `database`, in the form `jdbc:mysql://host:port/database?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC`. All five of `host`, `port`, `database`, `username`, and `password` are required—configuration validation (`validate()`) checks each one, and any missing field fails. The driver caches one HikariCP connection pool per device ID (one pool per device, max 5 connections), with the connection timeout set to `queryTimeout × 1000` milliseconds. ::: tip queryTimeout applies to both connecting and the query pace `queryTimeout` (default 30 seconds) is used as the pool's `connectionTimeout`: failing to acquire a connection, or a connection that stalls beyond this duration, fails. It is separate from the polling interval—if a slow SQL consistently approaches or exceeds it, optimize the SQL or add an index rather than just raising the timeout. ::: ### Point attributes (`point-attribute`) Point attributes answer "which value to query from this database, and where to write". Fill in the read/write SQL on each polled [Point](../introduction/concepts/point): | Attribute | code | Type | Default | Remark | |-------------|--------------|--------|---------|-----------------------------------------------------------------------------------------------| | Read Query | `readQuery` | STRING | (empty) | `SELECT` query for reading the point value | | Write Query | `writeQuery` | STRING | (empty) | `UPDATE`/`INSERT` using a single `?` placeholder for the written value (bound as a parameter) | ::: tip Read Query takes the first column of the first row `readQuery` is a plain `SELECT`, and the driver takes the **first column of the first row** of its result ( `rs.getObject(1)`) as the point's value—so a single-row, single-column query like `SELECT temperature FROM sensor WHERE id = 1` is the safest form. An empty result set yields `null`. The point's data type ([Point](../introduction/concepts/point) `pointTypeFlag`) decides how that value is parsed. `readQuery` is required on a point (enforced by `validatePoint()`); without it, point validation fails. `writeQuery` is required only when that point is to be written. ::: ### Write command attributes (`command-attribute`) This attribute can be configured on the write command, but is not consumed by the implementation: | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|---------|--------------------------------------| | Execute Query | `executeQuery` | STRING | (empty) | SQL query to execute for the command | ::: warning executeQuery is currently not consumed by the implementation Writing a value goes through the **point's `writeQuery`**: `write()` reads the `point-attribute` `writeQuery`, binds the command parameter with `setString(1, value)` into the single `?` placeholder, and executes the `UPDATE`/`INSERT`. The `command-attribute` `executeQuery` is kept only as a configuration item—nothing in the current driver code reads or executes it. There is no separate "run a SQL statement directly by command" path; writing always goes through `writeQuery`. Code is the source of truth. ::: ## Troubleshooting MySQL onboarding failures mostly cluster around connection, account permissions, query targeting, and field types. Work through them in order: 1. **Can't connect (device stays offline)**. First confirm `host:port` is reachable: `telnet <host> 3306` or `nc -vz <host> 3306`. The health check decides online via `conn.isValid(5)` (whether a valid connection can be obtained within 5 seconds); a failed connect or heartbeat reports offline. Common root causes: the database not listening on the public/container network, a firewall, or a `bind-address` restriction. 2. **Connects but is rejected (account / permission / SSL)**. Confirm `username`/`password` are correct and the account is authorized to connect from the driver's host (MySQL accounts are scoped as `user@host`; `root@localhost` cannot connect remotely). The URL the driver builds carries `useSSL=false&allowPublicKeyRetrieval=true`—if the target database enforces SSL or disables public key retrieval, the connect phase fails. A failed connect throws `ConnectorException` and invalidates that device's pool, which is rebuilt on the next cycle. 3. **No value / wrong row returned**. The driver only takes the first column of the first row, so `readQuery` must reliably pinpoint the target row. An empty result set yields `null`; when multiple rows return, only the first is used and may not be the row you meant. Write the `WHERE` primary-key condition fully so you don't pick the wrong row as the table grows. 4. **Value / type mismatch**. The point's `pointTypeFlag` decides how the returned string is parsed. Configuring a text column as a `FLOAT` point, or treating a date/enum column as numeric, can fail parsing. Use `CAST`/`CONVERT` in `readQuery`, or select only the target numeric column, so the returned value matches the point's type. 5. **Write command returns failure**. Writing requires exactly one `?` placeholder in `writeQuery` and a statement targeting a writable table and row. `write()` treats "affected rows > 0" as success—if the `WHERE` condition matches no rows, `executeUpdate()` returns 0 and the write is judged failed. Run the same `UPDATE` by hand in the database first to confirm it hits a row. A failed write throws `WritePointException` and invalidates the pool. 6. **Slow SQL drags down collection**. A `readQuery` that full-scans a large table or lacks an index may not return within the polling cycle, or may approach `queryTimeout`. Indexing the predicate columns and selecting only the necessary columns is a better fix than simply raising the timeout. ::: warning Write Query uses a `?` placeholder, not `${value}` When writing, `writeQuery` uses a **single** `?` placeholder for the value (e.g. `UPDATE sensor SET temperature = ? WHERE id = 1`), bound by the driver via `PreparedStatement.setString(1, value)`—this is prepared-statement parameter binding, not string concatenation, so a malicious value cannot alter the statement structure (no SQL injection). Do not concatenate the value into the SQL by hand, and do not use template syntax like `${value}`: it would neither be substituted nor give you injection protection. ::: ## How it lands in IoT DC3 - **`dc3.driver.code`**: `MysqlDriver` (type `DRIVER_CLIENT`, actively connects to the database and issues queries). This is a stable routing identifier—do not change it casually. - **Read capability**: ✓ implemented. `read()` executes the point's `readQuery` and takes the first column of the first row as the point value. - **Write capability**: ✓ implemented. `write()` executes the point's `writeQuery`, binding the written value as a `?` prepared-statement parameter; affected rows > 0 means success. - **Subscribe/report**: — not supported. MySQL is request-response; the driver only actively queries/writes and never passively receives pushes. This matches the `✓ / ✓ / —` for MySQL in the [driver capability matrix](./matrix). - **Polling interval**: default cron `0/30 * * * * ?` (read once every 30 seconds), configured under `schedule.read` in the driver's `application.yml`; there is also a `custom` schedule with default cron `0/5 * * * * ?` (every 5 seconds), but the base class `schedule()` is an empty implementation and database drivers do not use it. - **Health/online**: device health check defaults to cron `0/15 * * * * ?` with a lease timeout of `45 seconds`; the verdict relies on `conn.isValid(5)`. See [Device](../introduction/concepts/device) for the online-state mechanism. ::: info Implementation status: available This driver is a **complete implementation** (not a skeleton). Reading, writing, the health check, the per-device cached HikariCP pool, and pool invalidation-and-rebuild on failure are all in place, reusing the tested `AbstractJdbcDriverCustomService` base class. The only thing to note is that the `command-attribute` `executeQuery` is reserved but not consumed by the code—writing always goes through the point's `writeQuery` (see the warning above). ::: ### Minimal onboarding example Onboard the `temperature` column of the `id=1` row in a `sensor` table as a temperature point: 1. Create a [Device](../introduction/concepts/device) with `MySQL Driver`, and set the driver attributes `host=192.168.1.10`, `port=3306`, `database=iot`, `username=root`, `password=******`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attribute `readQuery=SELECT temperature FROM sensor WHERE id = 1`. 3. Start the driver, and within 30 seconds the queried temperature shows up in the [PointValue](../introduction/concepts/point-value). 4. If the point should be writable, add `writeQuery=UPDATE sensor SET temperature = ? WHERE id = 1` to its point attributes and configure a write [Command](../introduction/concepts/command) for it. ::: tip One driver instance can serve multiple databases A single MySQL driver process can serve multiple devices: each device connects to its own database per its driver attributes and holds its own connection pool (cached by device ID). When device metadata is deleted/updated, the corresponding pool is closed and rebuilt on demand. ::: ## Further reading - [Drivers overview](./index) — entry point and categories for all drivers - [Driver capability matrix](./matrix) — read/write/subscribe at a glance, including the MySQL row - [Device Onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Time-Series Data & Stream Processing](../foundations/data-pipeline) — how PointValues are stored, computed, and queried after entering the platform - [PostgreSQL Driver](./postgresql) — another database data source on the same JDBC base class --- # OPC DA Driver URL: https://docs.dc3.site/en/drivers/opc-da <script setup> import OpcDaDiagram from '../../.vitepress/theme/components/OpcDaDiagram.vue' </script> # OPC DA Driver `dc3-driver-opc-da` acts as an OPC DA client: it connects to a field OPC DA Server over Windows DCOM and periodically reads real-time values by the group and tag configured on each Point, and can also write values back to a tag. This page explains what OPC DA is, which attributes the driver exposes, how to troubleshoot connection failures, and its implementation status inside IoT DC3. > You are here: about to onboard a device that already has an OPC DA Server into DC3. Learn the protocol first, then > fill in [Attribute configuration](#attribute-configuration), and consult [Troubleshooting](#troubleshooting) if you > get > stuck. ## Protocol background OPC DA (OPC Data Access) is the classic industrial data-access specification on the Windows platform. Born in the PC era, it applies Microsoft's COM/DCOM component technology to the industrial floor: SCADA systems, HMI/configuration software, and PLC gateways mostly embed an OPC DA Server that exposes the points of underlying PLCs and instruments as " items". Upper systems read and write through a uniform OPC DA client without caring about each PLC's proprietary protocol. Common versions are OPC DA 2.0 / 3.0. In the [four-layer IoT architecture](../foundations/fieldbus), OPC DA sits at the **network layer** — it is the " universal translation layer" between field devices and upper systems. But its network transport is not an ordinary TCP port; it is **DCOM (Distributed COM)**: the client locates a Server by a COM class identifier (CLSID), and calls travel as DCOM remote procedure calls. This is why deploying and debugging OPC DA carries a heavy Windows footprint — and why the cross-platform [OPC UA](./opc-ua) was created later. <OpcDaDiagram lang="en" /> OPC DA organizes tags as "items under a group": the client first creates or finds a group on the Server, then adds the items to read/write into that group, and reads or writes their values on demand. Each item value carries a COM variant type (VARIANT), such as `VT_I4` (integer), `VT_R8` (double), `VT_BOOL`, `VT_BSTR` (string); the driver converts these into point values accordingly. ::: warning DCOM is a prerequisite, and only on Windows OPC DA is built on Windows COM/DCOM. The Server must run on a Windows host reachable over DCOM, with DCOM permissions configured at the OS level to allow remote access from the driver host. This is not part of the driver configuration, yet it is the decisive factor in whether a connection succeeds. ::: ## Attribute configuration OPC DA onboarding parameters come in two layers: **driver attributes** describe "which Server to connect to" ( device-level, one set per device), and **point attributes** describe "which tag to read" (point-level, one set per point). The two tables below come from the driver's `application.yml` (`driver-attribute` / `point-attribute`); the defaults are the driver's built-in defaults. ### Driver attributes (device-level `driver-attribute`) When onboarding an OPC DA device, fill in these [attributes](../introduction/concepts/attribute-config) on the [Device](../introduction/concepts/device). `host` points at the Server host, `clsId` locates the specific OPC DA Server, and `username` / `password` are the Windows credentials used for DCOM remote access: | Attribute | code | Type | Default | Description | |-----------|------------|--------|----------------------------------------|----------------------------------------------------| | Host | `host` | STRING | `localhost` | Host (IP or hostname) where the OPC DA Server runs | | CLSID | `clsId` | STRING | `F8582CF2-88FB-11D0-B850-00C0F0104305` | COM class identifier of the target OPC DA Server | | Username | `username` | STRING | `dc3` | Windows username for DCOM remote access | | Password | `password` | STRING | `dc3dc3` | Corresponding password | ::: tip CLSID is the OPC DA Server's COM identifier, not a port OPC DA locates a Server through a COM class identifier (CLSID), not a TCP port. The CLSID is determined by the vendor of the target OPC DA Server and can be found in the registry on the Server host or via an OPC server browsing tool. The default in `application.yml` is only a placeholder — always replace it with the real Server's CLSID when onboarding. ::: ### Point attributes (`point-attribute`) Fill in `group` and `tag` on each collected [Point](../introduction/concepts/point). The driver first finds (or creates) the group on the Server by `group`, then locates the tag item within that group by `tag` and reads/writes its value: | Attribute | code | Type | Default | Description | |-----------|---------|--------|---------|----------------------------------| | Group | `group` | STRING | `GROUP` | OPC DA group name | | Tag | `tag` | STRING | `TAG` | Full item name of the OPC DA tag | ::: info group / tag must match what the Server browses Naming styles differ by vendor; `tag` is commonly something like `Channel1.Device1.TagA`. These names must match the target Server's actual naming and should be taken from the Server's browse output, not guessed — a mismatched name makes the driver fail to find the item in that group and the read fails. ::: ### Collection and health - **Collection cycle**: default cron `0/30 * * * * ?` (one read round every 30 seconds), controlled by `dc3.driver.schedule.read`. - **Custom task**: `dc3.driver.schedule.custom` defaults to cron `0/5 * * * * ?`, but this driver's `schedule()` is an empty implementation (device liveness is owned by the SDK health job). - **Health/online**: device health check defaults to cron `0/15 * * * * ?`, with a lease timeout of `45 seconds` — see [Device](../introduction/concepts/device) for the online-status mechanism. ## Troubleshooting When OPC DA fails to connect, the vast majority of problems lie in DCOM and naming, not in the driver code itself. Work through them in order: 1. **Cannot connect / `ConnectorException`**: the most common root cause is DCOM. Check whether the Server host firewall allows DCOM ports, whether the remote account is granted "remote activation / remote access" permissions, and whether `username` / `password` is a valid account on that Windows host with rights to the Server. Best practice: first verify with a third-party OPC client tool on the driver host that you can connect to that CLSID, then onboard into DC3. 2. **Wrong CLSID**: `clsId` must be the target Server's real CLSID (the default is only a placeholder). Confirm it in the Server host registry or an OPC browsing tool; a wrong CLSID fails at the connection stage. 3. **Read fails / `ReadPointException`**: usually `group` or `tag` does not match the Server's naming, so the driver's `addItem` cannot find the tag under that group. Check the Point's `group` / `tag` character by character. On a read failure the driver **disposes and removes that device's connection**, and the next collection round reconnects automatically — if the tag name on the Server side stays wrong, it keeps failing. 4. **Write fails / `WritePointException` or `UnSupportException`**: writing only handles the `SHORT / INT / LONG / FLOAT / DOUBLE / BOOLEAN / STRING` point types. Only a completely unrecognized type code throws `UnSupportException`; a known type that is not in the handled set (such as `BYTE`) does not throw — the write is reported as a **failure returning `false`** (no value written). Also confirm the target item is writable on the Server and the current account has write permission. 5. **Device shows offline**: online state is maintained by the SDK health job (default 15-second check, 45-second lease). If the connection is repeatedly disposed and rebuilt due to DCOM jitter, the device toggles online/offline — stabilize the DCOM link first. 6. **Cross-platform limits**: the driver itself, based on J-Interop (a pure-Java DCOM implementation), can run on Linux, but the **peer Server must be a Windows DCOM endpoint**. Do not expect to connect to a non-Windows OPC DA endpoint. ## How it lands in IoT DC3 - **dc3.driver.code**: `OpcDaDriver` (a stable routing identifier tied to messaging routing and registration — do not change it casually). - **Driver name / type**: `OPC DA Driver` / `DRIVER_CLIENT` (actively connects to the OPC DA Server). - **Capabilities**: read ✓, write ✓, subscribe — , consistent with the [driver capability matrix](./matrix). Reads run on the periodic collection cycle (cron `0/30`); writes run on dispatched commands. The driver does not use OPC DA's change-subscription push; it polls actively on a cycle. ::: warning Implementation status: code is complete, but running depends on Windows / DCOM infrastructure `read()` / `write()` in `OpcDaDriverCustomServiceImpl` are **fully implemented** on top of the bundled OpenSCADA OPC DA client library (J-Interop): connections are established via `Server.connect()` and cached per device, reads call `item.read()` and convert by COM variant type, and writes build a `JIVariant` and call `item.write()`. A stale " work-in-progress skeleton / see TODO markers" warning lingers in the class javadoc, but there are no TODO markers left in the method bodies and it no longer matches the implementation — trust the method bodies. The real barrier is not the code but the requirement of a DCOM-configured Windows OPC DA Server to actually run end to end; without that environment it cannot be verified. ::: ::: tip Minimal onboarding example Onboard one tag from an OPC DA Server running at `192.168.1.10`: 1. Create a [Device](../introduction/concepts/device) using `OPC DA Driver`, and set the driver attributes `host=192.168.1.10`, `clsId=` (the real Server's CLSID), and `username` / `password` (a Windows account allowed to access the Server remotely). 2. Add a [Point](../introduction/concepts/point) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `group=Group1` and `tag=Channel1.Device1.Tag1` (per the target Server's actual naming). 3. Start the driver, and within 30 seconds you will see the collected value in [PointValue](../introduction/concepts/point-value). ::: ## Further reading - [Driver overview](./index) — the unified model and registration mechanism for all drivers - [Driver capability matrix](./matrix) — a quick read / write / subscribe lookup across drivers - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Industrial Buses & Protocols](../foundations/fieldbus) — where OPC DA sits at the network layer and how it compares with peers - [OPC UA Driver](./opc-ua) — the cross-platform, subscription-capable next-generation OPC --- # OPC UA Driver URL: https://docs.dc3.site/en/drivers/opc-ua <script setup> import OpcUaDiagram from '../../.vitepress/theme/components/OpcUaDiagram.vue' </script> # OPC UA Driver `dc3-driver-opc-ua` connects OPC UA servers to IoT DC3: acting as an OPC UA client, it connects to one or more servers, periodically reads node values according to the namespace and identifier configured on each [Point](../introduction/concepts/point), and supports writing values to nodes. After reading this page you can onboard an OPC UA device, configure its points, and troubleshoot the common reasons a connection fails. ## Protocol background OPC UA (OPC Unified Architecture) is the cross-platform data-interoperability standard for industrial automation. PLCs, SCADA, MES, and edge gateways commonly embed an OPC UA server that exposes field data as a "node tree." It supersedes classic OPC (the Windows DCOM-based [OPC DA](./opc-da)), replacing it with the platform-independent `opc.tcp://` binary protocol (HTTPS is also supported) and building security (certificates, signing, encryption) and information modeling into the spec. In the [four-layer IoT architecture](../foundations/fieldbus), OPC UA belongs to the **network layer (fieldbus)**: it is the protocol boundary between shop-floor devices and upper systems, facing PLCs/controllers downward and handing data to the data platform upward. Unlike Modbus, which addresses by register, or Ethernet/IP, which uses CIP tags, OPC UA addresses with an **object model** — every data point is a node, uniquely identified by a **NodeId**. A NodeId has two parts: - **namespace index**: an integer that separates identifier spaces from different sources to avoid collisions. - **identifier**: the node's name within that namespace — a string, numeric, or GUID. This driver uses **string identifiers**. For example, `namespace=2` with identifier `Demo.Static.Float` uniquely locates the node named `Demo.Static.Float` under namespace 2. The driver is built on Eclipse Milo and acts as the OPC UA client, actively connecting to the server — a classic "master polling" model: it does not listen for device-pushed updates but reads nodes one by one on each collection cycle. - **Driver name / code**: `OPC UA Driver` / `OpcUaDriver` - **Type**: `DRIVER_CLIENT` (actively connects to the server) <OpcUaDiagram lang="en" /> ## Attribute configuration OPC UA onboarding parameters come in two layers: **driver attributes** on the [Device](../introduction/concepts/device) say "which server to connect to," and **point attributes** on each [Point](../introduction/concepts/point) say "which node to read/write." Both layers originate from the driver's `application.yml`; fill them in when creating a device/point, or leave them blank to use the defaults below. ### Driver attributes (device-level `driver-attribute`) These three attributes combine into the endpoint address `opc.tcp://<host>:<port><path>`, telling the driver which endpoint of which OPC UA server to connect to. | Attribute | code | Type | Default | Description | |-----------|--------|--------|-------------|---------------------------------| | Host | `host` | STRING | `localhost` | Server hostname or IP | | Port | `port` | INT | `18600` | Server `opc.tcp` listening port | | Path | `path` | STRING | `/` | Endpoint path | For example, `host=192.168.1.20`, `port=4840`, `path=/milo` combine into `opc.tcp://192.168.1.20:4840/milo`. `host` and `port` are required (the driver's `validate()` checks both are non-empty); `path` may keep the default `/`. During endpoint discovery the driver connects to the **first** endpoint the server returns. ### Point attributes (`point-attribute`) Fill in these two on each collected point; together they form the target node's NodeId. | Attribute | code | Type | Default | Description | |-----------|-------------|--------|---------|-------------------------------| | Namespace | `namespace` | INT | `5` | Namespace index | | Tag | `tag` | STRING | `TAG` | String identifier (node name) | ::: tip NodeId = namespace + tag The driver combines `namespace` (the namespace index) and `tag` (the string identifier) into `NodeId(namespace, tag)` for reads and writes. For example, `namespace=2` and `tag=Demo.Static.Float` resolve to the node named `Demo.Static.Float` under namespace 2. The Point's data type (the `pointTypeFlag` of the [Point](../introduction/concepts/point)) must match the node's actual value type — read values are stringified before reporting, while writes pick the OPC UA data type from the Point type. ::: ::: info Write commands have no separate attribute The OPC UA driver has **no `command-attribute`**. To write a value to a node it reuses the Point's own `namespace` and `tag` to locate the target node, and the value type is decided by the Point type — the driver supports writing `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN` / `STRING` (see `writeNode()` in the source). So once a writable Point has its `namespace` and `tag` set, you can issue write commands without filling in any command attribute. ::: ### Collection and health check These come from `dc3.driver.schedule` and `dc3.driver.health` in `application.yml`; they are driver-level defaults, not configured per device. | Item | Config key | Default | Description | |------------------|-------------------------|------------------|---------------------------------------| | Collection cycle | `schedule.read.cron` | `0/30 * * * * ?` | Read all points once every 30 seconds | | Health check | `health.device.cron` | `0/15 * * * * ?` | Probe once every 15 seconds | | Lease timeout | `health.device.timeout` | `45` (seconds) | Mark offline if not renewed in time | The health check runs an idempotent `connect()` probe with the device's client: if it connects, the device is [online](../introduction/concepts/device); otherwise offline. ## Troubleshooting ::: warning The default port is 18600, not the standard 4840 The `port` default in the yml is `18600` (the port of the local built-in Milo sample server). In production, the vast majority of OPC UA servers use the standard port `4840`, so when onboarding a real device you must set `port` to the port the server actually listens on — do not just keep the default. `path` must also match the server's endpoint path: some servers expose the root path (set `/`), others a sub-path (such as `/milo` or `/OPCUA/SimulationServer`). Getting it wrong means the connection fails. ::: - **Anonymous identity rejected**: the driver connects with an anonymous identity (`AnonymousProvider`). If the server enforces username/password and disallows anonymous access, the connection is rejected. Enable anonymous access on the server, or open an anonymous policy for that endpoint first. - **Device stays offline**: the health check runs a `connect()` probe every 15 seconds and marks the device offline after repeated failures. First confirm the endpoint address formed from `host`/`port`/`path` is correct and reachable (`telnet host port` or `nc -vz host port` to verify the port is open), then confirm the server process is running and the firewall does not block the `opc.tcp` port. - **Read value is null or status code is not Good**: when reading a node, if the `StatusCode` is not Good or the value is empty, the driver throws `ReadPointException` and **proactively disconnects and evicts that connection** ( reconnecting on the next cycle). Common causes: a wrong NodeId (namespace or tag does not exist), no read permission on the node, or the node currently has no value. Use a tool such as UaExpert to verify the node `ns=<namespace>;s=<tag>` actually exists and is readable. - **Read/write timeout**: the driver's connect timeout is 5 s, read timeout 1 s, write timeout 1 s. Network jitter or a slow server easily causes timeouts, which likewise evict the connection and trigger a reconnect. If the server is genuinely slow, investigate the link latency on the network side rather than raising the per-point timeout. - **Write command has no effect**: the write value type must be one of `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN` / `STRING`, and must be compatible with the actual data type of the server node; on a type mismatch the server returns a non-Good status and the write is treated as failed. Confirm the Point's `pointTypeFlag` matches the server node type and that the node is writable by the client. - **Certificate-related errors**: on startup the driver generates a self-signed certificate `dc3-opc-ua-client.pfx` ( PKCS12, default password `password`, overridable via the `OPCUA_KEYSTORE_PASSWORD` environment variable) under the working directory `dc3/opc-ua`. If that directory is not writable, or certificate generation fails, the driver **falls back to a plain anonymous connection** (no client certificate). When the server's security policy requires a client certificate, a plain anonymous connection fails the handshake — make sure the certificate directory is writable and the generated client certificate is "trusted" on the server. ## How it lands in IoT DC3 - **`dc3.driver.code`**: `OpcUaDriver` — the driver's stable routing identifier in the system. Data and command paths address by it, so do not change it casually. - **Read**: ✓ implemented. On each collection cycle it calls `readValue()` per point to read the node, stringifies the value, and reports it as a [PointValue](../introduction/concepts/point-value). - **Write**: ✓ implemented. On a write command it reuses the Point's `namespace`/`tag` to locate the node and writes `INT`/`LONG`/`FLOAT`/`DOUBLE`/`BOOLEAN`/`STRING` based on the Point type. - **Subscribe/report**: — not provided. This driver is a master-polling model; it does not subscribe to the OPC UA server's data-change notifications (Subscription/MonitoredItem), only reads on a cycle. This matches the [driver capability matrix](./matrix) (read ✓ / write ✓ / subscribe —). ::: info Implementation status: available `OpcUaDriverCustomServiceImpl`'s `read()` / `write()` / `health()` / `validate()` / `event()` are all complete implementations (built on Eclipse Milo), not a skeleton. Reading nodes, writing the six types, connection caching and reconnect-on-failure, self-signed certificate generation, and clearing connections on device update/delete are all in place — it can be pointed at a real OPC UA server directly. ::: ### Minimal onboarding example Onboard a float node on the endpoint `opc.tcp://192.168.1.20:4840/milo`: 1. Create a [Device](../introduction/concepts/device) using `OPC UA Driver`, and set the driver attributes `host=192.168.1.20`, `port=4840`, `path=/milo`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `namespace=2`, `tag=Demo.Static.Float`. 3. Start the driver, and within 30 seconds you will see the collected value in [PointValue](../introduction/concepts/point-value). See [Device onboarding](../operation/device-onboarding) for the full walkthrough. ## Further reading - [Driver overview](./index) — all drivers and the common onboarding model - [Driver capability matrix](./matrix) — read / write / subscribe capabilities at a glance - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Industrial buses & protocols](../foundations/fieldbus) — the network layer and addressing model OPC UA belongs to - [OPC DA Driver](./opc-da) — the classic OPC (DCOM) version --- # Oracle Driver URL: https://docs.dc3.site/en/drivers/oracle <script setup> import OracleDiagram from '../../.vitepress/theme/components/OracleDiagram.vue' </script> # Oracle Driver `dc3-driver-oracle` onboards an Oracle database into IoT DC3 as a data source: acting as a database client, it runs a `SELECT` on each polling cycle and uses the queried value as the reading, and supports writing values into the database via the `UPDATE`/`INSERT` write query configured on the point. After reading this you can set the connection parameters on a [Device](../introduction/concepts/device) (including Oracle's distinctive SID / Service Name connection methods), the read/write SQL on each [Point](../introduction/concepts/point), and pinpoint common "can't connect / no value / write fails" problems. > You are here: a driver that onboards an existing database as a data source. Not all data comes from a fieldbus > device—much business data, historical data, and third-party results simply live in an Oracle table. ## Protocol background Oracle Database is the archetypal enterprise-grade relational database, first released in 1979, using SQL as its query language and organizing data into tables/rows/columns; it carries a great deal of core business systems and historical archives in finance, power, and manufacturing. In IoT scenarios it is often not a "field device" but a hub where data converges: business systems such as MES/ERP, third-party platforms, and historical databases all tend to drop their results into an Oracle table for downstream consumption. Onboard such a table as a data source, and the platform can poll its columns into [PointValues](../introduction/concepts/point-value) just like it polls a real device. This driver acts as a database client ([Driver](../introduction/concepts/driver) type `DRIVER_CLIENT`), connecting to an Oracle database over JDBC (`ojdbc11`, driver class `oracle.jdbc.OracleDriver`) and reading/writing values by the SQL configured on each [Point](../introduction/concepts/point). Its communication model is classic **request-response**—the driver, as a client, actively issues queries; the database never pushes, so collection is driven by cron polling. The shared logic for JDBC connections, connection pooling, and SQL execution lives in the abstract base class `AbstractJdbcDriverCustomService` (`dc3-common-sql` module), reused by all four database drivers (MySQL, PostgreSQL, Oracle, SQL Server), each of which only supplies JDBC URL construction and the driver class name. Seen through the [IoT data pipeline](../foundations/data-pipeline), database drivers sit beyond the network layer, at the entry point of "moving externally-structured data into the platform": the sensing layer and fieldbus protocols digitize physical quantities and the network layer delivers them, while the Oracle driver onboards results already accumulated in a database into the same pipeline—ultimately stored, queried, and consumed by alarms and AI just like values collected from real devices. Oracle's biggest difference from the other databases is **how it identifies a database instance**: Oracle locates an instance by either SID (System Identifier) or Service Name, and the driver builds a differently-shaped JDBC URL accordingly. Three driver-specific concepts that the configuration tables below rely on: <OracleDiagram lang="en" /> - **Connection Type**: `SID` or `ServiceName`. The driver builds a differently-shaped JDBC URL accordingly (see the diagram above); the two correspond to Oracle's different naming methods. - **Read Query**: a `SELECT` configured on the point; the driver runs it on each polling cycle and takes the **first column of the first row** of the result as the point's value. - **Write Query**: an `UPDATE`/`INSERT` configured on the point, using a single `?` placeholder for the value to write—when a write command fires, the command parameter is bound via prepared-statement parameter binding. ## Attribute configuration Onboarding an Oracle database requires filling in [attributes](../introduction/concepts/attribute-config) at three levels: device-level connection parameters (`driver-attribute`), each polled point's read/write SQL (`point-attribute`), and one reserved attribute on the write command (`command-attribute`). The attributes, types, and defaults below are taken from the driver's `application.yml` (`dc3-driver-oracle` module). ### Driver attributes (device-level `driver-attribute`) Driver attributes answer "which database to connect to, which account to use, which connection method, and the query timeout". Fill in one set per Oracle database on the [Device](../introduction/concepts/device): | Attribute | code | Type | Default | Remark | |-----------------|------------------|--------|-------------|--------------------------------------------------------------| | Host | `host` | STRING | `localhost` | Oracle host IP or hostname | | Port | `port` | INT | `1521` | Oracle port (standard 1521) | | Database | `database` | STRING | (empty) | Oracle database name | | Username | `username` | STRING | `root` | Oracle username | | Password | `password` | STRING | (empty) | Oracle password | | Query Timeout | `queryTimeout` | INT | `30` | SQL query timeout in seconds | | Connection Type | `connectionType` | STRING | `SID` | Connection method `[SID, ServiceName]` | | SID | `sid` | STRING | `ORCL` | Oracle SID (used when `connectionType=SID`) | | Service Name | `serviceName` | STRING | (empty) | Oracle service name (used when `connectionType=ServiceName`) | The driver builds the JDBC URL by `connectionType`: with `SID` it uses `sid` to form `jdbc:oracle:thin:@host:port:sid` ( default `sid=ORCL`), and with `ServiceName` it uses `serviceName` to form `jdbc:oracle:thin:@//host:port/serviceName`. Configuration validation (`validate()`) requires all six of `host`, `port`, `database`, `username`, `password`, and `connectionType`, and any missing field fails. Whether `sid` or `serviceName` takes effect depends on `connectionType` —see Troubleshooting below. The driver caches one HikariCP connection pool per device ID (one pool per device, max 5 connections), with the connection timeout set to `queryTimeout × 1000` milliseconds. ::: tip queryTimeout applies to both connecting and the query pace `queryTimeout` (default 30 seconds) is used as the pool's `connectionTimeout`: failing to acquire a connection, or a connection that stalls beyond this duration, fails. It is separate from the polling interval—if a slow SQL consistently approaches or exceeds it, optimize the SQL or add an index rather than just raising the timeout. ::: ### Point attributes (`point-attribute`) Point attributes answer "which value to query from this database, and where to write". Fill in the read/write SQL on each polled [Point](../introduction/concepts/point): | Attribute | code | Type | Default | Remark | |-------------|--------------|--------|---------|-----------------------------------------------------------------------------------------------| | Read Query | `readQuery` | STRING | (empty) | `SELECT` query for reading the point value | | Write Query | `writeQuery` | STRING | (empty) | `UPDATE`/`INSERT` using a single `?` placeholder for the written value (bound as a parameter) | ::: tip Read Query takes the first column of the first row `readQuery` is a plain `SELECT`, and the driver takes the **first column of the first row** of its result ( `rs.getObject(1)`) as the point's value—so a single-row, single-column query like `SELECT temperature FROM sensor WHERE id = 1` is the safest form. An empty result set yields `null`. The point's data type ([Point](../introduction/concepts/point) `pointTypeFlag`) decides how that value is parsed. `readQuery` is required on a point (enforced by `validatePoint()`); without it, point validation fails. `writeQuery` is required only when that point is to be written. ::: ### Write command attributes (`command-attribute`) This attribute can be configured on the write command, but is not consumed by the implementation: | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|---------|--------------------------------------| | Execute Query | `executeQuery` | STRING | (empty) | SQL query to execute for the command | ::: warning executeQuery is currently not consumed by the implementation Writing a value goes through the **point's `writeQuery`**: `write()` reads the `point-attribute` `writeQuery`, binds the command parameter with `setString(1, value)` into the single `?` placeholder, and executes the `UPDATE`/`INSERT`. The `command-attribute` `executeQuery` is kept only as a configuration item—nothing in the current driver code reads or executes it. There is no separate "run a SQL statement directly by command" path; writing always goes through `writeQuery`. Code is the source of truth. ::: ## Troubleshooting Oracle onboarding failures mostly cluster around the connection method (picking the wrong SID/Service Name), network, account permissions, and query targeting. Work through them in order: 1. **Wrong connection method (mixing SID and Service Name)**. With `connectionType=SID` the driver uses only `sid` to build the URL and ignores `serviceName`; with `connectionType=ServiceName` it uses only `serviceName` and ignores `sid`, and an empty `serviceName` throws immediately (`getRequiredConfig`). First confirm whether your instance exposes a SID or a service name (`lsnrctl status` shows the services registered with the listener), fill in that one, and don't fill both expecting the driver to pick automatically. 2. **Can't connect (device stays offline)**. First confirm `host:port` is reachable: `telnet <host> 1521` or `nc -vz <host> 1521`. The health check decides online via `conn.isValid(5)` (whether a valid connection can be obtained within 5 seconds); a failed connect or heartbeat reports offline. Common root causes: the listener not started or not registering that instance, a firewall blocking 1521, or a broken container network. 3. **Connects but is rejected (account / permission / instance name mismatch)**. Confirm `username`/`password` are correct, the account is not locked, and it has `CREATE SESSION`. `ORA-12505`/`ORA-12514` usually means the SID or Service Name is wrong (the listener received the request but found no matching instance/service); `ORA-01017` is a bad account or password. A failed connect throws `ConnectorException` and invalidates that device's pool, which is rebuilt on the next cycle. 4. **No value / wrong row returned**. The driver only takes the first column of the first row, so `readQuery` must reliably pinpoint the target row. An empty result set yields `null`; when multiple rows return, only the first is used and may not be the row you meant. Write the `WHERE` primary-key condition fully so you don't pick the wrong row as the table grows; to force a single row, use `WHERE ... AND ROWNUM = 1` or `FETCH FIRST 1 ROW ONLY`. 5. **Value / type mismatch**. The point's `pointTypeFlag` decides how the returned string is parsed. Configuring a text column as a `FLOAT` point, or treating a `NUMBER`/`DATE` column as another type, can fail parsing. Use `TO_CHAR`/ `CAST` in `readQuery`, or select only the target numeric column, so the returned value matches the point's type. 6. **Write command returns failure**. Writing requires exactly one `?` placeholder in `writeQuery` and a statement targeting a writable table and row. `write()` treats "affected rows > 0" as success—if the `WHERE` condition matches no rows, `executeUpdate()` returns 0 and the write is judged failed. Run the same `UPDATE` by hand in the database first to confirm it hits a row. A failed write throws `WritePointException` and invalidates the pool. ::: warning Connection Type decides whether to fill SID or Service Name With `connectionType=SID` the driver builds `jdbc:oracle:thin:@host:port:sid` using `sid` (default `sid=ORCL`); with `connectionType=ServiceName` it instead uses `serviceName` to build `jdbc:oracle:thin:@//host:port/serviceName`, in which case `serviceName` is required and throws before connecting if missing. The two correspond to Oracle's different naming methods—fill in whichever your instance actually exposes. ::: ::: warning Write Query uses a `?` placeholder, not `${value}` When writing, `writeQuery` uses a **single** `?` placeholder for the value (e.g. `UPDATE sensor SET temperature = ? WHERE id = 1`), bound by the driver via `PreparedStatement.setString(1, value)`—this is prepared-statement parameter binding, not string concatenation, so a malicious value cannot alter the statement structure (no SQL injection). Do not concatenate the value into the SQL by hand, and do not use template syntax like `${value}`: it would neither be substituted nor give you injection protection. ::: ## How it lands in IoT DC3 - **`dc3.driver.code`**: `OracleDriver` (type `DRIVER_CLIENT`, actively connects to the database and issues queries). This is a stable routing identifier—do not change it casually. - **Read capability**: ✓ implemented. `read()` executes the point's `readQuery` and takes the first column of the first row as the point value. - **Write capability**: ✓ implemented. `write()` executes the point's `writeQuery`, binding the written value as a `?` prepared-statement parameter; affected rows > 0 means success. - **Subscribe/report**: — not supported. Oracle is request-response; the driver only actively queries/writes and never passively receives pushes. This matches the `✓ / ✓ / —` for Oracle in the [driver capability matrix](./matrix). - **Polling interval**: default cron `0/30 * * * * ?` (read once every 30 seconds), configured under `schedule.read` in the driver's `application.yml`; there is also a `custom` schedule with default cron `0/5 * * * * ?` (every 5 seconds), but the base class `schedule()` is an empty implementation and database drivers do not use it. - **Health/online**: device health check defaults to cron `0/15 * * * * ?` with a lease timeout of `45 seconds`; the verdict relies on `conn.isValid(5)`. See [Device](../introduction/concepts/device) for the online-state mechanism. ::: info Implementation status: available This driver is a **complete implementation** (not a skeleton). Oracle's two SID / Service Name JDBC URL forms, reading, writing, the health check, the per-device cached HikariCP pool, and pool invalidation-and-rebuild on failure are all in place, reusing the tested `AbstractJdbcDriverCustomService` base class. The only thing to note is that the `command-attribute` `executeQuery` is reserved but not consumed by the code—writing always goes through the point's `writeQuery` (see the warning above). ::: ### Minimal onboarding example Onboard the `temperature` column of the `id=1` row in a `sensor` table as a temperature point (connecting to an `ORCL` instance by SID): 1. Create a [Device](../introduction/concepts/device) with `Oracle Driver`, and set the driver attributes `host=192.168.1.10`, `port=1521`, `database=iot`, `username=root`, `password=******`, `connectionType=SID`, `sid=ORCL`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attribute `readQuery=SELECT temperature FROM sensor WHERE id = 1`. 3. Start the driver, and within 30 seconds the queried temperature shows up in the [PointValue](../introduction/concepts/point-value). 4. If the point should be writable, add `writeQuery=UPDATE sensor SET temperature = ? WHERE id = 1` to its point attributes and configure a write [Command](../introduction/concepts/command) for it. 5. If your instance uses a service name, switch `connectionType` to `ServiceName` and fill in `serviceName` (leave `sid` empty). ::: tip One driver instance can serve multiple databases A single Oracle driver process can serve multiple devices: each device connects to its own database per its driver attributes and holds its own connection pool (cached by device ID). When device metadata is deleted/updated, the corresponding pool is closed and rebuilt on demand. ::: ## Further reading - [Drivers overview](./index) — entry point and categories for all drivers - [Driver capability matrix](./matrix) — read/write/subscribe at a glance, including the Oracle row - [Device Onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Time-Series Data & Stream Processing](../foundations/data-pipeline) — how PointValues are stored, computed, and queried after entering the platform - [MySQL Driver](./mysql) — another database data source on the same JDBC base class --- # PLC S7 Driver URL: https://docs.dc3.site/en/drivers/plcs7 <script setup> import PlcS7Diagram from '../../.vitepress/theme/components/PlcS7Diagram.vue' </script> # PLC S7 Driver `dc3-driver-plcs7` connects Siemens S7 series PLCs to IoT DC3: acting as an S7 client, it connects over TCP to one or more PLCs, periodically reads values according to the data block number and offset configured on each [Point](../introduction/concepts/point), and supports writing values back. After this page you can fill in the right `host` / `plcType` / `dbNum` attributes on a device, turn a DB variable into a readable and writable Point, and diagnose the most common cannot-connect, wrong-value, and write-failure problems. ## Protocol background S7 (also called S7comm / ISO-on-TCP) is the proprietary Ethernet protocol used by Siemens PLCs ( S7-200/300/400/1200/1500, S7-200 Smart, and SINUMERIK CNC systems). It runs on top of `ISO 8073 COTP`, encapsulated in TCP on the standard port `102`, and is used to read and write the PLC's internal memory areas directly — especially the **Data Blocks (DB)** that engineers define in STEP 7 / TIA Portal. In terms of communication model, S7 is a classic **master/slave, request-response** protocol: this driver acts as the master (client), actively opening connections and sending read/write requests to each PLC in turn, while the PLC responds passively and never pushes on its own. Addressing uses the numeric form "DB number + byte offset (+ bit offset)", aligned by engineering convention — which means that before onboarding you must look up, from the PLC program, which DB and which byte each variable lives at. In the four-layer IoT architecture, S7 sits on the wired-industrial side of the **network layer**: it solves the last mile of "how data inside a PLC gets read by an external system over Ethernet". For the communication models, addressing schemes, and byte-order trade-offs of industrial bus protocols, see [Industrial Bus and Protocols](../foundations/fieldbus). <PlcS7Diagram lang="en" /> A single driver process can connect to multiple PLCs; connections are reused per `deviceId`, and each PLC is distinguished by the `host` and `plcType` on its own [Device](../introduction/concepts/device). ## Attribute configuration S7 has no separate "write-command attribute" (`application.yml` has no `command-attribute`). Addressing information splits into two layers: **driver attributes** locate "which PLC to connect to", and **point attributes** locate "which variable inside the PLC to read". The fields, types, and defaults in both tables below come from the driver's `application.yml`. ### Driver attributes (device-level `driver-attribute`) When onboarding an S7 PLC device, fill in these [attributes](../introduction/concepts/attribute-config) on the [Device](../introduction/concepts/device). `host` and `port` together determine the connection target, while `plcType` selects which S7 addressing scheme the driver uses to resolve DB addresses and byte order. | Attribute | code | Type | Default | Description | |-----------|-----------|--------|----------------|-----------------------------| | Host | `host` | STRING | `192.168.0.20` | PLC IP address | | Port | `port` | INT | `102` | S7 TCP port, standard `102` | | PLC Type | `plcType` | STRING | `S1200` | PLC model, values below | ::: tip plcType decides how addresses are resolved Different S7 PLC models differ in DB addressing detail and data layout; `plcType` selects the matching S7 addressing scheme. Valid values come from the underlying `EPlcType` enum: `S200` / `S200_SMART` / `S300` / `S400` / `S1200` / `S1500` / `SINUMERIK_828D`. Typical mapping: use `S1200` for S7-1200, `S1500` for S7-1500, and `S200_SMART` for S7-200 Smart. If the value is wrong or outside the enum, the driver logs a warning and falls back to `S1200`. ::: ### Point attributes (`point-attribute`) Each [Point](../introduction/concepts/point) locates a single variable inside a PLC data block. The driver assembles these three items into an S7 address string and hands it to the underlying library: a non-boolean Point becomes `DB{dbNum}.{byteOffset}`, and only when the Point is boolean and `bitOffset > 0` does it become `DB{dbNum}.{byteOffset}.{bitOffset}`. | Attribute | code | Type | Default | Description | |-------------|--------------|------|---------|---------------------------------------------------------------| | DB Number | `dbNum` | INT | `0` | Data block number, counted from 0 | | Byte Offset | `byteOffset` | INT | `0` | Byte offset within the data block | | Bit Offset | `bitOffset` | INT | `0` | Bit offset within the byte (only for boolean Points when > 0) | ::: tip The Point type decides how many bytes are read Read/write width is derived from the Point's data type (the `pointTypeFlag` of the [Point](../introduction/concepts/point)) starting at `byteOffset`, with no extra configuration: `BOOLEAN` takes one bit, `BYTE` 1 byte, `SHORT` 2 bytes, `INT`/`FLOAT` 4 bytes, `LONG`/`DOUBLE` 8 bytes, `STRING` reads a string. So the same DB offset configured as different Point types reads values of different widths. ::: ::: warning bitOffset only applies to boolean Points and only when non-zero The driver uses bit addressing (an address with a third `.bit` segment) only when the type is boolean **and** `bitOffset > 0`. In every other case — non-boolean types, or boolean with `bitOffset=0` — it addresses at byte level as `DB{dbNum}.{byteOffset}`. Setting `bitOffset` on a float Point causes no error and has no effect; to move across bytes change `byteOffset`, do not use `bitOffset` to "skip" bytes. ::: Writing a Point reuses that Point's own attributes (`dbNum` / `byteOffset` / `bitOffset`) to locate the address, and the write width is determined by the value type carried by the command. Once a writable Point has these three items configured, it can be both read and written — no extra write-command configuration is needed. ## Troubleshooting When onboarding S7, these are the most common traps; most are PLC-side settings rather than driver problems. ::: danger The PLC side must allow PUT/GET access S7-1200/1500 **block** external PUT/GET communication by default — this is the most common cause of onboarding failure. In the CPU properties in TIA Portal you must check "Permit access with PUT/GET communication from remote partner". Otherwise the TCP connection establishes but every read/write is rejected by the PLC. ::: ::: warning The DB must have "Optimized block access" turned off If the data block being accessed has "Optimized block access" enabled, variables no longer have fixed byte offsets inside the DB, so locating by `byteOffset` reads wrong values or fails. In TIA Portal, select the DB → Properties → uncheck "Optimized block access"; after compiling and downloading, the offset addresses become stable and usable. ::: - **Port `102` unreachable**: S7 runs over TCP `102`; confirm the PLC IP (`host`) is reachable, the firewall is not blocking it, and `port` has not been changed to a non-standard value. Verify the link with `ping` and `telnet <host> 102` before blaming the driver. - **Wrong `plcType` breaks address resolution**: a wrong model falls back to `S1200`, which can read misaligned values on S7-300/400/1500. The log line `Unknown plcType ... fallback to S1200` means the value is outside the enum — correct it per the table above. - **Values come back wrong (byte order / offset)**: first confirm the DB has optimized block access turned off and that `dbNum` and `byteOffset` map one-to-one to the variable address in the PLC program; then confirm the Point's `pointTypeFlag` matches the PLC variable's type width (e.g. PLC `Real` → `FLOAT`, `DInt` → `INT`). - **Device stays offline / reconnects frequently**: when a read or write throws, the driver invalidates that connection (`invalidateConnection`) and rebuilds it on the next access. Repeated reconnects usually mean PLC-side rejection, network jitter, or an overloaded PLC; pinpoint with the `Driver connection failed` / `read failed` lines in the driver log. For the online-status and lease-timeout mechanism see [Device](../introduction/concepts/device). ## How it lands in IoT DC3 - **`dc3.driver.code`**: `PlcS7Driver` (driver name `PLC S7 Driver`, type `DRIVER_CLIENT`). This is a stable routing identifier — do not change it casually. - **Read**: supported. Default collection cron `0/30 * * * * ?` (one read round every 30 seconds); each Point is read by address and wrapped into a [PointValue](../introduction/concepts/point-value). - **Write**: supported. Reuses point attributes to locate the address and writes back to the PLC by the command value type. - **Subscribe / report**: not provided. S7 is a master/slave polling model and the driver does not listen for PLC-initiated pushes — consistent with "S7: read ✓ / write ✓ / subscribe —" in the [driver capability matrix](./matrix). - **Health check**: device health check defaults to cron `0/15 * * * * ?`, lease timeout `45` seconds. ::: info Implementation status: available `read()` / `write()` / `initial()` / `event()` / `validate()` in `PlcS7DriverCustomServiceImpl` are all fully implemented, backed by the `iot-communication` (`S7PLC`) library, with auto-reconnect enabled, connections reused per `deviceId`, and a `ReentrantLock` serializing reads/writes. This is a working driver, not a skeleton. ::: Minimal onboarding example: onboard an S7-1200 at IP `192.168.0.20:102` and read a 32-bit float at offset 0 in DB1: 1. Create a [Device](../introduction/concepts/device) using `PLC S7 Driver`, and set the driver attributes `host=192.168.0.20`, `port=102`, `plcType=S1200`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `dbNum=1`, `byteOffset=0`, `bitOffset=0`. 3. Confirm the PLC has PUT/GET enabled and that DB's optimized block access is off, start the driver, and within 30 seconds you will see the collected value in [PointValue](../introduction/concepts/point-value). ## Further reading - [Driver overview](./index) — the entry point and taxonomy of all drivers - [Driver capability matrix](./matrix) — read / write / subscribe capability at a glance - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Industrial Bus and Protocols](../foundations/fieldbus) — the network-layer communication models, addressing, and byte order S7 belongs to - [Melsec Driver](./melsec) — the Mitsubishi PLC Ethernet driver, also in the industrial bus / PLC category --- # PostgreSQL Driver URL: https://docs.dc3.site/en/drivers/postgresql `dc3-driver-postgresql` onboards a PostgreSQL database into IoT DC3 as a data source: it runs a `SELECT` per polling cycle and uses the queried value as the reading, and supports writing values via the `UPDATE`/`INSERT` write query configured on a point. By the end you can treat columns of an existing table as polled points, and understand the boundaries of the read and write SQL paths. ## Protocol background Not all data comes from a fieldbus device. A lot of business data, historical data, and results accumulated by third-party systems simply live in a PostgreSQL table—an MES work-order status, the settled cumulative quantity from a metering system, a database view that an upstream platform exposes and nothing else. This kind of data has no fieldbus protocol like Modbus or OPC UA to ride on, yet it is a genuine extension of the device world and needs to be brought into the unified point model to be managed and consumed. PostgreSQL is an open-source object-relational database (ORDBMS) known for strict SQL-standard compliance, strong transactions (MVCC), and rich data types (JSON/JSONB, arrays, ranges, geometry). It listens on port `5432` by default, and clients connect over standard JDBC (`org.postgresql.Driver`). Onboarding it into an IoT platform essentially means " treating a database as a class of device": one row, one column of a table becomes the source of a point's value. In the four-layer IoT reference architecture, a database-bridge driver straddles the boundary between the network and platform layers—it parses no fieldbus frames, but re-admits "data already landed in a table" into the polling pipeline as points. For how this path connects to time-series storage and stream processing, see [Time-Series Data & Stream Processing](../foundations/data-pipeline). ::: info How it differs from fieldbus drivers Drivers like Modbus and OPC UA face "live physical quantities"—registers change in real time, and what you read is the current field value. A database driver faces "data already persisted"—what you read is a snapshot written by some upstream system, possibly already stale. When treating a column as a point, align the polling cadence with the upstream write cadence, or you will repeatedly read the same old value. ::: ## Attribute configuration Onboarding uses three kinds of attributes: **driver attributes** (device level—which database, which account), **point attributes** (the read/write SQL per point), and **command attributes** (a reserved item, currently inert). Every default comes from the driver's `application.yml`; you fill in concrete values on the [Device](../introduction/concepts/device)/[Point](../introduction/concepts/point). For where attributes come from across the three layers, see [Attributes & Config](../introduction/concepts/attribute-config). ### Driver attributes (device-level `driver-attribute`) When onboarding a PostgreSQL database, fill in these [attributes](../introduction/concepts/attribute-config) on the [Device](../introduction/concepts/device). They decide which database to connect to, which account to use, and the connection timeout: | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|-------------|-----------------------------------------------------------------------------------| | Host | `host` | STRING | `localhost` | PostgreSQL host | | Port | `port` | INT | `5432` | PostgreSQL port | | Database | `database` | STRING | (empty) | Database name to connect to | | Username | `username` | STRING | `root` | Connection account | | Password | `password` | STRING | (empty) | Account password | | Query Timeout | `queryTimeout` | INT | `30` | Connection-acquire timeout (seconds); used as the Hikari pool `connectionTimeout` | The driver builds the JDBC URL from `host`, `port`, and `database` (in the form `jdbc:postgresql://host:port/database`). Device validation (`validate()`) checks that all five of `host`, `port`, `database`, `username`, `password` are present—any empty one fails; `queryTimeout` is optional and defaults to `30` seconds. Each device maps to its own HikariCP connection pool inside the driver (`maximumPoolSize=5`, `minimumIdle=1`), cached and reused by device ID. ### Point attributes (`point-attribute`) On each polled [Point](../introduction/concepts/point), fill in its read/write SQL: | Attribute | code | Type | Default | Remark | |-------------|--------------|--------|---------|------------------------------------------------------------------------------------------------------------| | Read Query | `readQuery` | STRING | (empty) | `SELECT` statement for reading the point value | | Write Query | `writeQuery` | STRING | (empty) | `UPDATE`/`INSERT` for writing, using a single `?` placeholder for the written value (bound as a parameter) | ::: tip Read Query takes the first column of the first row `readQuery` is a plain `SELECT`; after running it the driver takes the **first column of the first row** ( `rs.getObject(1)`) as the point's value, then `toString()`s it for the point to parse by its data type ([Point](../introduction/concepts/point) `pointTypeFlag`). So a single-row, single-column query like `SELECT temperature FROM sensor WHERE id = 1` is the safest form. `readQuery` is required on a point; point validation ( `validatePoint()`) fails without it. ::: ### Command attributes (`command-attribute`) The write command keeps one attribute, but it is not consumed by the implementation: | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|---------|-------------------------------------------| | Execute Query | `executeQuery` | STRING | (empty) | SQL to execute for the command (reserved) | ::: warning `executeQuery` is currently not consumed by the implementation Writing a value actually goes through the point's `writeQuery`: the driver's `write()` reads the `point-attribute` `writeQuery` and runs the `UPDATE`/`INSERT` with prepared-statement parameter binding. The `executeQuery` on `command-attribute` is kept only as a configuration item—no code in the current driver reads or executes it; there is no separate "execute a SQL string per command" path. To write values, put the SQL in the point's `writeQuery`; configuring `executeQuery` has no effect. ::: ### Polling & health - **Polling interval**: default cron `0/30 * * * * ?` (read once every 30 seconds). - **Custom interval**: the driver also has a custom schedule, default cron `0/5 * * * * ?` (every 5 seconds); the JDBC database driver's `schedule()` is an empty implementation, so this schedule currently does nothing. - **Health/online**: device health check defaults to cron `0/15 * * * * ?` with a lease timeout of `45 seconds`. The health check borrows a connection from the pool and uses `conn.isValid(5)` to decide reachability—if it can't connect, the device goes offline. See [Device](../introduction/concepts/device) for the online-state mechanism. ## Troubleshooting When a database-bridge onboarding fails, the cause is rarely the protocol itself—it is the connection parameters, the SQL shape, or the upstream database's state. Ordered by frequency: ::: warning Device stays offline / pool won't come up The health check decides with `conn.isValid(5)`. If a device is always offline, first confirm `host`/`port` is reachable (don't use `localhost` from inside a container to reach the host), `database` name case is correct, and the `username`/`password` account can log into that database. PostgreSQL is also gated by `pg_hba.conf`—if the source IP or auth method isn't allowed it rejects the connection outright; that error is on the database side, so open it up there. ::: ::: warning Read Query must be read-only and resolve to a single value The driver takes only the first column of the first row of the `readQuery` result, so the query should return a single row and column and pinpoint the target row with a `WHERE` primary-key condition. With multiple rows/columns only the first is used and may not be the row you meant; with no result the point value is `null`. Never put `UPDATE`/`DELETE` in `readQuery`—reading is a read-only path, and writing there corrupts data. Write the filter fully so you don't pick the wrong row as the table's data changes. ::: ::: warning Write Query uses a `?` placeholder, not `${value}` When writing, `writeQuery` uses a single `?` for the value (e.g. `UPDATE sensor SET temperature = ? WHERE id = 1`), and the driver binds the command parameter as a JDBC parameter via `ps.setString(1, value)`—prepared-statement binding, not string concatenation, inherently safe from SQL injection. Do not concatenate the value by hand, and do not use template syntax like `${value}`: it would neither be substituted nor give you injection protection. A write is judged successful by "affected rows > 0", so an `UPDATE` that hits 0 rows (the `WHERE` matched nothing) counts as a write failure. ::: ::: tip Identifier case follows PostgreSQL rules PostgreSQL folds unquoted identifiers to lowercase and matches double-quoted ones literally. A wrong-case `database` won't connect; table/column names in `readQuery`/`writeQuery` that don't match the case used at creation will report " does not exist". Fill them in with the real case, and double-quote identifiers in the SQL when needed. ::: ::: tip Query timeout / slow SQL `queryTimeout` (default 30 seconds) is used as the Hikari pool `connectionTimeout`—the upper bound on waiting to acquire a connection. For large tables or slow SQL, optimize the SQL, add indexes, and narrow the `WHERE` rather than simply raising the timeout—a slow query holds a pool connection (only 5 exist) and drags down polling of the whole batch. ::: ## How it lands in IoT DC3 - **Driver name / code**: `PostgreSQL Driver` / `PostgresqlDriver` (`dc3.driver.code` is a stable routing identifier the platform routes messages by—don't change it casually). - **Type**: `DRIVER_CLIENT`—the driver actively connects and issues queries; it does not listen for pushes. - **Read / write / subscribe**: consistent with the [driver capability matrix](./matrix)—read ✓, write ✓, subscribe —. Reads take the first value of a `SELECT`; writes use the prepared binding of `writeQuery`; a database has no change subscription, so values are pulled by periodic polling. ::: info Implementation status: available (not a skeleton) The PostgreSQL driver is an **available implementation**, not a skeleton. Connection, read, write, and health check are all provided by the shared `dc3-common-sql` abstract base service `AbstractJdbcDriverCustomService` (the same logic shared with MySQL, Oracle, and SQL Server); the PostgreSQL subclass only supplies JDBC URL construction, the driver class name `org.postgresql.Driver`, and the default port `5432`. The one caveat is the `command-attribute` `executeQuery`, which is reserved but unwired (see Attribute configuration above). ::: ### Minimal onboarding example Onboard the `temperature` column of the `id=1` row in a `sensor` table as a temperature point: 1. Create a [Device](../introduction/concepts/device) with `PostgreSQL Driver`, and set the driver attributes `host=192.168.1.10`, `port=5432`, `database=iot`, `username=root`, `password=******`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attribute `readQuery=SELECT temperature FROM sensor WHERE id = 1`. 3. Start the driver, and within 30 seconds the queried temperature shows up in the [PointValue](../introduction/concepts/point-value). For a writable point, also set `writeQuery=UPDATE sensor SET temperature = ? WHERE id = 1` on the same point and make its `rwFlag` writable. For a complete walkthrough, see [Device Onboarding](../operation/device-onboarding). ## Further reading - [Drivers Overview](./index) — categorization and selection map of all drivers - [Driver Capability Matrix](./matrix) — read/write/subscribe capabilities at a glance - [Device Onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Time-Series Data & Stream Processing](../foundations/data-pipeline) — how polled point values land in the time-series store and feed stream processing - [MySQL Driver](./mysql) — another JDBC database data source with an identical configuration structure --- # Redis Driver URL: https://docs.dc3.site/en/drivers/redis `dc3-driver-redis` treats Redis as a data source: a point reads a STRING key (GET) or a HASH field (HGET), and a write sets a STRING key (SET) or HASH field (HSET). It is useful for bridging cached values, counters, and lightweight state into the platform. ## Protocol background Redis is an in-memory key/value store. This driver maps a Point to a key (and optional field for hashes), polling the value on the collection cycle; writes propagate back to the same key. - **Driver name / code**: `Redis Driver` / `RedisDriver` - **Type**: `DRIVER_CLIENT (connects to Redis and reads/writes keys)` - **Underlying library**: Spring Data Redis (`StringRedisTemplate`) ## Attribute configuration ### Driver attributes (device-level `driver-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | (broker connection) | `spring.data.redis.*` | — | env | Configured via Spring Boot `spring.data.redis.*` properties | ### Point attributes (`point-attribute`) | Attribute | code | Type | Default | Description | |-----------|------|------|---------|-------------| | Key | `key` | STRING | (empty) | Redis key to read/write | | Data Type | `dataType` | STRING | `STRING` | STRING or HASH | | Field | `field` | STRING | (empty) | Hash field (required when `dataType=HASH`) | ## Collection and health - **Collection cycle**: default cron `0/30 * * * * ?` (one polling round over all points every 30 seconds). - **Health/online**: device health defaults to cron `0/15 * * * * ?`, lease timeout `45 seconds`. ## Capability matrix | Capability | Supported | Notes | |------------|-----------|-------| | Read | ✓ | | | Write | ✓ | | | Subscribe | — | | ::: info Implementation status: available :: Connection is configured through Spring Boot `spring.data.redis.*` (`REDIS_HOST` / `REDIS_PORT` environment variables); no driver-level connection attribute is required. ## Minimal onboarding example 1. Create a Device using `Redis Driver`. 2. Add a Point (`READ_ONLY`) with `key=counter:total` and `dataType=STRING`. 3. Start the driver; within 30 seconds the value appears in PointValue. ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough --- # Serial Driver URL: https://docs.dc3.site/en/drivers/serial <script setup> import SerialDiagram from '../../.vitepress/theme/components/SerialDiagram.vue' </script> # Serial Driver `dc3-driver-serial` connects RS232/RS485/RS422 serial devices that speak proprietary frames to IoT DC3: acting as the serial master, it periodically sends the HEX command configured on each [Point](../introduction/concepts/point), reads back the raw bytes, parses them into a value by frame header/footer, checksum, data offset and format, and supports commands that write values to the device. After this page you can configure the line parameters and frame-parsing rules for a "send some bytes, get some bytes back" device, and know where to look when it won't connect. ## Protocol background Serial is the plainest yet most universal way to wire up the industrial floor. **RS232** point-to-point and * *RS485/RS422** buses are widely used by meters, transmitters, energy meters, barcode scanners, PLC serial modules, and more. It only defines the physical and link layers — what voltage levels, how many wires, what baud rate to send and receive bytes at — and **says nothing about what the bytes mean**. Many devices do not speak a standard protocol like Modbus but a vendor-proprietary frame: send a fixed byte string, get a fixed-structure byte string back. In the four-layer IoT architecture, serial belongs to the **network layer**: it solves how a field device and the polling master exchange bytes — which signal, which line parameters — not what those bytes mean to the business. This driver turns that raw byte channel into a configurable protocol adapter: you describe "what to send" with a HEX command, "how to slice the response" with frame header/footer plus offset/length, and "how to verify and decode the sliced bytes" with a checksum type and data format. For the general network-layer background on addressing, byte order and polling, see [IoT network layer: industrial buses and protocols](../foundations/fieldbus). - **Driver name / code**: `Serial Port Driver` / `SerialDriver` - **Type**: `DRIVER_CLIENT` (actively opens the port and polls the device) - **Underlying library**: jSerialComm (one serial connection per [Device](../introduction/concepts/device), cached by device id) ::: tip Two terms first **HEX command**: a byte string written in hexadecimal, e.g. `01 03 00 00 00 0A C5 CD`; the spaces are only for readability and map to the actual bytes sent (the driver strips spaces and `-` before parsing). **Frame**: the full chunk of bytes the device replies with, usually a header, a data region, an optional checksum, and a footer; this driver uses the header/footer plus offset/length to "cut" the real data region out of the response, then decodes it. ::: ## Attribute configuration Serial configuration comes in three layers: **driver attributes** (`driver-attribute`, device-level) describe how the serial port is opened (line parameters); **point attributes** (`point-attribute`) describe what each collected point sends, how to slice the response, and what format to decode in; **command attributes** (`command-attribute`) describe where a writable point writes. The defaults and descriptions in the three tables below come from the driver `application.yml`; for the three-layer origin of attributes see [Attribute and Config](../introduction/concepts/attribute-config). ### Driver attributes (device-level `driver-attribute`) When onboarding a serial device, fill in these line parameters on the [Device](../introduction/concepts/device). They are passed verbatim to jSerialComm to open the port, so they **must match the device's serial settings one by one** — serial has no handshake negotiation, and any mismatch just yields garbage or a timeout: | Attribute | code | Type | Default | Description | |-------------|------------|--------|----------------|--------------------------------------------------------------------------------------------| | Serial Port | `port` | STRING | `/dev/ttyUSB0` | Serial port device path (e.g. /dev/ttyUSB0, COM3) | | Baud Rate | `baudRate` | INT | `9600` | Baud rate (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200) | | Data Bits | `dataBits` | INT | `8` | Data bits (5, 6, 7, 8) | | Stop Bits | `stopBits` | INT | `1` | Stop bits (1, 2) | | Parity | `parity` | INT | `0` | Parity (0=None, 1=Odd, 2=Even) | | Timeout | `timeout` | INT | `1000` | Read timeout in milliseconds; returns the bytes received so far if still short at deadline | ### Point attributes (`point-attribute`) On each collected [Point](../introduction/concepts/point), set three groups: what to send (`sendCommand`/ `receiveLength`), how to slice the response (`frameHeader`/`frameFooter`/`dataOffset`/`dataLength`/`checksumType`), and how to decode (`dataFormat`/`byteOrder`). Only `sendCommand` is required; the rest fall back to defaults when left blank: | Attribute | code | Type | Default | Description | |----------------|-----------------|--------|---------|-----------------------------------------------------------------------------------------------------------| | Send Command | `sendCommand` | STRING | (empty) | HEX command to send (e.g. `01 03 00 00 00 0A C5 CD`), required | | Receive Length | `receiveLength` | INT | `0` | Expected response length; 0=read until timeout/inter-frame gap, >0=read exactly this many bytes | | Frame Header | `frameHeader` | STRING | (empty) | Frame header in HEX (e.g. `01 03`), used to locate the frame start in the response | | Frame Footer | `frameFooter` | STRING | (empty) | Frame footer in HEX (e.g. `0D 0A`), used to locate the frame end | | Data Offset | `dataOffset` | INT | `0` | Data region start offset (**relative to after the frame header**; relative to frame start when no header) | | Data Length | `dataLength` | INT | `0` | Data region length in bytes (0=until footer/checksum region) | | Checksum Type | `checksumType` | STRING | `NONE` | Response checksum type: NONE, CRC16, XOR | | Data Format | `dataFormat` | STRING | `HEX` | Data format: HEX, ASCII, BINARY, FLOAT | | Byte Order | `byteOrder` | STRING | `BIG` | Byte order: BIG, LITTLE | ::: tip Parsing order: locate the frame, verify, then decode After receiving a response, the driver runs `parseResponse` → `SerialFrameParser.parse` in order: ① locate the frame start via `frameHeader` (`indexOf`, error if not found) and the frame end via `frameFooter` searching from after the header (`lastIndexOf`); ② take the data region start as "after the header plus `dataOffset`", and reserve a checksum region before the footer per `checksumType` (CRC16 takes 2 bytes, XOR takes 1); ③ if `checksumType≠NONE`, recompute the checksum over the bytes from after the header to the end of the data region and compare it with the checksum bytes in the response, erroring on mismatch; ④ slice the data region by `dataLength` (0=up to the checksum region) and decode by `dataFormat`+`byteOrder`. The Point's data type (the `pointTypeFlag` of the [Point](../introduction/concepts/point)) should match `dataFormat`: under `BINARY`/`FLOAT`, 1/2/4/8-byte regions are assembled into integers or floats per `byteOrder` (`FLOAT` uses 4 bytes single precision, 8 bytes double precision); a length not in 1/2/4/8 falls back to a HEX string. ::: ### Command attributes (`command-attribute`) A writable Point sets these on the write command: | Attribute | code | Type | Default | Description | |--------------|---------------|--------|------------|--------------------------------------------------------| | Send Command | `sendCommand` | STRING | `${value}` | HEX command template with the `${value}` placeholder | | Byte Order | `byteOrder` | STRING | `BIG` | Byte order for encoding the written value: BIG, LITTLE | On write, the driver renders the command param into the `${value}` slot of the `sendCommand` template, parses the whole result as HEX into bytes, and sends the frame without reading a response. ::: warning The write byteOrder is currently not applied during encoding `command-attribute` lists `byteOrder`, but `write()` only does a string replace `sendCommand.replace("${value}", value)` and then parses the whole result as HEX — `${value}` must already be valid HEX text, and `byteOrder` does no endianness conversion on it. To write a multi-byte numeric value, encode it into a correctly byte-ordered HEX string upstream and pass that as the command param. ::: ### Collection and health - **Collection cycle**: default cron `0/30 * * * * ?` (one polling round over all points every 30 seconds). - **Health/online**: device health check defaults to cron `0/15 * * * * ?`, with a lease timeout of `45 seconds`; the driver treats a device as online based on whether its serial port is open (`SerialPort.isOpen()`) — see [Device](../introduction/concepts/device) for the online-status mechanism. - **Custom task**: the yml has a `custom` schedule (`0/5 * * * * ?`), but the driver's `schedule()` is an empty implementation — serial drivers need no custom periodic task. ## Troubleshooting ::: warning You must compute the checksum bytes in sendCommand yourself This driver **does not append a CRC/checksum for you** — `sendCommand` is sent verbatim as a single byte string, so if the device requires a Modbus CRC or another checksum, you must compute it and write it into the command. Get it wrong and the device either does not reply or returns an exception frame, which shows up as an empty response or a parse error. Note that `checksumType` only verifies the **response** and never rewrites the command you send. ::: ::: warning dataOffset counts from "after the header", not byte 0 of the whole frame In the source the data region start is `start + dataOffset`, where `start` is the position **after the header** (the header length is skipped when `frameHeader` is set). So: with a header `01 03`, `dataOffset` is counted from the byte after `01 03`; without a header, `start=0` and only then does `dataOffset` count from the frame start. Counting the header into the offset skips extra bytes and decodes wrong data. The safest approach is either-or: use `frameHeader` to locate and fill `dataOffset` as the offset after the header, or omit `frameHeader` and count `dataOffset` straight from byte 0. ::: ::: warning A missing frame header/footer errors out If `frameHeader` is set but the bytes are not present in the response, `parse()` throws `Frame header not found`; the same goes for `frameFooter`. A truncated response (`timeout` too short, or `receiveLength` set larger than what arrives) likewise causes a missing header/footer or an empty data region (`No data region in serial frame`). First set `dataFormat=HEX` to dump the raw response, confirm the frame structure matches your config, then tighten each setting. ::: ::: warning A wrong checksum type makes good responses fail verification With `checksumType=CRC16` (this driver uses Modbus CRC16, polynomial `0xA001`, low byte first) or `XOR`, the driver recomputes the checksum over the bytes "from after the header to the end of the data region" and compares it with the checksum bytes in the response, throwing `checksum mismatch` if they differ. If the device's checksum range or algorithm differs (e.g. covers the header, or uses another polynomial), set it back to `NONE` and verify upstream, so good data is not dropped as bad. An unrecognized checksum name (not NONE/CRC16/XOR) is treated as `NONE`. ::: ::: tip A serial port is exclusive — watch for contention and permissions A single driver process caches an independent serial connection per device id and can serve several devices on different `port` paths at once; but one physical port can only be opened by one process at a time. Make sure the `port` is not held by a serial terminal tool or another driver process; on Linux the run user also needs read/write permission on `/dev/ttyUSB*` (commonly by adding the user to the `dialout` group). On a read/write failure the driver actively closes and removes the connection (`invalidateConnector`), reopening it on the next round. ::: ::: tip Each device on one RS485 bus gets its own Device Multiple devices on the same physical port (an RS485 bus) each need their own [Device](../introduction/concepts/device) pointing at the **same `port`**, distinguished by their own `sendCommand` (with different station addresses); the driver polls them point by point. Do not assign different `port` values to different devices on the same bus. ::: ## How it lands in IoT DC3 However proprietary the underlying frame, in the platform everything converges to the [PointValue](../introduction/concepts/point-value) of a single [Point](../introduction/concepts/point). The serial driver registers with `dc3.driver.code = SerialDriver`, a stable routing identifier the platform uses to dispatch read/write commands to this driver. <SerialDiagram lang="en" /> Per the [driver capability matrix](./matrix), this driver's capabilities are: | Capability | Supported | Notes | |------------|-----------|----------------------------------------------------------------------------------------------------------| | Read | ✓ | `read()` sends `sendCommand`, reads the response, slices the data region by frame structure and decodes | | Write | ✓ | `write()` renders the `${value}` template and sends the frame (no response read, no byte-order encoding) | | Subscribe | — | Master/slave polling model; devices do not push, polled on the collection cycle | ::: info Implementation status: available `SerialDriverCustomServiceImpl`'s `initial()`/`read()`/`write()`/`health()`/`event()`, the frame parser ( `SerialFrameParser`), and connection management (`SerialPortConnection`, on jSerialComm) are all fully implemented — not a skeleton. The read path supports HEX/ASCII/BINARY/FLOAT decoding and CRC16/XOR/NONE response checksum verification; connections are cached per device, and a device metadata UPDATE/DELETE event destroys the old connection ( `connectMap.remove` then `close()`). ::: ## Minimal onboarding example Onboard a temperature transmitter on `/dev/ttyUSB0` at 9600-8-N-1 that, after the query command, responds with `01 03 02 <2 data bytes> <2 CRC bytes>`: 1. Create a [Device](../introduction/concepts/device) using `Serial Port Driver`, and set the driver attributes `port=/dev/ttyUSB0`, `baudRate=9600`, `dataBits=8`, `stopBits=1`, `parity=0`. 2. Add a temperature [Point](../introduction/concepts/point) (`READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attributes `sendCommand=01 03 00 00 00 01 84 0A`, `dataOffset=3`, `dataLength=2`, `dataFormat=BINARY`, `byteOrder=BIG` (no `frameHeader`, so `dataOffset` counts from byte 0 of the response, skipping the three bytes `01 03 02` to land exactly on the data region). 3. Start the driver, and within 30 seconds you will see the collected value in [PointValue](../introduction/concepts/point-value). ::: tip For standard Modbus RTU, prefer the dedicated driver This driver is a "generic serial pass-through" — best for proprietary frames or when you need byte-level control of the frame. If the device speaks standard Modbus RTU (regular function codes, CRC, addressing), the [Modbus RTU Driver](./modbus-rtu) is far less work: it builds the frame, appends the CRC, and addresses by function code for you, with no hand-written `sendCommand`. ::: ## Further reading - [Driver overview](./index) — entry point to all protocol drivers and selection - [Driver capability matrix](./matrix) — quick reference of read/write/subscribe capabilities - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Industrial buses and protocols](../foundations/fieldbus) — network layer: the general background on addressing, byte order, and polling - [Modbus RTU Driver](./modbus-rtu) — the serial driver that speaks the standard Modbus protocol --- # SL651 Driver URL: https://docs.dc3.site/en/drivers/sl651 <script setup> import Sl651Diagram from '../../.vitepress/theme/components/Sl651Diagram.vue' </script> # SL651 Driver `dc3-driver-sl651` connects SL651-2014 hydrological telemetry stations to IoT DC3: it listens on a TCP port, passively receives telemetry reports pushed by remote stations, and turns the element at a configured position in the report body into a [PointValue](../introduction/concepts/point-value). After reading this page you will understand how it differs from a polling driver, how to fill in the driver and point attributes correctly, and how to troubleshoot the classic " reports arrive but no point values" problem. ## Protocol background SL651-2014 is the PRC water-conservancy industry standard *Hydrological Monitoring Data Communication Protocol*, used for data communication between telemetry terminals (RTUs) — rain gauges, water-level stations, flow stations — and a central station. Its typical use is remote telemetry for basin hydrology, urban waterlogging, reservoirs and dams, and irrigation metering: stations are scattered in the field and use links such as GPRS / 4G / BeiDou to send scheduled or event-triggered hydrological elements back to the center. Unlike field buses such as Modbus, where the master actively reads slaves, SL651 is a **server-side protocol**: telemetry stations scattered in the field push their collected data to a central server on their own schedule (on the hour, supplementary reports, alarms, and so on). This driver *is* that central server — it starts an SL651 TCP server on a listen port, waits for stations to connect and report, parses the telemetry elements out of the report body, matches the station address in the report header to a [Device](../introduction/concepts/device), and forwards the configured element as a point value. In the four-layer IoT architecture, SL651 sits at the **network layer**: it defines how station terminals transmit and aggregate sensing-layer data over wide-area links to the platform. It does not specify how sensors measure (perception layer) or how the platform stores and analyzes (application layer); it only governs frame structure, function codes, element encoding, and uplink/downlink exchange. To see where it sits in the protocol spectrum and the trade-offs of " server passively receiving reports" versus a polling model, see the [IoT network-layer chapter](../foundations/fieldbus). ::: info This is a listener driver, not a polling one Because data arrives **asynchronously and unsolicited** from the stations, the SDK `read` / `write` methods do not participate in collection — by design `read` returns `null` and `write` returns `false`. Scheduled reads are disabled ( `schedule.read.enable: false`); the driver keeps only an internal `schedule.custom` cron and the device health check, and the real collection is triggered by station reports. ::: ::: tip Two core terms - **Station address**: the address in the SL651 report header that identifies the sender. The driver takes the bytes from `getRemoteStationAddress()`, converts them to an uppercase hex string, and matches them against the device's `deviceCode` or `deviceName` (case-insensitive). - **Telemetry element**: an ordered list of measured values in one report body (water level, rainfall, voltage, …). The driver collects every report body's `getBodyElements()` into one ordered list, and a Point picks one of them by its index `index`. ::: ## Attribute configuration Onboarding an SL651 station involves attributes at two levels: **driver attributes** (`driver-attribute`, which decide how the server listens) and **point attributes** (`point-attribute`, which decide which element to take from the report body). All come from the driver's `application.yml`, mapping to the `dc3.driver.sl651.port` / `dc3.driver.sl651.pwd` config keys and the point config `index`. The values are filled in on the [Device](../introduction/concepts/device) instance; for the three-layer origin of attributes see [Attribute and Config](../introduction/concepts/attribute-config). ### Driver attributes (device-level `driver-attribute`) `port` decides which TCP port the whole driver process starts the SL651 server on; `pwd` is the authentication password passed when constructing `SL651Server`, used for the station's access credential check. Both have defaults and fall back to them when left blank. | Attribute | code | Type | Default | Description | |---------------|--------|--------|---------|----------------------------------------| | Listen Port | `port` | INT | `5001` | TCP port the SL651 server listens on | | Auth Password | `pwd` | STRING | `0000` | Remote station authentication password | ::: tip The port is driver-level and shared by every station in the process `port` decides which TCP port the whole driver process listens on; every station reporting to that port shares one server instance. Different stations are distinguished by their **station address** (i.e. the device's `deviceCode` / `deviceName`), not by port. A metadata change (device add / update) triggers `restartServer()`, and deleting a device calls `stopServer()`. ::: ### Point attributes (`point-attribute`) Each collected [Point](../introduction/concepts/point) needs just one attribute — its index into the report body's element list. When a report arrives from a station, the driver collects all report-body elements into an ordered list in order; the Point's `index` selects which one to take. | Attribute | code | Type | Default | Description | |---------------|---------|------|---------|-------------------------------------------------------| | Element Index | `index` | INT | `0` | Zero-based index into the telemetry body element list | `index=0` takes the first element, `index=1` the second, and so on. Points whose index is out of range (`index < 0` or `index >= elements.size()`) are skipped for that round without error. `validatePoint()` requires `index` to be present; the point config fails validation if it is missing. ### End-to-end pipeline <Sl651Diagram lang="en" /> At startup `startServer()` constructs `SL651Server` via reflection and registers an `ISl651MessageListener`. Whenever a station reports, the `onMessage` callback hands the report header's station address (hex string) and the report body's element list to `forwardTelemetry()`: it iterates the devices under this driver, and for each device whose address matches, iterates its points, takes the value by `index`, assembles `PointValue` objects, and forwards them in a batch via `driverSenderService.pointValueSender()`. ## Troubleshooting - **Reports arrive but no point values**: the most common cause is a **station address that does not match the device code**. The driver matches the report header's station address (uppercase hex string) against the device's `deviceCode` or `deviceName` (case-insensitive); a mismatch is silently dropped. First confirm the actual `stationAddr` reported from the driver log (the DEBUG-level `Driver SL651 message received` prints it), then copy it verbatim into `deviceCode`. - **A point reads the wrong value**: `index` is a zero-based element-list index whose order is determined by the station configuration; it is not an SL651 identifier code, nor a register address. Verify each `index` against the actual element order the station reports — do not guess by unit. - **Station cannot connect / authentication fails**: confirm the `port` on the device matches the station's target port and the network is reachable; `pwd` must equal the station's configured access credential (default `0000`). Changing `port` triggers a server restart and a brief disconnect. - **`sl651ApiMissing` warning in the driver log**: the runtime is missing the `iot-communication` SL651 classes, so the server does not start (`Driver SL651 server unavailable`). A normal build bundles the dependency; if you trimmed dependencies, add it back. - **Device stays offline**: the device health check cron is `0/15 * * * * ?` with a `45 second` lease timeout. If the station's reporting interval exceeds 45 seconds (e.g. hourly or long-interval supplementary reports), being judged offline between two reports is expected; see [Device](../introduction/concepts/device) for the online determination. - **A misconfigured write command has no effect**: this driver's `write` returns `false` by design, there is no `command-attribute`, and a write command on a Point is never dispatched. For remote control of a station, use the station's own downlink channel; it is outside this driver's scope. ## How it lands in IoT DC3 - **`dc3.driver.code`**: `Sl651Driver` (driver name `SL651 Hydrological Telemetry Driver`, type `DRIVER_CLIENT`). This is a stable routing identifier — do not change it casually. - **Read / write / subscribe capability**: **subscribe / report only**. `read` returns `null`, `write` returns `false`, scheduled reads are off, and collection is driven entirely by station reports. This matches the [driver capability matrix](./matrix): read —, write —, subscribe ✓. - **Collection and health**: passive listening with no active read cycle; the driver also has an internal `schedule.custom` cron `0/5 * * * * ?` (`schedule()` is currently a no-op and does not collect). The device health check cron is `0/15 * * * * ?` with a `45 second` lease timeout. ::: warning The device code must equal the station address, or the data is dropped The driver matches the **station address (uppercase hex string)** in the report header against the device's `deviceCode` or `deviceName` (case-insensitive). If they do not match, the report is silently dropped — you will see the driver receiving reports yet no point values. Confirm the address the station actually reports, and copy it verbatim into `deviceCode` before onboarding. ::: ::: warning index is the "Nth element" of the body, not a register address `index` is the **element-list index** the driver parses out, zero-based, matching the order of elements in the station's report one-to-one. It is not an SL651 identifier code, nor any register address. The element order is determined by the station configuration, so confirm which value each `index` maps to against the station's report content before onboarding. ::: ::: info Implementation status: available (graceful degradation when the server dependency is missing) The SL651 server is invoked via reflection on the `iot-communication` library's `SL651Server`, and the report parsing and forwarding pipeline is complete, so this is an **available** driver. If the runtime lacks the library's SL651 classes, `startServer()` logs an `sl651ApiMissing` warning and skips startup without affecting the rest of the process; a normal build already bundles the dependency. ::: ### Minimal onboarding example Onboard a water-level station with station address `12345678` reporting to local port `5001`: 1. Create a [Device](../introduction/concepts/device) using `SL651 Hydrological Telemetry Driver`, and **set the device code `deviceCode` to `12345678`** (it must match the address the station reports), with driver attributes `port=5001` and `pwd=0000`. 2. Add a water-level [Point](../introduction/concepts/point) to the [Profile](../introduction/concepts/profile) bound to the device (set `pointTypeFlag` to match the reported element's actual type, `READ_ONLY`), and set the point attribute `index=0` (take the first element of the report body). 3. Start the driver and let the station push data; as soon as the station reports, the matched Point shows up in [PointValue](../introduction/concepts/point-value). ## Further reading - [Driver overview](./index) — categories and selection of all drivers - [Driver capability matrix](./matrix) — read / write / subscribe capability per driver - [Device onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [IoT network-layer chapter](../foundations/fieldbus) — trade-offs of server-side passive ingest vs polling - [Listening Virtual Driver](./listening-virtual) — the same passive-listener, report-triggered collection paradigm --- # SNMP Driver URL: https://docs.dc3.site/en/drivers/snmp <script setup> import SnmpDiagram from '../../.vitepress/theme/components/SnmpDiagram.vue' </script> # SNMP Driver `dc3-driver-snmp` connects SNMP-capable network and data-center devices to IoT DC3: it targets an OID on the device's MIB tree, periodically sends SNMP GET to read values, and supports sending SNMP SET to an OID to write values. By the end you will be able to onboard a router, switch, or UPS and collect its port status, traffic, temperature/humidity, and other metrics as [PointValues](../introduction/concepts/point-value). ## Protocol background SNMP (Simple Network Management Protocol) is the most common management protocol for network and data-center equipment. It runs over UDP on default port `161`. It belongs to the [network layer](../foundations/fieldbus) of the IoT four-layer architecture — like Modbus and OPC on the industrial floor, it solves the problem of "reading/writing a value in some address space," except its devices are not PLCs and meters but IP network elements: routers, switches, UPSes, rack PDUs, printers, server NICs. Each managed device holds a MIB (Management Information Base) tree, and every readable/writable data point on that tree has a unique object identifier OID (Object Identifier, e.g. `1.3.6.1.2.1.1.1.0`). A manager uses the OID to locate " which value to read": - **Scalar objects** end with the instance identifier `.0`, e.g. the system description `sysDescr` is `1.3.6.1.2.1.1.1.0`; - **Table entries** (such as per-port traffic or status) end with a row index, e.g. `...10.1` and `...10.2` for ports 1 and 2. SNMP has three versions: v1 / v2c / v3. v1 and v2c use a cleartext `community` string as the passphrase — simple to configure and the most common in the field; v3 introduces USM (User-based Security Model) for authentication and encryption. Built on the SNMP4J library, this driver acts as an SNMP manager and actively connects to devices: a read Point sends a GET to its OID, a write Point sends a SET to the OID, and one long-lived SNMP session is reused per device. The typical use case is data-center and network monitoring — bandwidth, port up/down, CPU/memory utilization, room temperature/humidity, UPS battery, etc. Any SNMP-capable device can be managed once its OIDs are configured. <SnmpDiagram lang="en" /> ## Attribute configuration SNMP connection parameters and collection targets are filled in at two levels: connecting to a device uses **driver attributes** (device-level), and locating each data point uses **point attributes** (point-level). The attribute names, types, and defaults all come from the driver `application.yml` `driver-attribute` / `point-attribute` / `command-attribute` definitions. ### Driver attributes (device-level `driver-attribute`) When onboarding an SNMP device, fill in these [Attributes](../introduction/concepts/attribute-config) on the [Device](../introduction/concepts/device). `host` / `port` decide which device and UDP port to connect to, `version` + `community` are the v1/v2c identity passphrase, and `timeout` / `retries` control request fault tolerance. | Attribute | code | Type | Default | Description | |-------------------|-------------------|--------|-------------|------------------------------------------------------| | Host | `host` | STRING | `127.0.0.1` | SNMP device IP | | Port | `port` | INT | `161` | SNMP port (standard 161) | | Version | `version` | STRING | `v2c` | SNMP version (`v1` / `v2c`) | | Community | `community` | STRING | `public` | Community string (read-only / read-write passphrase) | | USM Username | `usmUsername` | STRING | (empty) | SNMPv3 USM username (not used in v1/v2c) | | USM Auth Protocol | `usmAuthProtocol` | STRING | `MD5` | SNMPv3 auth protocol (MD5/SHA) | | USM Auth Password | `usmAuthPassword` | STRING | (empty) | SNMPv3 auth password | | Timeout | `timeout` | INT | `5000` | Request timeout in milliseconds | | Retries | `retries` | INT | `1` | Number of request retries | ::: warning The three USM fields are reserved for SNMPv3 and have no effect today `usmUsername` / `usmAuthProtocol` / `usmAuthPassword` are the SNMPv3 USM security fields. They are declared in `application.yml`, but the driver's `buildTarget()` builds only a `CommunityTarget` and sets `version1` or `version2c` based on `version`. The current implementation supports only v1 and v2c, so these three are never read even if filled in; set `version` to `v1` or `v2c`. ::: `validate()` marks `host` / `port` / `version` / `community` as required — missing any one fails device validation. ### Point attributes (`point-attribute`) On each collected [Point](../introduction/concepts/point), fill in `oid` to specify which data point to read; `snmpType` labels that value's SNMP data type. | Attribute | code | Type | Default | Description | |-----------|------------|--------|----------------|---------------------------------------------------------------------------------| | OID | `oid` | STRING | (empty) | SNMP object identifier (e.g. `1.3.6.1.2.1.1.1.0`) | | SNMP Type | `snmpType` | STRING | `OCTET_STRING` | SNMP data type (INTEGER/GAUGE32/COUNTER32/OCTET_STRING/TIMETICKS/IPADDRESS/OID) | ::: tip The OID selects which data point is collected; snmpType is mainly for writes On read, the driver sends a GET to the configured `oid` and reports the returned `VariableBinding` value as-is via `variable.toString()` as the [PointValue](../introduction/concepts/point-value) — `snmpType` is not used in reads. Its real role is on write: `createVariable()` uses it to convert the string into the correct SNMP variable type. `validatePoint()` marks `oid` as required, so a read Point missing `oid` fails validation. ::: ### Writes reuse the point attributes — no separate write command needed Writes and reads share the same `oid` / `snmpType` on the Point: `write()` (SET) reads `oid` and `snmpType` from `pointConfig` (point-attribute), sends a SET to that OID, and builds the value per `snmpType`. A writable Point only needs `oid` and `snmpType` configured on the point — no need to repeat them on a write command. `snmpType` values supported by `createVariable()`: `INTEGER`/`INTEGER32`, `GAUGE32`/`COUNTER32`/`UNSIGNED_INTEGER32`, `COUNTER64`, `TIMETICKS`, `OID`, `IPADDRESS`, `NULL`; anything else is treated as `OCTET_STRING`. ::: info `command-attribute` is not read by the write path today `application.yml` declares a `command-attribute` (`oid` / `snmpType`), but `write()`'s signature only takes `driverConfig` and `pointConfig` — command attributes are not passed in, and this driver does not override `execute()`. So that `command-attribute` is a placeholder declaration today and is never read on write. Configure a writable Point's `oid` / `snmpType` on the Point itself, not on a write command — otherwise the write falls back to the point defaults ( `oid` empty, `snmpType=OCTET_STRING`). ::: ### Collection and health - **Collection cycle**: default cron `0/30 * * * * ?` (one read every 30 seconds, from `schedule.read.cron`). - **Health / online**: device health check default cron `0/15 * * * * ?`, lease timeout `45 seconds`. `health()` decides online by "whether the device has an established SNMP session" — if `clientMap` holds the device it is treated as online; otherwise it tries to build a session, and online once built. See [Device](../introduction/concepts/device) for the online-state mechanism. ::: info An established SNMP session does not mean the device is reachable `getConnector()` builds a local UDP transport (`DefaultUdpTransportMapping`); once `listen()` succeeds it is cached as " online" without probing the device. So after a device goes offline, the health check may briefly still report online — a real failure only surfaces on the next `read()` timeout, at which point the driver does `clientMap.remove(deviceId)` to destroy the session, and the next health check flips to offline. ::: ## Troubleshooting ::: warning Scalar OIDs usually end with `.0` — don't drop it A scalar (single-value) object's OID ends with the instance identifier `.0`, e.g. `sysDescr` is `1.3.6.1.2.1.1.1.0`, not `1.3.6.1.2.1.1.1`. Table entries (such as per-port traffic) end with a row index instead (e.g. `...10.1`, `...10.2`). When the OID is wrong, the device returns `noSuchObject`/`noSuchInstance`, and `variable.toString()` reports it as a plain string PointValue — it looks like "collected" but is invalid data, which is easy to be misled by during diagnosis. ::: ::: warning A wrong community times out silently SNMP uses the `community` string as its passphrase. If the community does not match, or the device does not grant that community access, the device usually sends no reply; the `response.getResponse()` from `snmp.send()` is `null` and the driver throws `ReadPointException("SNMP response is null...")`. This shows up as a request timeout rather than an explicit "auth failed." Before onboarding, confirm the `host`, `port`, `community`, and `oid` combination returns a value on the command line with `snmpget -v2c -c public <host> 1.3.6.1.2.1.1.1.0`. ::: ::: warning Firewall blocks UDP 161 / device has SNMP disabled SNMP runs over UDP, not TCP; many firewalls pass TCP by default but block UDP, and switches/servers often have the SNMP agent disabled by default. The symptom is again a timeout. First confirm the target device has the SNMP service enabled and that inbound UDP `161` traffic from the manager to the device is allowed. ::: Before onboarding, verify the link on the command line with net-snmp tools — the driver uses the same SNMP4J semantics, so if the command line returns nothing, don't create the device in DC3 yet: ```bash # Minimal connectivity check snmpget -v2c -c public 192.168.1.20:161 1.3.6.1.2.1.1.1.0 # If no value comes back, rule out each: host pingable? UDP 161 open? community correct? SNMP enabled? snmpwalk -v2c -c public 192.168.1.20:161 1.3.6.1.2.1.1 # walk the system subtree to see if the device answers ``` ::: warning version only accepts v1 / v2c — v3 is treated as v2c The driver's `buildTarget()` only recognizes `v1` (case-insensitive); any other value — including `v3` — falls through to the `version2c` branch. If the device only allows SNMPv3, this driver cannot connect, and there is no explicit " version unsupported" error — it just shows up as a community-validation timeout. Make sure the device allows v1/v2c access. ::: ::: warning A write returning true does not mean the device accepted it `write()` returns `true` as soon as it gets a non-null `response`; it does not check the response PDU's `errorStatus`. Some devices return a response with an error code (rather than no reply) for a read-only OID or an unauthorized write, and the driver still treats it as success. After writing a critical parameter, read the OID back to confirm it took effect. ::: ## How it lands in IoT DC3 - **`dc3.driver.code`**: `SnmpDriver` (driver name `SNMP Driver`, type `DRIVER_CLIENT`, actively connects to devices). This is a stable routing identifier and must not be changed casually. - **Read / write / subscribe capability**: read ✓, write ✓, subscribe —, consistent with the [driver capability matrix](./matrix). The driver polls actively as an SNMP manager and does not listen for device pushes, so there is no subscribe direction. ::: info Implementation status: available In `SnmpDriverCustomServiceImpl`, `read()` (GET), `write()` (SET), `getConnector()` (session management), `health()`, and `event()` (destroying a session on device update/delete) are all implemented; the SNMP4J v1/v2c send/receive path is complete and usable. Known boundaries: SNMPv3/USM is not wired up (see the three USM fields above), `write()` does not check the response `errorStatus`, and the health check is a local session-liveness check rather than an end-to-end probe. These are deliberate trade-offs in the current implementation and do not affect normal v1/v2c collection and writes. ::: Minimal onboarding example — onboard a switch at IP `192.168.1.20:161` with community `public`, collecting its system description (`sysDescr`, OID `1.3.6.1.2.1.1.1.0`): 1. Choose `SNMP Driver` to create a [Device](../introduction/concepts/device), filling the driver attributes `host=192.168.1.20`, `port=161`, `version=v2c`, `community=public`. 2. Add a description [Point](../introduction/concepts/point) (`pointTypeFlag=STRING`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, with the point attribute `oid=1.3.6.1.2.1.1.1.0`. 3. Start the driver; within 30 seconds the device's system description string appears in the [PointValue](../introduction/concepts/point-value). See [Device onboarding](../operation/device-onboarding) for the full flow. ## Further reading - [Drivers overview](./index) — the general driver model, registration, and lifecycle - [Driver capability matrix](./matrix) — read/write/subscribe capability of all 28 drivers - [Device onboarding](../operation/device-onboarding) — a full onboarding flow - [Industrial Buses & Protocols](../foundations/fieldbus) — the network layer SNMP belongs to, and the "protocol parameters are driver attributes" model - [IoT Protocols & Wireless Networks](../foundations/iot-protocols) — the wireless and lightweight IoT half of the network layer - [CoAP Driver](./coap) — another lightweight IoT protocol over UDP --- # SQL Server Driver URL: https://docs.dc3.site/en/drivers/sqlserver <script setup> import SqlserverDiagram from '../../.vitepress/theme/components/SqlserverDiagram.vue' </script> # SQL Server Driver `dc3-driver-sqlserver` onboards a Microsoft SQL Server database into IoT DC3 as a data source: acting as a database client, it runs a `SELECT` on each polling cycle and uses the queried value as the reading, and supports writing values into the database via the `UPDATE`/`INSERT` write query configured on the point. After reading this you can set the connection parameters (including encryption options) on a [Device](../introduction/concepts/device), the read/write SQL on each [Point](../introduction/concepts/point), and pinpoint common "can't connect / TLS handshake fails / no value / write fails" problems. > You are here: a driver that onboards an existing database as a data source. Not all data comes from a fieldbus > device—much business data, historical data, and third-party results simply live in a SQL Server table. ## Protocol background SQL Server is Microsoft's enterprise relational database, shipping since 1989, using T-SQL as its query language and organizing data into tables/rows/columns; it is heavily used in Windows and enterprise IT environments. In IoT scenarios it is often not a "field device" but a hub where data converges: MES/ERP, SCADA front-ends, third-party platforms, and historical archives all tend to drop their results into a SQL Server table for downstream consumption. Onboard such a table as a data source, and the platform can poll its columns into [PointValues](../introduction/concepts/point-value) just like it polls a real device. Seen through the four-layer IoT reference architecture, a database driver talks to the database over TDS on TCP/IP ( default port `1433`); it is the entry point for moving external data into the platform, and its transport sits at the * *network layer**—see the [IoT network-layer chapter](../foundations/data-pipeline) for how data enters the platform pipeline. This driver acts as a database client ([Driver](../introduction/concepts/driver) type `DRIVER_CLIENT`), connecting to a SQL Server instance over JDBC (driver class `com.microsoft.sqlserver.jdbc.SQLServerDriver`) and reading/writing values by the SQL configured on each [Point](../introduction/concepts/point). Its communication model is classic **request-response**—the driver, as a client, actively issues queries; the database never pushes, so collection is driven by cron polling. The shared logic for JDBC connections, connection pooling, and SQL execution lives in the abstract base class `AbstractJdbcDriverCustomService` (`dc3-common-sql` module), reused by all four database drivers ( MySQL, PostgreSQL, Oracle, SQL Server), each of which only supplies JDBC URL construction and the driver class name. Two driver-specific concepts that the configuration tables below rely on: <SqlserverDiagram lang="en" /> - **Read Query**: a `SELECT` configured on the point; the driver runs it on each polling cycle and takes the **first column of the first row** of the result as the point's value. - **Write Query**: an `UPDATE`/`INSERT` configured on the point, using a single `?` placeholder for the value to write—when a write command fires, the command parameter is bound via prepared-statement parameter binding. ## Attribute configuration Onboarding a SQL Server database requires filling in [attributes](../introduction/concepts/attribute-config) at three levels: device-level connection parameters (`driver-attribute`), each polled point's read/write SQL (`point-attribute`), and one reserved attribute on the write command (`command-attribute`). The attributes, types, and defaults below are taken from the driver's `application.yml` (`dc3-driver-sqlserver` module). ### Driver attributes (device-level `driver-attribute`) Driver attributes answer "which database to connect to, which account to use, the query timeout, and whether the connection is encrypted". Fill in one set per SQL Server database on the [Device](../introduction/concepts/device): | Attribute | code | Type | Default | Remark | |--------------------------|--------------------------|--------|-------------|-----------------------------------------------------------------| | Host | `host` | STRING | `localhost` | SQL Server host IP or hostname | | Port | `port` | INT | `1433` | SQL Server port (standard 1433) | | Database | `database` | STRING | (empty) | SQL Server database name | | Username | `username` | STRING | `root` | SQL Server username | | Password | `password` | STRING | (empty) | SQL Server password | | Query Timeout | `queryTimeout` | INT | `30` | SQL query timeout in seconds | | Encrypt | `encrypt` | STRING | `false` | Whether to encrypt the connection (TLS) | | Trust Server Certificate | `trustServerCertificate` | STRING | `true` | Whether to trust the server certificate (skip chain validation) | The driver builds the JDBC URL from these attributes, in the form `jdbc:sqlserver://host:port;databaseName=...;encrypt=...;trustServerCertificate=...;` (semicolon-delimited, unlike MySQL's `?key=value` form). All five of `host`, `port`, `database`, `username`, and `password` are required—configuration validation (`validate()`) checks each one, and any missing field fails. The driver caches one HikariCP connection pool per device ID (one pool per device, max 5 connections), with the connection timeout set to `queryTimeout × 1000` milliseconds. ::: tip queryTimeout applies to both connecting and the query pace `queryTimeout` (default 30 seconds) is used as the pool's `connectionTimeout`: failing to acquire a connection, or a connection that stalls beyond this duration, fails. It is separate from the polling interval—if a slow SQL consistently approaches or exceeds it, optimize the SQL or add an index rather than just raising the timeout. ::: ::: warning encrypt and trustServerCertificate are STRING and must be set together Both attributes are STRING type—fill the string `"true"`/`"false"`, not a boolean; they are spliced verbatim into the JDBC URL. The SQL Server JDBC driver performs a TLS handshake and validates the server certificate when `encrypt=true`; if the server uses a self-signed certificate, validation fails and the connection errors out. When enabling encryption against a self-signed instance, you must also set `trustServerCertificate=true` to skip certificate-chain validation. For plaintext testing on a trusted network, just keep the default `encrypt=false`. ::: ### Point attributes (`point-attribute`) Point attributes answer "which value to query from this database, and where to write". Fill in the read/write SQL on each polled [Point](../introduction/concepts/point): | Attribute | code | Type | Default | Remark | |-------------|--------------|--------|---------|-----------------------------------------------------------------------------------------------| | Read Query | `readQuery` | STRING | (empty) | `SELECT` query for reading the point value | | Write Query | `writeQuery` | STRING | (empty) | `UPDATE`/`INSERT` using a single `?` placeholder for the written value (bound as a parameter) | ::: tip Read Query takes the first column of the first row `readQuery` is a plain `SELECT`, and the driver takes the **first column of the first row** of its result ( `rs.getObject(1)`) as the point's value—so a single-row, single-column query like `SELECT temperature FROM sensor WHERE id = 1` is the safest form. An empty result set yields `null`. The point's data type ([Point](../introduction/concepts/point) `pointTypeFlag`) decides how that value is parsed. `readQuery` is required on a point (enforced by `validatePoint()`); without it, point validation fails. `writeQuery` is required only when that point is to be written. ::: ### Write command attributes (`command-attribute`) This attribute can be configured on the write command, but is not consumed by the implementation: | Attribute | code | Type | Default | Remark | |---------------|----------------|--------|---------|--------------------------------------| | Execute Query | `executeQuery` | STRING | (empty) | SQL query to execute for the command | ::: warning executeQuery is currently not consumed by the implementation Writing a value goes through the **point's `writeQuery`**: `write()` reads the `point-attribute` `writeQuery`, binds the command parameter with `setString(1, value)` into the single `?` placeholder, and executes the `UPDATE`/`INSERT`. The `command-attribute` `executeQuery` is kept only as a configuration item—nothing in the current driver code reads or executes it. There is no separate "run a SQL statement directly by command" path; writing always goes through `writeQuery`. Code is the source of truth. ::: ## Troubleshooting SQL Server onboarding failures mostly cluster around connection, TLS handshake, account permissions, query targeting, and field types. Work through them in order: 1. **Can't connect (device stays offline)**. First confirm `host:port` is reachable: `telnet <host> 1433` or `nc -vz <host> 1433`. The health check decides online via `conn.isValid(5)` (whether a valid connection can be obtained within 5 seconds); a failed connect or heartbeat reports offline. Common root causes: SQL Server's TCP/IP protocol not enabled, the instance listening only on named pipes, a firewall blocking 1433, or a dynamic port not pinned to 1433. 2. **Encryption / certificate handshake fails**. When `encrypt=true`, the driver performs a TLS handshake and validates the server certificate; against a self-signed instance without `trustServerCertificate=true`, the connect phase reports certificate-chain validation failure. Either set `trustServerCertificate=true` to skip validation, or install a certificate issued by a trusted CA on the server. For plaintext testing on a trusted network, keep `encrypt=false`. Note both are filled as the strings `"true"`/`"false"`. 3. **Connects but is rejected (account / permission)**. Confirm `username`/`password` are correct and the account has read (or write) permission on the target tables. SQL Server supports both SQL authentication and Windows authentication; this driver uses SQL authentication with `username`/`password`—if the instance only allows Windows ( integrated) authentication, the SQL account is rejected. A failed connect throws `ConnectorException` and invalidates that device's pool, which is rebuilt on the next cycle. 4. **No value / wrong row returned**. The driver only takes the first column of the first row, so `readQuery` must reliably pinpoint the target row. An empty result set yields `null`; when multiple rows return, only the first is used and may not be the row you meant. Write the `WHERE` primary-key condition fully so you don't pick the wrong row as the table grows. 5. **Value / type mismatch**. The point's `pointTypeFlag` decides how the returned string is parsed. Configuring a text column as a `FLOAT` point, or treating a `datetime`/`bit` column as numeric, can fail parsing. Use `CAST`/`CONVERT` in `readQuery`, or select only the target numeric column, so the returned value matches the point's type. 6. **Write command returns failure**. Writing requires exactly one `?` placeholder in `writeQuery` and a statement targeting a writable table and row. `write()` treats "affected rows > 0" as success—if the `WHERE` condition matches no rows, `executeUpdate()` returns 0 and the write is judged failed. Run the same `UPDATE` by hand in the database first to confirm it hits a row. A failed write throws `WritePointException` and invalidates the pool. ::: warning Write Query uses a `?` placeholder, not `${value}` When writing, `writeQuery` uses a **single** `?` placeholder for the value (e.g. `UPDATE sensor SET temperature = ? WHERE id = 1`), bound by the driver via `PreparedStatement.setString(1, value)`—this is prepared-statement parameter binding, not string concatenation, so a malicious value cannot alter the statement structure (no SQL injection). Do not concatenate the value into the SQL by hand, and do not use template syntax like `${value}`: it would neither be substituted nor give you injection protection. ::: ## How it lands in IoT DC3 - **`dc3.driver.code`**: `SqlserverDriver` (type `DRIVER_CLIENT`, actively connects to the database and issues queries). This is a stable routing identifier—do not change it casually. - **Read capability**: ✓ implemented. `read()` executes the point's `readQuery` and takes the first column of the first row as the point value. - **Write capability**: ✓ implemented. `write()` executes the point's `writeQuery`, binding the written value as a `?` prepared-statement parameter; affected rows > 0 means success. - **Subscribe/report**: — not supported. SQL Server is request-response; the driver only actively queries/writes and never passively receives pushes. This matches the `✓ / ✓ / —` for SQL Server in the [driver capability matrix](./matrix). - **Polling interval**: default cron `0/30 * * * * ?` (read once every 30 seconds), configured under `schedule.read` in the driver's `application.yml`; there is also a `custom` schedule with default cron `0/5 * * * * ?` (every 5 seconds), but the base class `schedule()` is an empty implementation and database drivers do not use it. - **Health/online**: device health check defaults to cron `0/15 * * * * ?` with a lease timeout of `45 seconds`; the verdict relies on `conn.isValid(5)`. See [Device](../introduction/concepts/device) for the online-state mechanism. ::: info Implementation status: available This driver is a **complete implementation** (not a skeleton). Reading, writing, the health check, the per-device cached HikariCP pool, and pool invalidation-and-rebuild on failure are all in place, reusing the tested `AbstractJdbcDriverCustomService` base class; the SQL Server subclass only customizes JDBC URL construction (including `encrypt`/`trustServerCertificate`), the driver class name, and the default port. The only thing to note is that the `command-attribute` `executeQuery` is reserved but not consumed by the code—writing always goes through the point's `writeQuery` (see the warning above). ::: ### Minimal onboarding example Onboard the `temperature` column of the `id=1` row in a `sensor` table as a temperature point: 1. Create a [Device](../introduction/concepts/device) with `SQL Server Driver`, and set the driver attributes `host=192.168.1.10`, `port=1433`, `database=iot`, `username=sa`, `password=******` (keep the default `encrypt=false` while testing over a trusted network). 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attribute `readQuery=SELECT temperature FROM sensor WHERE id = 1`. 3. Start the driver, and within 30 seconds the queried temperature shows up in the [PointValue](../introduction/concepts/point-value). 4. If the point should be writable, add `writeQuery=UPDATE sensor SET temperature = ? WHERE id = 1` to its point attributes and configure a write [Command](../introduction/concepts/command) for it. ::: tip One driver instance can serve multiple databases A single SQL Server driver process can serve multiple devices: each device connects to its own database per its driver attributes and holds its own connection pool (cached by device ID). When device metadata is deleted/updated, the corresponding pool is closed and rebuilt on demand. ::: ## Further reading - [Drivers overview](./index) — entry point and categories for all drivers - [Driver capability matrix](./matrix) — read/write/subscribe at a glance, including the SQL Server row - [Device Onboarding](../operation/device-onboarding) — a complete onboarding walkthrough - [Time-Series Data & Stream Processing](../foundations/data-pipeline) — how PointValues are stored, computed, and queried after entering the platform - [MySQL Driver](./mysql) — another database data source on the same JDBC base class, with the same configuration structure --- # TCP/UDP Driver URL: https://docs.dc3.site/en/drivers/tcp-udp <script setup> import TcpUdpDiagram from '../../.vitepress/theme/components/TcpUdpDiagram.vue' </script> # TCP/UDP Driver `dc3-driver-tcp-udp` connects any device that "exchanges a raw byte stream over a TCP or UDP port" to IoT DC3: for each [Point](../introduction/concepts/point) it sends a HEX command, reads back the raw bytes, then carves out the data by frame rules and converts it to a value. After reading this you can collect from and write to proprietary devices that have no standard protocol stack, and know where byte order, frame offset, and connection backoff go wrong. ## Protocol Background TCP and UDP are the two transport-layer protocols of the [TCP/IP suite](../foundations/iot-protocols): TCP is connection-oriented and provides a reliable, ordered byte stream; UDP is connectionless and delivers datagrams best-effort. In the four-layer IoT architecture (perception → network → platform → application), they sit in the * *network layer**—the common carrier beneath higher-level application protocols (MQTT, CoAP, Modbus TCP, and so on). Many field devices run no standard protocol stack: serial-to-Ethernet modules, home-grown microcontrollers, proprietary-protocol gateways—they often just "you send some bytes on a port, they reply with some bytes." Such devices cannot be onboarded with any one protocol-specific driver. `dc3-driver-tcp-udp` is their generic base—it pulls in no third-party protocol library, talks to JDK `Socket` / `DatagramSocket` directly, and leaves "what command to send and how to parse the reply" entirely to [Attribute/Config](../introduction/concepts/attribute-config). The behavioral difference between TCP and UDP in this driver matters: - **TCP**: caches one long-lived connection per device (`tcpConnectMap`) to avoid redoing the three-way handshake every poll; the connection is invalidated and reconnected on disconnect or communication error. - **UDP**: connectionless—each poll creates a fresh `DatagramSocket`, sends, waits for the reply, then closes it. ::: info HEX command and frame What you exchange with the device is binary. This driver writes commands uniformly as a hex string (e.g. `01 03 00 00 00 02`; whitespace is ignored). The whole chunk of bytes the device returns is one frame; `dataOffset` / `dataLength` locate the real data inside the frame, and `dataFormat` decides how those bytes become a point value. ::: - **Driver name / code**: `TCP/UDP Raw Driver` / `TcpUdpDriver` - **Type**: `DRIVER_CLIENT` (the driver actively connects to the device and sends commands) ## Attribute Config Attributes come from the driver's `application.yml` in three layers: **driver attributes** go on the [Device](../introduction/concepts/device) (one set of connection parameters per device), **point attributes** go on each [Point](../introduction/concepts/point) (describe what this channel reads and how to parse it), and **command attributes** go on a writable point's write command. The prose before each table explains what each attribute does. ### Driver Attributes (device-level `driver-attribute`) `protocol` chooses TCP or UDP; `host` / `port` point at the device's network address (the port default `502` is just a placeholder—change it to your device). `connectTimeout` is the TCP connect timeout and `readTimeout` is the read timeout while waiting for the reply, both in milliseconds. `delimiter` is reserved for delimiter-based framing; the current implementation frames mainly by `dataOffset`/`dataLength`. | Attribute | code | Type | Default | Description | |-----------------|------------------|--------|-------------|---------------------------| | Protocol | `protocol` | STRING | `TCP` | TCP or UDP | | Host | `host` | STRING | `localhost` | device IP / hostname | | Port | `port` | INT | `502` | device port | | Connect Timeout | `connectTimeout` | INT | `5000` | TCP connect timeout, ms | | Read Timeout | `readTimeout` | INT | `3000` | read-response timeout, ms | | Delimiter | `delimiter` | STRING | (empty) | Hex delimiter | ### Point Attributes (`point-attribute`) `sendCommand` is the HEX command sent when this channel polls; on receiving the reply the driver carves out a byte slice with `dataOffset` + `dataLength`, then converts it per `dataFormat`, with multi-byte values governed by `byteOrder`. `frameHeader` / `frameFooter` / `receiveLength` are reserved for frame header/footer and fixed-length reads. | Attribute | code | Type | Default | Description | |----------------|-----------------|--------|---------|--------------------------------------| | Send Command | `sendCommand` | STRING | (empty) | HEX command sent on poll | | Receive Length | `receiveLength` | INT | `0` | 0 means use delimiter | | Frame Header | `frameHeader` | STRING | (empty) | frame header HEX | | Frame Footer | `frameFooter` | STRING | (empty) | frame footer HEX | | Data Offset | `dataOffset` | INT | `0` | byte offset of data within the frame | | Data Length | `dataLength` | INT | `0` | data byte length | | Data Format | `dataFormat` | STRING | `HEX` | HEX/ASCII/INT16/UINT16/INT32/FLOAT | | Byte Order | `byteOrder` | STRING | `BIG` | byte order: BIG / LITTLE | ::: tip dataFormat decides how the reply becomes a value The driver carves out a byte slice from the reply using `dataOffset` + `dataLength`, then converts it per `dataFormat`: `HEX` returns the hex string as-is, `ASCII` decodes to text (trailing whitespace is trimmed), `INT16/UINT16/INT32/FLOAT` parse as numbers (multi-byte values are governed by `byteOrder`, `BIG` for big-endian and `LITTLE` for little-endian). `INT16/INT32/FLOAT` require the carved slice to be ≥2/≥4 bytes respectively; when too short it falls back to HEX. If `dataLength=0`, no slicing happens and the whole reply is returned as HEX. ::: The flow below strings together the key hops of "one poll" from sending the command to landing a value: <TcpUdpDiagram lang="en" /> ### Write Command Attributes (`command-attribute`) A writable point's write command takes a `sendCommand` template with a `${value}` placeholder. On write the driver replaces `${value}` with the actual command value, then sends it to the device as a HEX command (TCP reuses the long-lived connection, UDP creates a temporary socket). | Attribute | code | Type | Default | Description | |--------------|---------------|--------|------------|---------------------------------------------------------------------| | Send Command | `sendCommand` | STRING | `${value}` | write command template; `${value}` is replaced by the command value | ::: warning The write path reads `sendCommand` from the point attribute In the source, `write()` takes `sendCommand` from the **point attribute** (`pointConfig`), not the command attribute. If a writable point has no `sendCommand` set in its point attributes, the write returns failure because the command is empty. The `command-attribute` `${value}` template applies in the `execute()` rendering flow. ::: ## Troubleshooting - **The point value is a long HEX string when you expected a number**: usually `dataOffset` + `dataLength` exceeds the actual reply length, or `dataLength=0`. On out-of-range, the driver does **not** error—it skips slicing and returns the whole reply as raw HEX. Capture a real reply frame first, count which byte the target data starts at and how many bytes it spans, then match `dataOffset` / `dataLength`. - **Sign/magnitude clearly wrong**: `byteOrder` does not match the device for a multi-byte value. Use `BIG` for big-endian devices, `LITTLE` for little-endian; `UINT16` and `INT16` differ by a sign when the high bit is 1—pick the format that matches the device's semantics. - **`sendCommand` fails to parse or reads wrong values**: `sendCommand` / `frameHeader` / `frameFooter` are all parsed as hexadecimal (whitespace is ignored, so `01 03 00 00` is fine). Feeding non-hex characters (e.g. decimal `10` meant as a number) makes parsing fail. `dataFormat=ASCII` only affects how reply bytes decode to text—the command itself must still be HEX. - **Device stays offline / temporarily won't connect**: after 3 consecutive TCP connect or read/write failures the driver enters a **60-second backoff window** and pauses reconnection; during it the device is reported offline, and it retries automatically once the window passes—one successful exchange resets the counter. On a brief offline, first check whether you're inside the backoff window, then check `host`/`port`/firewall. - **Read timeout**: `readTimeout` defaults to 3000ms. A slow reply or UDP packet loss triggers a read timeout (UDP throws `SocketTimeoutException` when no reply arrives). Raise `readTimeout` as needed, and for UDP confirm the peer actually sends a reply. - **A UDP device is always reported online**: UDP is connectionless, so `health()` reports UDP online by default (no probe). "Online" does not mean data flows—still check whether the point has fresh values landing. ## How It Works in IoT DC3 - **`dc3.driver.code`**: `TcpUdpDriver`—a stable routing identifier the platform uses to dispatch commands to this driver; do not change it casually. - **Read / write / subscribe**: this driver's `read()` actively sends a command to collect, and `write()` renders a command to write—both are implemented; it offers **no subscribe**—`schedule()` is an empty method with no custom periodic task. This matches "read ✓ / write ✓ / subscribe —" for this driver in the [driver capability matrix](./matrix). - **Collection & health**: default collection cron `0/30 * * * * ?` (one poll every 30 seconds); device health-check cron `0/15 * * * * ?` with a `45-second` lease timeout. TCP devices are judged online by cached connection state or a quick connect attempt; UDP is reported online by default. ::: info Custom schedule is enabled but does nothing `schedule.custom` is enabled by default with cron `0/5 * * * * ?`, but `schedule()` is an empty method—this driver implements no custom periodic task, so the schedule performs no work. It is an intentional placeholder and does not affect normal collection. ::: ### Minimal Onboarding Example Onboard a TCP device at `192.168.1.50:8899` and collect a 16-bit temperature (device replies `01 03 04 00 FA 12 34 ...`, temperature in bytes 3 and 4): 1. Create a [Device](../introduction/concepts/device) with `TCP/UDP Raw Driver`, set driver attributes `protocol=TCP`, `host=192.168.1.50`, `port=8899`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=INT`, `READ_ONLY`) to the device's bound [Profile](../introduction/concepts/profile), set point attributes `sendCommand=010300000001`, `dataOffset=3`, `dataLength=2`, `dataFormat=INT16`, `byteOrder=BIG`. 3. Start the driver; within 30 seconds the parsed value appears in [PointValue](../introduction/concepts/point-value) ( `00FA` → `250`). ## Further Reading - [Drivers Overview](./index) — the panorama and grouping of all 28 protocol drivers - [Driver Capability Matrix](./matrix) — read/write/subscribe capability across drivers - [Device Onboarding](../operation/device-onboarding) — a full onboarding walkthrough - [IoT Protocols & Wireless Networks](../foundations/iot-protocols) — the network layer TCP/UDP sits in and its relation to higher application protocols - [Modbus TCP Driver](./modbus-tcp) — a standardized TCP-protocol example to contrast with this generic driver --- # Virtual Driver URL: https://docs.dc3.site/en/drivers/virtual `dc3-driver-virtual` is the **virtual (simulation) driver** of IoT DC3: it connects to no real device, instead generating random [point](../introduction/concepts/point) [values](../introduction/concepts/point-value) on the collection schedule, and simulates command execution and event reporting. After this page you can use it to exercise the whole onboarding path end to end, and understand which of its capabilities are real implementations versus placeholders. > You are here: you have no real PLC/sensor yet and want to validate the platform end to end first, or you want the > simplest [driver](../introduction/concepts/driver) template to copy when writing your own protocol. Next, > see [device onboarding](../operation/device-onboarding). ## Protocol background Virtual is not a fieldbus or industrial protocol but a **simulation driver**—it exercises the whole IoT DC3 onboarding flow (create a [device](../introduction/concepts/device), configure a [Profile](../introduction/concepts/profile), run collection, watch [point values](../introduction/concepts/point-value)) while sending no network frames at all; every value is fabricated locally at random. Because it has no real protocol layer, it does **not** belong to any network-protocol tier of the IoT four-layer architecture; it is a "device-less onboarder" used on the platform side for demos, learning, and load testing. Typical uses: - **Trying out / demoing the platform**——with no real hardware at hand, validate the end-to-end path first. - **Learning the driver model**——it is the simplest driver; its source `VirtualDriverCustomServiceImpl` is the reference template for writing a custom driver. - **Load testing & integration**——bulk-create devices and points to observe the platform under a sustained data stream. On read it fabricates a value by the point's data type: `STRING` returns the fixed `abcd1234`, `BOOLEAN` returns a random true/false, and any other type returns a random float between `0` and `100`. What gets fabricated depends only on the point's own data type ([Point](../introduction/concepts/point)'s `pointTypeFlag`), not on the `tag` content. ::: info The virtual driver has no real protocol layer Other driver pages link to their protocol spec; this one does not—Virtual implements no wire protocol. Its "link" lives entirely inside IoT DC3. For real protocol drivers, see the [driver overview](./index). ::: ## Attribute configuration Attributes fall into two kinds: **driver attributes** (`driver-attribute`) filled on the [device](../introduction/concepts/device), and **point attributes** (`point-attribute`) filled on each [point](../introduction/concepts/point). The driver also declares command attributes and event attributes, used for template rendering in command execution and event reporting. These definitions come from the driver module's `application.yml`; each is explained below with its purpose and origin. ### Driver attributes (device-level `driver-attribute`) When onboarding a virtual device, fill the two items below. Note: the virtual driver **never actually connects** to `host:port`; these are placeholders that keep the same config shape as a real driver so the drill is easy to follow. | Attribute | code | Type | Default | Remark | |-----------|--------|--------|-------------|-----------------------------------------------| | Host | `host` | STRING | `localhost` | Device IP (placeholder, no real connection) | | Port | `port` | INT | `18600` | Device port (placeholder, no real connection) | ### Point attributes (`point-attribute`) Fill one `tag` on each collected point, as a placeholder identifying the point on the device: | Attribute | code | Type | Default | Remark | |-----------|-------|--------|---------|----------------| | Tag | `tag` | STRING | `TAG` | Point tag name | ::: tip The fabricated value depends on point type, not on tag The virtual driver ignores the actual content of `tag`; fabrication looks only at the point's data type `pointTypeFlag`: `STRING` yields `abcd1234`, `BOOLEAN` a random boolean, numeric types a random float in `0~100`. Want boolean flips? Configure the point as `BOOLEAN`. Want a continuous curve? Configure `FLOAT`. ::: ### Command attributes (`command-attribute`) The virtual driver implements **command execution** (`execute()`): on dispatch it renders `payloadTemplate` with the command params and the device/command context to obtain the request payload, then renders and parses `responseTemplate` into a mock response. None of this touches a real device. | Attribute | code | Type | Default | Remark | |-------------------|--------------------|--------|------------|-------------------------------------------------------| | Payload Template | `payloadTemplate` | STRING | `${value}` | Request payload template rendered with command params | | Response Template | `responseTemplate` | STRING | `{}` | Mock response template | ::: tip Templates use `${...}` placeholders rendered from the command context Placeholders such as `${value}`, `${deviceCode}`, `${commandCode}`, `${deviceId}`, `${commandName}` are substituted one by one (string replace) from the command params and the device/command context. When `responseTemplate` is a JSON object, its fields are parsed verbatim into the command result; otherwise the whole string is returned as the `response` field. The result also carries the rendered `payload`. ::: ### Event attributes (`event-attribute`) The virtual driver periodically simulates [event](../introduction/concepts/event) reporting for the device (one round every 30 seconds). The event attributes use JSON-Path-like expressions to tell the driver which path in the simulated message holds the event code, and which holds the event payload. | Attribute | code | Type | Default | Remark | |-----------------|-----------------|--------|---------------|---------------------------------------------| | Event Code Path | `eventCodePath` | STRING | `$.eventCode` | JSON path used to resolve the event code | | Payload Path | `payloadPath` | STRING | `$.payload` | JSON path used to resolve the event payload | ::: warning Only simple dotted paths are supported, not full JSONPath The driver's internal `resolvePath()` only walks the Map segment by segment on `.` (e.g. `$.payload.value`); it does not support array indices, filter expressions, or other full JSONPath syntax. The simulated message looks like `{"eventCode":"...","payload":{"value":...,"deviceCode":...,"source":"virtual"}}`, and falls back to the event's own `eventCode` when a path resolves to nothing. ::: ## Troubleshooting Virtual almost never fails for "cannot reach the device" (it never connects), so common issues center on scheduling, type, and configuration: - **No point values within 30 seconds**: confirm the driver is registered and online, the [Profile](../introduction/concepts/profile) bound to the device has enabled points, and the collection schedule `dc3.driver.schedule.read.enable=true` (on by default, cron `0/30 * * * * ?`). The first value can take up to one collection period. - **Value shape is not as expected** (you wanted a boolean but got a number): check the point's `pointTypeFlag`. A mismatched type only yields an unexpected value shape, **not an error**—type is a point-side convention. - **Write command always fails / nothing echoed back**: this is expected. The driver's point write `write()` is a placeholder that always returns `false`; see "How it lands in IoT DC3" below. For a working downstream path, use [command execution](#attribute-configuration) (`execute()`, via command attributes) or switch to a real driver. - **No event reports arrive**: event reporting is driven by the internal timer `dc3.driver.schedule.custom` (cron `0/5 * * * * ?`) and throttled to a 30-second interval; the device must also have **enabled** event definitions, otherwise that round is skipped. - **Device shows offline**: health check cron `0/15 * * * * ?`, lease timeout `45 seconds`. If the driver process stops or misses its periodic heartbeat, the device is marked offline—see [device](../introduction/concepts/device) for the online mechanism. - **Wrong host/port yet values still appear**: see the pitfall below—the virtual driver neither validates nor connects to `host:port`. ::: warning host/port are placeholders; reachability never affects output The virtual driver never opens a connection to `host:port`, so it produces random values even with a non-existent address. In other words, **it cannot validate real network connectivity**——to test a real link, switch to the driver for that protocol. ::: ## How it lands in IoT DC3 - **`dc3.driver.code`**: `VirtualDriver` (driver name `Virtual Driver`). This is the stable routing identifier the platform uses to route devices, commands, and events to this driver; do not change it casually. - **Type**: `DRIVER_CLIENT`——the driver is the active side, producing data on a schedule. - **Capabilities** (aligned with the [driver capability matrix](./matrix)): | Capability | Status | Notes | |-----------------------------|-------------|-----------------------------------------------------------| | Read `read()` | Available | Fabricates random values by point type; fully implemented | | Write `write()` | Placeholder | Point write always returns `false`, writes to no device | | Command execute `execute()` | Available | Template render + mock response; fully implemented | | Event report `schedule()` | Available | Simulates one event-report round every 30 seconds | ::: warning Point write is a placeholder `write()` returns `false` directly in source—any request to write a value through a point always "fails" and changes no state. This matches Virtual's "Write = —" in the [driver capability matrix](./matrix). When you need a working downstream capability, use command execution (`execute()`, with `command-attribute`), or switch to a real driver. ::: ::: info Events/commands are real implementations, but differ from the matrix's "protocol subscribe" meaning The matrix marks Virtual's "Subscribe/Report" as `—`, meaning it has no passive subscription on a real protocol layer. But in code, both `execute()` and `schedule()` (event reporting) are fully implemented simulation capabilities—this page labels them honestly per source. The validation methods `validate()`/`validatePoint()` currently perform no real checks and always pass. ::: ### Minimal onboarding example No real hardware needed—onboard one virtual device and watch the data flow: 1. Create a [device](../introduction/concepts/device) with `Virtual Driver`, set driver attributes `host=localhost`, `port=18600` (defaults are fine). 2. Add a temperature [point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`) to the [Profile](../introduction/concepts/profile) bound to the device, and set the point attribute `tag=temperature`. 3. Start the driver; within 30 seconds you'll see a continuously changing random value between `0` and `100` in the [point values](../introduction/concepts/point-value). ## Further reading - [Driver overview](./index) — pick a protocol by category and open its page - [Driver capability matrix](./matrix) — the real read/write/subscribe implementation of every driver at a glance - [Device onboarding](../operation/device-onboarding) — one complete onboarding flow - [Custom driver](../development/driver-authoring) — implement your own protocol driver on the `virtual` template - [Listening Virtual Driver](./listening-virtual) — the passive-listening simulation/onboarding driver --- # Zigbee Driver URL: https://docs.dc3.site/en/drivers/zigbee > **`dc3-driver-zigbee` connects Zigbee devices to IoT DC3** — it joins a Zigbee network through a serial coordinator, > periodically reads node data via ZCL attributes, and supports commands that write values to ZCL attributes. After reading this page you will understand where Zigbee sits in the IoT network layer, know which attributes to fill on the driver side and the point side to onboard a Zigbee node, and be clear on how far the current implementation goes and which capabilities you cannot yet rely on. ## Protocol background Zigbee is a **low-power, low-rate, short-range wireless mesh protocol** built on the IEEE 802.15.4 physical/link layer, operating in the 2.4 GHz unlicensed band. Its typical use is the large number of battery-powered sensor nodes in smart home and building automation — temperature/humidity, door contacts, occupancy, switches, lights, smart plugs, and so on. Such devices carry little data and need long battery life, which makes running a full IP stack impractical; instead they form their own Zigbee network where nodes can relay for each other (mesh), with a single **coordinator** handling network formation, ingress, and egress. In the [four-layer IoT architecture](../foundations/iot-protocols), Zigbee belongs to the wireless access technologies of the **network layer** — it solves "how to transmit a low-rate device's signal power-efficiently," not how to speak IP directly. A Zigbee network does not connect to the public network on its own; it is aggregated through a coordinator/gateway and then uplinked, similar to BLE and unlike application-layer messaging protocols such as MQTT/CoAP. In IoT DC3 the coordinator takes the form of a **USB serial dongle** plugged into the host running the driver; the driver acts as a Zigbee application-layer client, mapping each [Point](../introduction/concepts/point) to one ZCL attribute in the Zigbee network for reads and writes. Zigbee addressing has several levels. Each Zigbee device is uniquely identified by its **IEEE address** (64-bit extended address, fixed at the factory); a specific data point inside a device is then located by three levels: **endpoint → cluster → attribute (ZCL attribute)**. Understanding this addressing is the prerequisite for configuring point attributes. ::: tip Three-level addressing: endpoint / cluster / attribute A Zigbee node may have several endpoints (multi-function devices); each endpoint hosts several ZCL clusters (e.g. Temperature Measurement `1026`, Relative Humidity `1029`), and each cluster holds several attributes. `cluster` + `attribute` decide which physical quantity is read — the Point's data type ([Point](../introduction/concepts/point)'s `pointTypeFlag`) must match the actual type of that ZCL attribute. ::: ## Attribute configuration Onboarding a Zigbee device involves two layers: **driver attributes (`driver-attribute`)** configure the coordinator side — one coordinator serves the whole Zigbee network; **point attributes (`point-attribute`)** locate each collected point to one specific ZCL attribute in the network. The fields in both tables come from the driver's `application.yml` ( `dc3.driver.driver-attribute` / `point-attribute`); the defaults are the `default-value` entries in that yml. ### Driver attributes (device-level `driver-attribute`) These attributes are filled on the [Device](../introduction/concepts/device) and describe how the coordinator dongle on this host connects and which Zigbee network it joins. `serialPort` and `baudRate` define the serial connection, `dongleType` selects the coordinator adapter, and `panId` and `channel` decide which network and channel to join (`0` means auto). | Attribute | code | Type | Default | Description | |-------------|--------------|--------|----------------|----------------------------------------------------| | Serial Port | `serialPort` | STRING | `/dev/ttyUSB0` | Zigbee coordinator serial port | | Baud Rate | `baudRate` | INT | `115200` | Serial port baud rate | | Dongle Type | `dongleType` | STRING | `TELEGESIS` | Coordinator dongle type (TELEGESIS, EMBER, CONBEE) | | PAN ID | `panId` | INT | `0` | PAN ID (0=auto) | | Channel | `channel` | INT | `0` | Channel (0=auto, 11-26) | ### Point attributes (`point-attribute`) Each collected [Point](../introduction/concepts/point) uses IEEE address + endpoint + cluster + attribute to uniquely locate one ZCL attribute in the Zigbee network. `nodeIeeeAddress` picks the node; `endpointId` / `clusterId` / `attributeId` narrow down to that specific attribute following the three-level addressing above. | Attribute | code | Type | Default | Description | |-------------------|-------------------|--------|---------|----------------------------------------------------| | Node IEEE Address | `nodeIeeeAddress` | STRING | (empty) | Zigbee node IEEE address (e.g. `00158D0001234567`) | | Endpoint ID | `endpointId` | INT | `1` | Endpoint ID | | Cluster ID | `clusterId` | INT | `0` | Cluster ID (e.g. `1026`=Temperature Measurement) | | Attribute ID | `attributeId` | INT | `0` | Attribute ID (e.g. `0`=Measured Value) | ### Write command attributes (`command-attribute`) Writable points fill the same four-level addressing on the write command, but pointing at the target attribute to write. The fields share the names and meanings of the point attributes; they just feed the `write` path. | Attribute | code | Type | Default | Description | |-------------------|-------------------|--------|---------|--------------------------| | Node IEEE Address | `nodeIeeeAddress` | STRING | (empty) | Zigbee node IEEE address | | Endpoint ID | `endpointId` | INT | `1` | Endpoint ID | | Cluster ID | `clusterId` | INT | `0` | Cluster ID for writing | | Attribute ID | `attributeId` | INT | `0` | Attribute ID for writing | ### A minimal onboarding example Onboard a temperature sensor node with IEEE address `00158D0001234567`: 1. Create a [Device](../introduction/concepts/device) with `Zigbee Driver`, and fill the driver attributes `serialPort=/dev/ttyUSB0`, `baudRate=115200`, `dongleType=TELEGESIS`, `panId=0`, `channel=0`. 2. Add a temperature [Point](../introduction/concepts/point) (`pointTypeFlag=FLOAT`, `READ_ONLY`) to the [Profile](../introduction/concepts/profile) bound to the device, and fill the point attributes `nodeIeeeAddress=00158D0001234567`, `endpointId=1`, `clusterId=1026` (Temperature Measurement cluster), `attributeId=0` (Measured Value). 3. Make sure the node has joined the coordinator's Zigbee network, start the driver, and within 30 seconds you will see the value in [Point Value](../introduction/concepts/point-value). ## Troubleshooting When onboarding Zigbee devices, problems usually live in the serial port, network joining, address format, or the boundaries of the current implementation. The checklist below follows the order "connect first, then locate, then read correctly." ::: warning Coordinator not found / serial port busy The default serial port is `/dev/ttyUSB0` at baud rate `115200`. First confirm the dongle is plugged in, the host can see the serial device (e.g. `ls /dev/ttyUSB*`), and the port is not held by another process. For containerized deployments, pass the host serial device through into the container (e.g. `--device=/dev/ttyUSB0`); otherwise the driver will never connect to the coordinator after startup. **Note**: in the current implementation the serial port and baud rate are hardcoded — see the implementation status below. ::: ::: warning Node reports "node not found" A "node not found" error on read/write usually means `nodeIeeeAddress` is wrong or the node has not joined. The IEEE address is 16 continuous hex characters (e.g. `00158D0001234567`) — **no colons and no `0x` prefix**. The address is fixed at the factory and can be found in the coordinator/gateway device list. The node must also have joined this coordinator's Zigbee network (permit-join) before it can be addressed. ::: ::: warning Endpoint / cluster / attribute not found An "endpoint/cluster/attribute not found" error means one level of the three-level addressing is wrong. The endpoint of a multi-function device is not necessarily `1`; the cluster ID must match the actual physical quantity (Temperature Measurement `1026`, Relative Humidity `1029`); the attribute ID must match the specific attribute under that cluster ( Measured Value is often `0`). Use the coordinator tooling to inspect which endpoints and clusters the target node exposes, then fill the point attributes accordingly. The read path takes the **most recent cached value** of that ZCL attribute (`attribute.getLastValue()`). If the node has never reported the attribute, or attribute binding/reporting is not configured, you may read `0` (the default placeholder). ::: ::: warning Data type mismatch The Point's `pointTypeFlag` must match the actual type of the ZCL attribute. A temperature measured value is a signed integer (in units of 0.01 °C) parsed as `FLOAT`/numeric; reading a string-like cluster as numeric or vice versa yields meaningless values. Verify the ZCL attribute's data type before configuring the point. ::: ::: warning Driver / device shows offline The driver-level health check depends on whether `networkManager` is initialized: the driver is offline while the coordinator is not connected. Device-level online state uses the lease mechanism described in [Device](../introduction/concepts/device) (health check cron `0/15 * * * * ?`, lease timeout `45 seconds`). Note that in the current implementation the device-level health check returns online whenever the device record is valid (no reachability check by IEEE address), so it cannot be used to tell whether an individual node is actually reachable — see the implementation status below. ::: ## How it lands in IoT DC3 - **`dc3.driver.code`**: `ZigbeeDriver` (driver name `Zigbee Driver`, type `DRIVER_CLIENT` — connects to the coordinator and polls nodes). This is a stable routing identifier; do not change it casually. - **Collection cycle**: default cron `0/30 * * * * ?`, reading ZCL attributes every 30 seconds. - **Health / online**: the device health check defaults to cron `0/15 * * * * ?` with a `45-second` lease timeout; see [Device](../introduction/concepts/device) for the online-state mechanism. - **Read / write capability**: the read path takes the most recent cached value of the ZCL attribute on the coordinator side (`attribute.getLastValue()`) rather than synchronously polling the device over the air on every read; this differs from request-response drivers such as `ble` and `coap`. - **Subscribe capability (not implemented yet)**: `initial()` currently **only registers a coordinator network-state listener** (`addNetworkStateListener`, which merely logs network UP/DOWN), and **does not listen for node joining ( node-join/announce) or ZCL attribute reports**, nor does it configure attribute binding/reporting. The cached value the read path returns therefore depends on the node reporting on its own or on reporting being configured by external tooling — the driver itself does not capture join or report events, so the "subscribe" column for Zigbee in the [driver capability matrix](./matrix) is marked as not implemented. ::: warning Work in progress (skeleton) This driver is currently a **skeleton** — protocol-level I/O is not yet fully implemented. Treat it as an onboarding template, not a production-ready driver. There are several `TODO` markers in the method bodies; the key limitations are: - **Serial port and baud rate are hardcoded**: `initial()` hardcodes `/dev/ttyUSB0` and `115200` and **does not read** the `serialPort` / `baudRate` driver attributes. If the coordinator is not on `/dev/ttyUSB0`, you must first wire up the configuration-reading logic, otherwise the attributes have no effect. - **Only the Telegesis adapter is bundled**: the code only imports and uses `ZigBeeDongleTelegesis`, so a `dongleType` of `EMBER` / `CONBEE` will not switch the adapter yet. - **Device-level health check always online**: `health(driverConfig, device)` returns online whenever the device record is valid (it only returns offline when the device or its id is null) and does not actually verify node reachability by IEEE address. ::: ::: warning The write command does not actually dispatch yet The write path (`writeAttribute`) validates that the node / endpoint / cluster / attribute exist, but only logs a line — it **does not actually write the value to the ZCL attribute**. Until the write capability is completed, a point with a write command will appear to succeed while the device state stays unchanged — do not rely on it for real control. ::: ## Further reading - [Drivers Overview](./index) — all driver groups and the selection entry point - [Driver Capability Matrix](./matrix) — Zigbee's read / write / subscribe capability versus similar drivers - [Device Onboarding](../operation/device-onboarding) — a complete device onboarding flow - [IoT Network Layer](../foundations/iot-protocols) — where Zigbee sits in wireless access and network convergence - [BLE Driver](./ble) — onboarding another kind of low-power short-range wireless device --- # Data Intelligence & AIoT URL: https://docs.dc3.site/en/foundations/aiot <script setup> import AiotDiagram from '../../.vitepress/theme/components/AiotDiagram.vue' </script> # Data Intelligence & AIoT Once data is collected and stored in the time-series database, the real value is only beginning: turning a flood of point values into "what's happening now, what's coming next, and what to do about it." This chapter covers application-layer intelligence — real-time monitoring, historical analysis, predictive maintenance, anomaly detection — and how large language models step into IoT operations. By the end you will have a framework for deciding what belongs to rules versus models, where AI should run, and where IoT DC3 lands this intelligence. > You are here: you already understand how [time-series data and stream processing](./data-pipeline) aggregate values. > This chapter makes "decisions" on top of that data — it is the intelligent part of the application layer in the > four-layer architecture. ## What This Layer Is / Why It Exists The lower layers solve "bring the physical world into the digital one": perception collects, the network transports, the platform stores and normalizes. By now you hold a stream of semantically labeled, continuous, queryable point values. But data alone produces no value — **nobody pays for "the temperature was 73.2°C at 8 a.m. yesterday"; they pay for "the boiler may overheat in two hours, so lower the load now."** The reason application-layer intelligence exists is to turn data into actionable judgment. What this layer does converges into four categories, escalating from "see now → see the past → see the future → no human watching": - **Real-time monitoring**: threshold, state, and trend checks on the current value to catch excursions immediately. The need is **low latency** — the verdict must be computed the moment a value lands (or on the same stream before it lands). - **Historical analysis**: aggregation, comparison, and correlation over time, answering "why did this device's energy use rise this month" or "under which conditions are failures most frequent." The need is **wide scans** and slicing by dimension. - **Predictive maintenance**: learning "what normal looks like" from historical patterns to foresee degradation and failure ahead of time, turning "fix after it breaks" into "fix before it does." The need is a **model**, not a fixed threshold. - **Anomaly detection and smart alarming**: spotting behavior that deviates from a normal baseline, and compressing "a pile of raw excursions" into "a few alarms with context that someone can act on," so an alarm storm doesn't drown operations. These four are not a flat feature list but an inherent line of tension: the closer to the "see now" end, the more you need **low latency** and lightweight compute; the closer to the "see the future" end, the more you need **large data volumes** and **model capability**. In one system, real-time monitoring may run in a millisecond loop at the edge while prediction and historical analysis run as offline jobs in the cloud — understanding this tension is what tells you where each kind of intelligence belongs and which means to use. AIoT (AI of Things) is the umbrella term for this layer: **letting AI take part in the IoT sense–decide–act loop** rather than only producing after-the-fact reports. Its boundary is not "how fancy a model you used" but "whether the model's judgment can write back to the physical world" — only when it can issue commands and trigger actions does the loop truly close. ## Key Technologies and Trade-offs Application-layer intelligence is not a single algorithm but a pipeline: **collect → analyze/model → decide → act**, then feed the outcome back into collection to form a loop. The diagram below is the skeleton of this universal pattern — across industry, energy, buildings, and cities the concrete shape varies, but the loop structure is the same. <AiotDiagram lang="en" /> Take the loop apart and every hop carries a trade-off: **Rules or models?** This is the first choice to make, and it is not "models are fancier, so use them everywhere." For * *deterministic judgments** — fixed thresholds, state machines, simple trends — rules are the bargain: explainable, auditable, zero training cost, millisecond-fast. Only when "normal" is hard to describe with a threshold (multi-variable coupling, drift with operating conditions, periodic swings) is a model worth it. Most production systems are **rules as the floor + models for reinforcement**: rules cover the known hard constraints, models find the anomalies rules can't express. **Where does AI run?** The split among device, edge, and cloud is fundamentally a balance of **latency, bandwidth, compute, and data breadth**: - **On-device AI**: runs on the device/sensor, making the lightest local judgments (is this vibration abnormal?). Lowest latency, no network dependence — but limited compute and model size, and no global view. - **Edge AI**: runs on the field gateway or edge box, aggregating a cluster of devices for real-time detection and preprocessing, compressing a "raw stream" into an "event stream" before sending it up. It balances latency and breadth, and can stay autonomous when the link is down. - **Cloud AI**: the richest in compute and data — suited to training models, cross-device/cross-site global analysis, and LLM-driven operations. The cost is latency and bandwidth, so it is unfit for millisecond loops. A sound architecture is often **train in the cloud, infer at the edge, react on the device**: train on full history in the cloud, push the model down to the edge for low-latency inference, and let the device handle only the final fast reaction. This split is not either/or — it is one model carrying different latency responsibilities at different locations. **One pattern, many industries.** Industry, energy, buildings, and cities look wildly different, yet the skeleton is the same loop — **collect → analyze → decide → act**. Rather than piling up industry after industry, it is clearer to see how this abstraction maps onto any scenario: - **Collect** varies in its data source — PLC registers in industry, meter readings in energy, temperature/humidity and access control in buildings, roadside sensors in cities; what is constant is that all normalize into a stream of semantically labeled point values. - **Analyze** varies in the metric of interest — line yield, load curves, comfort, traffic density; what is constant is the same two-tool combination of rules and models. - **Decide** varies in trigger conditions and thresholds, and **act** varies in its target — shed load, shift peaks, adjust fans, time the lights; what is constant is the loop requirement that "judgment must write back to the physical world." In other words, once you understand this universal loop, any industry solution can be placed at a glance: you can see what it does within "collect–analyze–decide–act" and which hop it is missing. What IoT DC3 provides is the **general substrate** for this loop, not a finished solution for any one industry. **LLM + IoT** is a newly added capability layer. It does not replace the analytics stack above; it wraps operations in a **natural-language interface** and **autonomous orchestration**: - **Natural-language operations**: "plot the temperature trend of boiler #3 over the past week" replaces writing queries and clicking menus, lowering the operating bar. - **Tool / function calling**: the model doesn't answer from memory — it calls the platform's real APIs to query devices, read points, and issue commands, so answers are traceable and actions actually take effect. - **Retrieval-augmented generation (RAG)**: feed the model device manuals, SOPs, and past work orders as context so its advice fits this system's reality instead of being generic. ::: warning "Sounds right" is not "can be trusted" LLMs hallucinate — confidently. In IoT this is especially dangerous: if a model invents a point value that doesn't exist or issues the wrong command, the consequences act on the physical world. So the trustworthy approach is: **let the model read real data only through tools** (not from training memory), and **require human confirmation for high-risk write actions**. ::: ## Engineering Notes Turning the trade-offs above into engineering, a few lessons recur: - **Align latency tiers with the scenario**: millisecond loops (safety interlocks, e-stop) must never depend on a cloud round-trip — push them to the edge or device; minute-scale trend forecasts and reports belong in the cloud. First ask "how long can this decision wait, worst case," then decide where it runs. - **Alarms should reduce noise, not add to it**: raw excursions arrive in clusters. Engineering needs **debouncing (only count after N sustained seconds), state machines (fire/recover/close to avoid flapping), and aggregation plus grading (P0–P3)** to collapse "ten thousand excursions" into "three alarms worth acting on." Otherwise, the more alarms, the fewer people read them. - **Predictive maintenance needs a "normal baseline" first**: a model's value comes from knowing what normal looks like. Without enough labeled history, no algorithm can learn the baseline — so the quality of data collection and storage is a prerequisite for prediction, not a later optimization. - **AI cannot bypass permissions and tenant boundaries**: a model acts on behalf of some user/account; what it can see and do must never exceed that account's own permissions. Cross-tenant data must be invisible to AI too. In a multi-tenant system this is a hard constraint, not an option. - **Models drift over time and need continual calibration**: device aging, changing operating conditions, and seasonal shifts quietly move the "normal baseline," so a model accurate yesterday may false-alarm today. Prediction and anomaly detection are not "train once, use forever" — they need retraining and replay evaluation, or false alarms will gradually erode operators' trust in the alarms. - **Write actions must be auditable, reversible, and confirmable**: reads are safe; a wrong write is hard to undo. Engineering must wrap writes with **human confirmation, idempotency keys, timeout expiry, and end-to-end auditing** — the more autonomous the AI, the thicker this guardrail must be. ## How It Lands in IoT DC3 IoT DC3's application-layer intelligence concentrates on two paths, both built on the point values already normalized by the lower layers and on unified authentication. Their shared trait: **every AI action ultimately goes through the platform's real APIs, has principal context injected by the gateway, and is then subject to RBAC permission checks and tenant isolation at the Auth Center** — the model never gains more permission than its corresponding account. **Path one: the [Agentic Center](../ai/agentic) (platform-native conversational AI operations).** Built on Spring AI, it connects an OpenAI-compatible LLM to devices, points, data, and commands. Users ask in natural language; the model calls built-in platform tools as needed to query metadata, read live values, and — under controlled authorization — trigger device reads and writes. This is exactly the "tool calling" pattern above in practice: the model reads real data, not training memory. ::: info There are 10 built-in tools, not 8 The Agentic Center ships **10** `@Tool` tool classes: `TenantTool`, `UserTool`, `DeviceTool`, `DriverTool`, `ProfileTool`, `PointTool`, `PointValueTool`, `SystemTool`, `CommandTool`, `EventTool`. Early copy mentioned "8"; the count that matches the code and directory is 10. ::: ::: warning Tool calling is on by default, but can be turned off Tool calling is controlled by the environment variable `AGENTIC_TOOL_CALLING_ENABLED`, default `true`. Set it to `false` and the model degrades to pure chat, never touching any device/data interface — configure it this way to allow Q&A only in a restricted environment. Persistent conversation memory is controlled by `AGENTIC_MEMORY_ENABLED`: the `.env.example` deployment template sets it to `false` (off by default); if the variable is not provided, the framework's built-in default is on — go by your actual deployment. ::: ::: danger High-risk writes are never executed directly The Agentic Center's write tool **never issues a command directly**. It first creates a pending Action (status `PENDING`, default expiry `now + 10 minutes`) and returns `pendingConfirmation=true`; the write command runs only after the user confirms by calling `POST /action/confirm` with the `action_id`. This is a separate implementation from the MCP gateway's risk gating below — see the [Agentic Center](../ai/agentic) for details. ::: **Path two: [AI Agent / MCP](../ai/mcp) (exposing tools safely to external agents).** The gateway offers a JSON-RPC 2.0 MCP Resource Server at `POST /mcp`; the tool catalog is auto-aggregated from the OpenAPI of the four centers (~330+ tools), with an external agent deciding which to call. It targets the "build your own agent and let the model orchestrate autonomously" scenario, with stricter constraints than the conversational path: - **OAuth 2.1 only**: MCP access accepts only short-lived JWTs issued by OAuth 2.1 (default 15-minute validity), with mandatory PKCE (S256) for public clients and refresh-token rotation. **There is currently no Personal Access Token ( PAT)** or other long-lived static-token access method. - **Three-layer tool-visibility filter**: the tools returned by `tools/list` = principal RBAC permissions ∩ this MCP connection's tool whitelist ∩ risk policy (HIGH-risk tools hidden by default, must be explicitly enabled). What an agent can see and call is decided by all three layers together. - **HIGH-risk two-phase confirmation**: a high-risk tool call first returns `CONFIRM_REQUIRED` + a `confirmId`; the client must re-call with `confirmId` + an idempotency key, and the server verifies it is not expired, the parameter digest matches, and it is consumed once — auditing the whole thing. ::: info MCP resources / prompts are not yet implemented The MCP protocol's `resources` (resource exposure) and `prompts` (prompt templates) are **not yet implemented** in IoT DC3 — they are planned; only `tools` is offered today. The `tools/list_changed` change notification is **not event-pushed** either — the tool catalog is not synced to connected agents in real time; refreshing it requires a manual call to the admin endpoint `POST /mcp/tool/catalog/refresh` (or a rebuild after API registrations change) (`PT5M` is the `confirm-ttl` for high-risk two-step confirmation, unrelated to catalog refresh). ::: **Alarms and notifications** pick up the "anomaly detection and smart alarming" engineering notes above. DC3's [alarms and notifications](../operation/alarms) use a rule engine for deterministic judgments, `dc3_rule_state` as a state machine (fire/recover/close) for debouncing, alarm grading (P0–P3) and multi-channel delivery ( email/SMS/webhook) for noise reduction and dispatch — this is the "rules as the floor" half, complementing the "models for reinforcement" AI paths above: rules cover the hard constraints you can write down, AI helps people understand and act. Place all three back on that loop diagram: **collect** and **analyze** are handled by the lower layers and alarm rules, **decide** lives in the rule engine or the LLM, and **act** is alarm notification or a confirmed command — AI is not a separate stack but is wired into this existing sense–decide–act–feedback chain. ## Further Reading - [Time-Series Data & Stream Processing](./data-pipeline) — the input to intelligent analysis: how point values aggregate, store, and become queryable - [IoT Security](./security) — the same auth, tenant isolation, and transport security the AI paths must pass - [IoT Technology Overview](./) — the four-layer reference architecture, to place the application layer in the whole - [AI Overview](../ai/) — an overview and selection guide for DC3's two AI access methods - [Agentic Center](../ai/agentic) — conversational AI operations, 10 built-in tools, high-risk action confirmation - [AI Agent / MCP](../ai/mcp) — OAuth 2.1 + MCP, exposing tools safely to external agents - [Alarms & Notifications](../operation/alarms) — rule engine, state-machine debouncing, grading, and multi-channel notification --- # Time-Series Data & Stream Processing URL: https://docs.dc3.site/en/foundations/data-pipeline <script setup> import DataPipelineFlowDiagram from '../../.vitepress/theme/components/DataPipelineFlowDiagram.vue' import DataPipelineIngestDiagram from '../../.vitepress/theme/components/DataPipelineIngestDiagram.vue' </script> # Time-Series Data & Stream Processing The real test of the IoT platform layer is **how to store, compute over, and query an endless stream of point values**. This layer is neither the device nor the business application — it is the "data backbone" wedged in between: thousands of timestamped readings pour in every second, they have to be written and retained, and at the same time pulled out on demand by dashboards, alarms, and AI. By the end of this chapter you will understand why time-series data needs storage and pipelines built for it, know what batch and stream processing are each good for, and be able to map this general paradigm onto IoT DC3's [data plane](../architecture/data-plane) — the link where a point value is delivered asynchronously through RabbitMQ, lands in a TimescaleDB hypertable, and then enters the latest-value cache. ## What This Layer Is / Why It Exists In the four-layer reference architecture, the platform layer's job is to "store, manage, and compute." Once the perception layer produces physical quantities and the network layer delivers them, the platform layer faces a workload utterly unlike a traditional business system: **time-series data**. Time-series data has a few shared traits, and understanding them is understanding why you can't just force-fit it into an ordinary relational table: - **High write, low update**: data is almost purely append-only — once a reading lands, it is essentially never modified. Write throughput is the dominant pressure, and the strengths of a traditional database — transactions, row-level updates, foreign keys — are barely used here. - **Naturally time-indexed**: every record carries a timestamp, and the overwhelming majority of queries are "the values of some point on some device over some time range." Time is the primary query dimension, not an optional column. - **Recent-hot, old-cold**: freshly acquired values are read constantly (live dashboards, current alarm evaluation), while values from months ago are scanned only in the occasional trend analysis. Access heat decays fast over time. - **Value decays with precision**: nobody cares about millisecond detail from a year ago — an hourly or daily mean/extreme is enough. This is exactly where **downsampling** and **retention policies** earn their keep. Store this data in an ordinary relational table and the problems surface quickly: once a single table swells past hundreds of millions of rows, time-range scans get slower and slower; B-tree indexes keep splitting and bloating under constant appends; and with no built-in expiry, old data can only be cleaned by hand-written scripts. What the platform layer needs is a storage and processing paradigm **tailored for "time + append + hot/cold tiering"** — and that is precisely why time-series databases and stream-processing pipelines exist. ## Key Technologies & Trade-offs Getting time-series data right relies on a pipeline, not a single component. A typical IoT data pipeline is a chain of four stages: the acquisition side publishes readings to a **message bus** for decoupling, the consumer side **persists** the messages into time-series storage, and on top of that storage a **query** layer serves applications and algorithms. <DataPipelineFlowDiagram lang="en" /> A few key technologies and trade-offs sit along this pipeline: **Time-series databases: the hypertable and partitioning idea.** The common approach across solutions (TimescaleDB, InfluxDB, TDengine) is to **automatically slice one big logical table into many small pieces**. Take TimescaleDB's * *hypertable**: to the user it's just a normal table, read and written with standard SQL; underneath it auto-slices data by a time dimension (plus a device/tag dimension) into individual **chunks**. The payoff is obvious — a query with a time range only scans the relevant chunks (chunk pruning) instead of the whole table; writes always land on the "newest" chunk, so the index stays local rather than inserting all over a billion-row table; and expired data can be dropped whole-chunk, orders of magnitude faster than row-by-row `DELETE`. Partitioning is the foundation of every bit of time-series performance. **Downsampling and retention: trade precision for cost.** Since data value decays over time, there's no need to pay a high-precision storage bill for old data. Two complementary strategies: a **retention policy** automatically drops raw data past a certain age; **downsampling / continuous aggregates** pre-roll high-frequency raw values into low-frequency summaries (e.g. per-minute raw → hourly mean/extreme), keeping the long-term trend while shrinking the footprint dramatically. Layer **columnar compression** on top and cold chunks usually compress to a fraction of their original size. Together, these three make "keep three years of data" economically viable. **The message bus: decoupling and backpressure.** If the acquisition side and the persistence side are wired directly, a jitter at either end drags the other down — a burst from devices instantly overwhelms the database, and one slow query in the database blocks the acquisition threads. Inserting a **message bus** in between (RabbitMQ, Kafka, an MQTT broker, etc.) decouples the two ends: producers just publish, consumers drain at their own pace. This brings in **backpressure **: when downstream can't keep up, messages accumulate in the queue instead of being dropped, and the risk is controlled with **prefetch limits**, **consumer concurrency**, and **dead letters plus TTL** — accumulation is bounded, timeouts dead-letter, and consumption scales horizontally. The bus lets the pipeline "elastically absorb" traffic spikes instead of meeting them head-on. **Batch vs. stream: two computing postures.** The same time-series data admits two algorithmic styles: - **Batch**: accumulate a batch, then compute/write it all at once. High throughput and low per-unit overhead, but latency — suited to "hourly reports" or "daily trends" where latency doesn't matter, and to batched persistence on the write side to amortize I/O cost. - **Stream**: compute as soon as data arrives. Low latency, able to finish alarm evaluation and sliding-window aggregation as a value is persisted (or even before) — suited to "alert the instant temperature exceeds a limit" or " live dashboard," anything that demands sub-second response. These aren't an either/or; they're **two forks of the same pipeline**: the hot path goes through stream processing for real-time, the cold path through batch for throughput. In practice a common combination is "batch the writes to amortize, evaluate alarms in stream for immediacy." ## Engineering Notes A few lessons worth keeping when you take the paradigm above into production: - **Write throughput is always the first constraint**. A time-series system's capacity starts with "how many points per second can it write." Batched writes, sensible chunk sizes, and avoiding heavy index maintenance during writes all yield to write throughput. Read optimization comes second. - **Distinguish "acquisition moment" from "persistence moment"**. When a device read a value and when it was written to the store are two different things, and their difference is the pipeline latency. Store both timestamps so you can order correctly and monitor link delay. - **Beware nullable values in aggregations**. Time-series tables often mix in non-numeric payloads — strings, JSON — whose numeric column is null. Run `AVG`/`SUM`/`MAX` without explicitly filtering nulls and the result is silently skewed — a pit people fall into repeatedly. - **Plan hot/cold tiering early**. Compression and retention policies are best defined at table-creation time; bolting them on after data has grown into the billions is a painful retrofit. Compressed chunks are usually read-only, so confirm your query path still works. - **Tune backpressure parameters to the load**. Prefetch count, consumer concurrency, queue TTL — these aren't constants you guess: prefetch too large wastes memory, too small can't saturate throughput; concurrency too high contends for DB connections, too low can't keep up with consumption. Start with conservative defaults, then tune against measured traffic. - **Dead letters aren't a trash can**. Messages that time out, fail to parse, or repeatedly fail processing land in the dead-letter queue, and someone needs to watch them and have a record — not let them pile up or vanish silently. ## How It Lands in IoT DC3 IoT DC3's [data plane](../architecture/data-plane) is one concrete implementation of the general pipeline above. A point value goes from device to queryable through exactly those four stages — "acquisition → message bus → consume & persist → cache/query": <DataPipelineIngestDiagram lang="en" /> **Message bus: asynchronous RabbitMQ delivery + 7-day TTL + dead letters.** A point value a driver acquires is not written straight to the database; it is published to RabbitMQ's topic exchange `dc3.e.value`, and the data center `dc3-center-data`'s durable queue `dc3.q.value.point` collects every driver's values via the wildcard `dc3.r.value.point.*`. This queue declares a **7-day TTL** (`604800000` ms) and a **dead-letter exchange** `dc3.e.point_value_dead`: a message stays in the queue for at most 7 days, and on timeout or reject it dead-letters rather than being silently dropped. This is exactly "message-bus decoupling + backpressure + dead-letter fallback" realized in DC3. ::: info Consumer concurrency is the default tier, not the high-throughput tier The point queue's consumer `PointValueReceiver` doesn't specify a `containerFactory`, so it runs on the **default listener container factory**: `concurrentConsumers=2`, `maxConcurrentConsumers=8`, `prefetchCount=10`, manual ack. `RabbitConfig` also exposes a high-throughput factory `highThroughputRabbitListenerContainerFactory` (`concurrent=4`, `max=32`, `prefetch=100`), but **no listener currently opts in** — the high-throughput factory exists and is off by default. When you need it, add `containerFactory` to the `@RabbitListener` explicitly. ::: **Batched persistence: one of two paths by inbound rate.** The data center doesn't blindly write row by row. It splits by inbound rate — below `POINT_BATCH_SPEED` (default `100`) it persists immediately; above the threshold it hands off to a Quartz scheduled batch job, with `POINT_BATCH_INTERVAL` (default `5`, in **seconds**) participating in the rate calculation. This is exactly the engineering trade-off of "batch the writes to amortize I/O, write immediately at low volume for real-time." **Time-series storage: the TimescaleDB hypertable `dc3_point_value`.** Point values land in the TimescaleDB **hypertable ** `dc3_point_value` (in the `dc3_history` schema). It puts the partitioning idea into practice — two-dimensional partitioning by the time dimension `create_time` with **one chunk per 1 day** and the device dimension `device_id` with **16 hash buckets** — and carries two data-lifecycle policies: **chunks older than 7 days are compressed columnar automatically**, and **data older than 180 days is dropped automatically**. Hot/cold tiering, compression, and retention are all out-of-the-box hard facts here. ::: danger num_value is nullable: aggregate queries must use num_value IS NOT NULL `dc3_point_value.num_value` (`DOUBLE PRECISION`) is `NULL` for non-numeric or JSON payloads. Any `AVG`/`SUM`/`MAX`/`MIN` aggregation **must** add `WHERE num_value IS NOT NULL`, or nulls from string-typed points get mixed in and skew the result; the hypertable also has a partial index covering only `num_value IS NOT NULL`, so skip the predicate and you miss the index too. This is precisely "beware nullable values in aggregations" from the Engineering Notes, made concrete in DC3. ::: **Latest-value cache: the Caffeine hot path.** As the write path persists, it simultaneously pushes the latest value into a local **Caffeine latest-value cache**; a latest-value read (`point_value/latest`) hits that cache first and falls back to TimescaleDB only on a miss. The historical-range query (`point_value/list`) skips the cache and scans the hypertable directly. This is "recent-hot, old-cold" realized on the read path: the hot latest value goes through an in-memory cache, and cold data goes through a time-range scan of the time-series store. The link's reliability rests on three overlapping mechanisms: every message is stamped `PERSISTENT` before publish ( paired with the `durable` queue, it survives a broker restart), the consumer uses manual ack (ack only on success, requeue on exception, dead-letter on validation failure), and publisher confirms (a confirm callback tracks delivery). Once a value is persisted, it is also handed **synchronously** to the alarm engine for evaluation — DC3's embodiment of stream processing's "compute as soon as data arrives." For the full link, model transformation, and read-endpoint examples, see the [data plane](../architecture/data-plane). ## Further Reading - [Edge & Cloud Architecture](./edge-cloud) — the upstream of this pipeline: whether acquisition runs at the edge or in the cloud, and how drivers push down - [Data Intelligence & AIoT](./aiot) — the downstream of this pipeline: how persisted data is analyzed and consumed by large models - [IoT Technology Overview](./) — the four-layer reference architecture and DC3's layer-by-layer mapping - [Data Plane](../architecture/data-plane) — the full link of a point value from device to storage in DC3, and its hard constraints - [Services & Topology](../architecture/services) — where the data center, message bus, and time-series store sit in DC3's service map --- # Edge & Cloud Architecture URL: https://docs.dc3.site/en/foundations/edge-cloud <script setup> import EdgeCloudDiagram from '../../.vitepress/theme/components/EdgeCloudDiagram.vue' </script> # Edge & Cloud Architecture The IoT platform layer is not "a server" — it is a continuum stretching from the field to the data center. Where each piece of computation runs — close to the device or centralized in the cloud — sets the system's latency, bandwidth, availability and privacy boundaries. This chapter explains how cloud, edge and device divide the work, what actually distinguishes edge computing from fog computing, when computation *must* be pushed down to the edge, and finally how it lands in IoT DC3's platform layer: one gateway plus four center services, where protocol drivers can sit near the field, center services can be centralized, and Facade modes switch between distributed and in-process with a single flag. By the end you can decide where a given collection task, alarm rule, or command dispatch belongs — on the device, at the edge, or in the cloud. ## What This Layer Is / Why It Exists We split the IoT platform layer into three tiers because each tier lives under fundamentally different physical constraints. **Device** is the hardware itself — sensors, actuators, PLCs, meters. It has minimal compute, speaks only its own protocol, and exists to turn physical quantities into transmittable signals and to land commands onto registers. It should not carry business logic, and it certainly cannot run model inference. **Cloud** is the remote data center — near-unlimited compute, cheap storage, easy centralized management and global analytics. It excels at funneling data from thousands of devices into one place to run historical analysis, train models, and serve a unified API and UI. Its cost is "distance": every hop crosses a wide-area network whose latency, bandwidth and stability are out of your hands. **Edge** is the tier wedged between the two — deployed near the devices on site (the workshop, the substation, the building's equipment room), stronger than a device but weaker than the cloud, yet **only one LAN hop from the device**. Its entire reason for existing is to do, in place, the computation that "can't wait for the cloud, and shouldn't." Why do we need the edge at all? It comes down to four hard realities: - **Latency**: an emergency-stop interlock on a production line demands millisecond response; a command that round-trips through the cloud — tens to hundreds of milliseconds — may arrive after the accident. The tighter the control loop, the more the decision must be made nearby. - **Bandwidth**: one vibration sensor produces thousands of samples per second; shipping the full raw waveform of hundreds of devices to the cloud is both expensive and unsustainable over a WAN. The edge first downsamples, extracts features, aggregates — and ships only the *meaningful results*. - **Availability**: the link from field to cloud will drop. While it is down, collection cannot stop, local interlocks cannot fail, alarms cannot go silent — the edge must run autonomously offline and backfill once connectivity returns. - **Privacy & compliance**: camera frames, process recipes, energy-usage detail often may not — or should not — leave the plant. Processing sensitive data in place at the edge and uploading only desensitized results is a hard requirement in many industries. Put differently: **the cloud owns "breadth," the edge owns "speed" and "resilience," the device owns "connection."** They do not replace one another — they are one continuum cut along responsibility lines. ## Key Technologies & Trade-offs ### Edge Computing vs. Fog Computing: often conflated, differently weighted Both terms mean "push computation down from the cloud toward the data source." The difference is **which layer it lands on, and who carries it**: - **Edge computing**: computation happens at the very edge of the network — on the device itself or on an edge gateway right next to it. It emphasizes "as close to the point of data origin as possible" — single-point, lightweight, tied to a specific site. - **Fog computing**: proposed by groups such as OpenFog, it emphasizes building a **layered, distributed compute-and-networking tier** *between* device and cloud — possibly spanning multiple gateways, local servers, even regional facilities. It is a coordinated fabric, not a single edge node. A practical mnemonic: edge computing is the act of "doing the work at the edge"; fog computing is "organizing that edge compute into a coordinated, scheduled tier of infrastructure." The rest of this chapter does not force the distinction — we use "edge" to mean "the near-source compute tier between device and cloud." ### How the Device–Edge–Cloud Tiers Collaborate The diagram below lays out each tier's responsibilities and the data/command flows. What matters is not what sits in each box, but **which edge is a LAN and which is a WAN** — that is what decides where each piece of computation belongs. <EdgeCloudDiagram lang="en" /> Solid lines are uplink data, dashed lines are downlink commands; device-to-edge is "one LAN hop," edge-to-cloud is the WAN crossing. Once that boundary is clear, the "where does it go" answer surfaces on its own: latency-sensitive logic that must keep working offline goes to the **edge** (or even the device); the global view, mass storage and model training go to the **cloud**. ### The Edge Gateway's Responsibilities The edge gateway is the load-bearing wall of this tier. It must carry at least five jobs: - **Protocol adaptation**: normalize the field's motley protocols (Modbus, OPC UA, BACnet…) into one unified data shape the platform understands. This is its most basic and least skippable duty. - **Filtering & aggregation**: downsample, deduplicate, extract features, and window-aggregate before uplink, spending bandwidth where it counts. - **Local cache & backfill**: persist data locally when the link is down, replay it in order once it recovers, losing no points. - **Edge autonomy**: keep collecting, run local rules and interlocks, and raise alarms in place while offline — without depending on a cloud heartbeat. - **Security boundary**: as the sole ingress/egress between the field network and the outside, carry authentication, encryption and minimal exposure — field devices are never bared directly to the public network. The trade-off: the more the gateway carries, the more autonomous and jitter-resistant the field becomes — but operations and consistency grow harder too (more edge nodes mean config sync, version upgrades and observability all become problems). **How much the edge does versus the cloud is this tier's central design choice.** ### Digital Twin: the physical entity mirrored on the digital side A digital twin is a **continuously synchronized digital mirror** of each physical device / line / plant: it aggregates that entity's live point values, historical curves, model structure and operating state, letting you observe, simulate, even predict the physical side from the digital one. It depends on the whole chain above: the device collects, the edge aggregates, the cloud consolidates — only then does the twin have "living" data to feed on. The twin usually lives in the cloud (it needs the global data and compute), but its **real-time refresh** depends on the edge promptly pushing the latest values up. Its value is reorganizing " scattered point values" into an "entity-centric" view — exactly the leap from raw data toward intelligent operations. ### The Cloud Platform's Core Capabilities A cloud-side platform is typically built around four capability families, mapping neatly onto "how devices are managed, how connections are managed, how data is used, how rules run": - **Device Management**: modeling, registration, lifecycle, remote config and firmware for devices/profiles/points — answering "what devices exist, and what can they do." - **Connection Management**: device online/offline state, heartbeat and timeout, authentication and sessions — answering "who is connected, and how stable is it." - **Rule Engine**: condition-triggered actions over the data stream — threshold alarms, linkage, forwarding — turning " data" into "action." - **Data Service**: time-series storage, query and aggregation, outward-facing APIs — turning a flood of point values into a consumable data asset. Together these four form the cloud platform's skeleton. Worth stressing: not all of them must stay in the cloud. **The rule engine and parts of the data service can perfectly well be pushed down to the edge** — and that is precisely the practical space of "how edge and cloud divide the work." ## Engineering Notes A few principles run through the design of any cloud–edge–device system: - **Allocate compute by "can it wait for the cloud."** Ask first: can this logic stop while the link is down? What can't (control interlocks, local alarms, collection caching) goes to the edge; what can wait and needs a global view ( trend analysis, model training, cross-plant comparison) goes to the cloud. - **Ship "results," not "raw streams."** Let the edge aggregate and extract features first; WAN bandwidth is forever scarce — don't spend it on redundancy you could compress away locally. - **The edge must run autonomously offline.** Design the cloud as "a dependency that will drop": core functions can't stall while it's down, and must backfill and reconcile automatically on recovery. - **The command path needs explicit failure semantics.** Downlink commands cross a WAN and time out more easily; you must distinguish "success," "failure," and "timeout," and never fake a failure as success — or upper layers will make worse decisions on false data. - **One data model spans all three tiers.** If the device's raw signal, the edge's aggregate, and the cloud's stored value all speak differently, twins and analytics are off the table. A stable "point semantics" should run unbroken from edge to cloud. ## How It Lands in IoT DC3 IoT DC3's platform layer is not one monolith but [one gateway plus four center services](../architecture/services), with protocol drivers connecting the field on the south side. This shape distributes naturally across "edge" and "cloud." **Protocol drivers = the layer you can push to the edge.** DC3's protocol drivers (`dc3-driver-*`) handle protocol adaptation and near-field collection — exactly the edge gateway's core role. Drivers do **not** talk to the Data Center directly; they exchange asynchronously over RabbitMQ — point values flow north, commands flow south. That async decoupling is the very precondition for splitting edge and cloud deployment: drivers can run close to the field, with the MQ buffer absorbing WAN jitter so collection never back-pressures into dropped connections when the cloud slows down. **The four center services = the centralizable cloud-side capabilities. ** [Auth Center dc3-center-auth, Manager Center dc3-center-manager, Data Center dc3-center-data, Agentic Center dc3-center-agentic](../architecture/services) cover the "device management / connection management / rule engine / data service" set above — of which connection management (device/driver online-offline state, lease expiry) and the rule (alarm) engine fall mainly on Data Center `dc3-center-data`, while device/profile/point metadata is handled by Manager Center; the centers also provide auth/tenancy and LLM/tool-calling capabilities. Point values ultimately land in a TimescaleDB time-series store and become queryable — the concrete form of the cloud-side "data service." **Facade mode = the edge/cloud division switch.** Calls between center services are written against the contract interfaces in `dc3-common-facade-api`; at runtime `DC3_FACADE_MODE` picks the implementation: [ `grpc` (the distributed default)](../architecture/facade-modes) makes each center its own process collaborating across processes — fitting a "centers centralized in the cloud, drivers scattered at the edge" topology; `local` (single process) collapses all centers into one process on one machine, fitting local and small single-host setups. In other words, "how edge and cloud divide the work, distributed or not" is collapsed in DC3 into one deployment flag, not two codebases — the same business logic switches between the two shapes by changing `DC3_FACADE_MODE`. ::: tip Mapping this chapter's concepts onto DC3 - Edge gateway's "protocol adapt / filter / collect" → protocol drivers `dc3-driver-*` - Async decoupling buffer between edge and cloud → RabbitMQ (point values up / commands down) - Cloud-side "device metadata management" → Manager Center `dc3-center-manager` - Cloud-side "connection/state management" (online-offline, lease expiry) → Data Center `dc3-center-data` - Cloud-side "data service" → Data Center `dc3-center-data` + TimescaleDB - Edge/cloud division deployment switch → Facade mode `DC3_FACADE_MODE` ::: ::: info On the boundary of "edge autonomy" The "offline edge autonomy" this chapter describes is a general goal of cloud–edge–device architecture. In DC3, drivers deployed near the field and decoupled from the centers via MQ provide the structural basis for this division; how much local capability a given driver retains while offline depends on that driver's implementation. When in doubt, treat the driver's source and the [Architecture](../architecture/) description as authoritative. ::: For the full service topology, port assignments and startup dependencies, see [Architecture](../architecture/) and [Services & Topology](../architecture/services); for the interface-wiring detail behind the edge/cloud split, see [Facade Modes](../architecture/facade-modes). ## Further Reading - [Time-Series Data & Stream Processing](./data-pipeline) — how point values are stored, aggregated and streamed once in the cloud - [IoT Protocols & Wireless Networks](./iot-protocols) — what protocols run on the device-to-edge hop, and how to weigh them - [IoT Technology Overview](./) — the four-layer reference architecture and DC3's overall positioning - [Architecture](../architecture/) — how DC3's gateway + four centers + drivers collaborate - [Services & Topology](../architecture/services) — the six deployable units, ports and startup dependencies - [Facade Modes](../architecture/facade-modes) — `grpc` vs `local`: switching between distributed and in-process --- # Industrial Buses & Protocols URL: https://docs.dc3.site/en/foundations/fieldbus <script setup> import FieldbusDiagram from '../../.vitepress/theme/components/FieldbusDiagram.vue' </script> # Industrial Buses & Protocols Devices on the industrial floor speak dozens of mutually unintelligible "dialects" — PLCs use vendor-proprietary protocols, meters use metering standards, buildings use automation buses. This chapter makes clear what problem each protocol solves, what model it communicates over, how it addresses a single data point, and what to weigh when choosing one. By the end you will be able to read a field device's protocol parameters (register address, byte order, function code) and know how IoT DC3 unifies them into point values. > You are here: the "industrial wired" side of the network layer. For wireless and lightweight IoT protocols > see [IoT Protocols & Wireless Networks](./iot-protocols); for where the upstream physical quantities come from > see [Sensing & Measurement](./sensing). ## What This Layer Is / Why It Exists To get a temperature from a sensor to the platform, the physical quantity is first turned into an electrical signal by a transmitter, digitized by an acquisition device, and finally transported over the network via some **protocol**. Industrial protocols are the language contract for that "last mile": they dictate how bytes are ordered, how addresses are encoded, whether it is request-response or subscribe-push, and who is active versus passive. Why are there so many of them, and why are they such a mess? Because they were born in different eras, industries, and closed vendor ecosystems: - **Historical baggage**. Modbus was created in 1979 for PLC serial communication and is still the most common protocol on the floor; OPC DA is bound to Windows COM/DCOM, a product of the PC era; only later came the cross-platform OPC UA. - **Vendor walls**. Siemens' S7, Mitsubishi's MELSEC (MC protocol), Omron's FINS, Rockwell's EtherNet/IP — every PLC vendor has its own proprietary protocol, mutually incompatible, locking in customers. - **Industry standards**. Power dispatch has IEC 60870-5-104, building automation has BACnet, utility metering has DLMS/COSEM, automotive and embedded have CAN — each industry set a standard for its own needs. The result: the same "read one value" action addresses differently, frames differently, and represents data types differently across protocols. The value of this layer is understanding the **common model** behind those differences — once you see that they are all "read/write a value in some address space," the heterogeneity stops being scary. ## Key Technologies & Trade-offs Set syntax details aside and the differences between industrial protocols concentrate in four dimensions: * *communication model, addressing, byte order and data types, and polling cadence**. Grasp these four and any unfamiliar protocol becomes approachable. ### Three communication models - **Master/Slave / request-response**. A master polls slaves one by one with requests and waits for replies; the slave never speaks unprompted. Modbus, IEC 104 (client issues a general interrogation), S7, MELSEC, and FINS are essentially this model. Simple and deterministic, but the master gets nothing it does not ask for, and real-time-ness is bounded by the poll cycle. - **Client/Server**. More symmetric than master/slave: an OPC UA client can browse the server's address space, read and write on demand, and **subscribe** — the server pushes when a value changes, sparing needless polling. Powerful, but with heavier handshake and session overhead. - **Publish/Subscribe**. CAN broadcasts ID-tagged frames onto the bus and receivers filter by ID; MQTT (see the wireless side) subscribes by topic. No central polling, naturally suited to multi-receiver, event-driven scenarios. ### Addressing: register vs tag vs object "Which point to read" is a completely different concept across protocols: - **Numeric address (register / IOA / OBIS)**. Modbus locates coils/registers by function code + 0-based offset; IEC 104 locates telemetry/telesignal by Information Object Address (IOA); DLMS locates COSEM objects by a 6-segment OBIS code (e.g. `1.0.1.8.0.255` = total active energy). Addresses are numbers, aligned by engineering convention. - **Symbolic tag (Tag/Item)**. EtherNet/IP (CIP) addresses by tag name: a variable in the PLC is called `Motor_Speed`, and the driver reads/writes by name without caring about physical addresses; OPC addresses by NodeId/ItemId. More readable, but the name must match character for character. - **Object + attribute**. BACnet models each quantity as an object (e.g. Analog Input #1) + attribute (Present_Value); DLMS's COSEM objects also have numbered attributes (attribute 2 = current value). ### Byte order and data types Industrial devices are mostly Big-Endian, but when a 32-bit `FLOAT` spans two 16-bit registers, the **register order** can still be swapped (four layouts: ABCD / CDAB / BADC / DCBA) — the single most common pitfall on the Modbus floor. The protocol itself often only moves bytes; **how to interpret that byte string is decided by configuration**: whether it is a 16-bit integer or a 32-bit float, low byte first or high byte first, whether to apply a multiplier and offset. Get the byte order wrong and a float reads as a meaningless large number. ### Polling mechanism Master/slave protocols fetch data by timed polling: too short a cycle overwhelms the device and bus, too long hurts real-time-ness. Subscription protocols (OPC UA, CAN, MQTT) improve on this — they push only on change. In practice points are often grouped by importance: critical quantities polled often, auxiliary ones rarely. The diagram below groups this chapter's protocols by **application domain**, each domain mapping to a typical communication model and addressing style: <FieldbusDiagram lang="en" /> ::: tip There is no "best" protocol, only the "most suitable" Start from what the device itself supports — most field device protocols are fixed by the vendor, and you can only adapt. When you do have a choice, weigh it: pick OPC UA for cross-vendor interoperability; pick a subscription protocol to save bandwidth and go event-driven; pick DLMS for pure meter reading; pick Modbus or CAN for lightweight, low-cost embedded. ::: ## Engineering Notes - **Ports differ per protocol — do not mix them up**. Modbus TCP is `502`, EtherNet/IP is `44818`, IEC 104 is `2404`, DLMS over TCP is commonly `4059`. Use the wrong port and you will not connect. - **Addresses are engineering conventions — verify before onboarding**. Modbus `offset` is a 0-based protocol address (" 40001" should be `offset=0`, not `40001`); IEC 104's COT/CA/IOA byte lengths must match the peer exactly, or the whole frame parses misaligned; CIP tag names are case-sensitive and must match character for character. - **Align data type and byte order with the device**. The Point's data type decides how bytes are assembled: a multi-register 32-bit float needs the right register order; a CAN frame payload is sliced by `dataOffset`/ `dataLength`/`byteOrder`. Configure a `REAL` as a `DINT` and the float bytes will be parsed as an integer into a meaningless large number. - **Distinguish read and write services/codes**. Modbus reads with `01/02/03/04` and writes with `05/06/15/16`; many protocols use different services for read and write, so a writable Point needs a separate write command configured. - **Fail loudly — never fake success**. When a device is unreachable or parsing fails, the right behavior is to record the failure and back off, not to echo a cached value or pretend the write succeeded — the latter leads upper layers to decide on bad data. ## How It Lands in IoT DC3 Facing so many heterogeneous protocols, IoT DC3's strategy is: **one [protocol driver](../drivers/) per protocol, confining protocol-layer differences inside the driver, and unifying upward into semantically labeled point values**. Whether the underlying layer is a Modbus register, a CIP tag, or an OBIS code, it lands on the platform as the same [PointValue](../introduction/concepts/point-value) of a [Point](../introduction/concepts/point), and the upper layers — storage, query, alarming, AI — need not care about protocol details at all. DC3 ships **28 drivers** in total, and most industrial protocols in this chapter have a corresponding one: - [Modbus TCP](../drivers/modbus-tcp) / [Modbus RTU](../drivers/modbus-rtu) — Ethernet / serial Modbus master - [OPC UA](../drivers/opc-ua) / [OPC DA](../drivers/opc-da) — OPC Unified Architecture client / classic Data Access - [S7](../drivers/plcs7) — Siemens PLC - [MELSEC](../drivers/melsec) — Mitsubishi PLC (MC protocol) - [FINS](../drivers/fins) — Omron PLC - [BACnet/IP](../drivers/bacnet-ip) — building automation - [SNMP](../drivers/snmp) — network device monitoring - [EtherNet/IP](../drivers/ethernet-ip), [IEC 104](../drivers/iec104), [DLMS](../drivers/dlms), [CAN](../drivers/can) — Rockwell CIP / power SCADA / smart meters / Controller Area Network ### Protocol parameters are driver attributes; the device instance fills the values Every protocol parameter in this chapter lands in DC3 as an **attribute** declared by the driver, for which the device instance fills a **config value** — this mechanism is covered in [Attribute and Config](../introduction/concepts/attribute-config). Take Modbus TCP: driver-level attributes `host`/ `port` identify the slave, point-level attributes `slaveId`/`functionCode`/`offset` locate the register, and write-command attributes specify the write function code and value template. Switch to EtherNet/IP and the point attributes become `tagName`/`tagType`; switch to DLMS and they become `logicalName` (OBIS) / `attributeId`. The byte order and register order discussed earlier likewise surface as corresponding point attributes. In other words, the protocol knowledge in this chapter is not abstract theory — it maps directly to every field you fill in when onboarding a device in DC3. Understand the protocol and you understand the driver's attribute table. ::: warning Some drivers are currently protocol skeletons (WIP) Not every driver has finished its protocol-layer I/O. [EtherNet/IP](../drivers/ethernet-ip), [IEC 104](../drivers/iec104), [DLMS](../drivers/dlms), and [CAN](../drivers/can) are currently **skeleton implementations**: the attribute tables, collection cycles, and addressing semantics are in place and can be filled in, but how far the actual protocol send/receive has progressed varies — - [IEC 104](../drivers/iec104), [DLMS](../drivers/dlms): `read()`/`write()` explicitly throw a "not implemented" exception to fail fast (the SDK records the failure and backs off, rather than faking success); the protocol I/O is not yet written. - [EtherNet/IP](../drivers/ethernet-ip): the upper-layer flow (fetch by tag, encode/decode, socket connect) is in place, but the CIP protocol framing (`RegisterSession`/`ForwardOpen`/encapsulation frame) is not yet complete. - [CAN](../drivers/can): `read()`/`write()` shell out to the Linux `can-utils` tools (`candump`/`cansend`) via `ProcessBuilder` and can actually send/receive, but byte slicing/type conversion (`dataOffset`/`dataLength`, etc.) and native SocketCAN I/O are not yet in place, and the `data` template on the write path is not actually wired up yet. Treat them as **starting-point templates** for the corresponding protocol, not production-ready products. The implementation status on each driver page is authoritative. ::: ::: info Modbus / OPC UA / S7 / BACnet and others are working drivers [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), and [SNMP](../drivers/snmp) have implemented protocol-layer read/write; [FINS](../drivers/fins) works but its current read path only supports 16-bit types (see its driver page). Before onboarding, defer to the attribute table and notes on each driver page. ::: ## Further Reading - [IoT Protocols & Wireless Networks](./iot-protocols) — the wireless side of the network layer: MQTT, CoAP, LwM2M, NB-IoT - [Sensing & Measurement](./sensing) — where the values the protocols carry come from: sensors, transduction, range and accuracy - [IoT Technology Overview](./) — the four-layer reference architecture and a reading map for this part - [Connectivity & Drivers](../drivers/) — how 28 drivers unify heterogeneous devices into one ingress - [Attribute and Config](../introduction/concepts/attribute-config) — how protocol parameters become driver attributes filled by the device instance --- # Auto-Identification & Positioning URL: https://docs.dc3.site/en/foundations/identification <script setup> import IdentificationChoiceDiagram from '../../.vitepress/theme/components/IdentificationChoiceDiagram.vue' import IdentificationEntityDiagram from '../../.vitepress/theme/components/IdentificationEntityDiagram.vue' </script> # Auto-Identification & Positioning The first step in any IoT system is letting machines recognize every object and every place in the physical world. This chapter covers the part of the perception layer that speaks not in measured physical quantities but in **identity** and **coordinates**: auto-identification — barcodes, RFID, NFC — answers "what is this, which one is it"; positioning — GNSS, cell-tower, UWB, Bluetooth beacons — answers "where is it." By the end you will know the range, capacity and cost boundaries of each technology, and how the idea of "give every thing an identity" lands in IoT DC3 as `deviceId` and `tenantId`. > You are here: the perception layer already turns physical quantities into signals > via [Sensing & Measurement](./sensing); this chapter adds the parallel sensing track of "identity and location." Next, > see [Fieldbus & Protocols](./fieldbus) for how that data leaves the field. ## What This Layer Is / Why It Exists Sensors answer "how much"; identification and positioning answer "which one" and "where." Both belong to the perception layer, but what they produce is not a continuous analog value — it is a **discrete identifier** and a **spatial coordinate**. They are the "primary key" and the "address" that map real-world objects onto digital records. Why a whole category for this? Because a machine facing thousands of physical objects cannot distinguish, track, or bind history to them without identity. A carton scanned at dozens of nodes from factory to shelf; a forklift roaming a warehouse the system must keep locating — identification gives an object a **stable name**, positioning gives it a * *live coordinate**, and only together do they make the physical world truly **addressable**. These technologies share a profile: low information density (often just a number), fast reads, and a per-unit cost low enough to deploy at scale. That is why almost every engineering trade-off here turns on one triangle — **range, capacity, and per-item cost**. Longer range needs more power or a battery; larger capacity needs a richer chip; and scale forces the cost down hard. Understand the triangle and you understand where each technology below fits. ## Key Technologies & Trade-offs Start with identification. The **barcode (1D)** is the cheapest identity carrier: black-and-white bars encoding a dozen-odd digits, carried by a sheet of paper and a drop of ink — but small in capacity, requiring close-range optical alignment, and unreadable once smudged. The **2D code (QR / DataMatrix)** encodes in two dimensions, jumping to kilobytes of capacity with built-in error correction, so a partially damaged code still recovers — hence its reach from payments to equipment nameplates. But it is still optical: it needs line of sight. **RFID** swaps optics for radio waves, and its core value is **no line of sight, batch reads**. It comes in three bands. **Low frequency LF (~125 kHz)** penetrates well and resists metal/liquid interference, but reads only centimeters at low speed — used for animal chips and access cards. **High frequency HF (13.56 MHz)** reads tens of centimeters at moderate speed and is the physical basis of NFC. **Ultra-high frequency UHF (860–960 MHz)** reaches several meters and inventories hundreds of tags at once — the workhorse of warehouse and logistics batch identification, though prone to metal and liquid reflection. By power source there are two kinds: a **passive tag** has no battery and harvests energy from the reader's field — cheap (down to cents), near-infinite lifespan, but limited range; an **active tag** carries a battery and transmits on its own — tens of meters of range and able to carry sensor data, but costly and lifespan-bound. An RFID system is always a **reader** plus **tags**: the reader powers and transceives, the tag carries an ID and responds. **NFC** is essentially the close-range subset of 13.56 MHz HF RFID (typically within 4 cm), distinguished by being peer-to-peer, bidirectional, and either active or passive — and already built into nearly every phone, which makes it the de facto standard for tap-to-pair provisioning, mobile payment, and digital business cards. Now positioning. **GNSS (Global Navigation Satellite System)** is the cornerstone of outdoor positioning, solving 3D coordinates from the arrival-time differences of multiple satellite signals; the representative systems are the US **GPS ** and China's **BeiDou (BDS)**, and modern chips are usually multi-constellation, accurate to meters and to centimeters with differential augmentation — but satellite signals do not pierce roofs, so it **mostly fails indoors**, with a slow first fix and higher power draw. **Cell-tower positioning** estimates location from cellular cell info, needs no extra hardware and works indoors and out, but is accurate only to tens or hundreds of meters — fit for rough or fallback positioning. **UWB (Ultra-Wideband)** measures time-of-flight with nanosecond pulses, reaching **10–30 cm** indoor accuracy — the leading choice for high-precision indoor positioning of people, assets, and robots, at the cost of pre-deployed anchor stations and higher spend. **Bluetooth beacons** broadcast periodically and the receiver estimates distance from signal strength (RSSI) — cheap to deploy and readable by any phone, but RSSI is environment-sensitive, so accuracy is usually meter-level, fit for zone-level ("which exhibit area") rather than precise positioning. Place both groups on a "range vs. cost" trade-off plane and the picture clears up: <IdentificationChoiceDiagram lang="en" /> ::: tip There is no "best," only "best fit" Batch warehouse inventory → UHF RFID; outdoor fleet dispatch → GNSS; precise indoor people-tracking → UWB; lightweight mobile provisioning → NFC. A single scenario often combines them — e.g. UWB real-time positioning plus 2D-code asset registration. ::: ## Engineering Notes When you put these systems into production, the recurring traps are rarely "which technology" — they are physical and engineering details. **Medium and environment** decide success. UHF RFID reads unreliably on metal shelves and liquid containers, needing anti-metal tags or tuned antenna polarization; barcodes fail under grease, heat, and outdoor sun, so industrial sites switch to laser marking or metal-nameplate 2D codes. Validate read rates against real conditions before committing, not against lab specs. **Band equals compliance**. RFID and UWB operate in regulated radio bands allocated differently by country (e.g. UHF RFID is 865–868 MHz in Europe, 902–928 MHz in North America, 920–925 MHz in China). Cross-region deployments must confirm device band and transmit power are compliant, or they will interfere with others or be banned. **The identity scheme must be globally unique**. A chip alone is not enough — a number only means something if it never repeats within a large enough scope. Industry built coding standards for exactly this, the most representative being * *EPC (Electronic Product Code)** — a global object-coding scheme for RFID tags that packs "manufacturer + product + serial" into one identifier, giving every individual item (not just every product type) a one-of-a-kind identity. EPC's idea is the essence of IoT identification: **a thing can be tracked across the network only after it has a globally unique ID**. **Match accuracy to cost on demand**. Do not deploy UWB for a "which room" requirement, and do not expect Bluetooth beacons to reach centimeters. Each order-of-magnitude gain in positioning accuracy usually costs a step-change in hardware and deployment; first nail down how accurate the business truly needs, then pick the technology. ::: warning Not read ≠ does not exist Both RFID and barcode scanning have miss rates, and all positioning has error. A system must tolerate "temporarily not read" — back it with re-reads, redundant points, timeouts, and state machines, rather than assuming every read succeeds. This is the same engineering reality as the "values may be missing" of sensor acquisition. ::: ## How It Lands in IoT DC3 The core idea of IoT identification — **give every thing a globally unique, attributable identity** — maps directly into IoT DC3, only DC3 sits one layer higher: it does not itself read RFID tags or scan codes (that is the job of field devices and acquisition terminals); it establishes a **digital identity and ownership boundary** for every object onboarded to the platform. DC3 **uniquely identifies** a [Device](../introduction/concepts/device) with `deviceId`. A specific field machine — a PLC, a meter, a thermostat — corresponds to one `Device` on the platform, addressed stably across the whole system by its `deviceId`, which binds its history and links its commands and events. This is one with the "give every thing an identity" idea: EPC gives every item a network-wide unique code, `deviceId` gives every onboarded device a platform-wide unique identifier — except DC3's identity is registered in software and depends on no particular physical-tag technology. DC3 **draws the ownership and isolation boundary** with the [Tenant](../introduction/concepts/tenant) (`tenantId`). Every business record carries a `tenantId`, by which the platform slices data into non-crossing partitions — company A's devices, points, and data are invisible to company B. If `deviceId` answers "which device is this," `tenantId` answers " whose device is it, who may see it." The "identity + ownership" duality of identification technology (an EPC number plus the manufacturer prefix it belongs to) is, in DC3, exactly the `deviceId + tenantId` combination. <IdentificationEntityDiagram lang="en" /> ::: info DC3 does not do "RFID tag management" DC3's identity model is platform-level device registration and tenant isolation; it does not bundle field-side identification features like RFID card issuance, reader management, or scan-based check-in/out. This chapter pairs identification technology with DC3 to highlight the **shared identification idea** (a globally unique ID plus an ownership boundary), not to claim DC3 provides those field capabilities. If a site uses RFID or scanning for acquisition, that data enters through protocol drivers as ordinary data and still resolves to some `deviceId` under some `tenantId`. ::: In one line: identification and positioning make the physical world **addressable**; DC3 makes every onboarded object * *addressable and attributable** — the former is IoT's entry point, the latter is where the platform begins to govern those objects. ## Further Reading - [Sensing & Measurement](./sensing) — the other half of the perception layer: turning physical quantities into computable signals - [Fieldbus & Protocols](./fieldbus) — how data from identification and sensing leaves the field over a bus - [IoT Technology Overview](./) — back to the four-layer reference architecture, to place identification and positioning globally - [Device](../introduction/concepts/device) — how `deviceId` uniquely identifies one field device in DC3 - [Tenant](../introduction/concepts/tenant) — how `tenantId` draws the data ownership and isolation boundary --- # IoT Technology Overview URL: https://docs.dc3.site/en/foundations/ The Internet of Things is not a single technology — it is a layered system that brings the physical world into the digital one. This part walks through the core knowledge of IoT along the industry's classic **four-layer reference architecture** — perception, network, platform, and application, plus security that cuts across all four — and shows at every layer how IoT DC3 implements it. By the end you will hold a complete map from "sensor to AI-driven operations," and know exactly where DC3 sits on that map. ## The Four-Layer Reference Architecture Take an IoT system apart and data flows bottom-up while control flows top-down: the perception layer turns physical quantities into digital signals, the network layer carries those signals reliably, the platform layer stores, manages and computes over the data at scale, and the application layer turns data into business value. Security belongs to no single layer — it is a cross-cutting concern that runs through all of them. <FourLayersDiagram lang="en" /> This layering is not dogma but a set of **responsibility boundaries**: each layer solves only its own problem and collaborates with its neighbors through clear interfaces. Its value is that any IoT platform, any IoT product, can be located on this map. IoT DC3 is no exception. ## How DC3 Sits on the Four Layers IoT DC3 is not another restatement of "generic IoT theory" — it is a **runnable implementation** of this four-layer architecture. Layer by layer: - **Perception → Profile and Point**. The physical quantities produced by field sensors, actuators and meters are modeled in DC3 as the [Profile](../introduction/concepts/profile), the [Device](../introduction/concepts/device) and the [Point](../introduction/concepts/point) — capturing semantics like "temperature" or "switch" stably. See [Sensing & Measurement](./sensing) and [Auto-ID & Positioning](./identification). - **Network → protocol drivers**. Dozens of heterogeneous protocols — Modbus, OPC UA, MQTT, BACnet… — are unified by DC3's [28 protocol drivers](../drivers/) and normalized into semantically labeled point values. See [Industrial Buses & Protocols](./fieldbus) and [IoT Protocols & Wireless](./iot-protocols). - **Platform → center services and the data plane**. Device metadata is managed by the [center services](../architecture/services); point values flow through the [data plane](../architecture/data-plane) into a TimescaleDB time-series store and become queryable. See [Edge & Cloud Architecture](./edge-cloud) and [Time-Series & Streaming](./data-pipeline). - **Application → operations and AI**. On top of the data, DC3 offers [operations and alarms](../operation/), and through the [Agentic Center and MCP](../ai/) lets large language models join the sense–decide–act–feedback loop. See [Data Intelligence & AIoT](./aiot). - **Security → auth, tenancy, RBAC**. The cross-cutting security shows up in DC3 as [authentication, tenant isolation and RBAC](../architecture/auth-rbac), plus TLS on the wire. See [IoT Security](./security). ## How to Read This Part You can read this part straight through as a structured primer on IoT, or jump in by layer: to learn how sensors are chosen, read the **perception layer**; to weigh MQTT against NB-IoT, read the **network layer**; to sort out how edge and cloud split the work, read the **platform layer**. Each chapter opens with "what this layer is, key technologies and trade-offs, engineering notes," and closes with "how it lands in IoT DC3," so theory and implementation form a loop. ## Further Reading - [Core Concepts](../introduction/concepts) — DC3's base objects: Profile, Device, Point, Tenant - [Architecture](../architecture/) — DC3's own service topology, data plane and command plane - [Connectivity & Drivers](../drivers/) — how 28 protocol drivers bring heterogeneous devices in --- # IoT Protocols & Wireless Networks URL: https://docs.dc3.site/en/foundations/iot-protocols <script setup> import IotProtocolsMqttDiagram from '../../.vitepress/theme/components/IotProtocolsMqttDiagram.vue' import IotProtocolsWirelessDiagram from '../../.vitepress/theme/components/IotProtocolsWirelessDiagram.vue' </script> # IoT Protocols & Wireless Networks Fieldbuses connect the machines on a shop floor, but the wider Internet of Things—battery-powered sensors, water meters out in the countryside, smart hardware on the public internet—relies on a different family of "light, frugal, far-reaching" protocols and wireless technologies. This chapter covers the IoT side of the network layer on two fronts: the upper-layer **application messaging protocols** (MQTT, CoAP, LwM2M, HTTP, AMQP) and the lower-layer **wireless and wide-area access** (BLE, Zigbee, LoRa/LoRaWAN, NB-IoT, 5G), plus the "power–bandwidth–range–cost" trade-offs between them. By the end you will be able to pick the right protocol stack for a class of devices, and know which IoT DC3 driver each choice maps to. > You are here: the previous chapter, [Fieldbuses & Industrial Protocols](./fieldbus), dealt with deterministic > communication close to the field. This chapter steps one level outward, into the world of IoT protocols built for > massive, low-power, long-range endpoints. ## What This Layer Is / Why It Exists Back to the [four-layer reference architecture](./): the network layer is responsible for "carrying the signals produced by the perception layer reliably." Fieldbuses solve connectivity inside the factory walls—cabled, strongly real-time. But the other half of IoT looks completely different: devices number in the thousands, are scattered across wide areas, run on batteries, have bandwidth measured in KB, and often sit behind unreliable wireless links and the public internet. Under such constraints, the classic "master polls every device" model is both power-hungry and unscalable. So IoT protocols evolved along two main lines. One is the **application messaging protocols**: they define "what a message looks like, how it is delivered, and how reliable it is," running on top of TCP/UDP, independent of which wireless carries them underneath. MQTT decouples devices and platforms through publish/subscribe, CoAP compresses HTTP's request/response model down to a few dozen bytes over UDP, LwM2M layers a device-management object model on top of CoAP, and HTTP/REST is still widely used because of its ubiquity. The other line is **wireless and wide-area access**: it decides "how the signal travels through the air," from BLE at a few meters to LoRa at several kilometers, up to the carrier networks of NB-IoT and 5G. The two lines are orthogonal: the same MQTT payload can run over Wi-Fi or over an NB-IoT cellular link. The key to understanding the network layer is to look at "how a message is organized" and "how a signal is transmitted" separately, then compose them per scenario. ## Key Technologies & Trade-offs ### Application protocols: how messages are organized and delivered **MQTT** is the de facto messaging bus of IoT. At its core is the **publish/subscribe (pub/sub)** model: a device does not talk to the platform directly—it **publishes** messages to a **topic**, and the platform receives them by * *subscribing** to those topics. The two sides are decoupled through an intermediate **broker** (a message relay server such as EMQX, Mosquitto, or the RabbitMQ MQTT plugin); neither needs the other's address, nor for both to be online at once. This "pushed by subscription rather than polled" semantics is precisely what makes massive-device scenarios power-efficient and horizontally scalable. MQTT describes delivery guarantees with three **QoS (Quality of Service)** levels, declared independently by the publishing and subscribing sides, with the weaker side prevailing: - **QoS 0 (at most once)**: fire and forget—no acknowledgement, no retransmission. The cheapest; if it is lost, it is lost. Good for high-frequency telemetry that can tolerate gaps. - **QoS 1 (at least once)**: the receiver must reply `PUBACK`, and unacknowledged messages are resent—no loss, but * *possible duplicates**, so the downstream must be idempotent. - **QoS 2 (exactly once)**: a four-way handshake (`PUBREC`/`PUBREL`/`PUBCOMP`) guarantees no loss and no duplication—the most reliable and the heaviest, suited to non-repeatable critical commands. Two more common mechanisms: **retain** lets the broker cache the "last" message for each topic, so a new subscriber receives the current value the moment it connects rather than waiting for the next report—ideal for "state" topics; * *LWT (Last Will and Testament)** lets the broker send a preset message on the device's behalf when it disconnects abnormally, so the platform can detect the offline state. On versioning, **MQTT 3.1.1** has long been the mainstream; **MQTT 5.0** adds enhancements such as reason codes (making errors more diagnosable), user-defined properties, a request/response pattern, and shared subscriptions (multiple consumers load-balancing the same topic). When selecting, first confirm that both the device firmware and the broker support the target version—capability is the intersection of the two ends, and one-sided 5.0 support does not auto-enable its features. The diagram below shows the basic pub/sub topology—both devices and platform talk only to the broker: <IotProtocolsMqttDiagram lang="en" /> **CoAP (Constrained Application Protocol)** takes another route: 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** ( 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. CoAP also supports the **Observe** extension, letting a client "subscribe" to a resource so the server pushes on value changes—filling the gap of pure request/response. **LwM2M (Lightweight M2M)** does not start from scratch—it is **built on top of CoAP**, supplying the "device management" layer that CoAP lacks. It abstracts device capabilities into an **object tree**: `Object` (e.g. `3303` = Temperature) / `Object Instance` (multiple instances of the same kind) / `Resource` (e.g. `5700` = Sensor Value), and accessing a value means giving the path `/<objectId>/<objectInstanceId>/<resourceId>`. Firmware upgrade, remote configuration, and subscription reporting are all standardized into this model, which is why LwM2M is common in carrier-grade endpoints that need remote operation (NB-IoT modules, smart meters). **HTTP/REST** still has a place in IoT: it was not designed for constrained devices, its headers are bloated, and keep-alive is costly, so it is **not suitable** for high-frequency reporting from battery endpoints; but it is universal, easy to debug, and almost every upstream system speaks REST, so the many "pull data from a third-party platform API or from a gateway with a RESTful interface" scenarios still use it. **AMQP** sits at the other end—it is a reliable queueing protocol for enterprise messaging middleware (RabbitMQ being its implementation), with heavier payloads and state machines than MQTT, generally used for reliable message flow **between platform and backend** rather than direct connection to constrained endpoints. In short: choose MQTT for high-frequency telemetry and decoupling at scale; CoAP for very constrained, sporadic reporting; LwM2M when you need to manage devices remotely; HTTP to integrate ready-made REST interfaces; AMQP for reliable backend queues. ### Wireless and wide-area access: how the signal travels Application protocols decide "what a message looks like," but a message ultimately lands on a physical link. Choosing a wireless technology is essentially a **multi-objective trade-off**: the farther it travels, the more power it tends to use or the slower it gets; the more power-efficient it is, the more bandwidth it sacrifices; unlicensed bands save money but are prone to congestion. Grouping the mainstream technologies into three coverage tiers makes their niches visible at a glance—within a tier they further split by rate and power: <IotProtocolsWirelessDiagram lang="en" /> Quantifying the trade-off into a reference table makes it easier to work backward from a scenario: | Technology | Range | Rate | Power | Band | Typical scenario | |------------|--------------------------------|----------|----------|--------------------|----------------------------------------------------| | BLE | tens of meters | low | very low | 2.4 GHz unlicensed | wearables, beacons, near-field provisioning | | Zigbee | tens~hundreds m (mesh extends) | low | low | 2.4 GHz unlicensed | low-rate smart-home/building devices | | LoRaWAN | several km | very low | very low | Sub-GHz unlicensed | remote metering, agriculture/environment | | NB-IoT | wide area (carrier) | low | low | licensed | metering, manhole covers, fixed low-freq reporting | | 5G | wide area (carrier) | high | high | licensed | video, AGVs, remote control | - **BLE (Bluetooth Low Energy)**: typically tens of meters, low rate, extremely power-efficient—running for months to years on a coin cell. Suited to wearables, beacons, near-field sensing and provisioning; usually needs a phone or gateway to relay onto the internet. - **Zigbee**: short-range **self-organizing mesh** based on IEEE 802.15.4, where nodes relay for each other to extend coverage—power-efficient and suited to large numbers of low-rate devices in smart homes/buildings, aggregated through a coordinator/gateway before uplinking. - **LoRa / LoRaWAN**: the **LPWAN (Low-Power Wide-Area Network)** representative. LoRa is the physical-layer modulation (long range, interference-resistant); LoRaWAN is the network protocol above it. Operating in unlicensed Sub-GHz bands, it reaches several kilometers in cities and farther in the countryside, at very low rates (hundreds of bps to tens of kbps), with extremely frugal endpoints—a typical "wide coverage, low rate, self-built network" scenario, such as remote metering, agriculture and environmental monitoring. - **NB-IoT (Narrowband IoT)**: a carrier cellular LPWAN, running in licensed bands carried by the telecom network—good coverage and penetration (basements, manhole covers), power-efficient endpoints, massive connections, but low rate and higher latency. No need to build base stations; suited to wide-area, fixed, low-frequency reporting metering endpoints, often paired with LwM2M/CoAP. - **5G**: high bandwidth, low latency and massive connectivity in one, spanning from enhanced mobile broadband to industrial-control-grade uRLLC. The most capable, but also the highest in power, module and tariff costs—suited to high-value scenarios sensitive to bandwidth/latency such as video, AGVs and remote control, not coin-cell sensors. To distill the trade-off into one sentence: **there is no "best" wireless, only the "best-matched" one**—first fix the scenario's range, reporting frequency, battery budget and per-node cost, then work backward to the choice. ## Engineering Notes - **Decouple protocol from wireless**: selection is two steps—first choose the application protocol by message model ( pub/sub vs. request/response, whether device management is needed), then choose the wireless/access by physical constraints. The two are orthogonal; don't conflate them. - **Higher QoS is not always better**: the four-way handshake of QoS 2 significantly amplifies overhead and latency under weak networks or massive devices. QoS 0/1 suffices for most telemetry; reserve "exactly once" for truly non-repeatable critical commands, and make the downstream **idempotent** when using QoS 1. - **For UDP protocols, check the firewall first**: CoAP/LwM2M run over UDP, and a failure to connect is usually the UDP port (`5683`/`5684`) being blocked by a firewall or a broken NAT mapping, rather than a wrong application config—verify the link before the app. - **Passive receipt ≠ "online" even with no data**: pub/sub and Observe are device-initiated pushes. The platform's " online" judgement must rest on lease/keep-alive/LWT, not on an "acquisition interval"; a long silence does not necessarily mean the link is down, but don't assume it is alive either. - **Save power on the link**: an endpoint's power budget is dominated by the radio transceiver and keep-alive handshakes, not by MCU computation. To cut power, first reduce reporting frequency, use a lighter QoS, and enable deep sleep/DRX—rather than optimizing business logic. - **Encrypt on the public internet**: MQTT and CoAP running over the public internet/cellular must enable TLS (`8883`) and DTLS (`5684`) respectively, with proper device identity (certificates/PSK) and topic-level authorization, to prevent spoofing or unauthorized subscriptions. ### The convergence trend Early IoT was a set of "protocol islands"—a proprietary protocol and a dedicated gateway per device kind. The trend is converging: the application layer is consolidating into the two-strong landscape of **MQTT + CoAP/LwM2M** (MQTT for high-frequency telemetry, CoAP/LwM2M for constrained devices and management); the access side layers **LPWAN ( NB-IoT/LoRa) and 5G** complementarily, covering the full spectrum from "wide and frugal" to "fast and powerful." The platform then uses a **unified protocol-adaptation layer** to normalize these heterogeneous accesses into one data model—which is exactly what the IoT DC3 driver layer does. ## How It Lands in IoT DC3 DC3 implements each of these "light protocols" as a standalone protocol [driver](../drivers/) (`dc3-driver-*`); at startup it registers itself and the [attributes](../introduction/concepts/attribute-config) it accepts with the manager center, then acquires data per [Point](../introduction/concepts/point) and writes per [Command](../introduction/concepts/command). The application protocols in this chapter map to four drivers: - **[MQTT driver](../drivers/mqtt)** (`dc3-driver-mqtt`): type `DRIVER_SERVER`—it **acts as a server, subscribes to MQTT topics and passively receives** device reports rather than actively polling. Downstream has two paths: the point `write()` publishes the raw value directly per `commandTopic`/`commandQos`; only the command `execute()` renders the command-attribute `payloadTemplate` before publishing. Which broker to connect to is decided by the deployment environment variables `MQTT_BROKER_HOST` / `MQTT_BROKER_PORT`, so this driver has **no device-level driver attributes **; on the MQTT side it can work with a broker such as **EMQX**, while the docker-compose stack defaults to injecting the RabbitMQ MQTT plugin (`dc3-rabbitmq:1883`; the dev profile's YAML port fallback is `2883`). - **[CoAP driver](../drivers/coap)** (`dc3-driver-coap`): type `DRIVER_CLIENT`, based on Eclipse Californium, actively connecting to devices—reads issue a GET to the point's `readPath`, writes a PUT to `writePath`, over UDP `5683`; the acquisition interval defaults to 30 seconds in the base config, overridden to 5 seconds by the dev profile (active by default). - **[LwM2M driver](../drivers/lwm2m)** (`dc3-driver-lwm2m`): embeds an Eclipse Leshan LwM2M server; devices register with their `endpoint` name, and resources are read/written by the point's three-part `objectId/objectInstanceId/resourceId` path. - **[HTTP driver](../drivers/http)** (`dc3-driver-http`): type `DRIVER_CLIENT`, using `WebClient` to call a REST endpoint periodically and extract the value from the JSON response per `responsePath`. ::: warning The MQTT driver has "passive arrival via subscription" semantics `dc3-driver-mqtt`'s `read()` does not actively return an acquired value—scheduled reads are off by default ( `schedule.read.enable=false`), and a point value is **received passively after the device publishes** it. If values never arrive, first confirm the device is actually publishing to the subscribed topic and that the topic strings match exactly on both sides—rather than checking the "acquisition interval." ::: ::: warning The MQTT / LwM2M drivers are currently skeleton implementations In the source, `dc3-driver-mqtt`'s `read()` is a reference stub and `health()` always reports online, and `dc3-driver-lwm2m`'s class comment is marked "work-in-progress skeleton"; protocol-level I/O is not yet fully implemented. Treat them as onboarding templates and configuration references, not production-ready drivers; defer to each [driver page](../drivers/) and the source for specifics. ::: As for the **wireless/access technologies** in the second half of this chapter (BLE, Zigbee, LoRa, NB-IoT, 5G), they belong to the physical-link layer: DC3 does not speak the air interface directly—it sits on top of them. BLE and Zigbee each have a corresponding driver (see the "IoT / Wireless" group in the [Drivers overview](../drivers/)); LoRa/NB-IoT/5G endpoints are usually first aggregated into a broker or REST gateway, then onboarded uniformly through DC3's MQTT/CoAP/HTTP drivers—the concrete product form of the "unified protocol-adaptation layer" described above. ## Further Reading - [Fieldbuses & Industrial Protocols](./fieldbus) — the other half of the network layer: close-range, strongly deterministic - [Edge & Cloud Architecture](./edge-cloud) — above protocol access, how data is split between edge and cloud - [IoT Technology Overview](./) — the four-layer reference architecture and the DC3 panorama - [MQTT Driver](../drivers/mqtt) — how pub/sub, QoS and brokers land in DC3 - [Connectivity & Drivers](../drivers/) — how 28 protocol drivers bring heterogeneous devices in --- # IoT Security URL: https://docs.dc3.site/en/foundations/security <script setup> import SecurityDiagram from '../../.vitepress/theme/components/SecurityDiagram.vue' </script> # IoT Security IoT wires "things that can go online" to "a physical world you can act on," so a single gap threatens data and control at once — reading a meter is a privacy problem, but tampering with a valve command is a safety incident. This chapter walks through the threats and countermeasures of IoT security across four faces — device, communication, platform, data — and shows how that thinking lands in IoT DC3 as authentication, tenant isolation, and transport encryption. > You are here: you've finished the [four-layer reference architecture](./) and want to understand the security that > cuts across all four. By the end you can draw a threat-to-countermeasure map and know which defenses DC3 places at > each > layer. ## What This Layer Is / Why It Exists Security belongs to no single layer among perception, network, platform, and application — it is a **cross-cutting concern** that runs through all four. The reason is blunt: attackers don't follow your layering, they probe for the thinnest spot. Device firmware can be reflashed, links can be sniffed and replayed, platform APIs can be called without authorization, and the database can be exfiltrated. If any one link falls, doing the others well counts for nothing. So IoT security must be discussed across all four layers at once, with a default stance of "everything beyond the boundary is untrusted." IoT security is also harder than traditional IT, in three ways. First, **devices are resource-constrained**: field sensors and gateways have little compute and memory and may run on batteries, so they can't afford heavy crypto or frequent key rotation. Second, **devices are physically reachable**: they sit on the shop floor, in fields, on streets, where an attacker can hold one, pry open the chip, read the Flash, hook the debug port — pure software defenses can't stop a physical attack. Third, **scale and heterogeneity**: a platform may onboard dozens of protocols and thousands of devices, so patching uniformly or rotating certificates everywhere is extremely costly, and any one aging device can become the way into the whole network. Because of these constraints, the goal of IoT security is not "absolute safety" but **defense in depth**: every layer sets a gate, so no single breach loses the whole game. The four layers follow, then one diagram aligns threats with countermeasures. ## Key Technologies and Trade-offs ### Device Security: Trust Starts in Hardware The device is the physically reachable link, so defense must establish trust from the moment it boots. **Secure Boot** has the bootloader verify firmware signatures stage by stage and refuse to run anything that fails — blocking "flash in malicious firmware" at the root; the root of that trust chain is a root key fused into the chip, immutable. **Key storage** decides whether the private key can be read off: leaving it in ordinary Flash is running naked, while the right move is a Secure Element (SE) or Trusted Execution Environment (TEE) that keeps keys "usable but not readable." * *Firmware update (OTA)** must verify the signature before writing and support rollback to a known-good version, or one hijacked update can mass-compromise a whole fleet. The trade-off is cost: chips with SE/TEE and Secure Boot cost more, and the OTA channel needs extra signing and staged rollout. Severely constrained devices often manage only "signed updates plus software-layer key protection," reserving the stronger hardware root of trust for critical nodes. ### Communication Security: Encryption, Authentication, Anti-Replay The device-to-platform link is exposed on the network by nature, and three things must hold at once. **Encryption** uses TLS (for TCP, e.g. MQTT over TLS, HTTPS) or DTLS (for UDP, e.g. CoAP) to protect the link against eavesdropping and tampering. **Authentication** must be mutual: the server certificate stops a device from connecting to a fake platform ( man-in-the-middle), while the device proves who it is with a certificate or a pre-shared key (PSK), stopping device spoofing. **Anti-replay** must defeat "record a valid message, replay it verbatim later" — using timestamps, monotonically increasing sequence numbers, or a one-time nonce to invalidate stale messages. The trade-off is constrained devices: a full TLS handshake's asymmetric math and certificate chain burden small devices, so compromises appear — TLS-PSK, session resumption, lighter elliptic-curve algorithms. However light it gets, you cannot drop "authentication plus anti-replay," or encryption merely encrypts the channel for the attacker too. ### Platform Security: Authentication, Authorization, Tenant Isolation, Audit Device data converges on the platform, which makes the platform a high-value target. **Authentication** answers "who are you" — a login yields a token that is verifiable and time-limited. **Authorization** answers "what may you do" — RBAC ( role-based access control) binds subject, role, and resource, holding to **least privilege** and **fail-closed** (no permission found means deny, never default to allow). **Multi-tenant isolation** answers "which data may you touch" — orthogonal to authorization: having "read device" permission does not mean reading another tenant's devices; weak isolation lets one tenant see, or even operate, another tenant's field devices. **Audit** records "who did what, when," both for after-the-fact accountability and for real-time anomaly detection. ### Data Security: Privacy, Masking, Compliance IoT data often ties back to people and the field — a smart meter's load curve reveals whether anyone is home, a location trace is someone's whereabouts. **Privacy** demands minimal collection and purpose limitation: don't collect what you shouldn't. **Masking** demands that sensitive fields be redacted or anonymized before display, export, or handing to a third party (including feeding a large model). **Compliance** raises these to hard requirements: GDPR, personal-information laws and the like constrain collection, storage, and cross-border transfer, and the cost of violation far exceeds any one technical fault. Data security also covers encryption at rest (column/disk encryption) and minimal retention (delete on expiry), so that "even if exfiltrated, what's taken is ciphertext or incomplete." ### Threat Model: Spread the Attack Surface Out Security design needs targets, so align the four layers' countermeasures to concrete threats. The diagram below labels five typical threats and their defenses along the data flow: <SecurityDiagram lang="en" /> - **Device spoofing**: impersonating a legitimate device to report fake data or solicit commands — defeated by strong device-side authentication (one-device-one-secret, certificates). - **Firmware tampering**: flashing in backdoored firmware — held off by secure boot plus signed OTA plus rollback. - **Replay attack**: recording a valid message and replaying it verbatim — defeated by timestamps, sequence numbers, and nonces that invalidate stale messages. - **Man-in-the-middle (MITM)**: sniffing or rewriting packets in the link — defeated by mutual authentication plus encryption, so a forged peer fails certificate validation. - **DDoS**: flooding the entry with requests — mitigated by converging the entry to a single gateway, with rate limiting and a firewall. - **Privilege escalation / cross-tenant**: a legitimate identity reaching beyond its scope — gated by RBAC's fail-closed and tenant isolation together. ## Engineering Notes - **Untrusted by default, verify beyond the boundary**: don't assume "the internal network is safe." Backend services, even when not directly exposed, must verify the caller's identity in case someone bypasses the gateway and connects directly. - **Fail closed, not open**: when an auth component hits a transient fault, prefer treating the request as "no permission" and rejecting it over "error means allow" — the latter turns a hiccup into a backdoor. - **Tier keys, tighten by environment**: development can use weak defaults for convenience, but production must force strong random keys; better still, make the program **fail to start** when it finds a weak key in production, catching the problem before release. - **Fewer entries is better**: converge the external surface to a single gateway; field-protocol ports (Modbus, raw TCP/UDP) must never face the public internet — most have no authentication, and exposing them is opening the door. - **Encrypt transport by default**: the message bus, the broker, and the HTTP entry all run over TLS when traffic crosses a network; cleartext is acceptable only for local testing. - **Auditable and traceable**: key operations (login, authorization changes, command dispatch) leave a trail, and logs must never contain cleartext keys or passwords. ## How It Lands in IoT DC3 DC3's security backbone centers on [Authentication · Tenancy · RBAC](../architecture/auth-rbac), backed by the deployment baseline in [Security Policy](../community/security). It concretizes the four-layer thinking above into the points below, and strictly separates "implemented" from "designed but not yet implemented." ### Platform Authentication: Two-Step Login and Tokens DC3 faces the outside world through one entry only, the gateway `dc3-gateway`. Login is a **two-step handshake**, mirroring the anti-replay idea from communication security: 1. `POST /api/v3/auth/token/salt`: send `tenant` and `name`, confirm the tenant exists, and get a random salt. The salt is **stateless** — the server neither stores it nor enforces an expiry; it is only checked together with the next login request. The "5 minutes" is merely a usage hint in the response text, honored client-side, not a server-enforced timeout today. 2. `POST /api/v3/auth/token/generate`: send `tenant`, `name`, `salt`, and the `password` hashed with the salt; on success, get an access token, **valid for 12 hours**. The salt prevents a cleartext password or a fixed hash from being replayed on the wire. The minted JWT is **bound to `principal_id` + `tenant_id`** (not the username); on logout the identity goes onto a Caffeine denylist, so an old token, even with a valid signature, is rejected because it was issued before the logout point. ### Platform Trust Propagation: Gateway Signing + HMAC Pass-through Center services each have their own HTTP port, unmapped externally by default. The risk: anyone who can reach a backend port directly only needs to forge a "I am tenant A's admin" header, and a backend that trusts it unconditionally is impersonated. DC3's answer separates **authentication** from **trust** — authentication happens once at the gateway, trust travels as an HMAC-SHA256 signature: - The gateway's `AuthenticGatewayFilter` verifies the three headers `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` against the Auth Center, resolves the real principal, serializes it to `X-Auth-Principal`, signs it with the shared secret into `X-Auth-Sign`, and passes both downstream. - The backend's `GatewayJwtConverter` recomputes the HMAC with the same secret, compares it to `X-Auth-Sign` in * *constant time**, and rejects on mismatch; it also rejects when `tenantId` or `principalId` is missing. - When HMAC is disabled, the gateway **actively strips any inbound `X-Auth-Sign`**, so a downstream service can't be tricked by a fake signature the client brought along. ::: danger Production HMAC/key fail-fast (hard constraint) In `pre` / `pro` environments, if `AUTH_HMAC_SECRET` is empty or still equals the default `io.github.pnoker.dc3`, the service **fails to start** (throws `IllegalStateException`; the check is `HmacAuthConfig.isProtectedEnvironment()`). This is intentional: better not to start than to run a production instance on development keys. In production, inject a strong random value (e.g. `openssl rand -base64 48`) via an environment variable, never hardcode it or write it to logs. `DC3_SECURITY_KEY` (the Auth Center's token signing key, default `dc3.security.key.2026.io.github.pnoker`) is subject to a **"must exist" startup check only** — missing it fails startup, but there is no "must not equal the default weak value" rejection; so it still must be actively replaced with a strong random value, since once it leaks an attacker can forge login tokens. ::: ### Platform Authorization: RBAC, Fail-Closed Once the signature is verified and the principal is in hand, RBAC decides "what may you do": `principal → roles (per-tenant) → resource codes (global)`. Permission resolution carries a 5-minute short cache (keyed by `(tenantId:principalId)`). The failure semantics matter most: ::: danger No permission found = deny When permission loading hits a transient fault, `GatewayJwtConverter` still creates an "authenticated but empty-authority" token, so any `@PreAuthorize` guard returns **403**. This is deliberate fail-closed — never dress up a backend hiccup as an allow. ::: ### Multi-Tenant Isolation: Controller-Layer Checks RBAC decides "may you do this kind of operation," tenant isolation decides "may you touch this row of data" — the two are orthogonal. Isolation lives at the **controller layer**: - After fetching an entity by ID, `BaseController.requireTenant()` compares the entity's `tenantId` with the caller's tenant; on mismatch (or non-existence) it throws `NotFoundException`, **returning 404 rather than 403** — deliberately using "does not exist" to avoid leaking "whether a cross-tenant resource exists." Batch queries go through `filterTenant()`, which drops entries not belonging to the tenant. ::: warning No database-layer tenant safety net The current implementation has **no** MyBatis-Plus tenant row interceptor; isolation rests entirely on the controller-layer `requireTenant` / `filterTenant`. When adding queries, you must apply the tenant check yourself — the SQL layer will not scope by tenant automatically. ::: ### Communication and Data Security: Deployment Baseline Transport encryption and data protection live in the production baseline of [Security Policy](../community/security): RabbitMQ and EMQX disable TLS by default and must enable it when traffic crosses a network (e.g. `RABBITMQ_SSL_ENABLED=true`, over the TLS port); the gateway HTTP entry should sit behind a reverse proxy that terminates HTTPS; field-protocol ports must never face the public internet (`DC3_BIND_HOST` defaults to `127.0.0.1`, so you must explicitly set it to `0.0.0.0` to expose anything). ::: info External identity (IdP) not yet implemented The `dc3_identity_provider` (external IdP config for OIDC/SAML, etc.) and `dc3_external_identity` (external-identity binding) tables are already created in `02-iot-dc3-auth.sql`, and `principal.source_type` reserves the `EXTERNAL` value, but the corresponding **login endpoint is not implemented and stays closed**. The only working login path today is the local-credential two-step handshake above. ::: ::: tip OAuth 2.1 for AI callers For access by large models / MCP clients, DC3 ships a separate OAuth 2.1 authorization server (mandatory PKCE, refresh-token rotation, tools filtered by scope and risk level) — see [Agentic & MCP](../ai/) and [Authentication · Tenancy · RBAC](../architecture/auth-rbac). ::: ## Further Reading - [Data Intelligence & AIoT](./aiot) — how data, once inside the security perimeter, becomes insight and automated decisions - [IoT Technology Overview](./) — the four-layer reference architecture and where security cuts across - [Authentication · Tenancy · RBAC](../architecture/auth-rbac) — the full path of login, HMAC pass-through, RBAC, and tenant isolation - [Security Policy](../community/security) — supported versions, vulnerability reporting, and the minimum pre-production baseline --- # Sensing & Measurement URL: https://docs.dc3.site/en/foundations/sensing <script setup> import SensingDiagram from '../../.vitepress/theme/components/SensingDiagram.vue' </script> # Sensing & Measurement The perception layer is the IoT's "skin and nerve endings" — it turns invisible, intangible physical quantities like temperature, pressure, and vibration into numbers a machine can read. This chapter explains how a sensor encodes the physical world into an electrical signal, what the common families and key metrics are, what conditioning a signal needs before it reaches a processor, and how those physical quantities are ultimately modeled in IoT DC3 as readable/writable [Points](../introduction/concepts/point). > You are here: the bottom of the four-layer reference architecture. Next, > read [Identification & Location](./identification), or return to the [IoT Technology Overview](./). ## What This Layer Is / Why It Exists Digital systems only process numbers, while the real world is all continuous physical quantities. A bridge spans the gap: the **sensor**. At heart it is an **energy converter** — it turns some physical quantity (temperature, force, light intensity, displacement…) into an easily measured electrical quantity (voltage, current, resistance, capacitance, frequency). Without this step, no amount of computing power can "see" the field. The perception layer is its own layer because it carries constraints no other layer has: it faces the physical world's noise, nonlinearity, thermal drift, and aging head-on, and its output is an **analog quantity carrying error** rather than a clean number. Keeping that error in check and digitizing the analog signal reliably is this layer's entire job. Upward it delivers one thing only: **a trustworthy numeric reading with a unit and a range**. In DC3, that reading is the value of a [Point](../introduction/concepts/point). By conversion principle, sensors fall into a few families; knowing the taxonomy helps you judge a sensor's temperament during selection: - **Resistive**: the quantity changes resistance. RTDs (PT100) measure temperature, strain gauges measure force and pressure, photoresistors measure light. Good linearity, but they need an excitation current and self-heating adds error. - **Capacitive / Inductive**: the quantity changes capacitance or inductance. Capacitive types measure displacement, humidity, liquid level; inductive types (LVDT) measure displacement. Non-contact and long-lived, but sensitive to parasitics. - **Piezoelectric**: force produces charge, so they **measure dynamic quantities only** (vibration, shock, sound). High bandwidth, but cannot measure static force. - **Thermoelectric**: a temperature difference produces an EMF (thermocouple), with an extremely wide range (up to thousands of degrees), but it needs cold-junction compensation. - **Semiconductor / Photoelectric**: PN junctions, Hall elements, photodiodes turn temperature, magnetic field, and light into electrical signals — the basis of MEMS and on-chip integration. ## Key Technologies & Trade-offs From sensing a physical quantity to handing over a number, a sensor runs a fixed pipeline: the **sensing element** first turns the quantity into a weak electrical signal, **signal conditioning** amplifies, filters, and linearizes it into a suitable range, and the **A/D converter (ADC)** quantizes the continuous analog voltage into a discrete digital code. Every hop on this pipeline shapes the quality of the final reading. <SensingDiagram lang="en" /> **Signal conditioning** is the analog world's preprocessing. A sensing element's output is often just millivolts, high-impedance, and noisy — not something an ADC can take directly. The conditioning circuit must: amplify (an instrumentation amp lifts the magnitude), filter (an anti-aliasing low-pass removes high-frequency noise), level-shift ( align to the ADC input range), excite (supply constant current/voltage to resistive sensors), and take the difference across a bridge to reject common-mode interference. How well conditioning is done often matters more to accuracy than the ADC's bit count. **A/D conversion** has two independent dimensions; don't conflate them: - **Sample rate** sets time resolution. By the Nyquist theorem, the sample rate must exceed twice the signal's highest frequency, or **aliasing** occurs — high frequencies masquerade as low ones, unrecoverable after the fact. Vibration needs several kHz or more; room temperature is fine at once per minute. - **Quantization bits** set amplitude resolution. 12 bits slice the range into 4096 steps, 16 bits into 65536. More bits resolve finer, but cost more, run slower, and are noise-limited (effective number of bits, ENOB, is usually below the nominal count). When selecting and evaluating a sensor, you look at the following set of metrics — which almost always trade off against one another: | Metric | Meaning | Engineering trade-off | |-------------|-----------------------------------------------|--------------------------------------------------------------| | Range | Upper/lower bounds of what can be measured | A wider range means coarser resolution at the same bit count | | Accuracy | How close a reading is to the true value | High-accuracy parts cost markedly more | | Resolution | The smallest change that can be distinguished | Limited jointly by ADC bits and the noise floor | | Sample Rate | Samples per unit time | Higher consumes more bandwidth, power, storage | | Linearity | How proportionally output tracks input | Nonlinearity needs lookup-table / polynomial correction | | Drift | Slow shift over time/temperature | Decides how often recalibration is due | ::: warning Accuracy ≠ Resolution High resolution does not mean correct. A thermometer that displays down to 0.001℃ may, if uncalibrated, carry an absolute error of 2℃. **Resolution is "how finely you see," accuracy is "how correctly you see"** — evaluate them separately. ::: ::: tip Calibration is the prerequisite for a trustworthy engineering value Factory parameters drift over time. Calibration measures the sensor against a known standard, records the deviation, and builds a correction relationship (zero + slope, and the full curve when needed). In DC3, the most common linear correction lands directly on the Point's scaling parameters (see below). ::: **MEMS (Micro-Electro-Mechanical Systems)** is the technology of building the sensing structure and circuitry together on a silicon die, mass-producing micron-scale moving structures with semiconductor processes. It makes sensors extremely small, cheap, and power-efficient — today's phone accelerometers, gyroscopes, microphones, and barometers are nearly all MEMS. The cost is that per-part accuracy and long-term stability usually trail traditional industrial-grade devices, so the field still runs MEMS and traditional sensors side by side, per scenario. ## Engineering Notes - **Fix the range before discussing resolution.** Range is a hard constraint set by what you measure; within a fixed range you trade ADC bits for resolution — but the noise floor is the real lower bound. - **Sample rate obeys the signal, not habit.** What quantity you measure and how fast it changes decide the sample rate and whether anti-aliasing is needed. Oversampling then decimating is often cheaper than piling on bits. - **Leave room for linearization and calibration.** Nonlinear sensors (thermocouples, thermistors) require curve correction; even linear parts need zero and slope calibration. Pin down the "raw code → engineering value" conversion so the field stays maintainable. - **Drift sets the maintenance cadence.** Translate the datasheet's temperature and time drift straight into "how often to recalibrate," and write it into the maintenance plan rather than reacting once readings clearly stray. - **An actuator is the mirror of a sensor.** If a sensor turns a physical quantity into an electrical signal (input), the **actuator** is the reverse energy converter, turning a signal back into physical action (output): a motor turns, a valve opens, a relay switches, a heater warms. A complete control loop is the "sense → decide → act → sense again" cycle — the perception layer must both read accurately and drive effectively. In DC3, reading maps to read-only Points and writing to writable Points, both expressed by the same Point model. ## How It Lands in IoT DC3 "One quantity" in the physical world is abstracted in DC3 as **one Point**. The model has three layers, matching "type — template — instance": - A [Profile](../introduction/concepts/profile) is a **capability template for a class of devices**. Create one Profile for the "ZS-100 temperature-humidity sensor," define its shared capabilities once, and every same-model device reuses it. - A [Point](../introduction/concepts/point) is **one concrete measurement point** under a Profile. Each collected or written physical quantity is one Point, carrying all of that quantity's metadata: data type `pointTypeFlag`, read/write capability `rwFlag`, engineering unit `unit`, and scaling parameters `multiple`/`baseValue`/`valueDecimal`. - A [Device](../introduction/concepts/device) is **one physical thing in the field**, bound to a Profile via `profileId` and thereby inheriting all its Points. So the physical concepts in this chapter land precisely in DC3: - **Unit and range** → the Point's `unit` field (e.g. `℃`, `kPa`), describing what the quantity is. - **Read vs. write (sensor vs. actuator)** → the Point's `rwFlag`: a sensor reading uses `READ_ONLY`, a controllable point (actuator, setpoint) uses `READ_WRITE` or `WRITE_ONLY`. Whether a Point can be written is decided solely by `rwFlag`. - **Calibration and linear scaling** → the Point converts the **raw code** the driver reads into an **engineering value **, with a formula that exactly mirrors the last hop of this chapter's signal chain: ```text engineering value = raw value × multiple + baseValue (then rounded by valueDecimal) ``` Example: a temperature transmitter register reads `2531`; with `multiple=0.01`, `baseValue=0`, `unit=℃`, `valueDecimal=2`, the Point's value after conversion is `25.31 ℃`. This is exactly how a sensor's linear calibration parameters are baked into the model. ::: info One temperature reading = one Point's value A field temperature sensor reading 25.3℃ right now is, in DC3, one [PointValue](../introduction/concepts/point) of the temperature Point on its device. The physical quantity travels sense → conditioning → ADC → scaling, and what lands is precisely this one number. For exact field semantics, the Point concept page and the source code are authoritative. ::: This way, the perception layer's engineering details (sensing principle, conditioning, ADC, calibration) collapse into a few stable Point attributes; upper services need not care about the sensor model and face only "a digital quantity with a unit, a read/write capability, and already scaled to its engineering value." The physical world's complexity is absorbed, once, at the Point layer. ## Further Reading - [Identification & Location](./identification) — the other half of perception: acquiring identity and position - [IoT Technology Overview](./) — the four-layer reference architecture and where this layer sits - [Point](../introduction/concepts/point) — where a physical quantity lands in DC3: type, read/write, unit, scaling - [Profile](../introduction/concepts/profile) — the capability template for a class of devices, aggregating all Points - [Device](../introduction/concepts/device) — the mirror of a physical thing, binding a Profile to inherit its Points --- # Frontend Development URL: https://docs.dc3.site/en/frontend/ The IoT DC3 frontend is built on **Vue 3 + TypeScript + Vite + Element Plus**. Source code lives in the `dc3-web/` directory. ## Environment Setup | Tool | Minimum Version | Notes | |---------|-----------------|-----------------------------------------------------------| | Node.js | 20 LTS | Use fnm / nvm to manage versions | | pnpm | 9+ | Package manager, version locked in `packageManager` field | ```bash # Install pnpm (if not already) corepack enable && corepack prepare pnpm@latest --activate # Verify versions node -v # >= v20 pnpm -v # >= 9 ``` ## Quick Start ```bash # 1. Enter the frontend project directory cd dc3-web # 2. Install dependencies pnpm install # 3. Start dev server (default http://localhost:8080) pnpm dev ``` Once the dev server is running: - Frontend pages: `http://localhost:8080` - Proxies backend API to `http://localhost:8000` (gateway port) by default - To change the backend address: edit the proxy config in `vite.config.ts` ::: tip Backend dependency Frontend development requires backend services to be running. At minimum, you need the gateway `dc3-gateway` (port 8000). Start the full stack with docker-compose: ```bash # From the repo root make up-dev # Starts gateway + 4 centers + common drivers ``` ::: ## Project Structure ``` dc3-web/ ├── src/ │ ├── api/ # REST API request wrappers │ ├── components/ # Reusable components │ │ ├── card/ # InfoCard pattern (single-entity form + save/reset) │ │ ├── chart/ # Chart components (AntV G2/G6) │ │ ├── entity/ # Entity detail / list components │ │ ├── layout/ # Layout, menu, navbar │ │ └── agentic/ # AI chat components │ ├── composables/ # Vue Composables │ ├── config/ # Application configuration │ │ ├── axios/ # Axios instance and interceptors │ │ ├── i18n/ # Internationalization (zh / en) │ │ ├── router/ # Route definitions │ │ └── types/ # Entity type definitions │ ├── store/ # Pinia state management │ ├── styles/ # Global styles │ ├── utils/ # Utility functions │ └── views/ # Page components │ ├── device/ # Device management │ ├── driver/ # Driver management │ ├── home/ # Dashboard │ ├── login/ # Login │ ├── point/ # Point management │ ├── profile/ # Profile (device template) management │ └── settings/ # System settings ├── tests/ # Tests (Vitest + Playwright) ├── vite.config.ts # Vite configuration ├── tsconfig.json # TypeScript configuration └── package.json # Dependencies and scripts ``` ## Menu System The frontend menu is driven by two layers: 1. **Backend database** `dc3_menu` table — stores menu item definitions and permissions. 2. **Frontend router config** `src/config/router/` — maps menus to Vue page components. The full chain for adding a new menu item: ``` Write to dc3_menu table -> Register frontend route -> i18n translation -> Bind permission point ``` All four layers must be updated; missing any one breaks the menu. See the [Contributing Guide](../community/contributing). ## Common Commands | Command | Description | |-------------------|----------------------------| | `pnpm dev` | Start dev server | | `pnpm build` | Production build | | `pnpm preview` | Preview production build | | `pnpm test` | Run unit tests | | `pnpm test:e2e` | Run E2E tests (Playwright) | | `pnpm lint` | ESLint check | | `pnpm type-check` | TypeScript type check | ## Testing The project includes three layers of testing: - **Unit tests** (Vitest): `tests/unit/` and `tests/component/` - **API contract tests**: `tests/api/` — snapshot tests ensuring API wrapper interfaces don't break - **E2E tests** (Playwright): `tests/e2e/` — browser end-to-end tests CI gate: `pnpm lint && pnpm type-check && pnpm test && pnpm build` See the [Test Debugging Guide](./test-debugging). ## Environment Variables Frontend environment variables are organized by mode under `src/config/env/`: ```typescript // .env.development VITE_API_BASE_URL=http://localhost:8000 VITE_APP_TITLE=IoT DC3 (Dev) ``` Restart the dev server after changing the backend address. --- # Test Debugging FAQ URL: https://docs.dc3.site/en/frontend/test-debugging Quick answers to the failures that recur most often when writing or running the frontend tests. Pair this with `tests/README.md` (in the `dc3-web/` project) for the conventions, and `tests/guardrails/ai-guardrails.test.ts` for the mechanically-enforced rules. ## "Unexpected Vue warning: ..." thrown from a test The setup file in `tests/setup/vitest.setup.ts` promotes `[Vue warn]` / `[Vue error]` to thrown errors. The most common causes: - **Failed to resolve component: el-xxx** — the component template uses an Element Plus component you haven't stubbed. Add it to `layoutStubs` in `tests/setup/stubs/element-plus.ts` (preferred) or pass it under `global.stubs` in the mount call. - **injection "Symbol(router)" not found** — the component calls `useRouter()` / `useRoute()` but the test didn't mount with a router. Use a memory router: ```ts import { createMemoryHistory, createRouter } from 'vue-router'; const router = createRouter({ history: createMemoryHistory(), routes: [...] }); mount(Comp, { global: { plugins: [i18n, router] } }); ``` - **Failed setting prop "modelValue"** — already in the allowlist; if you see a different prop, add the regex to `VUE_WARN_ALLOWLIST` with a comment explaining why it can't be fixed at the source. To temporarily bypass while debugging locally: ```bash VITEST_ALLOW_VUE_WARN=1 pnpm test ``` Don't commit code that needs the bypass — the warning indicates a real contract gap. ## "Cannot access X before initialization" in vi.mock factory Symptom: `ReferenceError: Cannot access 'someMock' before initialization` pointing at a `vi.mock(...)` call. Root cause: the factory references a top-level `const`, but `vi.mock` is hoisted to the top of the file by Vitest, so the const isn't initialized yet. Fix: wrap the spies in `vi.hoisted`: ```ts const apiMocks = vi.hoisted(() => ({ doSomething: vi.fn(), })); vi.mock('@/api/foo', () => apiMocks); ``` The shared-mocks guardrail enforces this for files with more than one `vi.mock`. ## Test passes locally but fails on CI for "leftover state" Look for module-level state that survives between tests: - **Module-level reactive cache** (e.g. `useEntityNames`'s `cache` / `inflight` objects). Reset by `vi.resetModules()` + re-importing in `beforeEach`. - **Pinia store persisted across tests**. Always `setActivePinia(createPinia())` in `beforeEach`. - **localStorage / sessionStorage**. The global setup clears these in `afterEach`, but if your test relies on data being absent at the start of the run, clear in `beforeEach` too. ## "as unknown as T" or "as never" guardrail failure The guardrail forbids erasing types via double assertion. Replace with: - A typed fixture builder: `function makeRequest(): Request { return { … }; }` - `// @ts-expect-error — intentionally invalid input for whitelist test` on the single line that violates the contract. If the test really needs `as never` to satisfy a generic constraint, that usually means the production code is mistyped; fix it at the source. ## "tests/component/foo.test.ts contains wrapper.vm.x()" guardrail failure Drive the component through its public surface — props, slots, emits, DOM events. Calling `wrapper.vm.someMethod()` couples the test to internals that change whenever the component is refactored. If you genuinely need an escape hatch, use `defineExpose` in the component and call through the expose surface (still `wrapper.vm.someMethod()`, but tests survive the refactor because expose is part of the contract). ## "describe must use lowercase verb (no should...)" guardrail failure Rephrase. The describe block names the subject, the it block names the behaviour: ```ts // ❌ it('should validate before searching', …); // ✅ it('validates before searching', …); ``` ## Coverage drops below threshold Run `pnpm test:coverage` locally. The console report shows per-file gaps. If you genuinely added new untested code, add the test; if you removed covered code (intentional refactor), the percentage may rise enough to hide the drop, otherwise lower the threshold in `vitest.config.ts` and flag it in the PR description. Don't game the threshold by writing tautological tests — the "forbids tautological assertions" guardrail catches `expect(true).toBe(true)`. ## Snapshot diff is huge after a small API change The `tests/api/api-contracts.test.ts.snap` file is ~2800 lines because it covers every API wrapper. A small change to a wrapper triggers a small focused diff inside it; a giant diff means many wrappers shifted. Ask: - Did the auth headers change shape? (storage-format change) - Did the URL prefix move? (proxy / version bump) - Was a new wrapper added to a tracked module? Run `pnpm test:api -u` to update only after manually reading the diff. ## Where do new fixtures go? `tests/fixtures/`. Don't inline a 30-line sample tree in a test if a sibling test could use the same shape. The guardrail checks the directory exists; convention asks you to keep adding files (`auth.ts`, `menu.ts`, `rows.ts` as starting points). --- # Production Deployment URL: https://docs.dc3.site/en/guide/deployment The complete deployment path from a single-host Compose stack to Kubernetes / Helm: how to pick among the five modes, the exact commands, and the hardening checklist to run before connecting real devices. > You are here: you have already brought the platform up with > [Deployment Modes & Image Registries](./usage) and now want to decide how production should look. Environment > variable details live in [Environment Variables](../quickstart/environment). ## The five deployment modes | Mode | Files (under `iot-dc3/`) | Runtime | Use for | Scaling | |------|--------------------------|---------|---------|---------| | Single host Compose | `dc3/docker-compose-db.yml` + `dc3/docker-compose.yml` | Docker / Podman Compose | evaluation, demo, small production | none (singleton) | | Compose scale | `dc3/docker-compose-db.yml` + `dc3/docker-compose-scale.yml` | Docker Compose v2 | single-node production with replicas | `docker compose up --scale <svc>=N` | | Docker Swarm | `dc3/docker-compose-swarm.yml` | Docker Swarm mode | multi-node swarm cluster | `docker service scale dc3_<svc>=N` | | Kubernetes | `dc3/deploy/k8s/` (kustomize) | any k8s cluster | production Kubernetes | `kubectl scale` / HPA | | Helm | `dc3/deploy/helm/dc3/` | Kubernetes | GitOps / repeatable installs | values + HPA | Every mode runs the same images with the same environment variables - a topology tuned on Compose behaves identically elsewhere; only *where replicas live* and *how traffic reaches them* differ. The full command runbook lives in the main repo at [`dc3/doc/DEPLOYMENT.md`](https://github.com/pnoker/iot-dc3/blob/main/dc3/doc/DEPLOYMENT.md). ### Image availability (know this before picking a mode) The release CI publishes only the **app images** (web, gateway, centers, drivers) to Docker Hub `pnoker/*` and Aliyun `registry.cn-beijing.aliyuncs.com/dc3/*`. The **dependency images** `dc3-postgres` and `dc3-rabbitmq` are built locally by `docker-compose-db.yml` and are **not published** - build and push them to your own registry for swarm / k8s / helm: ```bash DC3_IMAGE_REGISTRY=my.registry/dc3 ./dc3/deploy/k8s/scripts/push-images.sh ``` ## Mode 1 - single host Compose (shortest path) ```bash make up-db # PostgreSQL + RabbitMQ (docker-compose-db.yml) make up STACK=app # app stack: web, gateway, centers, drivers (docker-compose.yml) make logs ``` Only `web` (8080/8443) and `listening-virtual` (device TCP 6270 / UDP 6271) are ever exposed; every backend port stays on the internal network. See [Deployment Modes & Image Registries](./usage). ## Mode 2 - Compose scale (single node, replicas) `docker-compose-scale.yml` is the app stack rebuilt for replicas: no `container_name`/`hostname` pins, no per-replica host ports, and `deploy.resources` limits enforced by Compose v2: ```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 ``` ## Mode 3 - Docker Swarm `dc3/docker-compose-swarm.yml` is a self-contained full stack (dependencies included) with an overlay network and `deploy:` blocks for replicas / updates / restarts / resources: ```bash docker swarm init # a single node is enough to start 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 ignores `depends_on`/`build` - startup relies on healthchecks plus the restart policy. `web` publishes with `mode: ingress` and can run multiple replicas; `listening-virtual` must stay at 1 replica (device connection affinity). On multi-node swarms, put the stateful volumes on shared storage (NFS/Ceph). ## Mode 4 - Kubernetes (kustomize) `dc3/deploy/k8s/` ships production-grade manifests: CPU-based HPA and PodDisruptionBudgets for the stateless tier, rolling updates with `maxUnavailable: 0`, StatefulSets + PVCs for postgres/rabbitmq, and an Ingress routing `/api/` to the gateway and `/` to `web`: ```bash cp dc3/deploy/k8s/secret.env.example dc3/deploy/k8s/secret.env # edit the secrets first 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 ``` ## Mode 5 - Helm `dc3/deploy/helm/dc3` parameterizes the same topology: the service list is driven by the `services:` / `drivers:` maps, so enabling a driver or tuning replicas never touches the templates: ```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=<random> \ --set-string secrets.AUTH_HMAC_SECRET=<random> helm upgrade dc3 dc3/deploy/helm/dc3 --reuse-values --set services.gateway.replicas=4 helm rollback dc3 1 ``` ## Who can scale - and who cannot | Service | Scalable? | Load balancing semantics | |---------|-----------|--------------------------| | `web` | 1 replica (owns host ports) | put your own LB in front for more capacity | | `gateway` | ✅ | the nginx in `dc3-web` resolves `dc3-gateway` to every replica and round-robins (restart `web` after scaling to refresh addresses) | | centers | ✅ (HA semantics) | HTTP routes from the gateway are load-balanced by Spring Cloud Gateway; center-to-center gRPC keeps one channel per fixed target - a replica restart fails over the channel, but it is not request-balanced | | drivers | ✅ | replicas consume the same RabbitMQ queues; one message is handled by exactly one replica | | `listening-virtual` | ❌ must stay at 1 | inbound device sockets are pinned to one container | | postgres / rabbitmq | ❌ stateful singletons | for HA use managed services or your own primary/standby | ::: warning The gRPC balancing boundary between centers Center-to-center calls use `static://` fixed targets with one channel per client. For request-level balancing use a Kubernetes Service (kube-proxy round-robins per connection) or a client-side LB. HTTP traffic through the gateway is balanced at every tier in every mode. ::: ## Production hardening checklist 1. **Secrets** - replace `DC3_SECURITY_KEY`, `AUTH_HMAC_SECRET`, all database/broker passwords and the LLM API key with strong random values. The `pro` profile **refuses to start** with weak keys. Never commit real secrets into `secret.env` / values files. 2. **TLS** - terminate TLS at the edge (web nginx ships a hardened TLS config; k8s: ingress + cert-manager; swarm: a proxy in front of `web`). Enable RabbitMQ TLS (`RABBITMQ_SSL_ENABLED=true`, port 5671) and PostgreSQL TLS for cross-node traffic. 3. **Backups** - schedule `pg_dump`/pgBackRest with off-site copies and rehearse restore; TimescaleDB grows continuously, so plan capacity per the [FAQ](../community/faq) sizing (8 cores / 16 GB / 100 GB SSD minimum for the full stack). 4. **High availability** - PostgreSQL primary/standby (or a managed service) + a RabbitMQ cluster; stateful volumes on replicated storage for multi-node swarm/k8s. 5. **Observability** - layer on [Observability](./observability) (Prometheus + Grafana + ELK) and alert on readiness/liveness. 6. **Network** - restrict egress (centers only need the LLM endpoint), keep backend ports off the host, enable Pod Security Admission `baseline` on k8s. 7. **API surface** - Swagger/OpenAPI is disabled on the `pro` profile (release images build with `PROFILE=pro`); confirm no debug endpoints are reachable before go-live. ## FAQ - **Why is a scaled center not balancing every gRPC request?** Center-to-center gRPC uses `static://` fixed targets with one channel per client. Replicas provide failover and rollout safety; request-level balancing needs a client-side LB or k8s (per-connection round-robin via ClusterIP). HTTP is balanced at every tier (nginx -> Spring Cloud Gateway -> centers). - **Can I run a driver at 2 replicas?** Yes for outbound protocol drivers (they are queue workers); no for `listening-virtual`, which owns inbound device sockets - keep it at 1 replica. - **Can postgres/rabbitmq have replicas?** Not with these configs - they are stateful singletons. For HA run managed services and point the ConfigMap / environment at them. - **Do the k8s/helm configs need the dependency images?** Yes - build and push them with `scripts/push-images.sh` (or `kind load` on single-node clusters). Source of truth in the repo: `dc3/docker-compose-scale.yml`, `dc3/docker-compose-swarm.yml`, `dc3/deploy/`, `dc3/doc/DEPLOYMENT.md`. --- # Deployment & Operations URL: https://docs.dc3.site/en/guide/ <script setup> import GuideIndexDiagram from '../../.vitepress/theme/components/GuideIndexDiagram.vue' </script> # Deployment & Operations This section takes IoT DC3 from a `java -jar` on your laptop to a fleet of orchestrated containers — and then makes it observable and debuggable in production. It covers deployment topologies, image registries, the observability stack, logging conventions, and troubleshooting. By the end you'll know where each topic lives and where the line falls between local development and containerized deployment. > You are here: you've already [brought the stack up locally and run your first device](../quickstart/), and now you > want to deploy, observe, and operate it. ## Two routes — draw the boundary first Every topic here lands on one of two routes. They pull their environment variables from different places, and mixing them up is the single most common pitfall: - **Local development**: dependencies (PostgreSQL, RabbitMQ, optionally EMQX/ELK/Prometheus) run in containers, but the Java processes (the gateway, the four centers, and the drivers) run directly in your IDE or via `java -jar`. This route is owned by [Quick Start](../quickstart/) — it's fast to debug and picks up code changes immediately. - **Containerized deployment**: the gateway, the four centers, the drivers, and their dependencies are all orchestrated as containers. This route is owned by [Deployment Modes & Image Registries](./usage). ::: warning Environment variables don't cross over automatically The root-level `.env` **is for Docker Compose only** — it is not injected into Java processes running on your host. When you run Java locally, use `dc3/env/dev.env` (read by the IDE EnvFile plugin) or `source dc3/env/dev.env.sh` (shell export) to point the services at the ports Compose publishes on `localhost` (PostgreSQL `35432`, RabbitMQ `35672`). Pointing a local Java process at the in-container hostnames (`dc3-postgres`, `dc3-rabbitmq`) will always fail the connection. ::: ## How to read this section The five sub-pages each cover one stage of the operations lifecycle: bring the services up (deployment and image registries, production deployment), make them visible (observability, logging), and make them fixable when something breaks (troubleshooting). <GuideIndexDiagram lang="en" /> - **[Deployment Modes & Image Registries](./usage)** — container image selection, registry switching, and Compose orchestration. `make` picks the registry through `REGISTRY` (`auto`/`global`/`cn`): `global` uses the default registry, `cn` uses a mainland-China mirror. The fastest way to confirm the stack comes up is `make up-db`; on a China network use `make up-db-cn` instead. - **[Production Deployment](./deployment)** — the full path from single-host Compose to Docker Swarm / Kubernetes / Helm: choosing among the five modes, image availability, who can scale and who cannot, and the production hardening checklist. - **[Observability](./observability)** — how the app and its dependencies plug into Grafana, Prometheus, and ELK (the optional `optional` stack). Run `make up-optional` to bring up EMQX/ELK/Prometheus/Grafana; ports are listed under the "Observability Stack" section of the environment-variable reference (Grafana `3000`, Kibana `5601`). - **[Logging Conventions](./logging)** — `dc3-common-log` emits colored console logs for human debugging and rolling JSON file logs for machine parsing (timestamp/logger/thread/level/MDC/message/stack). Messages use stable English event names with SLF4J parameterized placeholders, so they're easy to search and correlate across modules. - **[Troubleshooting](./troubleshooting)** — finding and fixing the common issues: slow builds, JDK version mismatches, port conflicts, DB/MQ connection failures, Gateway 401/403, and drivers that fail to register. ## A few of the most common commands The container stack's lifecycle runs through `make`, following the pattern `make <op>-<stack>[-<registry>]`. Here are the commands you'll type most often (run them from the `iot-dc3/` directory): ::: code-group ```bash [Start the dependency stack] # Start PostgreSQL + RabbitMQ (minimal dependencies) make up-db # On a China network, use the mainland-China image registry make up-db-cn # Add the optional observability stack: EMQX / ELK / Prometheus / Grafana make up-optional ``` ```bash [View logs] # Follow a stack's logs (last 200 lines) make logs STACK=db # Show only the specified services make logs SERVICES="gateway agentic" ``` ```bash [Prerequisite for running from local source] # Point local Java processes at the ports Compose publishes to localhost source dc3/env/dev.env.sh ``` ::: ::: tip Startup order For a distributed bring-up, start in the order Auth → Manager → Data → Agentic → Gateway → Driver: Auth has no dependencies and starts first; the Gateway starts only after the four centers are healthy (`gateway`'s `depends_on` requires auth/manager/data/agentic to all be `service_healthy`); and drivers can register only after Manager Center and RabbitMQ are ready. See [Troubleshooting · Drivers fail to register](./troubleshooting) for details. ::: ## Verify the wiring: walk the golden path once After deployment, the fastest way to confirm the gateway and the authentication chain are connected is to log in: fetch the salt, then exchange the salted credential for a token. Every external request goes through one HTTP entry point, the gateway (default `8000`). ```bash # 1) Fetch the salt (public endpoint; use within 5 minutes; the tenant/username below are example values) curl -X POST http://localhost:8000/api/v3/auth/token/salt \ -H 'Content-Type: application/json' \ -d '{"tenant":"default","name":"dc3"}' # 2) Hash the credential with the salt, then exchange it for a token (valid for 12 hours) curl -X POST http://localhost:8000/api/v3/auth/token/generate \ -H 'Content-Type: application/json' \ -d '{"tenant":"default","name":"dc3","salt":"<salt returned by the previous step>","password":"<salted hash>"}' ``` Once you have the token, protected endpoints require three headers: `X-Auth-Tenant`, `X-Auth-Login`, and `X-Auth-Token`. A 401/403 here almost always means a missing or expired token — see [Troubleshooting · Gateway returns 401 or 403](./troubleshooting). ## Further reading - [Deployment Modes & Image Registries](./usage) — container images, registry switching, and Compose orchestration - [Observability](./observability) — integrating Grafana, Prometheus, and ELK - [Logging Conventions](./logging) — log message style, levels, and output format - [Troubleshooting](./troubleshooting) — locating and fixing startup and connection issues - [Quick Start](../quickstart/) — bring the stack up locally and run your first device (the starting point of the local-development route) --- # Logging Conventions URL: https://docs.dc3.site/en/guide/logging <script setup> import LoggingDiagram from '../../.vitepress/theme/components/LoggingDiagram.vue' </script> # Logging Conventions IoT DC3 logs are read by two audiences: you, during local development, and machines, during production troubleshooting. This page covers structured messages, MDC context, level conventions, the redaction hard line, and how container logs rotate — so your logs are searchable and never leak secrets. > You are here: writing business code or chasing a production issue, and want to know what to log and where to find it. > Read alongside [Observability](./observability) and [Troubleshooting](./troubleshooting). ## Why it is designed this way A log line earns its keep not when it is written, but three days later when someone greps for it. In a microservice deployment a single device command crosses the gateway, the data center, and a driver across several processes, with logs scattered across containers. If every message phrases things differently, omits key IDs, and cannot be correlated, troubleshooting turns into a needle-in-a-haystack search. That is why IoT DC3 splits logging into two layers of responsibility: - **Application code** emits **stable event names + structured parameters**. The same event uses the same phrasing and parameter order across every module, so a cross-process chain can be reassembled. - **The Appender in `dc3-common-log`** owns the final format — colored console output locally for humans, JSON file output for machines to parse and collect. Business code never hardcodes content for a particular output format. So when you change the collection stack (ELK, Loki, ...), you change the Appender, not hundreds of `log.info` calls. ## How a log line flows The diagram below traces the full path from code to a persisted or collected log: the application emits an event, it passes through MDC context slots, and is then formatted by two separate Appenders. <LoggingDiagram lang="en" /> Both Appenders attach to `root` (default level `INFO`) and are configured by the `logback.xml` in `dc3-common-log`. The JSON Appender uses `net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder`, emitting `timestamp`, `version`, `message`, `loggerName`, `threadName`, `logLevel`, `logLevelValue`, **`mdc`**, `contextName`, `stackTrace` field by field. The `mdc` entry is the reserved slot for the trace correlation described in the next section. ## MDC: the reserved trace-context slot MDC (Mapped Diagnostic Context) is SLF4J's thread-local context: key-value pairs placed in MDC are rendered into every subsequent log line on that thread. The `logback.xml` in `dc3-common-log` already mounts the `<mdc/>` provider in the JSON encoder, reserving an output slot for this — any field placed in MDC will appear per line in the JSON `mdc` entry. The intended use of MDC is to populate `traceId`, `tenantId`, and `userId` at request entry, which gives you two things: - **Cross-service correlation**: a single `traceId` threads through gateway → data center → driver. Search by `traceId` and the entire call chain comes back together. - **Tenant attribution**: every log line carries `tenantId`, so you can answer "which tenant triggered this" directly — consistent with the platform's [tenant isolation](../architecture/auth-rbac) boundary. ::: info MDC auto-injection is not yet wired The encoder's `<mdc/>` provider is in place, but the codebase currently has **no** filter, interceptor, or AOP aspect writing `traceId`/`tenantId`/`userId` into MDC (no `MDC.put` anywhere in the repo), so the `mdc` entry in today's JSON output is effectively empty. The trace correlation described above is a **planned, not-yet-implemented** capability. Until it is wired up, pass any field you need to correlate on explicitly as a message parameter per the next section ( e.g. `tenantId`, `deviceId`). ::: ## Writing structured messages Log bodies use English, stable event names, and SLF4J parameterized placeholders `{}`. Placeholders keep the message template constant (so `grep` and log aggregation can group by template), with variables passed as arguments: ```java log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}", toolName, tenantId, deviceId); log.warn("Agentic tool failed, tool={}, tenantId={}, deviceId={}", toolName, tenantId, deviceId, e); ``` Where applicable, order parameters as below so events of the same kind look identical across modules: ```text module/action, tenantId, userId, resource IDs, filters, status/result, durationMs ``` Examples: ```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 Do not concatenate strings, do not drop the stack trace Avoid `+` concatenation and `String.format` — they break the message template and are evaluated whether or not the level is enabled. After catching an exception, unless you mean to hide the stack trace, pass the exception object as the * *last argument** (not `e.getMessage()`); SLF4J renders the full stack trace automatically: ```java // ✅ Stable template + full stack trace log.warn("Point read command failed, tenantId={}, deviceId={}, pointId={}", tenantId, deviceId, pointId, e); // ❌ Concatenation + lost stack trace log.info("Device registered: " + deviceId); log.error("Failed to register device: {}", e.getMessage()); ``` ::: ### Declarative method logging: `@Logs` `dc3-common-log` provides the `@Logs` annotation (intercepted by the `LogsAspect` Spring AOP aspect) for declarative method-level logging without the boilerplate. The annotation members are `value` (the log message), `type` ( `LogsTypeEnum`, one of `INFO`/`WARN`/`DEBUG`/`ERROR`, default `INFO`), `tag` (a classification tag), and `save` (whether to persist, default `false`): ```java @Logs(value = "warn-resource", type = LogsTypeEnum.WARN, tag = "resource", save = true) public void someMethod() { // method body } ``` ::: info Currently used only by tests The `@Logs` aspect is implemented, but production code does not yet use it on any Controller or Service (only `LogsAspectTest` covers it). It exists as an optional declarative logging capability; for business logging the convention remains the SLF4J parameterized style described earlier on this page. ::: ## Log level conventions Levels are not chosen at random — they set default production output volume and alert noise. The `root` default is `INFO`, which means `trace`/`debug` are not persisted by default. Put information at the right level so that during troubleshooting you have what you need and silence the rest. The table below gives the conventions; learn the criteria for each tier first, then apply them: | Level | Criteria (when to use) | |---------|------------------------------------------------------------------------------------------------------------------------------------------------| | `trace` | High-frequency diagnostic detail, off by default, opened temporarily only when drilling into a single issue | | `debug` | Request details, tool invocations, query conditions, branch decisions useful during troubleshooting; not emitted by default | | `info` | Lifecycle events, startup summaries, long-task success summaries, significant state transitions — should be visible in steady-state production | | `warn` | Recoverable failures, illegal client input, retries, degradation, external-dependency anomalies that already have a fallback | | `error` | Unrecoverable errors, errors requiring operator attention, or errors that cause the current operation to fail | The key question: **did the current operation fail, and does it need human intervention?** Failure without a fallback is `error`; failure that is degraded or retried is `warn`; key checkpoints in the normal flow are `info`; the rest of the diagnostic detail goes to `debug`. Third-party framework noise is already throttled to `WARN` per package in `logback.xml` (e.g. `org.springframework.*`, `com.zaxxer.hikari`, MyBatis); do not turn them back up in business code. ## Redaction: keys and secrets are never written in the clear ::: danger Keys, tokens, and passwords are never logged in the clear Never log keys, Bearer tokens, passwords, the full `Authorization` header, raw private payloads, or arbitrary request bodies at any level. Once it is written to a log, it is in the file, in the collection system, in the backups — and you cannot take it back. When you need evidence, log only **derived information**: presence, length, a few leading characters + length, or a resource ID. On a validation failure, for example, log `tokenPrefix=eyJ0..., tokenLen=212`, not the entire token. ::: This hard line is most critical for two high-risk fields — leaking their plaintext is equivalent to compromising the entire authentication chain (see [the env directory](../quickstart/environment)): - `DC3_SECURITY_KEY` — the Token signing key for Auth Center. - `AUTH_HMAC_SECRET` — the HMAC-SHA256 secret the gateway uses to sign `X-Auth-Principal` for the backend. For command values that may be sensitive (such as the `value` written to a point), log only derived information — length, presence, or a resource ID — never the raw value: ```java // ✅ Keep only derived information log.info("Point write accepted, tenantId={}, deviceId={}, pointId={}, valueLen={}", tenantId, deviceId, pointId, value.length()); // ❌ Logging the raw command value or credentials log.info("Point write, value={}, token={}", value, token); ``` ## Container log rotation The in-app `logback.xml` already provides file rolling (`SizeAndTimeBasedRollingPolicy`: default 200MB per file, 20GB total cap, 30 historical files retained, archived daily as `.gz`). Under container deployment, though, the process's `stdout`/`stderr` are owned by the container runtime, so disk usage must be bounded separately at the Compose layer — otherwise a long-running container's logs can fill the host disk. The `dc3` Compose files use a shared `x-logging` anchor to attach a uniform Docker `json-file` driver rotation policy to every application service: ```yaml # dc3/docker-compose-dev.yml x-logging: &default-logging driver: json-file options: max-size: ${DC3_LOG_MAX_SIZE:-10M} # rotate a container log file once it reaches this size max-file: "${DC3_LOG_MAX_FILE:-20}" # number of rotated files to retain ``` The two knobs are tuned in the root `.env` (Compose-only, not injected into the local Java process): | Variable | Default | Purpose | |--------------------|---------|----------------------------------------------------| | `DC3_LOG_MAX_SIZE` | `10M` | Rotation threshold for a single container log file | | `DC3_LOG_MAX_FILE` | `20` | Number of rotated log files to retain | At the defaults, each container uses at most about `10M × 20 = 200M` of disk. To view and follow container logs: ::: code-group ```bash [podman] podman logs -f --tail 200 dc3-center-data ``` ```bash [make] # Run from iot-dc3/, follows the last 200 lines of the current stack make logs ``` ::: ::: info In-app rotation vs container rotation The two rotation mechanisms are independent. `logback.xml` governs the rolling files written to `LOG_FILE` inside the container (by default in a temp directory, and larger); `DC3_LOG_MAX_SIZE`/`DC3_LOG_MAX_FILE` governs the `stdout`/ `stderr` captured by the container runtime. Production collection usually sources from the latter (`json-file`), with the former serving as secondary in-container retention. ::: ## Further reading - [Observability](./observability) — how logs, metrics, and traces work together, and how to bring up the ELK/Grafana stack - [Troubleshooting](./troubleshooting) — once you have a `traceId`/`tenantId`, how to localize a failure - [The env directory](../quickstart/environment) — the source and boundary of variables like `DC3_LOG_*`, `DC3_SECURITY_KEY`, and `AUTH_HMAC_SECRET` --- # Observability URL: https://docs.dc3.site/en/guide/observability <script setup> import ObservabilityDiagram from '../../.vitepress/theme/components/ObservabilityDiagram.vue' </script> # Observability Log aggregation and metrics monitoring in IoT DC3 are an **optional** stack: a single command, `make up-optional`, starts EMQX, ELK (Elasticsearch + Logstash + Kibana), Prometheus, and Grafana. This page covers what each component does, the port it exposes, how to feed service logs into Kibana and service metrics into Grafana, and the environment variables that tune heap memory and toggle APM. > You are here: you can already [deploy and start the platform](./usage), and now want to add log search and metrics > dashboards to a running environment. ::: info The observability stack is optional This stack is not part of the default startup set. Once `make up-db` (PostgreSQL + RabbitMQ) and the application stack are up, the platform runs. The observability components need a separate `make up-optional` to start, add memory overhead, and are enabled on demand. ::: ## Why it's a separate stack Observability is split out of the core stack so that "getting the platform running" and "observing it" stay independent. Someone evaluating the golden path shouldn't pay hundreds of megabytes of heap for Elasticsearch they won't look at. When operators need to troubleshoot or watch trends, they layer this stack on top. It lives in `dc3/docker-compose-optional.yml`, alongside the three core stacks (db/dev/app), and shares the same `dc3net` network, so services can reach each other by container alias (for example `dc3-elasticsearch` and `dc3-prometheus`). The stack handles two jobs: - **Logs** — Logstash collects and normalizes the JSON file logs emitted by services, writes them into Elasticsearch, and Kibana lets you search and correlate them by field. - **Metrics** — Prometheus scrapes metrics from services and exporters on a schedule, and Grafana renders them on dashboards. EMQX is bundled in as well, acting as the MQTT broker for MQTT-style drivers and direct device connections. ## Components and ports `make up-optional` starts the containers below at once. The ports are the host-published ports, bound to `127.0.0.1` by default and controlled by `DC3_BIND_HOST`. The table is a quick reference; each component's role is covered after it. | Container | Role | Host port (default) | Control variable | |-------------------------|---------------------------------------------|------------------------------------------------------------|--------------------------------------------------| | `dc3-emqx` | MQTT broker + Dashboard | MQTT `31883`, Dashboard `18083`, WS `38083`, MQTTS `38883` | `DC3_EMQX_MQTT_PORT` / `DC3_EMQX_DASHBOARD_PORT` | | `dc3-elasticsearch` | Log storage and search engine | internal `9200` (not published) | `DC3_ES_JAVA_OPTS` | | `dc3-logstash` | Log collection and normalization pipeline | internal (not published) | `DC3_LS_JAVA_OPTS` | | `dc3-kibana` | Log search and visualization UI | `5601` | `DC3_KIBANA_PORT` | | `dc3-apm` | APM (application performance) data receiver | internal (not published) | `APM_AGENT_ENABLE` (application-side toggle) | | `dc3-prometheus` | Metrics scraping and time-series storage | internal `9090` (not published), 7d retention | — | | `dc3-postgres-exporter` | PostgreSQL metrics exporter | internal (not published) | — | | `dc3-nginx-exporter` | Frontend nginx metrics exporter | internal (not published) | — | | `dc3-grafana` | Metrics dashboard UI | `3000` | `DC3_GRAFANA_PORT` / `GF_SERVER_ROOT_URL` | ::: tip Only want a few of them? You don't have to start the whole stack. Filter with `SERVICES` — for example, to start only monitoring: ```bash make up STACK=optional SERVICES="prometheus grafana" ``` ::: EMQX's ports also show up in [Deployment modes and image registries](./usage): MQTT drivers connect to `31883`, and operators sign in to the Dashboard on `18083` to inspect connections. Kibana (`5601`) and Grafana (`3000`) are the two human-facing entry points — Kibana for searching logs, Grafana for viewing metrics. Elasticsearch, Logstash, APM, Prometheus, and the two exporters **publish no ports to the host** and talk only within `dc3net`. They're backend pipelines, not something you open directly. ## How logs are ingested (ELK) Services write logs as JSON files (see [Logging conventions](./logging) for the message style). The output directory is shared through a Docker volume named `logs`: the core stack writes into this volume, and Logstash mounts it at `/usr/share/logstash/dc3/logs` to read from it. Logstash parses and tags the logs, writes them to Elasticsearch, and you search them in Kibana. <ObservabilityDiagram lang="en" /> To run the chain: 1. Make sure the core stack (dev or app) is running and already writing into the `logs` volume. 2. Run `make up-optional` to start ELK. Logstash reads from the `logs` volume automatically. 3. Open `http://localhost:5601` in a browser to reach Kibana, and search by fields like service name, `tenantId`, or event name. ::: warning Elasticsearch is memory-hungry — tune the heap first The JVM heaps for Elasticsearch and Logstash default to small values so they start on a dev machine: `DC3_ES_JAVA_OPTS` defaults to `-Xms512m -Xmx512m`, and `DC3_LS_JAVA_OPTS` to `-Xms256m -Xmx256m`. In production or under heavy log volume, raise them to fit the machine, for example: ```bash DC3_ES_JAVA_OPTS="-Xms2g -Xmx2g" make up-optional ``` ::: ### APM is off by default The `dc3-apm` container starts with ELK, but **whether the application reports APM data depends on `APM_AGENT_ENABLE`, which defaults to `false`**. That variable lives in the core stack (`docker-compose.yml` / `docker-compose-dev.yml`) and controls whether services attach the Java APM Agent to report performance data to `dc3-apm`. To turn it on, set it explicitly when starting the core stack: ```bash APM_AGENT_ENABLE=true make up STACK=app ``` ::: info Starting the apm container ≠ enabling APM Running `make up-optional` alone does not collect APM data. `dc3-apm` is only the receiving end. Unless the core stack's `APM_AGENT_ENABLE` is `true`, the application never attaches the Agent, so there's nothing to report. ::: ## How metrics are ingested (Prometheus / Grafana) Each service exposes a Prometheus-format metrics endpoint through Micrometer. Prometheus scrapes those endpoints on a schedule, along with the two exporters — `postgres-exporter` (database metrics) and `nginx-exporter` (frontend nginx metrics). Prometheus keeps 7 days of time-series data locally (`--storage.tsdb.retention.time=7d`), and Grafana queries it as a data source to draw dashboards. To run it: 1. Run `make up-optional` to start Prometheus, the two exporters, and Grafana. 2. Open `http://localhost:3000` in a browser to reach the Grafana dashboards. 3. Grafana's external URL is set by `GF_SERVER_ROOT_URL` (defaults to `http://localhost:3000`). If you reach it through a reverse proxy or from another host, update this variable so the generated links don't point at `localhost`. ```bash # Fix the Grafana root URL for reverse-proxy/remote access (example value) GF_SERVER_ROOT_URL="https://ops.example.com/grafana" make up-optional ``` Prometheus publishes no port to the host and is normally queried through Grafana. To look at it directly, temporarily add a port mapping in the compose file, or use `podman exec` to drop into the container. ## Constraints and boundaries - **Not started by default**: the core path doesn't depend on this stack. If it goes down, device access, command dispatch, and data persistence keep working — you just lose log search and metrics dashboards. - **Memory floor**: Elasticsearch is the heaviest component here. On an 8 GB dev machine, run it at the `DC3_ES_JAVA_OPTS` default or lower, and prefer filtering with `SERVICES` to start only what you need. - **Data retention**: Prometheus keeps a fixed 7 days; longer retention means changing `--storage.tsdb.retention.time` or attaching long-term storage. Elasticsearch and Logstash data lives in their named volumes (`elasticsearch`, `logstash`, `logs`), which `make reset` deletes along with everything else — use with care. - **Dual APM toggle**: the `dc3-apm` container sits in the optional stack, and the `APM_AGENT_ENABLE` switch sits in the core stack. Both have to be on for APM data (see above). - **Port binding**: only `127.0.0.1` is bound by default. To reach Kibana/Grafana/EMQX Dashboard from another machine, set `DC3_BIND_HOST` to `0.0.0.0` (or a specific NIC IP), and weigh the exposure yourself. ## Further reading - [Deployment modes and image registries](./usage) — the full picture of the four compose stacks (db/dev/app/optional) and the `make` startup methods - [Logging conventions](./logging) — the message style and field conventions of service logs, which decide what you can search by in Kibana - [Troubleshooting](./troubleshooting) — the diagnostic path when things won't start, won't connect, or hit port conflicts --- # Troubleshooting URL: https://docs.dc3.site/en/guide/troubleshooting <script setup> import TroubleshootingDiagram from '../../.vitepress/theme/components/TroubleshootingDiagram.vue' </script> # Troubleshooting This page walks you through the most common startup and connection failures, and helps you tell them apart fast. Each section follows the same shape: **Symptom → Root cause → Diagnosis** — what you'll see, why it happens, and which log line or port to check. By the end you'll know whether you're stuck on a dependency, an environment variable, a port, or authentication. > If you landed here, you're probably following [Local development from source](../quickstart/) or bringing up the > container stack and hit a startup or connection error. Use the decision flow below to classify the problem, then jump > to > the matching section. Run all commands from the `iot-dc3/` directory unless noted otherwise. ## First, classify the problem: troubleshooting decision flow Most "won't start / won't connect" cases fall into five buckets: dependencies not ready, environment variables not loaded, port in use, services started out of order, and authentication chain issues. Working through the diagram below top to bottom beats guessing one by one. The platform exposes a single HTTP entry point through the gateway (`8000`); the center services talk to each other over gRPC facades; drivers and the data center are decoupled through RabbitMQ. So when the dependencies underneath them (PostgreSQL / RabbitMQ) fail to come up, everything above cascades into failure. <TroubleshootingDiagram lang="en" /> The order of this chain is deliberate. Unloaded variables point every connection at the wrong host. A port in use kills the process during the bind phase. And the startup order of dependency services decides whether the gRPC facades and driver registration can succeed at all. Clear the first four gates, and almost everything left traces back to a root cause in the log keywords. ## Dependencies not ready: can't connect to PostgreSQL / RabbitMQ **Symptom**: The startup log keeps printing `Connection refused` or `Connection to localhost:35432 refused`, or RabbitMQ reports `Channel shutdown` / `vhost not found`. The center services start, then exit. **Root cause**: The PostgreSQL or RabbitMQ container hasn't started yet, its health check hasn't passed, or — when running from local source — the connection parameters point at the wrong host/port. The key gotcha is that **the host address differs from the in-container address**: services inside containers reach each other via `dc3-postgres:5432` and `dc3-rabbitmq:5672`, while a local Java process on the host has to go through the published ports `localhost:35432` and `localhost:35672`. **Diagnosis and resolution**: First confirm the dependency stack is running and the published ports match the application variables. ::: code-group ```bash [make] make ps STACK=db # check whether the postgres / rabbitmq containers are healthy make config STACK=db # print the effective compose config; verify published ports make logs STACK=db # follow dependency container logs (last 200 lines) ``` ```bash [podman] podman ps # list running containers and their port mappings podman exec dc3-postgres psql -U dc3 -d dc3 -c "select 1" # connect directly to verify the database is available ``` ::: Once the containers report `healthy`, running from local source **requires loading the environment variables first**, so connections point at the dependencies Compose publishes to localhost: ```bash source dc3/env/dev.env.sh ``` For RabbitMQ specifically, make sure these variables match the actual container (defaults are in [Environment variables explained](../quickstart/environment)): `RABBITMQ_HOST`, `RABBITMQ_PORT` (`35672` locally, `5672` in-container), `RABBITMQ_USERNAME`, `RABBITMQ_PASSWORD`, `RABBITMQ_VIRTUAL_HOST` (default `dc3`). A vhost mismatch shows up right away as `Channel shutdown`, immediately after the connection is established. ::: tip Why wait for the health check first The center services open the database connection pool and RabbitMQ channels very early in startup. If the dependencies are still initializing (on first start, PostgreSQL runs 7 initdb scripts to create tables and seed data), a service that starts too early exits on connection failure. Wait until `make ps STACK=db` reports healthy before starting the upper layers — it saves you a needless round of restarts. ::: ## Environment variables not loaded: connections point at the wrong host **Symptom**: The dependency containers are clearly running, but local source still can't connect — or connects to an unexpected host/port. You edited the root `.env`, and it seems to have no effect on the local Java process. **Root cause**: The root `.env` **serves Docker Compose only**. It is not injected into the local Java process. When you run a jar locally from an IDE or the command line, you have to load `dc3/env/dev.env(.sh)` explicitly — it points connections at the dependency ports Compose publishes on localhost. The three files have different roles: | File | Used by | Purpose | |----------------------|----------------------|-------------------------------------------------------------------------| | Root `.env` | Docker Compose | Image registry, tags, published ports; **not injected into local Java** | | `dc3/env/dev.env` | IDE (EnvFile plugin) | Local Java runs, no `export` | | `dc3/env/dev.env.sh` | Shell | Local Java runs, with `export`, loaded via `source` | **Resolution**: Before starting from the command line or scripts, run `source dc3/env/dev.env.sh`. In the IDE, attach `dc3/env/dev.env` through the EnvFile plugin. For the full variable catalog and the host-to-in-container address mapping, see [Environment variables explained](../quickstart/environment). ## Port in use: the process exits during the bind phase **Symptom**: Startup fails, and the log contains `Address already in use` or `Web server failed to start. Port 8400 was already in use`. Ports such as `8000`, `8300`, `8400`, `8500`, `8600`, `9300`, `9400`, `9500` are already taken. **Root cause**: The same port is held by a leftover process from a previous run that didn't exit cleanly, or by another program. These ports map to the gateway HTTP (`8000`), the four center HTTP ports (Auth `8300` / Manager `8400` / Data `8500` / Agentic `8600`), and the three gRPC ports (Auth `9300` / Manager `9400` / Data `9500`). **Diagnosis (cross-platform)**: First find which process holds the port and get its PID, then decide whether to kill it or change the port. ::: code-group ```bash [macOS / Linux (lsof)] lsof -i :8400 -sTCP:LISTEN # list the process and PID listening on 8400 kill <PID> # confirm it's a leftover process before killing it ``` ```bash [Linux (ss)] ss -ltnp 'sport = :8400' # show the process (with PID) listening on 8400 ``` ```bash [Linux (netstat)] netstat -ltnp | grep ':8400' # on older systems, netstat finds the PID just as well ``` ```powershell [Windows (PowerShell)] Get-NetTCPConnection -LocalPort 8400 -State Listen | Select-Object OwningProcess Stop-Process -Id <PID> # verify before killing the holding process ``` ::: **Resolution**: If the port is held by a program you'd rather not kill, override the port through an environment variable or the root `.env` so the DC3 service avoids the conflict. Common override variables: - `DC3_GATEWAY_PORT`, `DC3_AUTH_PORT`, `DC3_MANAGER_PORT`, `DC3_DATA_PORT`, `DC3_AGENTIC_PORT` (Compose published ports) - `SERVER_PORT`, `GRPC_SERVER_PORT` (override that process's HTTP / gRPC port when running a single service locally) ::: warning When running multiple services locally at once `SERVER_PORT` / `GRPC_SERVER_PORT` are **per-process** overrides. Set them only when running a single service locally and you need to avoid the default ports. When running several services in parallel, give each a different value, or they'll fight over the same port. ::: ## Started out of order: dependency services not yet ready **Symptom**: Driver registration fails, gRPC calls report `UNAVAILABLE`, or a center service starts and then errors out because it can't reach a downstream dependency. **Root cause**: The center services collaborate over gRPC facades, and a driver registers with the management center on startup and depends on RabbitMQ. Start a downstream service before its upstream is ready, and the connection fails. The correct startup order is **Gateway → Auth → Manager → Data → Agentic → Driver**. **Diagnosis and resolution**: 1. Start in the order Gateway → Auth → Manager → Data → Agentic → Driver, and wait for each to become ready before starting the next. 2. When running from local source, confirm you've run `source dc3/env/dev.env.sh`. 3. Check the management center and driver logs to confirm the gRPC target addresses (`CENTER_MANAGER_HOST` etc., default `localhost`) are reachable. 4. Confirm `dc3.driver.code` is unique and stable — a duplicate code gets registration rejected. ::: danger Don't change the driver code casually `dc3.driver.code` is the driver's stable routing identifier; the data center uses it to route commands back to the right driver instance. Once it's live, leave it alone — changing it stops commands from reaching devices already bound to that driver. ::: ## Authentication failures: 401 / 403 and HMAC **Symptom**: Calling a protected endpoint through the gateway returns `401` (unauthenticated) or `403` (unauthorized). **Root cause**: The request doesn't carry a valid token, or the tenant/login/token trio is incomplete. Platform login is a two-step flow: fetch the salt first, then hash the password with the salt to exchange it for a token. Every subsequent protected request must carry the three headers `X-Auth-Tenant`, `X-Auth-Login`, and `X-Auth-Token`. **Diagnosis and resolution**: Log in to get a token first, then call the endpoint with the headers. The happy path uses the real endpoints below (swap the example values for your environment's actual values): ```bash # 1) Fetch the salt (public endpoint; use within 5 minutes) curl -X POST http://localhost:8000/api/v3/auth/token/salt \ -H 'Content-Type: application/json' \ -d '{"tenant":"default","name":"dc3"}' # 2) Hash the password with the salt, then exchange for a token (valid for 12 hours) curl -X POST http://localhost:8000/api/v3/auth/token/generate \ -H 'Content-Type: application/json' \ -d '{"tenant":"default","name":"dc3","salt":"<salt returned in the previous step>","password":"<password hashed with the salt>"}' # 3) Call the protected endpoint with the trio of headers 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 returned in the previous step>' \ -H 'Content-Type: application/json' \ -d '{"current":1,"size":10}' ``` If the 401s cluster on the gateway-to-backend hop rather than being a user token problem, they're almost certainly about the HMAC signature. The gateway signs the injected `X-Auth-Principal` with HMAC-SHA256 using `AUTH_HMAC_SECRET`, and the backend trusts the principal only after the signature verifies. For how to authenticate inside Swagger UI, see the [API documentation](../development/api-documentation). ::: danger HMAC fails fast in pre/production environments When the Spring profile (or `spring.env`) is `pre` or `pro`, an empty `AUTH_HMAC_SECRET` — or one still set to the default weak key `io.github.pnoker.dc3` — makes the service throw `IllegalStateException` at startup and refuse to boot. This is a deliberate security gate. Before going to `pre`/`pro`, you **must** replace `AUTH_HMAC_SECRET` and `DC3_SECURITY_KEY` with strong random values, and neither may be logged or hardcoded. ::: ## pre/pro profile won't start locally **Symptom**: Starting locally with the `pre` / `pro` profile produces connection errors (`UnknownHostException` / `Connection refused`), or the service fails fast and exits outright. **Root cause**: `pre` / `pro` target container-stack deployment, so their connection parameters default to container hostnames rather than localhost. The data source `POSTGRES_HOST` defaults to `dc3-postgres`, `RABBITMQ_HOST` defaults to `dc3-rabbitmq`, and the gRPC channels use in-container addresses like `static://${CENTER_AUTH_HOST:dc3-center-auth}:9300`. Locally those hostnames don't resolve, so connections fail. On top of that comes the HMAC security gate: under `pre` / `pro`, an empty `AUTH_HMAC_SECRET` or the default weak key makes the service fail fast and refuse to start (see the previous section). **Resolution**: For local source debugging, always use the `dev` profile — it points connections at the dependency ports Compose publishes to localhost. Reach for `pre` / `pro` only when you're actually validating the containerized deployment shape, and make sure the container hostnames resolve and the HMAC / security keys are configured to production requirements. ## Build and image issues These aren't runtime connection problems — they belong to the build and packaging phase, grouped here separately. **Maven build is very slow** — usually parallelism or heap memory isn't taking effect. The repo already ships sensible defaults: `.mvn/maven.config` contains `-T 1C`, and `.mvn/jvm.config` contains `-Xms512m -Xmx1024m`. If it's still slow, raise the heap or free up CPU on your machine. **Wrong Java version** — you see `unsupported class file major version` or Maven Enforcer errors. The project requires JDK 21. Run the two commands below to confirm **the Java Maven actually uses** is also 21 — the two can differ: ```bash java -version mvn -version ``` **Docker image build fails** — usually the Maven packaging inside the image failed, or dependencies weren't built ahead of time. Confirm Maven passes on the host first, then build the image: ```bash make package make build STACK=db ``` **Image source isn't what you expected** — that's the image registry selection. Switch with `REGISTRY`: `global` uses the default registry (Docker Hub `pnoker`), `cn` uses the mainland China mirror (Aliyun). ```bash make up STACK=db REGISTRY=global # default registry make up STACK=db REGISTRY=cn # mainland China mirror ``` ## Want to debug faster Run Auth, Manager, and Data in a single JVM by starting `dc3-center-single`, and skip the startup coordination between multiple services: ```bash source dc3/env/dev.env.sh java -jar dc3-center/dc3-center-single/target/dc3-center-single.jar ``` ::: info Single process is for local debugging only Single-JVM mode is handy for quick local validation, but **it is not the production deployment shape** — production is still the distributed topology of gateway + four centers + drivers. ::: ## Further reading - [Local development from source](../quickstart/) — the full steps to bring up dependencies locally, load environment variables, and get your first driver working end to end - [Environment variables explained](../quickstart/environment) — the host-to-in-container address mapping and the full catalog of port and connection variables - [Services and topology](../architecture/services) — how the gateway, four centers, and drivers fit together, and the dependency relationships behind the startup order - [API documentation](../development/api-documentation) — how to use Swagger UI and the authentication headers --- # Deployment Modes and Image Registries URL: https://docs.dc3.site/en/guide/usage <script setup> import UsageStackDiagram from '../../.vitepress/theme/components/UsageStackDiagram.vue' </script> # Deployment Modes and Image Registries IoT DC3 runs as four Compose stacks: `db` brings up dependencies, `dev` builds from source, `app` pulls prebuilt images, and `optional` adds observability. This page covers what each stack does, the `make` lifecycle, which registry images come from, and which ports are exposed. By the end you'll know which stack to pick, which registry to use, and which ports to lock down in production. > If you're ready to run the platform, you're in the right place. To set up environment variables first, > see [Environment Variables](../quickstart/environment). To develop locally from source, > see [Quick Start](../quickstart/). ## Four stacks, each with its own role There's no single Compose file — the platform is split by responsibility into four stacks, one per Compose file under `dc3/`. They layer on top of each other: dependencies come up first, then the application, then observability as needed. - **`db`** (`docker-compose-db.yml`): the infrastructure layer, just two containers — `dc3-postgres` (PostgreSQL with AGE/TimescaleDB/pgvector, holding metadata, time-series values, alarms, and Agentic sessions) and `dc3-rabbitmq` (the message bus for data and command streams). Every other stack waits for this one. - **`dev`** (`docker-compose-dev.yml`): the source-build stack. It builds `dc3-gateway` and the four centers ( auth/manager/data/agentic) from local `Dockerfile`s. Use it when you're changing backend code and need local debugging. The frontend isn't part of this stack — start it separately with `pnpm dev`. - **`app`** (`docker-compose.yml`): the prebuilt-image stack. It pulls remote images and runs them as-is, including `dc3-web`, the gateway, the four centers, and a set of driver containers. Use it for evaluation, demos, and production — no compilation, fast startup. - **`optional`** (`docker-compose-optional.yml`): observability and optional dependencies — EMQX, Elasticsearch/Logstash/Kibana, Prometheus, Grafana, APM, and several exporters. Add it when you need it; it doesn't sit on the critical path. See [Observability](./observability). ::: info Choosing between dev and app Use `dev` when you need to change Java code (built on the spot, rebuild after each change). Use `app` when you just want to run the platform for evaluation or production (pull images, fastest path). Both share the same `db`/`optional` dependency stacks. ::: ## Deployment topology: what faces outward, what stays internal The diagram shows how the four stacks layer and where the ingress boundary sits. **In the production form (the `app` stack), only `dc3-web` and `dc3-driver-listening-virtual` are published to the host.** The gateway and the four centers live on the internal `dc3net` network, reached through the frontend reverse proxy or internal calls — never exposed directly. <UsageStackDiagram lang="en" /> ::: danger Only web and listening-virtual are exposed In the `app` (production) stack, only `dc3-web` (8080/8443) and `dc3-driver-listening-virtual` (TCP 6270 / UDP 6271) are published to host ports. The gateway's 8000, the four centers' HTTP/gRPC ports, the database, and the message queue * *all stay on the internal network** — do not expose them to the public internet. For debugging convenience, the `dev` stack additionally publishes the gateway's 8000, each center's HTTP port ( 8300/8400/8500/8600), and the gRPC ports for auth/manager/data (9300/9400/9500; agentic has no gRPC server). That's a development convenience — **don't carry it into production**. All published ports bind to `DC3_BIND_HOST=127.0.0.1` ( local only) by default; switch to `0.0.0.0` only when you need cross-host access, and narrow the port list first. ::: ## The make lifecycle: one set of commands for every stack All stacks share the same `make` targets. Variables pick the stack, the services, and the image registry. Run every command from the `iot-dc3/` directory. Core lifecycle targets: - `make build` — build images (the `dev` stack compiles on the spot; the `app` stack usually needs no build) - `make up` — start (`-d`, detached) - `make down` — stop and remove containers (keeps data volumes) - `make config` — render and validate the Compose config without starting anything - `make logs` — follow logs (`-f --tail=200`) - `make reset` — ⚠️ down + **delete data volumes**, requires explicit confirmation (see the danger note below) Variables for picking the stack and services: | Variable | Default | Purpose | |------------|------------------|---------------------------------------------------------------------------------------------------------------| | `STACK` | `dev` | Pick the stack: `db` / `dev` / `app` / `optional` | | `SERVICES` | (empty = all) | Operate only on the listed services, space-separated, e.g. `SERVICES="gateway agentic"` | | `GROUP` | (empty) | Predefined service group: `center` (the four centers) / `core` (centers + gateway) / `drivers` (driver group) | | `COMPOSE` | `podman compose` | Container runtime (this repo standardizes on podman) | `GROUP` is shorthand for `SERVICES` — `center` expands to `auth manager data agentic`, `core` adds `gateway`, and `drivers` expands to the built-in driver set. You can combine `SERVICES` and `GROUP`. ::: code-group ```bash [Start the full environment] # dependencies → observability → source-build stack make up STACK=db make up STACK=optional make up STACK=dev ``` ```bash [Start only some services] make up STACK=db make up SERVICES="gateway agentic" # start only gateway + agentic center make up GROUP=core # start the four centers + gateway make logs SERVICES="gateway agentic" # tail logs for just these two ``` ```bash [Validate and shut down] make config STACK=app # render the config only, don't start make down STACK=dev # stop the dev stack, keep data ``` ::: ::: danger reset deletes data volumes `make reset` runs down and **deletes data volumes** — every metadata record, time-series value, and alarm in PostgreSQL is lost. It has a hard gate: it runs only when `CONFIRM_RESET_VOLUMES=true` is set, otherwise it refuses. ```bash make reset STACK=db CONFIRM_RESET_VOLUMES=true ``` Be careful in production. Once the volume is gone, the next database startup re-runs the initdb seed scripts (see the final section). ::: ## Image registries: REGISTRY picks the repository, DC3_IMAGE_TAG picks the version `REGISTRY` decides which repository images come from. At the `make` layer it resolves to `DC3_IMAGE_REGISTRY` — the namespace Compose actually reads: | `REGISTRY` | Resolved `DC3_IMAGE_REGISTRY` | Use for | |------------------|-------------------------------------------------------------------------------------------|------------------------------------------------| | `auto` (default) | Reads `DC3_IMAGE_REGISTRY` from the environment/`.env`, falling back to `pnoker` if unset | Custom private repository, or following `.env` | | `global` | `pnoker` (Docker Hub) | Overseas / general networks | | `cn` | `registry.cn-beijing.aliyuncs.com/dc3` (Aliyun) | Mainland China, faster pulls | Any other value fails with `Unsupported REGISTRY`. The image version is controlled by `DC3_IMAGE_TAG` (default `2026.6`) — all services and dependency images share the same tag. In production, pin a specific version rather than `latest`. ::: code-group ```bash [Docker Hub (global)] make up STACK=db REGISTRY=global make up STACK=app REGISTRY=global ``` ```bash [Aliyun (cn, faster in China)] make up STACK=db REGISTRY=cn make up STACK=app REGISTRY=cn ``` ::: For example, under `cn` the gateway image resolves to `registry.cn-beijing.aliyuncs.com/dc3/dc3-gateway:2026.6`; under `global` it's `pnoker/dc3-gateway:2026.6`. The collapsed command reference in the final section lists the full image set. ::: warning Makefile uses REGISTRY, Compose uses DC3_IMAGE_REGISTRY Don't confuse the two. `REGISTRY=auto|global|cn` is the `make` selector; it injects the matching `DC3_IMAGE_REGISTRY` namespace into Compose. When you run `podman compose` directly, set `DC3_IMAGE_REGISTRY` yourself. ::: ## After the stack is up: seed data and external verification Once the `app`/`dev` stack is running, the platform is ready. The simplest way to check the external ingress is through the frontend `dc3-web` (default `http://127.0.0.1:8080`). To hit the API from the command line you go through the gateway — but the gateway isn't exposed in the `app` stack, so this is usually done under the `dev` stack (which publishes 8000). Login is two steps: first `POST /api/v3/auth/token/salt` to get the salt, then `POST /api/v3/auth/token/generate` to trade it for a token valid for 12 hours. After that, requests carry the three auth headers `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token`. ```bash # Only available under the dev stack where the gateway is exposed; all values are examples, replace with your own tenant/account # 1) Get the salt (public) 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"}' # → returns a salt string (example; use within 5 minutes) # 2) Hash the password with the salt to exchange for a token (public), returning an access token valid for 12 hours 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":"<from previous step>","password":"<hashed password>"}' ``` When the database starts for the first time on an **empty data volume**, the `dc3-postgres` entrypoint runs the **7 seed scripts** under `initdb` in filename order, building the schema and base data in one pass: | Order | Script | Contents | |-------|-----------------------------|-------------------------------------------------------| | 00 | `00-iot-dc3-extensions.sql` | Enable extensions | | 01 | `01-iot-dc3-common.sql` | Common tables | | 02 | `02-iot-dc3-auth.sql` | Menus, resources, users, roles, OAuth/MCP | | 03 | `03-iot-dc3-data.sql` | Runtime data: alarms, notifications, rules | | 04 | `04-iot-dc3-manager.sql` | Entity management: devices, drivers, points, profiles | | 05 | `05-iot-dc3-history.sql` | Time-series hypertables | | 06 | `06-iot-dc3-agentic.sql` | Sessions, messages, attachments | ::: warning Seed scripts run once, on an empty database These scripts run exactly once — when the data volume is empty. They won't re-run once the volume has data, and editing the SQL won't take effect on its own. To re-seed, first clear the volume with `make reset ... CONFIRM_RESET_VOLUMES=true` (which loses data). ::: ::: danger Production secrets must be random The `DC3_SECURITY_KEY` and `AUTH_HMAC_SECRET` in `.env.example`, along with `POSTGRES_PASSWORD` / `RABBITMQ_PASSWORD` ( default `dc3dc3dc3`), are **publicly known weak defaults** — local use only. Replace them with strong random values before any production deployment. `AUTH_HMAC_SECRET` has fail-fast protection: when the Spring profile is `pre` or `pro` and the secret is empty or still equals the default `io.github.pnoker.dc3`, the service throws `IllegalStateException` at startup and refuses to come up. See [Environment Variables](../quickstart/environment) for what each secret does. ::: ## Full command and image reference The collapsible section below is the full original text of `dc3/doc/USAGE.md` — every `make` shortcut and the image coordinates for each service across the Docker Hub and Aliyun repositories. Keep it handy while operating. ::: details Expand the full command and image list <!--@include: ../../dc3/doc/USAGE.md--> ::: ## Further reading - [Environment Variables](../quickstart/environment) — the default value, scope, and production value of every `DC3_*` / runtime variable - [Observability](./observability) — wiring up EMQX/ELK/Prometheus/Grafana in the `optional` stack, and what to watch - [Local Development from Source](../quickstart/) — the local workflow using the `dev` stack + IDE for the backend and `pnpm dev` for the frontend --- # IoT DC3 · Open-Source Industrial IoT Platform URL: https://docs.dc3.site/en/ ## What is IoT DC3 IoT DC3 is a multi-protocol, cloud-native, AI-powered, open-source industrial IoT platform evolving toward AI agents (AGPL-3.0). It covers **device connectivity, data collection, operations management, and intelligent analytics** for industrial IoT. **28 driver modules** pull data up from heterogeneous devices and normalize it into semantically labeled point values; **Spring AI** then plugs large language models into operations — a model can query devices, read and write points, run commands, analyze alarms, and surface insights, closing the sense–decide–act–feedback loop. It's a good fit for teams that need to connect many industrial protocols, manage devices and points, query real-time and historical data, and build on the platform within the Spring ecosystem — including bringing AI into operations. To understand the problem it solves and how it compares to alternatives, start with [the pitch](/en/introduction/). ## Architecture at a glance The platform is one gateway, four center services, and a set of protocol drivers. Only the gateway's HTTP port faces outward; the centers talk to each other over gRPC, and drivers and the data center are decoupled through RabbitMQ. <TopologyDiagram lang="en" /> For how each hop works and why it's designed that way, see [Architecture](/en/architecture/). ## Tech stack - **Language & frameworks **: [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) - **Data, cache & scheduling**: PostgreSQL (+ TimescaleDB / AGE / pgvector) · Caffeine · MyBatis-Plus · Quartz - **Messaging**: RabbitMQ · gRPC · MQTT (Paho + EMQX) · Protobuf - **Security**: Spring Security · JWT · BouncyCastle - **Frontend**: Vue 3 · TypeScript 6 · Vite 8 · Element Plus · AntV G2/G6 (source in `dc3-web/` directory of this repo; the standalone `iot-dc3-web` repo is archived) See [Technology Stack](./development/technology-stack) for the full breakdown. ## License IoT DC3 is open source under the [AGPL-3.0 License](https://github.com/pnoker/iot-dc3/blob/release/LICENSE-AGPL.txt). For licensing details and commercial licensing, see [LICENSE.txt](https://github.com/pnoker/iot-dc3/blob/release/LICENSE.txt). --- # Attribute & Config URL: https://docs.dc3.site/en/introduction/concepts/attribute-config <script setup> import AttributeConfigRelationDiagram from '../../../.vitepress/theme/components/AttributeConfigRelationDiagram.vue' import AttributeConfigFlowDiagram from '../../../.vitepress/theme/components/AttributeConfigFlowDiagram.vue' </script> # Attribute & Config > **An Attribute is a [Driver](./driver)'s declaration of "which config items must be filled in to connect a device", while a Config is the concrete value a [Device](./device) supplies for those items.** One answers "which blanks exist to > fill in", the other answers "what this device filled into those blanks". Whether a device can be collected often hinges not on "what the temperature point is called", but on very concrete protocol details: which register a Modbus read targets, which URL an HTTP request hits, which Topic an MQTT subscription uses. These details vary by protocol and by device, so hard-coding them is a non-starter. IoT DC3 splits this into two layers: **the Driver declares which config items are needed (Attribute)**, and **the device instance fills in the values for those items (Config)**. The key to understanding it is to first separate the **three distinct things** at play here: | Layer | Belongs to | Question it answers | Who fills it | |--------------------------------------|----------------------|-----------------------------------------------|---------------------------------------------| | **Param (business parameter)** | [Profile](./profile) | which business fields a command/event carries | the modeler, defined in the template | | **Attribute (attribute definition)** | [Driver](./driver) | which config items this protocol needs | the driver developer, registered at startup | | **Config (config value)** | [Device](./device) | what this device fills into those items | the integrator, on the device edit page | `Param` is "business semantics" (temperature, mode, fault code), belongs to the [Profile](./profile), and is protocol-agnostic; `Attribute` / `Config` are "protocol mapping" (register address, Topic, payload template), and belong to the driver and device. This page covers only the latter two layers — for Param, see [Command](./command) and [Event](./event). ## A classic example: Attribute vs Config in one line > The Modbus driver declares "reading a point needs a register address" — this is an **Attribute** (the driver > registers "such a config item exists"). > Filling in "address = 40001" for device #3's temperature point — this is a **Config** (the concrete value of that > config item for this device). For the same `PointAttribute(registerAddress)`, device #1 might fill in 40001 and device #3 fill in 40003; switch to an MQTT driver and what gets declared is no longer a register address but a `topic`. An Attribute is the mold; a Config is the cast part poured from it. ## Where Attribute definitions come from ::: tip Attributes are not hand-built in the database — they are registered by the driver at startup A driver declares the attributes it supports in its own `application.yml` and **reports them to the Manager at startup **. The Manager persists them keyed uniquely by `tenant_id + driver_id + attribute_code`. Different protocols need different attributes, so the authoritative source of an attribute is the driver, not a manually edited page. ::: ```yaml dc3: driver: driver-attribute: # connection-level items: what connecting to this device gateway needs - attribute-name: Host attribute-code: host attribute-type-flag: STRING default-value: localhost point-attribute: # point-level items: what collecting each point needs - attribute-name: Register Address attribute-code: registerAddress attribute-type-flag: INT default-value: '' ``` `DriverAttribute` and `PointAttribute` differ only in **scope**: the former is filled once per device (connection info), the latter once per [Point](./point) (collection mapping). ## Key fields Attribute definition `DriverAttributeBO` / `PointAttributeBO` (the two have identical fields, sharing the same structure): | Field | Type | Meaning | |---------------------|----------------------------------------|-----------------------------------------------------------------------------| | `attributeName` | String | Attribute name (for display) | | `attributeCode` | String | Attribute code, matched against by configs (e.g. `host`, `registerAddress`) | | `attributeTypeFlag` | AttributeTypeEnum | Value type, see below | | `defaultValue` | String | Default value, used when a device leaves it blank | | `driverId` | Long | The owning [Driver](./driver) | | `attributeExt` | DriverAttributeExt / PointAttributeExt | Extension info (e.g. UI component, validation rules) | | `enableFlag` | EnableFlagEnum | Enable/disable status | | `tenantId` | Long | The owning [Tenant](./tenant) | Config value `DriverAttributeConfigBO` / `PointAttributeConfigBO`: | Field | Type | Meaning | |---------------|----------------|----------------------------------------------------------------------| | `attributeId` | Long | Which attribute definition it points to | | `configValue` | String | The actual value filled in (e.g. `40001`) | | `deviceId` | Long | The owning [Device](./device) | | `pointId` | Long | **`PointAttributeConfig` only**, which [Point](./point) it points to | | `configExt` | JsonExt | Config extension info | | `enableFlag` | EnableFlagEnum | Enable/disable status | | `tenantId` | Long | The owning [Tenant](./tenant) | ::: warning DriverConfig is per device, PointConfig is per point `DriverAttributeConfig` has only `deviceId`, because connection info is one set per device; `PointAttributeConfig` adds a `pointId`, because every point needs its own collection mapping. This is the direct consequence of the two layers having different scopes. ::: ## Value type AttributeTypeEnum `attributeTypeFlag` shares the same type system as [Point](./point): | Value | `STRING` | `BYTE` | `SHORT` | `INT` | `LONG` | `FLOAT` | `DOUBLE` | `BOOLEAN` | |-------|----------|--------|---------|-------|--------|---------|----------|-----------| ## Relationship to other concepts <AttributeConfigRelationDiagram lang="en" /> Attributes hang under a driver and are referenced by device configs; a point config is additionally bound to a specific [Point](./point). At modeling time you define a [Profile](./profile) (with points, commands, events); at onboarding time the driver declares attributes and the device fills configs — the two lines converge at the device. ## Registration and configuration flow <AttributeConfigFlowDiagram lang="en" /> 1. The driver reads `driver-attribute` / `point-attribute` from `application.yml` and reports them at startup. 2. The Manager inserts or updates the attribute definitions by unique key. 3. The device edit page loads the attribute columns for the current `driverId`, and the integrator fills in each value. 4. Config values land in the Config tables; at runtime the driver pulls them and assembles the actual protocol payload. ## What Attribute / Config can and cannot solve `Attribute + Config` solves **protocol mapping**: translating "this device's point/connection" into the concrete address, Topic, and template the driver can execute. It **cannot** on its own express "what the point itself is" or " which business parameters a command carries" — those are the responsibility of the [Profile](./profile) and Param. | Can solve | Cannot solve alone (needs other models) | |-----------------------------------------------------------------------|---------------------------------------------------------------------------------| | Which Host / port to connect this device gateway | Which points, commands, events this device class has → [Profile](./profile) | | Which register / path this point reads | Which business inputs/outputs a command carries → Param of [Command](./command) | | Different mapping values for the same Profile under different drivers | Which business fields an event reports → Param of [Event](./event) | ::: tip Locate it in one line Missing collection? Usually a **Config not filled or filled wrong** (wrong address/Topic). Missing capability? That's an **Attribute not declared** (the driver never registered that config item) — the latter means editing the driver's `application.yml` and restarting, the former is just a value change on the page, no restart needed. ::: ## Example Temperature sensor #3, bound to a Modbus driver: - Attributes registered by the driver: `DriverAttribute(host)`, `PointAttribute(registerAddress)`. - Configs filled by the device: `DriverAttributeConfig{ deviceId: 3, configValue: "192.168.1.10" }` (connect to this gateway); the temperature point fills `PointAttributeConfig{ deviceId: 3, pointId: temperature point, configValue: "40001" }`. At runtime the driver connects to `192.168.1.10`, reads register `40001`, and wraps the reading into that point's [PointValue](./point-value) for reporting. For device #1, just change `configValue` to a different address — the attribute definition is fully reused. ## Further reading - [Driver](./driver) — the declarer and registration source of attributes - [Point](./point) — the object that `PointAttributeConfig` binds to - [Command](./command) — the division of labor between Param (business parameters) and Attribute (protocol mapping) - [Event](./event) — the event side likewise separates Param from Attribute - [Device Onboarding](../../operation/device-onboarding) — the full flow of filling these configs on the page - [Core Concepts Overview](../concepts) — back to the concept map --- # Command URL: https://docs.dc3.site/en/introduction/concepts/command <script setup> import CommandRelationDiagram from '../../../.vitepress/theme/components/CommandRelationDiagram.vue' import CommandFlowDiagram from '../../../.vitepress/theme/components/CommandFlowDiagram.vue' </script> # Command > **A Command is one action request issued to a device**—restart, calibrate, switch mode, set temperature… Its > definition belongs to a [Profile](./profile), its invocation belongs to a [Device](./device), it carries a set of > input/output parameters, and it is executed by a [Driver](./driver) which returns a result receipt. A Command answers "make the device do something". It is the dual of an [Event](./event): events flow up (the device says "this happened"), commands flow down (the platform says "go do this"). ## What it is and why it exists Beyond "reporting values" and "having a single quantity read/written", industrial devices also need to be triggered to perform **action-type capabilities**: restart, firmware upgrade, mode switch, push a block of configuration from a template. Such actions often take parameters, need a receipt, and require timeout handling and auditing—modeling them as a structured sub-resource under a `Profile` is the **Command**. The definition lives in the thing model (what actions this kind of device can perform, what parameters each action takes); the invocation lands on a concrete device instance (a given device executed it once at a given moment). ### The key distinction: do not confuse the two downlinks DC3 has two independent downlink paths, and this is what beginners most often confuse: | Dimension | Write Point (PointCommand) | Custom Command (Command / CommandCall) | |-------------------|-----------------------------------------------------------|-------------------------------------------| | What changes | A **single quantity** (the value of one [Point](./point)) | Triggers **one parameterized action** | | Definition source | The [Point](./point)'s `rwFlag` includes WRITE | The standalone `dc3_command` table | | Driver interface | `DriverCustomService.write()` | `DriverCommand.execute()` | | Parameters | Single value (one target value) | Structured input/output parameter Map | | DTO | `PointCommandDTO` | `CommandCallDTO` / `CommandCallResultDTO` | | Queue prefix | `dc3.e.point_command` | `dc3.e.command` | The boundary in one line (see design doc point-command.md §1.2): **writing a point is runtime access on the attribute dimension; a custom command is an action capability at the thing-model layer.** ::: tip One example to nail the boundary Telling an air conditioner to "set the target temperature to 26 °C"—this is **writing a point**: `targetTemp` is a writable Point, you just write `26`; in essence you change a quantity. Telling an air conditioner to "run a self-clean cycle"—this is the **custom command** `selfClean`: it does not map to any quantity but to an action, possibly with parameters (e.g. `duration=30`), and returns a `resultCode` when done. Rule of thumb: if it boils down to "change the value of some Point", it's writing a point; if it "triggers an action flow", it's a command. ::: This page covers the **custom command**. For writing a point, see [Point](./point). ## Key fields Command definition `CommandBO` (belongs to a [Profile](./profile), table `dc3_command`): | Field | Type | Meaning | |-------------------|-----------------|------------------------------------------------------------------------------------------------| | `commandName` | String | Command name (for display) | | `commandCode` | String | Command identifier, unique within a `profileId`, matched on invocation (e.g. `setTemperature`) | | `commandTypeFlag` | CommandTypeEnum | Command type, see below | | `callTypeFlag` | CallTypeEnum | Call mode: `sync` / `async` | | `timeout` | Integer | Call timeout (seconds) | | `commandExt` | CommandExt | Extension config (protocol mapping, driver command template, idempotency, etc.) | | `profileId` | Long | Owning [Profile](./profile) | | `enableFlag` | EnableFlagEnum | Enabled / disabled state | | `tenantId` | Long | Owning [Tenant](./tenant) | Command parameter `CommandParamBO` (declares a command's input/output parameters, belongs to a command definition): | Field | Type | Meaning | |---------------------------|------------------------|-------------------------------| | `paramName` / `paramCode` | String | Parameter name / identifier | | `paramDirectionFlag` | ParamDirectionTypeEnum | Direction: `input` / `output` | | `paramTypeFlag` | PointTypeEnum | Parameter data type | | `requiredFlag` | Boolean | Whether required | | `defaultValue` | String | Default value | | `commandId` | Long | Owning command definition | ::: tip CommandParam reuses the Point type system `paramTypeFlag` uses the same `PointTypeEnum` as points (`STRING` / `INT` / `FLOAT` / `DOUBLE` / `BOOLEAN`…). Mind the distinction: **input** parameters are supplied by the caller at invocation (e.g. `temperature`), while **output** parameters are written back by the device after execution (e.g. `resultCode`). ::: Invocation body `CommandCallBO` (the submit payload for one call): `deviceId`, `commandId`, `commandCode`, `paramValues` (`Map<String,String>`, keyed by each parameter's `paramCode`). ## Command types | Type `commandTypeFlag` | Description | |------------------------|----------------| | `custom` | Custom command | | `config` | Config command | | `action` | Action command | ## Relationship to other concepts <CommandRelationDiagram lang="en" /> - A command **definition** hangs under a Profile, alongside [Point](./point) and [Event](./event), together describing " what this kind of device can do". - A command **invocation** is initiated by a [Device](./device); the protocol mapping the driver needs to execute is supplied by [Command/Event Attribute Config](./attribute-config) (`CommandConfig`), which sits at a different layer from the business `CommandParam`. ## Invocation lifecycle and receipt One invocation (`CommandCallDTO`) carries: `recordId`, `tenantId`, `deviceId`, `commandId`, `commandCode`, `paramValues`, `source`, `occurredAt`, `expireAt`. The data center persists it as a `dc3_command_history` record ( PENDING), publishes it to RabbitMQ, and after the driver executes it the driver returns `CommandCallResultDTO` ( `status`, `resultValues`, `errorCode`, `errorMessage`, `finishedAt`). <CommandFlowDiagram lang="en" /> Invocation state machine (`PointCommandStatusEnum`, shared with write-point): ``` PENDING → SENT → SUCCESS / FAILED / TIMEOUT / EXPIRED / DUPLICATE / DEAD ``` | Status | Meaning | |-----------------------|---------------------------------------------------------------------------| | `PENDING` | Record created, waiting to publish | | `SENT` | Published to RabbitMQ, waiting for the driver to execute | | `SUCCESS` / `FAILED` | Driver executed successfully / failed, result written back | | `TIMEOUT` / `EXPIRED` | Application-level timeout / `expireAt` passed before execution | | `DUPLICATE` / `DEAD` | Duplicate command rejected by dedup / rejected into the dead-letter queue | ::: warning A sync command is not "done on call" `callTypeFlag = sync` only means the caller is willing to wait for the receipt; it **does not mean the HTTP call returns the execution result immediately**. Today `/call` returns a `recordId`, with which the caller polls `get_by_record_id` for the terminal status. Whether it is truly "done" is determined by the `status` in the receipt—do not assume the device executed just because HTTP returned 200. ::: ## Example In an air conditioner's thing model, define a command: `commandCode = setTemperature`, `commandTypeFlag = action`, `callTypeFlag = sync`, `timeout = 10`, with one input parameter `temperature` (`paramDirectionFlag = input`, `paramTypeFlag = DOUBLE`, `requiredFlag = true`) and one output parameter `resultCode` (`output`, `STRING`). To invoke, submit `CommandCallBO{ deviceId: 1001, commandCode: "setTemperature", paramValues: { temperature: "26" } }`. The data center checks whether the device's `profileId` contains this command, persists a `dc3_command_history` record ( PENDING→SENT), and publishes it to the driver; the driver's `execute()` renders the protocol payload and sends it to the air conditioner, then returns `CommandCallResultDTO{ status: SUCCESS, resultValues: { resultCode: "OK" } }`, and the record advances to SUCCESS. ## API The data center service is mounted under `/data`: | Method | Path | Description | |--------|------------------------------------------|-------------------------------------------------------------| | POST | `/data/command_history/call` | Issue a custom command, returns `recordId` | | GET | `/data/command_history/get_by_record_id` | Fetch one call record and its terminal status by `recordId` | | POST | `/data/command_history/list` | Page through call records | ## Further reading - [Profile](./profile) — command definitions hang under the thing model - [Point](./point) — the other side of the write-point vs custom-command boundary - [Event](./event) — the dual of the downlink: commands down, events up - [Command/Event Attribute Config](./attribute-config) — how `CommandConfig` maps parameters into a protocol payload - [Command Plane](../../architecture/command-plane) — exchanges / queues / receipts / reliability of the downlink - [Data and Command Operations](../../operation/data-commands) — how to issue commands from the console --- # Device URL: https://docs.dc3.site/en/introduction/concepts/device <script setup> import DeviceRelationDiagram from '../../../.vitepress/theme/components/DeviceRelationDiagram.vue' import DeviceStateDiagram from '../../../.vitepress/theme/components/DeviceStateDiagram.vue' </script> # Device > **A Device is the platform-side mirror of one concrete field device**—a PLC, a thermostat, an electricity meter maps > to one `Device` in DC3. It binds one [Profile](./profile) that decides "what capabilities it has", binds > one [Driver](./driver) that decides "how it communicates", and at runtime its online/offline state is maintained by a > heartbeat lease. A Device answers "which physical machine is actually out there". It is neither the data itself nor the device's type definition: that a certain thermostat model "should have temperature and humidity points" is what the [Profile](./profile) says; the specific unit numbered `TC-001` on the shop floor, online right now, having just reported temperature 25.3℃, is a `Device`. Think of it this way: a [Profile](./profile) is like a class, a Device is like its instance. One Profile can be reused by many devices—100 thermostats of the same model share one Profile; but a Device **belongs to exactly one** Profile ( the old many-to-many binding was collapsed into single ownership during the thing-model refactor). Which [Points](./point) a device can collect, which [Commands](./command) it can receive, and which [Events](./event) it can report are all decided by the Profile it binds. A device's other binding is the [Driver](./driver): the Profile says "this device has a temperature point", but "read that temperature from which Modbus register" is the driver's job. `profileId` decides the capability model, `driverId` decides the communication channel; neither can be omitted. ## Key Fields Device business object `DeviceBO` (table `dc3_device`). Field names and types are taken from the source: | Field | Type | Meaning | |--------------|----------------|-----------------------------------------------------------------| | `deviceName` | String | Device name (for display, e.g. "Workshop 1 Thermostat") | | `deviceCode` | String | Device identifier | | `profileId` | Long | The owning [Profile](./profile), decides the capability model | | `driverId` | Long | The owning [Driver](./driver), decides the communication method | | `deviceExt` | DeviceExt | JSON extension holding protocol-agnostic custom configuration | | `enableFlag` | EnableFlagEnum | Enable/disable flag, see below | | `tenantId` | Long | The owning [Tenant](./tenant), for multi-tenant isolation | Common fields inherited from `BaseBO`: `id`, `remark` (description), `creatorId`/`creatorName`, `operatorId`/ `operatorName`, `createTime`/`operateTime`. ::: tip profileId is a single value, not a set Early on, a device could bind multiple Profiles (`Set<Long> profileIds`). After the thing-model refactor it was collapsed to a single `Long profileId`: one Profile can be reused by many devices, but a device belongs to exactly one Profile. ::: ## Enable Flag | `enableFlag` | `0` enable | `1` disable | |--------------|------------|-------------| `enableFlag` is a configuration-time switch (whether this device is included in collection), which is a different matter from the runtime online/offline state below: a disabled device does not participate in collection; only an enabled device is polled by the driver and has its heartbeat lease maintained. ## Relationships With Other Concepts <DeviceRelationDiagram lang="en" /> - A device obtains its [Point](./point), [Command](./command), and [Event](./event) definitions via `profileId`. - A device produces [PointValues](./point-value) (`device_id + point_id`) and event instances at runtime. - A device reaches its [Driver](./driver) via `driverId` to perform actual reads and writes. ## Online State and Heartbeat Lease A device's "online/offline" is not a field on `dc3_device`, but a separate **runtime state lease** maintained by the device/driver timeout-management mechanism, whose source of truth is the `dc3_entity_state` table ( `entity_type_flag = 6` denotes a device). The mechanism is "heartbeat renewal + timeout maintenance": <DeviceStateDiagram lang="en" /> - **Renewal**: the driver health-checks the device on a configured cycle and reports `DeviceStateDTO`; the Data Center pushes `expire_time` forward (`now + timeout`) and increments `lease_version`. - **Timeout**: a scanner wakes on a fixed tick and batch-marks devices whose `expire_time <= now()` and that are still in the online family as offline. Different devices' timeout lengths live in their own `expire_time`, not in the scan period. There are four states (following the design's `EntityStateStatus` contract): `0` online, `1` offline, `2` maintain, `3` fault. ::: warning Online state is queried from dc3_entity_state, not dc3_device `dc3_device` holds the device's **configuration metadata** (name, ownership, extension), not high-frequency heartbeats; for current online/offline state query `dc3_entity_state`. Writing heartbeats into `dc3_device` would pollute the metadata table, which is exactly what the timeout design avoids. ::: ## Example A Modbus thermostat on the shop floor is onboarded to DC3: first pick a [Profile](./profile) that describes the " thermostat" class of device (containing the `temperature` and `humidity` [Points](./point)), then pick a Modbus [Driver](./driver), and create a device `DeviceBO{ deviceName: "Workshop 1 Thermostat", deviceCode: "TC-001", profileId: 1024, driverId: 2048, enableFlag: enable }`. Once enabled, the Modbus driver reads registers according to the Profile's point configuration, producing [PointValues](./point-value); meanwhile it reports device health every 15 seconds, on which the Data Center renews this device's `expire_time` in `dc3_entity_state` by the 45-second lease TTL. When the driver loses connection on some cycle and the heartbeat stops, the scanner marks it `offline` after `expire_time` has passed. ## Further Reading - [Profile](./profile) — decides which points / commands / events a device has - [Driver](./driver) — decides how a device communicates - [Point](./point) — data point definitions under a Profile - [Device Onboarding](../../operation/device-onboarding) — step-by-step onboarding of a field device - [Concepts Overview](../concepts) — back to the concept map --- # Driver URL: https://docs.dc3.site/en/introduction/concepts/driver <script setup> import DriverRelationDiagram from '../../../.vitepress/theme/components/DriverRelationDiagram.vue' import DriverLifecycleDiagram from '../../../.vitepress/theme/components/DriverLifecycleDiagram.vue' </script> # Driver > **A Driver is a standalone protocol-adapter service instance (`dc3-driver-*`)**—it translates an industrial protocol ( > Modbus, OPC UA, MQTT…) into DC3's unified [Point](./point) read/write and [PointValue](./point-value) reporting. One > protocol maps to one driver module, and on startup the driver registers itself plus the config items it can accept > with > the management center. A Driver answers "how does DC3 actually talk to this [Device](./device)?". A device only describes "what is connected"; the thing that holds the protocol session, polls on a schedule, and translates register values into point values is the driver—a **running service process**. In other words: a device is a row of metadata, a driver is a program that runs. The easy confusion is "Driver" vs. "Device": one Modbus TCP driver instance (`dc3-driver-modbus-tcp`) can connect to hundreds or thousands of Modbus devices at once; each device tells the driver "my IP, port, slave address" through its [attribute config](./attribute-config). **A driver is a one-to-many protocol gateway; a device is an access point hanging under it.** ## What it is / why it exists Industrial field protocols are wildly diverse, and the DC3 core cannot bundle every protocol stack. So DC3 pushes "how to speak the protocol" down into independent driver services, and the core agrees with drivers on only one unified point read/write contract. Adding a protocol = writing a new `dc3-driver-*` service; the core and the Web app stay untouched. Each driver does one crucial thing on startup: **self-registration**. It registers with the management center carrying its identity (`DriverBO`) and "which config items I can accept" (a set of `DriverAttribute`). From this the management center knows: what this driver is called, where it runs, which [Tenant](./tenant) it belongs to, and which fields to fill in when configuring devices for it. ## Key fields Driver `DriverBO` (the identity metadata a driver service registers with the management center): | Field | Type | Meaning | |-------------------------|------------------|---------------------------------------------------------------------------------------| | `driverName` | String | Driver display name (e.g. `Modbus Tcp Driver`) | | `driverCode` | String | Driver code, the unique identifier defined in configuration | | `serviceName` | String | Driver service name, used for registration and routing (e.g. `dc3-driver-modbus-tcp`) | | `serviceHost` | String | Driver service host address | | `driverTypeFlag` | DriverTypeEnum | Driver runtime type, see below | | `driverExt` | DriverExt | Extended metadata (JSON) | | `enableFlag` | EnableFlagEnum | Enable flag | | `tenantId` | Long | Owning [Tenant](./tenant) | | `signature` / `version` | String / Integer | Data signature and version | Driver config item `DriverAttributeBO` (declares which [attribute config](./attribute-config) fields the driver can accept; reported together with the driver at registration): | Field | Type | Meaning | |---------------------|--------------------|-----------------------------------------------------------------------| | `attributeName` | String | Config item name (e.g. `Host`, `Port`) | | `attributeCode` | String | Config item identifier, matched against when a device supplies values | | `attributeTypeFlag` | AttributeTypeEnum | Config item data type (`string` / `int` / `long` / `float`…) | | `defaultValue` | String | Default value | | `driverId` | Long | Owning driver | | `attributeExt` | DriverAttributeExt | Extended config (JSON) | | `enableFlag` | EnableFlagEnum | Enable flag | | `tenantId` | Long | Owning tenant | ::: tip DriverAttribute is a "declaration of config items", not a "config value" `DriverAttribute` describes "this driver needs you to fill in `Host`, `Port`"—it is a **template**; the `192.168.1.10`, `502` a specific device actually fills in are [attribute config](./attribute-config) (`DriverAttributeConfig`). The former is produced by driver registration, the latter by you when configuring a device. ::: ## Driver types | Type `driverTypeFlag` | code | Description | |-----------------------|-----------------|---------------------------------------------------------------------------------------| | `DRIVER_CLIENT` | `driver-client` | Client-mode protocol driver, actively connects to devices (e.g. Modbus TCP polling) | | `DRIVER_SERVER` | `driver-server` | Server-mode protocol driver, waits for devices to connect (e.g. MQTT, listening-type) | | `GATEWAY` | `gateway` | Gateway driver | | `CONNECT` | `connect` | Connection driver | ## Relationship to other concepts <DriverRelationDiagram lang="en" /> - A driver **registers its identity once** and can carry collection for **many** [Devices](./device). - The `DriverAttribute` a driver registers is a template; each device fills values against it via [attribute config](./attribute-config). - The driver collects the [Points](./point) defined by the [Profile](./profile), translates the results into [PointValues](./point-value), and reports them. ## Startup registration and online status <DriverLifecycleDiagram lang="en" /> On startup, registration is triggered by `DriverInitRunner` (an `ApplicationRunner`): it builds a `RegisterBO` (carrying `tenant`, `driver`=`DriverBO`, `driverAttributes`, etc.) and calls `DriverRegisterService.initial()` to report to the management center, retrying with exponential backoff until it succeeds. After registration, a driver is not "online forever the moment it registers"—its **online status is a lease**: the SDK periodically triggers `DriverHealth.health()` to report a heartbeat, renewing a 45-second lease in `dc3_entity_state` (`entity_type_flag = 3` denotes a driver); when the lease expires unrenewed it is judged `offline`. State values are `online` / `offline` / `maintain` / `fault`. ::: warning Online status does not live in the metadata table `dc3_driver` stores the driver's **config metadata** (name, service name, tenant); changing it does not mean the driver is running. Whether a driver is currently online is read from the runtime state table `dc3_entity_state`, maintained by heartbeat lease renewal—after a process crash or network drop the lease naturally expires and flips to offline. To see " which drivers exist" look at the former; to see "is the driver reachable now" look at the latter. ::: ## Example You want to onboard a batch of Modbus TCP meters in a workshop: 1. Deploy and start a `dc3-driver-modbus-tcp` service instance; it registers `DriverBO{ serviceName: "dc3-driver-modbus-tcp", driverTypeFlag: DRIVER_CLIENT }` and declares config items `DriverAttribute{ attributeCode: "host", type: string }`, `{ attributeCode: "port", type: int }`. 2. In the Web app create a [Device](./device) attached to that driver, and fill in its [attribute config](./attribute-config) per the declaration: `host=192.168.1.10`, `port=502`. 3. Using these, the driver opens a Modbus session, periodically reads registers for the [Points](./point) defined in the [Profile](./profile), translates the raw values into [PointValues](./point-value), and reports them to the data center. 4. The driver reports a heartbeat every 15 seconds to renew its lease; one day the service process is killed, 45 seconds later the lease expires, the platform marks the driver `offline`, and its devices follow into the offline scan. ## Built-in drivers DC3 ships with **28** ready-to-use protocol drivers, covering industrial field protocols (Modbus RTU/TCP, OPC UA/DA, PLC S7, Melsec, BACnet/IP, IEC104, DLMS, SNMP, CAN…), IoT protocols (MQTT, CoAP, LwM2M, HTTP, ZigBee, BLE…), serial/network pass-through (Serial, TCP/UDP), and database access (MySQL, PostgreSQL, Oracle, SQLServer). For the full list and each driver's responsibility see the [Module Map](../../architecture/modules). ## Further reading - [Device](./device) — the access point under a driver; one driver, many devices - [DriverAttributeConfig](./attribute-config) — the connection values a device fills per the driver's declared DriverAttribute - [Point](./point) — the target data points a driver collects - [Core Concepts Overview](../concepts) — the object model and three-layer configuration at a glance - [Module Map](../../architecture/modules) — the list of 28 built-in drivers and the service topology - [Driver Authoring Guide](../../development/driver-authoring) — how to write your own `dc3-driver-*` --- # Event URL: https://docs.dc3.site/en/introduction/concepts/event <script setup> import EventRelationDiagram from '../../../.vitepress/theme/components/EventRelationDiagram.vue' import EventFlowDiagram from '../../../.vitepress/theme/components/EventFlowDiagram.vue' </script> # Event > **An event is a business occurrence reported proactively by a device**—a fault, an alert, a mode switch, a lifecycle > change… Its definition belongs to the [Profile](./profile), its instances belong to the [Device](./device); once > reported it is both persisted as a raw record and able to trigger an alarm. An event answers "what happened to the device", not "what is the current value of some quantity". The latter is a [PointValue](./point-value) (a periodically sampled numeric snapshot); the former is a discrete, semantically meaningful occurrence: for an access-control device, "temperature = 25.3℃" is a PointValue, while "the door was forced open" is an event. An event has two layers: the **definition** (which events this kind of device reports, and which parameters each event carries) lives in the Profile, backed by `dc3_event` / `dc3_event_param`; the **instance** (a specific device actually reported one at a specific moment) is reported by the driver and lands in `dc3_event_history`. ## Key Fields Event definition `EventBO` (table `dc3_event`): | Field | Type | Meaning | |------------------|-------------------|------------------------------------------------------------------------------| | `eventName` | String | Event name (for display) | | `eventCode` | String | Event identifier; reporting and alarm rules match on it (e.g. `DOOR_FORCED`) | | `eventTypeFlag` | EventTypeFlagEnum | Event type, see below | | `eventLevelFlag` | EventLevelEnum | Event level, see below | | `profileId` | Long | The owning [Profile](./profile) | | `eventExt` | JSON | Extended configuration | Event parameter `EventParamBO` (table `dc3_event_param`, declares which output parameters an event carries): | Field | Type | Meaning | |---------------------------|---------------|-----------------------------| | `paramName` / `paramCode` | String | Parameter name / identifier | | `paramTypeFlag` | PointTypeEnum | Parameter data type | | `eventId` | Long | The owning event definition | ::: tip Event parameters reuse the Point type system `paramTypeFlag` uses the same `PointTypeEnum` as Points (`STRING` / `INT` / `FLOAT` / `BOOLEAN`…); the value range of an event parameter is exactly identical to a [Point](./point) data type. ::: ## Event Types and Levels | Type `eventTypeFlag` | Description | |----------------------|-------------------| | `info` | Information event | | `alert` | Alert event | | `fault` | Fault event | | `lifecycle` | Lifecycle event | | Level `eventLevelFlag` | `0` LOW | `1` MEDIUM | `2` HIGH | `3` CRITICAL | |------------------------|---------|------------|----------|--------------| ## Relationship to Other Concepts <EventRelationDiagram lang="en" /> - The event **definition** hangs under the Profile, side by side with [Point](./point) and [Command](./command), together describing "what capabilities this kind of device has". - The event **instance** is reported by a [Device](./device) through a [Driver](./driver), carrying the `eventCode`, level, and a set of parameter values. ## Reporting Path and Lifecycle <EventFlowDiagram lang="en" /> A single report (`EventReportDTO`) carries: `recordId` (UUID), `deviceId`, `eventId`, `eventCode`, `eventTypeFlag`, `eventLevelFlag`, `paramValues`, `message`, `occurTime`. The data center first persists it as a **raw record** in `dc3_event_history`, then submits it to the alarm rule engine; only when a rule matches does it create/update a * *runtime alarm** in `dc3_entity_alarm`. ::: warning An EventHistory is not an alarm `dc3_event_history` is the raw record of "what the device said happened"—**logged on every report**; `dc3_entity_alarm` is the result of "the alarm engine deciding it needs attention"—**present only when a rule matches**. To ask "which events has a device reported" look at the former; to ask "which alarms exist now" look at the latter. Do not conflate them. ::: ## Example In an access-control device's Profile, define an event: `eventCode = DOOR_FORCED`, `eventTypeFlag = alert`, `eventLevelFlag = 3`, with parameter `openMethod` (String). When the field device is pried open, the driver reports `EventReportDTO{ eventCode: "DOOR_FORCED", paramValues: { openMethod: "pry" }, occurTime: ... }`; the data center records it in `dc3_event_history`, and because the level is CRITICAL it matches an alarm rule and creates an alarm in `dc3_entity_alarm`. ## Reporting API | Method | Path | Description | |--------|--------------------------------------------------|------------------------------------------| | POST | `/data/event_history/report` | Report an event | | GET | `/data/event_history/get_by_record_id?recordId=` | Query event record details by `recordId` | | POST | `/data/event_history/list` | Paginated query of event records | ## Further Reading - [Profile](./profile) — the event definition hangs under the Profile - [Command](./command) — the downstream dual: events go up, commands go down - [PointValue](./point-value) — complementary: continuous values vs. discrete occurrences - [Alarms and Notifications](../../operation/alarms) — how an event becomes an alarm - [Data Plane](../../architecture/data-plane) — exchanges / queues / reliability details of the upstream path --- # PointValue URL: https://docs.dc3.site/en/introduction/concepts/point-value <script setup> import PointValueRelationDiagram from '../../../.vitepress/theme/components/PointValueRelationDiagram.vue' import PointValueFlowDiagram from '../../../.vitepress/theme/components/PointValueFlowDiagram.vue' </script> # PointValue > **A point value is a single snapshot of one [point](./point) at one instant in time**—"the outlet temperature of pump > #3 was 25.3℃ at exactly 14:05:03." It belongs to `device + point`, carries a timestamp, is collected upstream by > a [driver](./driver), and lands in the TimescaleDB time-series store. A point value answers "what is this quantity **now / at that moment**." A [point](./point) is the "column definition" in the template (this kind of device has an "outlet temperature" measurement); a point value is the "reading" of that column across rows over time—one point produces thousands upon thousands of point values as time passes. It is the complementary upstream counterpart to an [event](./event): a point value is a periodic sample of a * *continuous value** (a temperature reading every second), an event is a discrete **business occurrence** (" over-temperature alarm" fired once). For a door-access device, "temperature = 25.3℃" is a point value; "door was forced open" is an event. Don't conflate a point value with "the point's current value": point values are individual **history records**—each sample appends a new row, append-only, never updated; "current value" is simply the result of querying the latest point value by `device_id + point_id`. ## rawValue vs calValue A single point value retains two values at once: - **`rawValue`**—the data the driver reads from the device verbatim, with no conversion. For example, the raw register code `6400` returned by a 4-20mA transmitter. - **`calValue`**—the human-readable engineering value computed from the conversion rule configured on the point ( `baseValue` / `multiple`, etc.). For example, `6400` converts to `25.3` (℃). Keeping the raw value matters for traceability and recomputation: if the conversion rule changes, the historical `rawValue` is still there, so the new engineering value can be recomputed. ## Key Fields Point value `PointValueBO` (table `dc3_point_value`): | Field | Type | Meaning | |------------------|---------------|-----------------------------------------------------------------------------------------------------------------| | `deviceId` | Long | Owning [device](./device) | | `pointId` | Long | Owning [point](./point) | | `rawValue` | String | Raw value, returned by the device verbatim, not converted | | `calValue` | String | Converted engineering value, human-readable | | `numValue` | Double | Numeric projection of `calValue`; populated when it parses cleanly as a double, NULL for booleans / JSON / text | | `hasLatestValue` | Boolean | Whether the latest-value query returned a real sampled value | | `driverId` | Long | The [driver](./driver) that collected the data | | `tenantId` | Long | Owning [tenant](./tenant) | | `createTime` | LocalDateTime | Collection / write time, i.e. the timestamp of this snapshot | | `operateTime` | LocalDateTime | Last operation time | ::: tip Why numValue exists Both `rawValue` and `calValue` are `String` (they must hold numbers, booleans, JSON, and text alike). `numValue` is a copy of `calValue` when it parses as a number, dedicated to AVG / MIN / MAX / SUM and time-series aggregation queries so they can use a numeric index and skip the per-row cast. For non-numeric points (digital, string, JSON), `numValue` is NULL, and aggregate queries use `num_value IS NOT NULL` to skip them outright. ::: ## Relationship to Other Concepts <PointValueRelationDiagram lang="en" /> - A point value is located jointly by `deviceId + pointId`: **which device's** **which measurement**. - A [point](./point) gives the column definition (type, unit, conversion rule); a point value is the runtime reading of that column, row by row. - An [event](./event) sits alongside point values on the upstream link—one continuous, one discrete, complementary. ## Collection and Upstream Flow <PointValueFlowDiagram lang="en" /> The driver reads `rawValue` from the device, computes `calValue` via the point's conversion rule, fills `numValue` when it parses as a number, and ships it upstream together with `deviceId` / `pointId` / `driverId` / `tenantId` / `createTime`; the Data Center **appends** it to the `dc3_point_value` hypertable (chunked by `create_time` at 1-day intervals plus hash-partitioned by `device_id`, with compression and a 180-day retention policy maintained automatically by TimescaleDB). ::: warning Point values are append-only—mind the retention policy `dc3_point_value` is an append-only history stream: each sample inserts a new row, with no UPDATE. Querying "current value" means taking the latest row, not reading some field that gets overwritten. It also carries a 180-day retention policy—data past that age is cleaned up automatically, so archive ahead of time if you need long-term retention. ::: ## Example The outlet-temperature point (`pointId=2048`) of pump #3 (`deviceId=1024`) has a conversion rule mapping the 4-20mA raw code to 0-100℃. At 14:05:03 the driver (`driverId=8`) reads register raw code `6400` and converts it to `25.3`: ```text PointValueBO{ deviceId: 1024, pointId: 2048, driverId: 8, tenantId: 1, rawValue: "6400", // returned by the device verbatim calValue: "25.3", // converted engineering value (℃) numValue: 25.3, // aggregatable as a number createTime: 2026-06-24T14:05:03 } ``` A second later another reading of `25.4` arrives… and so on, accumulating into a time-series stream. Querying "current outlet temperature" = take the latest row for `device_id=1024, point_id=2048`; querying "today's average temperature" = run AVG over `num_value` within the time window. ## Query API | Method | Path | Description | |--------|-------------------------------------------------------|-------------------------------------------------------------------| | POST | `/point_value/latest` | Paged query of the latest value per point | | POST | `/point_value/list` | Paged query of point value history | | GET | `/point_value/list_history_by_device_id_and_point_id` | Query history by `device_id + point_id` (`count` defaults to 100) | ## Further Reading - [Point](./point) — a point value is the runtime reading of a point - [Event](./event) — complementary: continuous value vs discrete occurrence - [Core Concepts Overview](../concepts) — back to the concept map - [Data Plane](../../architecture/data-plane) — exchange / queue / TimescaleDB details of the upstream collection link --- # Point URL: https://docs.dc3.site/en/introduction/concepts/point <script setup> import PointRelationDiagram from '../../../.vitepress/theme/components/PointRelationDiagram.vue' </script> # Point > **A Point is a single data item**—one concrete quantity to be collected from, or written to, a class of devices. Its > definition belongs to a [Profile](./profile); its runtime value is a [PointValue](./point-value). A Point answers "which quantities can be read or written on this kind of device." On an air conditioner, "indoor temperature", "set temperature", and "power state" are each a Point; their instantaneous numeric snapshots are [PointValues](./point-value). An analogy: a [Profile](./profile) is the header definition of a table, each Point is one **column** of it, and a [PointValue](./point-value) is the **cell** a given device fills into that column at a given moment. Two easy confusions: - **Point ≠ PointValue.** A Point is the definition of a "column" (its name, type, writability, unit), and is stable; a PointValue is the value in a "cell", changing as collection proceeds. See [PointValue](./point-value). - **Point ≠ Command.** A Point is a "quantity"; a [Command](./command) is an "action" (reboot, calibrate, switch mode). Whether a Point can be written is decided by its own `rwFlag`—it is **not, and never** registered in the `dc3_command` command table. Reading/writing a Point goes through the `PointCommand` path; custom commands go through the `Command` path. ## Key Fields Point `PointBO` (table `dc3_point`): | Field | Type | Meaning | |-----------------|----------------|----------------------------------------------------------------------------| | `pointName` | String | Point name (for display, e.g. "indoor temperature") | | `pointCode` | String | Point identifier, unique within a [Profile](./profile) | | `pointTypeFlag` | PointTypeEnum | Data type, see below | | `rwFlag` | RwTypeEnum | Read/write capability, see below | | `unit` | String | Engineering unit, e.g. `℃`, `kPa` | | `baseValue` | BigDecimal | Offset for linear conversion (default `0`) | | `multiple` | BigDecimal | Multiplier for linear conversion (default `1`) | | `valueDecimal` | Byte | Decimal precision, digits kept for floating-point values (default `6`) | | `profileId` | Long | Owning [Profile](./profile) | | `pointExt` | PointExt | Extended config (protocol mapping, constraints, collection strategy, etc.) | | `enableFlag` | EnableFlagEnum | Enable / disable state | | `tenantId` | Long | Owning [Tenant](./tenant) | ## Data Type `pointTypeFlag` | Enum | code | Meaning | |-----------------------------------|-----------------------------------|------------------| | `STRING` | `string` | String (default) | | `BYTE` / `SHORT` / `INT` / `LONG` | `byte` / `short` / `int` / `long` | Integer | | `FLOAT` / `DOUBLE` | `float` / `double` | Floating-point | | `BOOLEAN` | `boolean` | Boolean | ## Read/Write Capability `rwFlag` | Enum | code | Meaning | |--------------|------|----------------------------------------------------------| | `READ_ONLY` | `r` | Read only; can be collected, cannot be written (default) | | `WRITE_ONLY` | `w` | Write only; can be written | | `READ_WRITE` | `rw` | Readable and writable | ::: warning Writability is decided by rwFlag, not the command table Whether a Point can be written is determined **solely** by whether its `rwFlag` includes write capability (`WRITE_ONLY` or `READ_WRITE`). This has nothing to do with [Command](./command) (`dc3_command`)—Point read/write is not modeled in the command table. The center validates `rwFlag` before a write command: a write request against a read-only Point is rejected outright. ::: ## Raw Value and Engineering Value Conversion What a [Driver](./driver) reads from a device is often a **raw value** (a register integer, an ADC count, etc.). A Point converts it to a human-readable **engineering value** via a linear formula: ```text engineering value = raw value × multiple + baseValue (then rounded by valueDecimal) ``` Example: a temperature transmitter register reads `2531`, configured with `multiple = 0.01`, `baseValue = 0`, `unit = ℃`, `valueDecimal = 2`; the converted engineering value stored as a [PointValue](./point-value) is `25.31 ℃`. The defaults `multiple = 1`, `baseValue = 0` mean the raw value equals the engineering value, with no conversion. ## Relationship to Other Concepts <PointRelationDiagram lang="en" /> - A Point **definition** hangs under a [Profile](./profile), alongside [Command](./command) and [Event](./event), together describing "what capabilities this kind of device has." - A [Device](./device) belongs to one Profile and thus automatically owns all Points under it; runtime data lands as [PointValues](./point-value) keyed by `device_id + point_id`. ## Example Configure three Points for an air-conditioner Profile: | pointCode | pointName | pointTypeFlag | rwFlag | unit | Note | |---------------|--------------------|---------------|--------------|------|---------------------------------------------| | `indoor_temp` | Indoor temperature | `FLOAT` | `READ_ONLY` | `℃` | Collect only, `multiple=0.1` for conversion | | `set_temp` | Set temperature | `FLOAT` | `READ_WRITE` | `℃` | Readable, and a new setpoint can be written | | `power` | Power state | `BOOLEAN` | `READ_WRITE` | — | Read current state, write to start/stop | One air conditioner (`device_id=1001`) collects an engineering value of `26.5℃` for `indoor_temp` → it lands as one [PointValue](./point-value). To set it to 22℃, send one write `PointCommand` against `set_temp`; because its `rwFlag=READ_WRITE`, the write request passes validation and is dispatched to the driver. ::: tip A Point carries type, read/write, unit, and conversion together A newly created Point, if not configured explicitly, defaults to type `STRING`, read/write `READ_ONLY`, `baseValue=0`, `multiple=1`, `valueDecimal=6`, and an empty `unit`. When collecting a numeric quantity, remember to change it to the matching numeric type and configure conversion, otherwise it is stored as a raw string. ::: ## Further Reading - [Profile](./profile) — Point definitions hang under a Profile - [PointValue](./point-value) — the runtime value snapshot of a Point - [Command](./command) — action-type capability; Point read/write is not in this table - [Event](./event) — another capability type alongside Point - [Device Onboarding](../../operation/device-onboarding) — how a device picks a Profile and inherits its Points --- # Profile (Thing Model) URL: https://docs.dc3.site/en/introduction/concepts/profile <script setup> import ProfileRelationDiagram from '../../../.vitepress/theme/components/ProfileRelationDiagram.vue' import ProfileLifecycleDiagram from '../../../.vitepress/theme/components/ProfileLifecycleDiagram.vue' </script> # Profile (Thing Model) <Badge type="tip" text="Thing Model+" /> > **A Profile is the "capability template for one kind of device"** <Badge type="tip" text="Thing Model+" />—it > aggregates > the [Points](./point), [Commands](./command), and [Events](./event) shared by devices of the same model, describing " > what this kind of device can sample, control, and report". A [Device](./device) belongs to exactly one Profile, and > many > devices can reuse the same Profile. ## What it is / why it exists Imagine onboarding 100 temperature-and-humidity sensors of the same model. Configuring "temperature point, humidity point, calibration command, fault event" on each one separately means 100 rounds of duplicate work—change one thing and you change it 100 times. A Profile solves exactly this: **factor the capability definitions out into a single template** that device instances merely reference. An analogy with product vs. physical units: a Profile is like a "product spec sheet / factory specification", and a device is like "one physical unit manufactured to that spec". You write the spec once and can build many units from it. ::: tip How Profile relates to the "Thing Model" The "Thing Model" is a common industry design for modeling device capabilities. DC3's **Profile** is a **peer abstraction**—both answer "what capabilities does a kind of device have". DC3 did not adopt the `Product` / `ThingModel` naming; it chose **Profile**, and its capabilities are **stronger** than a typical thing model: a Profile supports [sharing scopes](#enumerations) (reuse across tenant / driver / user), version evolution, a weakly-structured `profileExt` extension, and more—more flexible than the fixed "one product, one thing model" structure. Think of it as **Profile ⊇ Thing Model**: anything a thing model can express, a Profile can too, but not vice versa (see the [design philosophy](../../architecture/domain-model)). ::: **Profile vs. Thing Model (see the "plus" at a glance):** | Dimension | Thing Model (industry-generic) | Profile (DC3) <Badge type="tip" text="Thing Model+" /> | |------------------|-----------------------------------------------|----------------------------------------------------------------------| | Positioning | An abstraction for device-capability modeling | Peer abstraction, stronger (a superset) | | Capability set | Properties / services / events | [Points](./point) / [Commands](./command) / [Events](./event) | | Reuse scope | Usually fixed per product | Three sharing scopes: tenant / driver / user (`profileShareFlag`) | | Versioning | Typically no explicit version | Explicit `version`, queryable and evolvable | | Extension fields | Relatively fixed structure | `profileExt` weakly-structured extension (can carry category / tags) | | Creation source | — | `profileTypeFlag`: system / driver / user | | Device binding | Implementation-dependent | Exactly one (`Device.profileId`, single foreign key) | > In one line: **a Profile is the enhanced version of a thing model**—it keeps the peer "capability template for a class > of devices" abstraction and layers platform capabilities (sharing, versioning, extension) on top. **Three pairs that are easy to confuse:** - **Profile vs. Device**: a Profile is the "class" (defined once); a Device is the "instance" (many onboarded). The point "temperature" is defined on the Profile, while "sensor #3's temperature right now = 25.3℃" —a [PointValue](./point-value)—is a device's runtime data. - **Profile vs. Driver**: a Profile describes "which capabilities a device has" (business semantics); a [Driver](./driver) describes "which protocol and how to connect" (connectivity). The same Profile can be paired with different drivers; the two are orthogonal. - **Aggregates vs. owns**: a Profile does not "store" the data of points / commands / events—it is only their **owning root**. `Point`, `Command`, and `Event` each link back to the Profile via `profileId`. ## Key fields Profile `ProfileBO` (table `dc3_profile`): | Field | Type | Meaning | |--------------------|----------------------|------------------------------------------------------------------------------------------| | `profileName` | String | Profile name (for display) | | `profileCode` | String | Profile code, unique within a tenant, serves as the model identifier | | `profileShareFlag` | ProfileShareTypeEnum | Sharing scope, see below | | `profileTypeFlag` | ProfileTypeEnum | Creation source, see below | | `version` | Integer | Model version, queryable and set manually | | `profileExt` | ProfileExt (JSON) | Weakly-structured extension field (designed to carry content such as `category`, `tags`) | | `enableFlag` | EnableFlagEnum | Enabled / disabled state | | `tenantId` | Long | Owning [Tenant](./tenant) | ::: tip A Profile does not hold its sub-capabilities as fields `ProfileBO` carries no list of points / commands / events—they are independent entities that link back through their own `profileId` foreign key. To find "which capabilities this Profile has", query `Point` / `Command` / `Event` separately rather than reading a field on `ProfileBO`. ::: ## Enumerations **Sharing scope `profileShareFlag` (`ProfileShareTypeEnum`)**—controls who can reuse this Profile: | Enum | code | Meaning | |----------|--------|---------------------------------------------------------------------------| | `TENANT` | tenant | Shared within the tenant; all devices under the tenant may reference it | | `DRIVER` | driver | Shared within a driver; devices belonging to that driver may reference it | | `USER` | user | Private to the user; visible only to its creator | **Creation source `profileTypeFlag` (`ProfileTypeEnum`)**: | Enum | code | Meaning | |----------|--------|-----------------------| | `SYSTEM` | system | Built into the system | | `DRIVER` | driver | Created by a driver | | `USER` | user | Created by a user | ## Relationship with other concepts <ProfileRelationDiagram lang="en" /> A Profile is the owning root of three kinds of capability—[Point](./point), [Command](./command), and [Event](./event) —which side by side answer "what this kind of device can do". A [Device](./device) binds **exactly one** Profile via `profileId`—a single foreign key, not a many-to-many relation. How a device connects is decided by its [Driver](./driver), orthogonal to the Profile. ## Lifecycle <ProfileLifecycleDiagram lang="en" /> First create the Profile and fill in its points / commands / events, then have many devices of the same model bind it; at runtime devices sample point values, receive commands, and report events according to the template; when capabilities change, bump `version`. ::: warning A device can bind only one Profile Early versions let a device bind multiple Profiles (`dc3_profile_bind` many-to-many); this has since converged to the single foreign key `Device.profileId`: **a device belongs to exactly one Profile**, while one Profile may be reused by many devices. A device's point set comes only from the single Profile its `profileId` points to—never mixed across Profiles. ::: ## Example Create a Profile for the "ZS-100 temperature-and-humidity sensor": `profileCode = ZS-100`, `profileShareFlag = TENANT` ( shared within the tenant), `version = 1`. Under it define two points (`temperature`, `humidity`), one command ( `CALIBRATE`), and one event (`SENSOR_FAULT`). The 100 sensors of this model onboarded afterward each point their `Device.profileId` at this single Profile to reuse all of its capabilities; next time you add a `max` constraint to the temperature point, you edit the Profile in one place, all 100 devices take effect at once, and `version` rises to 2. ## API Profile management endpoints are prefixed with `/profile` (Manager service): | Method | Path | Description | |--------|------------------------------|------------------------------------| | POST | `/profile/add` | Create a Profile | | POST | `/profile/update` | Update Profile metadata | | POST | `/profile/delete` | Delete a Profile | | GET | `/profile/get_by_id` | Get a Profile by ID | | POST | `/profile/list` | Page through Profiles | | GET | `/profile/list_by_device_id` | List the Profile bound by a device | ## Further reading - [Point](./point) — the data / control points a Profile aggregates - [Command](./command) — the action-type capabilities a Profile aggregates - [Event](./event) — the reporting capabilities a Profile aggregates - [Device](./device) — the instance of a Profile, bound via `profileId` - [Concepts overview](../concepts) — a tour of all core concepts - [Domain model](../../architecture/domain-model) — where Profile sits in DC3's domain language --- # Tenant URL: https://docs.dc3.site/en/introduction/concepts/tenant <script setup> import TenantRelationDiagram from '../../../.vitepress/theme/components/TenantRelationDiagram.vue' import TenantAuthDiagram from '../../../.vitepress/theme/components/TenantAuthDiagram.vue' </script> # Tenant > **A tenant is the isolation boundary for business data on the platform**—within a single deployment, company > A's [devices](./device), points, data and company B's are invisible to each other. Every business record carries a `tenantId`, and the platform uses it to slice data into mutually isolated partitions. A tenant answers "who owns this record, and who can see it." It is not a feature or a role, but a **data wall**: the token you receive after login is bound to one `tenantId`, and the devices you create afterward, the point values you collect, the commands you dispatch all get stamped with that label automatically; accessing another tenant's records by ID or in bulk gets them reported as nonexistent, or dropped. What's easy to confuse is tenant versus [principal](../../architecture/auth-rbac) and role. In one line: **a tenant governs "which data you can touch," a role governs "which kinds of operations you can perform," and the principal is " who is operating."** The three are orthogonal—you may have `device:get` permission (granted by a role), yet getting another tenant's device still fails (blocked by the tenant). Think of an office building: the access card decides which floor you can enter (tenant), your rank decides which meeting rooms you can open on your own floor (role), and the badge shows who you personally are (principal). ## Key Fields Tenant `TenantBO` (table `dc3_tenant`, inheriting `id` / `remark` / audit fields from `BaseBO`): | Field | Type | Meaning | |--------------|-----------------|-------------------------------------------------------------------------------------------------------------------------------| | `tenantName` | String | Tenant name (for display) | | `tenantCode` | String | Unique tenant code, used to locate the tenant at login; the tenant whose code is `default` is the system-administrator tenant | | `tenantExt` | TenantExt(JSON) | Extension config, reserved field | | `enableFlag` | EnableFlagEnum | Enable flag, see below | A tenant is not isolated: which tenant an identity "belongs to" is declared row by row by the tenant membership `TenantMembershipBO` (table `dc3_tenant_membership`), with a unique index on `(tenant_id, principal_id)`: | Field | Type | Meaning | |--------------------|----------------------|-------------------------------------------------------| | `tenantId` | Long | The owning tenant | | `principalId` | Long | The owning [principal](../../architecture/auth-rbac) | | `principalType` | PrincipalTypeEnum | Principal type: `USER` / `SERVICE_ACCOUNT` / `SYSTEM` | | `membershipStatus` | MembershipStatusEnum | Membership status: `ACTIVE` / `SUSPENDED` / `INVITED` | | `joinedTime` | LocalDateTime | Join time | ::: tip One person can belong to multiple tenants Because the unique index is on `(tenant_id, principal_id)`, the same `USER` principal can have one membership row under each of several tenants (multi-tenant membership). At login, `name + tenant` together locate which membership applies. By design a `SERVICE_ACCOUNT` belongs to only one tenant. ::: ## Enable Flag `enableFlag` | Value `EnableFlagEnum` | Database | Meaning | |------------------------|----------|----------| | `ENABLE` | `0` | Enabled | | `DISABLE` | `1` | Disabled | ## Relationship to Other Concepts <TenantRelationDiagram lang="en" /> - Every business entity implementing `TenantOwned` (which provides `getTenantId()`) is owned by some tenant and is the subject on which isolation is applied. - A principal joins a tenant via `dc3_tenant_membership`; once inside, RBAC (`dc3_role_principal_bind`) decides what operations it may perform. See [Auth · Tenant · RBAC](../../architecture/auth-rbac). ## How Isolation Is Enforced Tenant isolation lands at the controller layer: after fetching, it compares the entity's `tenantId` against the caller's tenant, and cross-tenant access is reported as nonexistent or dropped. <TenantAuthDiagram lang="en" /> - **Controller layer (single by ID)**: after fetching an entity, `BaseController.requireTenant()` compares the entity's `tenantId` against the caller's tenant; on mismatch (or a missing entity) it throws `NotFoundException`, returning * *404** to the outside. - **Controller layer (bulk)**: `BaseController.filterTenant()` keeps only entries belonging to the caller's tenant, dropping records of other tenants. - **Database-level auto-append of `WHERE tenant_id = ?`**: not currently enabled (`MybatisPlusConfig` only registers `PaginationInnerInterceptor`); a uniform backstop of this kind is still planned. ::: warning Cross-tenant access returns 404, not 403 This deliberately reports "does not exist" rather than "no permission"—to avoid leaking "whether a cross-tenant resource exists." So when you can't find a device, it may genuinely not exist, or it may belong to another tenant: to you the two are indistinguishable. Batch queries go through `filterTenant()`, which simply drops entries not belonging to your tenant rather than erroring out. ::: ## Example A development environment usually has just one default tenant whose `tenantCode = default`—which is also the * *system-administrator tenant**: only users in the `default` tenant can create/delete/update other tenants ( `TenantController` explicitly checks `"default".equals(tenantCode)`). Imagine a SaaS deployment adds a customer tenant `tenantCode = acme`. After `alice`, an operator of `acme`, logs in ( token bound to `acme`'s `tenantId`) and creates device `pumphouse-01`, the device is persisted with `tenant_id` automatically set to `acme`. At this point an administrator of the `default` tenant, even holding `device:get` permission, who queries `pumphouse-01` by its ID gets a 404 because `requireTenant()` fails the comparison—unless they first switch into the `acme` tenant context. Conversely `alice` cannot see any data of the `default` tenant. ## Management API Tenant management endpoints live in the auth center under the prefix `/tenant` (via the gateway, `/api/v3/auth/tenant`). Non-administrators can operate only on the tenant they belong to: | Method | Path | Description | |--------|-----------------------|--------------------------------------------------------| | POST | `/tenant/add` | Add a tenant (only the `default` tenant administrator) | | POST | `/tenant/delete` | Delete a tenant | | POST | `/tenant/update` | Update a tenant | | GET | `/tenant/get_by_id` | Query by ID | | GET | `/tenant/get_by_code` | Query by code | | POST | `/tenant/list` | Paged query | ## Further Reading - [Device](./device) — the most typical business entity that gets tenant-isolated - [Core Concepts and Mental Model](../concepts) — where the tenant boundary sits in the overall object model - [Auth · Tenant · RBAC](../../architecture/auth-rbac) — the full chain of principal, membership, RBAC and controller-layer tenant isolation - [Quick Start](../../quickstart/) — bring up the stack locally with the default `default` tenant --- # Core Concepts and Mental Model URL: https://docs.dc3.site/en/introduction/concepts <script setup> import ConceptsDomainDiagram from '../../.vitepress/theme/components/ConceptsDomainDiagram.vue' import ConceptsFlowDiagram from '../../.vitepress/theme/components/ConceptsFlowDiagram.vue' </script> # Core Concepts and Mental Model To use IoT DC3 well, you need a simple object model in your head. This page gives you that: a one-sentence summary, an entity-relationship diagram, a walk through each object, the often-confused "three layers of configuration," and the tenant boundary that runs through everything. After this, the terminology in the rest of the docs will make sense. > You are here: you've read the [platform positioning](./), and want the concepts straight before getting hands-on. > Next, see [choose a path by role](./paths) or jump to [Quick Start](../quickstart/). ## One-Sentence Mental Model > **Drivers connect devices, profiles describe capabilities, devices bind profiles, points carry data; the data center stores values and dispatches commands.** Breaking that down: a protocol **Driver** talks to devices. A **Profile** describes what a class of similar devices can do (which points, commands, and events they have). A **Device** is a concrete instance bound to a profile and a driver. A **Point** is a data item to collect or write. The collected value is a **PointValue**. ## Objects and Relationships These relationships are fixed: a profile contains multiple points, commands, and events. A device **binds to exactly one ** profile (since Phase-1, `Device.profileId` is a single foreign key, no longer many-to-many) and one driver. A point produces many point values. <ConceptsDomainDiagram lang="en" /> ## Object by Object - **Driver (`dc3-driver-*`)**: a protocol-adapter service that talks to devices or data sources. On startup it registers itself, plus the configuration items (attributes) it accepts, with the management center. The platform ships 28 built-in drivers covering Modbus, OPC UA, S7, MQTT, and more — see the [module map](../architecture/modules). - **Profile**: a capability template for similar devices. It records which points this kind of device has, which custom commands it supports, and which events it reports, so devices can reuse it. - **Device**: the platform's mirror of a real physical device in the field. It binds to one profile (which sets its points) and one driver (which sets how it communicates). - **Point**: a data item. Its key fields are `pointTypeFlag` (data type) and `rwFlag` (read/write direction). ::: tip Read/write is decided by the Point itself Whether a point can be written depends on its `rwFlag` (`READ_ONLY` / `WRITE_ONLY` / `READ_WRITE`), **not** on the command table. Writes to a `READ_ONLY` point are rejected. A point can also carry a `unit` and a linear conversion ( `baseValue` / `multiple`) that turns the raw value into an engineering value. ::: ## Three Layers of Configuration: Param, Attribute, Config This is the part people trip over most. IoT DC3 splits "configuration" into three layers, each answering a different question: | Layer | Object | Question it answers | Source | |--------------------------|------------------------------------------------------------------------------|------------------------------------------------------------------|----------------------------------------------------------| | Business layer Param | `CommandParam` / `EventParam` | Which input/output parameters does this command/event have | Defined in the profile model | | Protocol layer Attribute | `DriverAttribute` / `PointAttribute` / `CommandAttribute` / `EventAttribute` | **Which** configuration items does this driver have | Registered from `application.yml` when the driver starts | | Instance layer Config | `PointAttributeConfigDO`, etc. | The **concrete values** **this device** fills in for those items | Set by the user for the device/point | For example, the Modbus driver declares that "a point needs a register address" (the Attribute, registered by the driver), while "device #3's temperature point lives at address 40001" (the Config, the value the device instance supplies). Once this distinction clicks, the "configure point attributes" step in [Device Onboarding](../operation/device-onboarding) makes sense. ## Data Flow and Command Flow Around these objects, the platform runs two opposing pipelines. The **data flow** pulls device values up, stores them, and exposes them for querying. The **command flow** sends read/write requests back down to devices for execution. <ConceptsFlowDiagram lang="en" /> For the full implementation of both pipelines (exchanges, queues, lifecycle, acknowledgements), see the [Data Plane](../architecture/data-plane) and [Command Plane](../architecture/command-plane). ## Tenant Boundary Business data is isolated by **tenant (`tenantId`)**. When you call APIs, create devices, query data, or dispatch commands, keep the tenant context consistent. The platform checks the tenant context at the controller layer ( `requireTenant` / `filterTenant`) — accessing another tenant's records by id or in bulk is treated as not-found (a 404 rather than the data itself). In development the default tenant is usually `default`; in production it follows your organization and permission model. For how isolation is enforced layer by layer, see [Auth · Tenant · RBAC](../architecture/auth-rbac). ## Concept Reference Each core concept has its own entry covering its definition, key fields, relationships, lifecycle, and common pitfalls: - [Profile (Thing Model)](./concepts/profile) — capability template for a class of devices, aggregating points / commands / events - [Device](./concepts/device) — a platform mirror of one field device - [Driver](./concepts/driver) — a protocol adapter service that talks to devices - [Point](./concepts/point) — a single data item (a value to read or write) - [Point Value](./concepts/point-value) — a snapshot of a point's value at a moment - [Command](./concepts/command) — trigger a device action (vs. writing a point) - [Event](./concepts/event) — a business occurrence a device reports - [Attribute & Config](./concepts/attribute-config) — the Param / Attribute / Config layers - [Tenant](./concepts/tenant) — the business-data isolation boundary ## Further Reading - [Choose a Path by Role](./paths) — pick a reading order based on your goal - [Device Onboarding](../operation/device-onboarding) — turn the concepts into a real onboarding - [Domain Model](../architecture/domain-model) — DO/BO/VO layering and field details - [Quick Start](../quickstart/) — bring up the stack locally - [IoT Technology Overview](../foundations/) — place these concepts in the four-layer IoT architecture --- # Glossary URL: https://docs.dc3.site/en/introduction/glossary This page standardizes terminology across the docs: DC3 platform objects, general IoT terms, and the protocol and interface identifiers you'll meet in the docs and code. Technical identifiers (class names, table names, routing keys, HTTP paths, auth headers) are kept verbatim; when a name is ambiguous, this page is the authority. > You are here: you hit a term while reading [Core Concepts](./concepts) or the operations docs, and came back to look > it up. Every platform term links to its detail page. ## DC3 Platform Terms These are IoT DC3's own object model. Their relationships are fixed: drivers connect devices, profiles describe capabilities, devices bind profiles, points carry data, and the data center stores values and dispatches commands. Each row gives the canonical name and where to read more. | Chinese | English · Identifier | Description | Domain | |---------|---------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| | 驱动 | Driver · `dc3-driver-*` | A protocol-adapter service instance that talks to devices or data sources. See [Driver](./concepts/driver) | Device access | | 模板 | Profile | A device capability template aggregating points / commands / events. See [Profile](./concepts/profile) | Metadata | | 设备 | Device | A field-device instance bound to one Profile and one Driver. See [Device](./concepts/device) | Metadata | | 位号 | Point | A single data item; whether it can be written is decided by the Point's `rwFlag`. See [Point](./concepts/point) | Metadata | | 位号值 | PointValue | A collected real-time / historical value (always "PointValue", never "point reading / measurement"). See [Point Value](./concepts/point-value) | Data | | 网关 | Gateway · `dc3-gateway` | The sole external HTTP entry point (`8000`); aggregates center routes and injects auth context. See [Services](../architecture/services) | Access | | 鉴权中心 | Auth Center · `dc3-center-auth` | Authentication / tenant / RBAC / OAuth. See [Auth · Tenant · RBAC](../architecture/auth-rbac) | Center service | | 管理中心 | Manager Center · `dc3-center-manager` | Metadata management (driver / profile / device / point). See [Services](../architecture/services) | Center service | | 数据中心 | Data Center · `dc3-center-data` | Point-value persistence and command dispatch. See [Data Plane](../architecture/data-plane) | Center service | | 智能中心 | Agentic Center · `dc3-center-agentic` | LLM chat and tool calling. See [Services](../architecture/services) | Center service | | 租户 | Tenant · `tenantId` | The isolation boundary for business data. See [Tenant](./concepts/tenant) | Cross-cutting | | 属性 | Attribute | A driver protocol-layer configuration item, registered by the driver from its `application.yml` at startup. See [Attribute & Config](./concepts/attribute-config) | Configuration | | 配置 | Config | The concrete value a device instance supplies for an attribute. See [Attribute & Config](./concepts/attribute-config) | Configuration | ::: tip Don't mix "point reading / measurement / point" Across the docs, use "Point" for the data-item definition and "PointValue" for its runtime value. On first mention, a center service gets its "Chinese name + identifier"; afterwards either form is fine. ::: ## General IoT Terms These are common IoT-domain terms, not specific to DC3. Knowing them helps place DC3's objects in the larger picture — for example, drivers roughly sit between the perception and network layers, while the center services live at the platform layer. | Chinese | English · Identifier | Description | Domain | |---------|----------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------| | 感知层 | Perception Layer | The bottom IoT layer, where sensors and actuators interact directly with the physical world to collect and act | Layering | | 网络层 | Network Layer | The layer that transports perception-layer data to the platform over wired or wireless networks | Layering | | 平台层 | Platform Layer | The layer that aggregates, stores, and manages devices and data, exposing capabilities to applications | Layering | | 应用层 | Application Layer | The layer that consumes platform capabilities for concrete business scenarios (monitoring, scheduling, analytics) | Layering | | 传感器 | Sensor | A device that converts a physical quantity such as temperature or pressure into a readable electrical or digital signal | Perception | | 执行器 | Actuator | A device that receives control instructions and acts on the physical world (e.g. open a valve, start a motor) | Perception | | RFID | RFID | Radio-frequency identification: contactless reading and writing of electronic tags over radio to identify objects | Identification | | NB-IoT | NB-IoT | Narrowband IoT: a cellular standard for low-power, wide-coverage, massive-connection scenarios | Network | | MQTT | MQTT | A lightweight publish / subscribe messaging protocol, common for device reporting over constrained or unstable networks | Application protocol | | CoAP | CoAP | The Constrained Application Protocol: a REST-like protocol over UDP designed for low-power constrained devices | Application protocol | | LwM2M | LwM2M | Lightweight M2M device-management protocol built on CoAP, used for device registration and remote management | Device management | | 边缘计算 | Edge Computing | Processing data on or near the data source — on devices or gateways — to cut latency and backhaul bandwidth | Computing paradigm | | 雾计算 | Fog Computing | Distributed processing on network nodes between edge and cloud; an intermediate layer between the two | Computing paradigm | | 时序数据 | Time-series | Measurement data keyed by timestamp and produced in time order; point values are the canonical example | Data model | | 数字孪生 | Digital Twin | A real-time digital mirror of a physical entity, used for simulation, monitoring, and prediction | Modeling | | AIoT | AIoT | The convergence of AI and IoT, adding intelligent analysis and decision-making on top of collection and connectivity | Convergence paradigm | ## Protocol and Interface Identifiers These identifiers appear directly in the doc examples and the source: the message-bus exchanges, the driver routing key, the login endpoints, and the auth headers the gateway injects. Treat the source as the authority for exact usage; here we give each one's role in the pipeline. | Name · Identifier | Type | Description | Domain | |------------------------|---------------------------|----------------------------------------------------------------------------------------------------------------------------------|--------------| | `dc3.driver.code` | Driver routing identifier | A driver's stable routing identifier used for message-bus addressing; it is a stable identifier and must not be changed casually | Driver | | `dc3.e.value` | RabbitMQ exchange | The exchange for point-value reporting; a driver wraps a reading as a `PointValue` and sends it here | Data flow | | `dc3.e.point_command` | RabbitMQ exchange | The exchange for read/write command dispatch; the data center routes commands to the target driver through it | Command flow | | `X-Auth-Tenant` | HTTP auth header | The tenant identifier carried on protected endpoints, feeding downstream tenant isolation | Auth | | `X-Auth-Login` | HTTP auth header | The login-identity identifier carried on protected endpoints | Auth | | `X-Auth-Token` | HTTP auth header | The access token carried on protected endpoints | Auth | | `POST /token/salt` | HTTP endpoint (public) | Login step one: send `tenant` and `name` to get the salt; use it within 5 minutes (server does not enforce the timeout) | Login | | `POST /token/generate` | HTTP endpoint (public) | Login step two: send `tenant`, `name`, `salt`, and the salt-hashed `password` to get an access token valid for 12 hours | Login | ::: info Login is a two-step token exchange First `POST /token/salt` to get the salt, then hash the password with the salt and `POST /token/generate` to exchange it for an access token. With the token in hand, protected requests carry `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` through the gateway. Treat the source as the authority for exact fields. ::: ## Further Reading - [Core Concepts](./concepts) — a one-sentence mental model plus an entity-relationship diagram that ties the platform terms into an object model - [Domain Model](../architecture/domain-model) — DO/BO/VO layering and field details --- # Platform Positioning URL: https://docs.dc3.site/en/introduction/ <script setup> import IntroductionLoopDiagram from '../../.vitepress/theme/components/IntroductionLoopDiagram.vue' import IntroductionArchitectureDiagram from '../../.vitepress/theme/components/IntroductionArchitectureDiagram.vue' </script> # Platform Positioning IoT DC3 is a multi-protocol, cloud-native, AI-powered, open-source industrial IoT platform evolving toward AI agents. It spans device connectivity, data collection, operations management, and intelligent analytics — and ties them into a single closed loop: multi-protocol drivers collect data from heterogeneous devices and normalize it into point values that machines and people can both read, then large language models read that data and push commands back to the devices. By the end of this page you'll know what problems it solves, who it's for, and how it differs from typical IoT platforms. > Want to jump straight in? Go to [Quick Start](../quickstart/). Want the object model first? > See [Core Concepts](./concepts). ## The Two Gaps It Closes Most industrial sites get stuck in one of two places: 1. **Data can't get out, so AI can't use it.** Device data sits across different protocols and registers, with inconsistent formats and no semantics. Even when AI reaches it, there's nothing to consume. 2. **AI can only watch, not act.** When analytics or LLMs are integrated, they're usually read-only — they can't push decisions down to devices for execution. The loop breaks at the last step. Traditional IoT platforms tend to solve only one side. They're either strong at device connectivity or strong at analytics, and few close the full loop of collect → normalize → analyze → execute → feed back. Closing exactly these two gaps is the design goal of IoT DC3. ## One Closed Loop: From Device Data to AI Execution Turn that goal into a runnable pipeline and you get the core of how IoT DC3 works: drivers collect → the data center normalizes and stores → the LLM reads and analyzes → commands go out through tool calls → devices execute and acknowledge. <IntroductionLoopDiagram lang="en" /> The key to the loop is what a point value is. It's not raw data — it's a structured `PointValue` that carries semantic tags, a unit, a timestamp, and tenant context. The LLM calls platform APIs through Spring AI's native `@Tool`, so it can read and write, and every step is bounded by permission and confirmation checks (see [Agentic Center](../ai/agentic)). ## Who It's For - **IoT platform builders** who need to unify and centrally manage devices across many industrial protocols. - **Smart factory and equipment teams** doing production-line monitoring, device health, and predictive maintenance. - **Remote monitoring and control operators** in energy, agriculture, and urban infrastructure. - Backend developers at home in the Spring ecosystem who want to build on the platform. - Teams exploring AI-assisted operations, or agents that operate devices. ## Typical Scenarios | Scenario | What You Do with IoT DC3 | |-------------------|-------------------------------------------------------------------------------------------| | Smart Factory | Production-line monitoring, device health and predictive maintenance, OEE statistics | | Energy Monitoring | Remote metering and measurement, anomaly alerting | | Smart Agriculture | Greenhouse environment monitoring, irrigation control, yield prediction | | Smart City | Monitoring and remote operation of street lighting, environment, and municipal facilities | ## Capability Pillars 1. **Multi-protocol device connectivity** — 28 driver modules cover industrial fieldbuses, IoT wireless protocols, database bridging, basic communication, and simulation. 2. **AI capability integration** — the agentic center is built on Spring AI, so LLMs read and write points, run commands, and analyze alarms through tool calling, compatible with mainstream models including GPT, Claude, DeepSeek, and Qwen, with conversation memory persisted to the database. 3. **Cloud-native microservices** — Spring Boot 4 and Spring Cloud 2025, with the gateway as the single entrypoint, gRPC between services, stateless nodes that scale horizontally, and fault isolation. 4. **Real-time data engine** — drivers push telemetry through RabbitMQ into time-series storage, a rule engine drives multi-level alarms, and full command and event history keeps things traceable. 5. **Multi-tenant security and isolation** — `tenantId` runs through queries, gRPC calls, and cache keys, with isolation enforced at the database, cache, and API layers, plus JWT auth, RBAC, TLS, and audit logs. 6. **Developer friendly** — a Driver SDK for custom protocols, a separated Vue 3 + TypeScript frontend with REST and gRPC APIs, one-command startup with Podman or Docker Compose, and a path toward Kubernetes. ## How It Differs from Traditional IoT Platforms What sets IoT DC3 apart isn't multi-protocol support on its own — plenty of platforms have that. The combined advantage is: - **AI-native integration**: built in through Spring AI, not bolted on as a separate analytics service. - **Protocol breadth**: 28 drivers, including less common ones like database bridging. - **Structured AI output**: `PointValue` carries semantic tags, so models consume it directly. - **Closed-loop command execution**: LLM decisions go back down to devices for execution. - **Fully open source**: no proprietary core. - **Multi-tenant by design**: isolation is the foundation, not a patch. ::: info An Honest Boundary Multi-protocol support on its own isn't a differentiator — it's the price of admission. IoT DC3 also does **not** currently onboard RTSP/H.264 video streams. If video is your core requirement, evaluate it separately. ::: ## Tech Stack at a Glance 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). ## System Panorama The platform exposes one HTTP entry point — the gateway. Four centers sit behind it, each with its own job, and drivers onboard devices on the southbound side. The diagram shows how the roles fit together; for a layer-by-layer walkthrough, see [System Architecture](../architecture/). <IntroductionArchitectureDiagram lang="en" /> ## Further Reading - [Core Concepts and Mental Model](./concepts) — how drivers, templates, devices, and points relate - [Choosing a Path by Role](./paths) — distinct entry points for evaluation, onboarding, development, and contribution - [Quick Start](../quickstart/) — bring up the stack locally and run your first device end to end - [System Architecture](../architecture/) — break the closed loop down into the implementation details of every hop --- # License URL: https://docs.dc3.site/en/introduction/license This page is for developers, legal reviewers, and decision-makers who need to understand what license IoT DC3 uses, what rights you have, and what obligations it imposes. > You are here: evaluating whether to adopt or redistribute. For key technical decisions, also > read [Core Concepts](./concepts) and the [Contributing Guide](../community/contributing). ## License IoT DC3 Community Edition is licensed under the **GNU Affero General Public License v3.0 or later** (AGPL-3.0-or-later). The full legal text is at the repository root in `LICENSE-AGPL.txt` and `LICENSE.txt`. AGPL v3 extends GPL v3 with one critical clause: **if you serve the software over a network (SaaS), your modified source code must also be released to users**. This is directly relevant to IoT DC3's role as an industrial IoT platform. | You can | You must | |----------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------| | ✅ Commercial use | ⚠️ Keep the copyright notice and license text intact | | ✅ Modify the code | ⚠️ Release modifications under AGPL v3 as well | | ✅ Distribute internally | ⚠️ If you provide the software as a network service (including SaaS), you must make the complete source available | | ✅ Offer paid services, operations, or custom development | ⚠️ Include a prominent notice that the code is AGPL v3 licensed, along with the license text | ::: warning Network interaction triggers copyleft This is the key difference between AGPL and GPL: GPL triggers the source-disclosure obligation only when you "distribute binaries"; AGPL triggers it when users interact with your modified version over a network. In other words, even if you deploy IoT DC3 as an internal SaaS without distributing binaries, if you modified the code you must make the source available to your users. ::: ## Copyright ``` Copyright 2016-present the IoT DC3 original author or authors. ``` The project is copyrighted by the IoT DC3 original author and all contributors. By submitting code, you agree to license your contribution under AGPL v3 while retaining your individual copyright. ## Third-Party Dependencies IoT DC3 depends on many open-source components (Spring Boot, RabbitMQ, PostgreSQL, Netty, gRPC, etc.), each carrying its own license. Maven resolves them at build time, and each is governed by its respective license. For a complete inventory of third-party dependency licenses, run: ```bash mvn -s .mvn/settings.xml license:aggregate-add-third-party ``` ## Why AGPL v3 Typical industrial IoT platform deployments — factory on-premise installation, device connectivity, data collection — are naturally server-side deployments. We chose AGPL v3 to: - **Prevent closed forks**: vendors can't take IoT DC3, modify it slightly, and ship it as a proprietary product without releasing source. - **Protect user rights**: anyone using a derivative of IoT DC3 has the right to obtain the source code. - **Encourage upstream contributions**: AGPL's copyleft scope gives companies a strong incentive to push changes upstream rather than maintaining private forks. ## Further Reading - [Contributing Guide](../community/contributing) — how to submit code, with license compliance notes - [COPYRIGHT file](https://github.com/pnoker/iot-dc3/blob/main/COPYRIGHT) — the original copyright notice at the repository root - [AGPL v3 FAQ](https://www.gnu.org/licenses/agpl-3.0.html) — official GNU FAQ - [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) — the upstream Code of Conduct reference --- # Choose a Path by Role URL: https://docs.dc3.site/en/introduction/paths <script setup> import PathsDecisionDiagram from '../../.vitepress/theme/components/PathsDecisionDiagram.vue' </script> # Choose a Path by Role The docs cover everything from first evaluation to contributing, but the shortest path through them depends on what you're here to do. Pick the role that fits you and read the pages in the order listed. This decision diagram points you to the right lane at a glance; the detailed reading order for each is in the sections below. <PathsDecisionDiagram lang="en" /> ## I want to evaluate the platform first You want to know what it is and whether it's worth your time. Read in this order: 1. [Platform Positioning](./) — the problems it solves and how it compares to similar projects 2. [Core Concepts](./concepts) — the object model and mental model 3. [System Architecture Overview](../architecture/) — the whole picture in one diagram 4. Run a demo: bring up the stack from [Quick Start](../quickstart/), then import the sample data `iot-dc3/dc3/dependencies/postgres/demo/iot-dc3-demo.sql` to see real data flow. ## I need to onboard devices and run day-to-day operations You onboard devices or run operations — connecting devices, reading their data, sending commands, and handling alarms: 1. [Core Concepts](./concepts) — start by telling apart drivers, profiles, devices, and points 2. [Your First Device: End to End](../quickstart/first-device) — walk the full pipeline with the virtual driver 3. [Device Onboarding](../operation/device-onboarding) — connect devices speaking a real protocol 4. [Data and Commands](../operation/data-commands) — collection, history queries, and read/write commands 5. [Alarms and Notifications](../operation/alarms) — set up rules and notification channels ## I'm a backend developer doing custom development You want to extend the platform, most often by writing a new protocol driver: 1. [System Architecture Overview](../architecture/) → [Services and Topology](../architecture/services) 2. [Data Plane](../architecture/data-plane) and [Command Plane](../architecture/command-plane) — the two core pipelines 3. [Domain Model](../architecture/domain-model) — DO/BO/VO, facade boundaries, and CRUD verb conventions 4. [Driver Development](../development/driver-authoring) — derive a new driver from the `dc3-driver-virtual` template 5. [API Documentation](../development/api-documentation) and [Testing](../development/testing) ## I want to do automation / integrate AI You want to drive the platform from scripts or AI agents: 1. [CLI Guide](../automation/cli) — run the platform from the `dc3` command line 2. [AI Agent / MCP Integration](../ai/mcp) — let agents read and write devices safely over MCP 3. [Agentic Center](../ai/agentic) — the platform's built-in conversations and tool calls ## I want to contribute Drivers, fixes, and docs are all welcome: 1. [Development Overview and Conventions](../development/) — coding conventions and commit standards 2. [Testing](../development/testing) — local and CI test gates 3. [Contributing Guide](../community/contributing) · [Code of Conduct](../community/code-of-conduct) · [Security Policy](../community/security) --- # Module Inventory URL: https://docs.dc3.site/en/modules/ The IoT DC3 modules, grouped by repository directory. Each module links to its `README.md` or source directory on the `release` branch on GitHub. ::: tip Source of Truth Driver counts and module names match the current repository layout: 28 connectivity driver modules live under `dc3-driver/`. ::: ## Gateway | Module | Description | Documentation | |---------------|----------------------------------------------|--------------------------------------------------------------------------------| | `dc3-gateway` | Spring Cloud Gateway — the single HTTP entry | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-gateway/README.md) | ## Center Services | Module | Description | Documentation | |----------------------|--------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| | `dc3-center-auth` | Auth Center — tenants, users, roles, resources, and tokens | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-center/dc3-center-auth/README.md) | | `dc3-center-manager` | Manager Center — drivers, profiles, devices, points, and metadata | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-center/dc3-center-manager/README.md) | | `dc3-center-data` | Data Center — point values, queries, and command dispatch | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-center/dc3-center-data/README.md) | | `dc3-center-agentic` | Agentic Center — AI conversations, model providers, and tool calls | [README](https://github.com/pnoker/iot-dc3/tree/release/dc3-center/dc3-center-agentic) | | `dc3-center-single` | Single-process aggregated startup — handy for local debugging | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-center/dc3-center-single/README.md) | ## Protocol Drivers | Category | Module | Protocol / Purpose | |---------------------------------|--------------------------------|----------------------------------------| | Industrial protocols | `dc3-driver-modbus-tcp` | Modbus TCP | | Industrial protocols | `dc3-driver-modbus-rtu` | Modbus RTU | | Industrial protocols | `dc3-driver-opc-ua` | OPC UA | | Industrial protocols | `dc3-driver-opc-da` | OPC DA | | Industrial protocols | `dc3-driver-plcs7` | Siemens S7 | | Industrial protocols | `dc3-driver-bacnet-ip` | BACnet/IP | | Industrial protocols | `dc3-driver-ethernet-ip` | EtherNet/IP | | Industrial protocols | `dc3-driver-fins` | Omron FINS | | Industrial protocols | `dc3-driver-melsec` | Mitsubishi MELSEC | | Industrial protocols | `dc3-driver-iec104` | IEC 60870-5-104 | | Industrial protocols | `dc3-driver-sl651` | SL651 hydrological monitoring protocol | | Industrial protocols | `dc3-driver-dlms` | DLMS / COSEM | | IoT protocols | `dc3-driver-mqtt` | MQTT | | IoT protocols | `dc3-driver-coap` | CoAP | | IoT protocols | `dc3-driver-lwm2m` | LwM2M | | IoT protocols | `dc3-driver-http` | HTTP | | IoT protocols | `dc3-driver-ble` | Bluetooth Low Energy | | IoT protocols | `dc3-driver-zigbee` | Zigbee | | Data bridging | `dc3-driver-mysql` | MySQL data source | | Data bridging | `dc3-driver-postgresql` | PostgreSQL data source | | Data bridging | `dc3-driver-oracle` | Oracle data source | | Data bridging | `dc3-driver-sqlserver` | SQL Server data source | | Base communication & management | `dc3-driver-tcp-udp` | TCP / UDP | | Base communication & management | `dc3-driver-serial` | Serial | | Base communication & management | `dc3-driver-snmp` | SNMP | | Base communication & management | `dc3-driver-can` | CAN | | Simulation & debugging | `dc3-driver-virtual` | Virtual driver | | Simulation & debugging | `dc3-driver-listening-virtual` | Listening-style virtual driver | To write your own driver, see [Driver Authoring](../development/driver-authoring). ## API Contracts | Module | Purpose | Documentation | |-------------------|-----------------------------------------|--------------------------------------------------------------------------------------------| | `dc3-api-auth` | Auth Center gRPC / Protobuf contract | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-api/dc3-api-auth/README.md) | | `dc3-api-manager` | Manager Center gRPC / Protobuf contract | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-api/dc3-api-manager/README.md) | | `dc3-api-data` | Data Center gRPC / Protobuf contract | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-api/dc3-api-data/README.md) | | `dc3-api-driver` | Driver gRPC / Protobuf contract | [README](https://github.com/pnoker/iot-dc3/blob/release/dc3-api/dc3-api-driver/README.md) | ## Common Components | Category | Module | Purpose | |------------------------|-----------------------------------|----------------------------------------------------------------------------------| | Base models | `dc3-common-model` | Shared models — BO / VO / DTO / Builder / Ext | | Base capabilities | `dc3-common-public` | Shared capabilities — the `R<T>` response wrapper, `BaseService`, tenant markers | | Web | `dc3-common-web` | WebFlux, BaseController, OpenAPI, security baseline | | Constants & exceptions | `dc3-common-constant` | Constants, enums, value objects | | Constants & exceptions | `dc3-common-exception` | Exception hierarchy | | Data access | `dc3-common-dal` | Shared DAL foundation | | Data access | `dc3-common-postgres` | PostgreSQL / MyBatis-Plus configuration | | Data access | `dc3-common-sql` | SQL utilities | | Data access | `dc3-common-repository` | Point value storage abstraction | | Communication | `dc3-common-rabbitmq` | RabbitMQ configuration and constants | | Communication | `dc3-common-mqtt` | MQTT client configuration | | Communication | `dc3-common-facade-api` | Cross-service facade interfaces | | Communication | `dc3-common-facade-grpc` | gRPC facade implementation | | Communication | `dc3-common-facade-local-auth` | Auth local facade | | Communication | `dc3-common-facade-local-manager` | Manager local facade | | Communication | `dc3-common-facade-local-data` | Data local facade | | Domain capabilities | `dc3-common-auth` | Authentication, authorization, tenant, and token domain capabilities | | Domain capabilities | `dc3-common-manager` | Driver, profile, device, point, and metadata domain capabilities | | Domain capabilities | `dc3-common-data` | Point value, command, and data query domain capabilities | | Domain capabilities | `dc3-common-driver` | Driver SDK — registration, scheduling, collection, and command runtime | | Domain capabilities | `dc3-common-agentic` | AI conversation, model provider, tool call, and memory capabilities | | Gateway | `dc3-common-gateway` | Gateway filters and routing helpers | | Platform support | `dc3-common-log` | Logging configuration | | Platform support | `dc3-common-thread` | Thread pool configuration | | Platform support | `dc3-common-quartz` | Scheduling infrastructure | | Platform support | `dc3-common-api` | API utilities | | Platform support | `dc3-common-resource-registrar` | Resource registration | | Testing | `dc3-common-test` | Testcontainers, gRPC, RabbitMQ, and contract test infrastructure | ## Related Documentation - [Architecture Overview](../architecture/) - [Modules & Dependencies](../architecture/modules) - [Driver Authoring](../development/driver-authoring) - [API Documentation](../development/api-documentation) --- # Alarms and Notifications URL: https://docs.dc3.site/en/operation/alarms <script setup> import AlarmSourceFlowDiagram from '../../.vitepress/theme/components/AlarmSourceFlowDiagram.vue' import AlarmErDiagram from '../../.vitepress/theme/components/AlarmErDiagram.vue' import AlarmNotifyFlowDiagram from '../../.vitepress/theme/components/AlarmNotifyFlowDiagram.vue' </script> # Alarms and Notifications IoT DC3 folds "something went wrong" and "who needs to know" into a single runtime alarm table and one notification pipeline. This page covers how the five alarm sources all land in `dc3_entity_alarm`, how the three frontend alarm views filter that same table, and how notifications go out over email, SMS, or webhook once a rule fires. > You are here: you've already [onboarded a device](./device-onboarding) with data flowing, and now want to configure > alarms and notifications for anomalies. To see where the data comes from, revisit > the [Data Plane](../architecture/data-plane). ## Why one table and one pipeline Things fail from many directions: a rule trips a threshold, a device or driver heartbeat times out, a device reports a fault, a driver reports an anomaly, or a reported event triggers a rule. If every source had its own table and its own delivery path, operations teams would juggle multiple pages to piece things together. IoT DC3 takes a different route: * *every runtime alarm, no matter its source, lands in `dc3_entity_alarm`**. Two flags tell them apart — `alarm_source_flag` (where it came from) and `alarm_target_type_flag` (which entity it targets) — and composite indexes make views like Driver / Device / Point Alarm fast to filter. That table pairs with a rule → state machine → notification pipeline. Together they are the two pillars of the alarm subsystem: the table is the **record of fact**, the pipeline is the **trigger and delivery**. ## How the five sources flow into one table All five alarm sources write to `dc3_entity_alarm`; only the flag value differs. The values come from `AlarmSourceTypeEnum` — note that `EVENT_REPORT=5` and `SYSTEM=4` (5 is reserved for persistence compatibility and sits after 4 in the enum): <AlarmSourceFlowDiagram lang="en" /> `alarm_source_flag` (where it came from) and `alarm_type_flag` (what happened) are two independent dimensions — don't conflate them: - **Source `alarm_source_flag`**: `0=RULE`, `1=STATE_TIMEOUT`, `2=DEVICE_REPORT`, `3=DRIVER_REPORT`, `4=SYSTEM`, `5=EVENT_REPORT`. - **Type `alarm_type_flag`**: `0=RULE` (rule match), `1=OFFLINE` (heartbeat timeout), `2=FAULT` (internal device fault), `3=STATE_FLIP` (entity state flip), `4=REPORT` (external event report). ### Three frontend views, one table The three alarm pages under Settings all query `dc3_entity_alarm` (via `POST /api/v3/data/dashboard/alert/page`) and differ only by the `source` string in the request body: | View | Route Path | Filter Parameter | |--------------|--------------------------|------------------| | Driver Alarm | `/settings/alarm/driver` | `source=driver` | | Device Alarm | `/settings/alarm/device` | `source=device` | | Point Alarm | `/settings/alarm/point` | `source=point` | Filtering stays fast thanks to two composite indexes: `idx_entity_alarm_source_time (tenant_id, alarm_source_flag, create_time DESC)` serves paging by source and time, and `idx_entity_alarm_target (tenant_id, alarm_target_type_flag, entity_id, create_time DESC)` serves paging by entity dimension. Both lead with `tenant_id`, so alarm data is strictly isolated per tenant. ## How rules, the state machine, and notifications fit together Writing to `dc3_entity_alarm` only logs an entry. To make alarms "fire repeatedly without flooding, detect recovery, and reach the right people," the pipeline does the work: `dc3_rule` (rule definition) → `dc3_rule_state` (runtime state machine) → `dc3_notify` (notification config) → `dc3_notify_channel` (channel) → `dc3_notify_history` (delivery audit). The diagram below shows how these tables relate to `dc3_event_history`. These are logical associations — linked by id columns, with no foreign-key constraints in the database: <AlarmErDiagram lang="en" /> ### Trigger state machine: pending → firing → recovered → closed `dc3_rule_state` holds the runtime state of each rule for each entity (uniquely keyed by `fingerprint`), and `entity_state_flag` is constrained by `CHECK (entity_state_flag BETWEEN 0 AND 3)`: - `0=pending` awaiting trigger, `1=firing`, `2=recovered`, `3=closed`. - On a state flip it records `first_trigger_time` / `last_trigger_time` / `last_recover_time` / `last_notify_time` plus `trigger_count`, and backfills the current alarm's `alarm_id`. So one anomaly firing continuously just increments the count instead of spamming — and recovery is still detected. ### From trigger to delivery After a rule matches, alarm writing and notification sending run in this order. The pending record in `dc3_notify_history` is persisted synchronously inside the transaction; only then does dispatch happen asynchronously over RabbitMQ, with the status written back afterward: <AlarmNotifyFlowDiagram lang="en" /> `dc3_notify_channel.channel_type_flag` is constrained by `CHECK (channel_type_flag BETWEEN 0 AND 2)`, so `0=email`, `1=SMS`, `2=webhook`. `dc3_notify_history.status_flag` is constrained by `CHECK (status_flag BETWEEN 0 AND 4)`, covering the five states pending / sent / success / failed / retry — failures can be retried and increment `retry_count`. ## Event history vs runtime alarms: don't conflate them `dc3_event_history` and `dc3_entity_alarm` come up together, but they aren't the same thing. Event history is the **raw log of events a device reports on its own** — a device sends an `EventReportDTO`, which `EventReportReceiver` writes to `dc3_event_history`. A runtime alarm is the **unified record produced when any source triggers**. The link between them: an event report *may* trigger rule evaluation, which produces a runtime alarm with `alarm_source_flag=5` — but the event history itself stays an independent log. | Dimension | `dc3_event_history` (Event History) | `dc3_entity_alarm` (Runtime Alarm) | |-------------------|--------------------------------------------------------|--------------------------------------------------------------------------------| | Definition source | Event definitions in the template (`dc3_event` table) | Alarms produced by any rule/state trigger | | Initiator | Device reports via `EventReportDTO` | Rule engine, state timeout, device/driver/event report | | Storage table | `dc3_event_history` (raw log) | `dc3_entity_alarm` (unified record) | | State tracking | `acknowledge_flag` (0 unacknowledged / 1 acknowledged) | `confirm_flag` (0/1) + `dc3_rule_state` lifecycle | | Lifecycle | Created once, acknowledged afterward | Linked to `dc3_rule_state`, tracking trigger_count, first/last times, recovery | `dc3_event_history` also carries its own classification fields: `event_type_flag` (`0=info` / `1=alert` / `2=fault` / `3=lifecycle`) and `event_level_flag` (`0=LOW` / `1=MEDIUM` / `2=HIGH` / `3=CRITICAL`). Each report is uniquely identified by `record_id` (UUID). ## Hands-on: query and acknowledge To query a tenant's alarms, hit the data center's dashboard endpoint. Below is the real path and the request/response shape, with example values marked as such. ::: code-group ```bash [curl query alarms] # Query runtime alarms via the gateway (8000), filtering by source/type/confirmation state # The three X-Auth-* values are obtained after logging in via 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: <example: your tenant>' \ -H 'X-Auth-Login: <example: your login name>' \ -H 'X-Auth-Token: <example: token returned on login>' \ -d '{ "current": 1, "size": 20, "source": "device", "alarmTypeFlag": 0, "confirmFlag": 0 }' ``` ```json [response shape (example)] { "code": "R200", "data": { "current": 1, "size": 20, "total": 3, "records": [ { "id": "example-snowflake-ID", "source": "device", "sourceId": "example-source-entity-ID", "pointId": "example-point-ID", "alarmTypeFlag": 0, "confirmFlag": 0, "message": "example-alarm-description", "createTime": "2026-06-22T10:00:00" } ] } } ``` ::: ::: tip The three views are just one source parameter The Driver / Device / Point Alarm pages are the same endpoint with a different `source` (`driver` / `device` / `point`). To replicate them with curl, you only switch that one field. ::: ## Constraints and boundaries ::: warning Alarm levels are P0–P3 `alarm_level_flag` takes `0=P0 … 3=P3`, where 0 is the highest priority — mind the direction when sorting or filtering. ::: ::: danger Alarms are strictly isolated by tenant `dc3_entity_alarm`, `dc3_rule`, `dc3_rule_state`, and `dc3_notify*` all carry `tenant_id`, and every index leads with `tenant_id`. When adding queries or cache keys, preserve tenant scope — never read across tenants or drop `tenant_id`. ::: ::: info Table associations are logical, with no foreign-key constraints `rule_id`, `rule_state_id`, `alarm_id`, `notify_id`, `channel_id`, and the like are logical associations on id columns — there are no FK constraints in the database. On delete or cleanup, keep consistency at the business layer; the DB does not cascade. ::: ::: info Source code is authoritative The flag values and constraints on this page come from `AlarmSourceTypeEnum` / `AlarmTargetTypeEnum` and the `CHECK` constraints in `03-iot-dc3-data.sql` (repository path `dc3/dependencies/postgres/initdb/03-iot-dc3-data.sql`). Field names match the DO models and SQL; the concrete notification triggering and async dispatch live in service classes like `AlarmRuleTriggerService`. ::: ## Further reading - [Device Onboarding](./device-onboarding) — the prerequisite for alarms: get the device in and values flowing first - [Data Plane](../architecture/data-plane) — how device values are persisted, the data source for alarm rule evaluation --- # Data and Commands URL: https://docs.dc3.site/en/operation/data-commands <script setup> import DataCommandsFlowDiagram from '../../.vitepress/theme/components/DataCommandsFlowDiagram.vue' import DataCommandsStateDiagram from '../../.vitepress/theme/components/DataCommandsStateDiagram.vue' </script> # Data and Commands Once a device is connected, there are two things to verify: can you read its values, and can you push commands to it? This page covers both. First you'll use real `curl` calls to read a point's latest value and history, then issue a write command and poll for its result. The last section covers what happens at the edges: offline devices, read-only points, and failed writes. > You are here: you've already [connected your first device](../quickstart/first-device) and values are flowing into > storage. By the end of this page you'll be able to query values, issue commands, and tell whether a command truly > succeeded — on your own. ## Two opposite pipelines Data and commands run in opposite directions, but on your side both are just HTTP calls through the Gateway ( `dc3-gateway`, `8000`). **The data flow goes device → you.** The driver packages each acquisition into a PointValue, sends it through RabbitMQ's `dc3.e.value` exchange to the Data Center (`dc3-center-data`), and stores it in TimescaleDB's `dc3_point_value` hypertable. You read the latest value through `/api/v3/data/point_value/latest` and the historical range through `/api/v3/data/point_value/list`. This is a **query over facts that have already happened** — no side effects, safe to retry. **The command flow goes you → device.** You `POST` a read or write command to the Data Center, which records it in `dc3_point_command_history` (status `PENDING`), then routes it through the `dc3.e.point_command` exchange to the target driver. After the driver runs it against the device, the result comes back through `dc3.e.point_command_result`. The command interface **returns a `commandId` immediately** — actual success or failure is determined by polling with that ID. This is an **asynchronous write path with side effects**. <DataCommandsFlowDiagram lang="en" /> For the field-level details, model transformations, and RabbitMQ topology of each pipeline, see [Data Plane](../architecture/data-plane) and [Command Plane](../architecture/command-plane). This page only covers how to use them. ## Reading data: latest values and history The Data Center exposes two read interfaces. Both are `POST` (the body carries pagination and filter conditions) and each returns `Page<PointValueVO>`. Every value carries `deviceId`, `pointId`, `rawValue` (raw value), `calValue` ( engineering value), `numValue` (numeric projection, nullable), plus `createTime` and `operateTime`. Both are tenant-isolated (`tenantId`) and sit under the permission code `point_value:list`. Protected interfaces require the auth headers `X-Auth-Tenant`, `X-Auth-Login`, and `X-Auth-Token` — see the [API documentation](../development/api-documentation) for how to obtain them. `/api/v3/data/point_value/latest` returns the current value of each point. `deviceId` and `pointId` are optional — supplying only `deviceId` returns the latest values of all points on that device. Pagination fields nest inside the `page` object: ::: code-group ```bash [latest value] # Example deviceId / pointId — replace with your own curl -X POST http://localhost:8000/api/v3/data/point_value/latest \ -H 'Content-Type: application/json' \ -H 'X-Auth-Tenant: <tenant>' -H 'X-Auth-Login: <account>' -H 'X-Auth-Token: <token>' \ -d '{"deviceId": 1001, "page": {"current": 1, "size": 20}}' ``` ```bash [list historical range] # Use rangeKey (today/24h/7d/30d) or createTimeFrom / rangeHours to bound the time window curl -X POST http://localhost:8000/api/v3/data/point_value/list \ -H 'Content-Type: application/json' \ -H 'X-Auth-Tenant: <tenant>' -H 'X-Auth-Login: <account>' -H 'X-Auth-Token: <token>' \ -d '{"deviceId": 1001, "pointId": 2001, "rangeKey": "24h", "page": {"current": 1, "size": 100}}' ``` ::: Compared to `latest`, `/api/v3/data/point_value/list` adds time-filter fields such as `createTimeFrom`, `rangeHours`, and `rangeKey` (`today`/`24h`/`7d`/`30d`) for paging through history. The response looks roughly like this (sample values): ```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 may be null in aggregations The underlying `dc3_point_value.num_value` is `NULL` for non-numeric or JSON payloads. If you bypass the API and run aggregations like `AVG`/`SUM` directly on the hypertable, you must add `num_value IS NOT NULL`, or the results will be skewed. A point's raw and calculated values live in `raw_value` and `cal_value` respectively (both are text). ::: ## Issuing commands: read, write, and polling for results The command interfaces live in the Data Center, under the permission code `point_command:list`. They **don't return the execution result** — only a command ID. Execution is asynchronous; you take that ID and poll the history. `POST /api/v3/data/point_command/read` triggers a single read on demand, bypassing the acquisition cycle to request the value from the device right now. `POST /api/v3/data/point_command/write` writes a value to a writable point. Both accept an optional `commandId` for idempotent deduplication. ::: code-group ```bash [write command] curl -X POST http://localhost:8000/api/v3/data/point_command/write \ -H 'Content-Type: application/json' \ -H 'X-Auth-Tenant: <tenant>' -H 'X-Auth-Login: <account>' -H 'X-Auth-Token: <token>' \ -d '{"deviceId": 1001, "pointId": 2001, "value": "100"}' # The data field of the response body is the commandId (example): "a1b2c3d4-...." ``` ```bash [read command] curl -X POST http://localhost:8000/api/v3/data/point_command/read \ -H 'Content-Type: application/json' \ -H 'X-Auth-Tenant: <tenant>' -H 'X-Auth-Login: <account>' -H 'X-Auth-Token: <token>' \ -d '{"deviceId": 1001, "pointId": 2001}' ``` ```bash [poll for result] # commandId is the command ID obtained in the previous step curl 'http://localhost:8000/api/v3/data/point_command_history/get_by_command_id?commandId=a1b2c3d4-....' \ -H 'X-Auth-Tenant: <tenant>' -H 'X-Auth-Login: <account>' -H 'X-Auth-Token: <token>' ``` ::: Polling returns a `PointCommandHistoryVO`. Its key fields are `status` (execution status, `PointCommandStatusEnum`), `responseValue` (result / read-back value), `requestValue`, `finishTime`, and `expireTime`. Status starts at `PENDING` and moves forward from there — it's only truly done when you see `SUCCESS`: ```json { "data": { "commandId": "a1b2c3d4-....", "deviceId": 1001, "status": "SUCCESS", "responseValue": "100", "finishTime": "2026-06-22T12:00:01" } } ``` ::: tip Commands have a default 10-second time-to-live The command DTO's `expireAt` defaults to `time of dispatch + 10s`. If the device isn't reached before then, the driver — upon consuming the command and finding `now > expireAt` — marks it `EXPIRED` instead of waiting indefinitely. If polling stays at `PENDING`/`SENT` for a long time, suspect the driver or an offline device first. ::: ## Command state machine: where does it go after PENDING A command's lifetime advances one cell at a time through `dc3_point_command_history.status`. `PENDING` means it's just been persisted and is waiting to be published; once the RabbitMQ publisher-confirm returns, it moves to `SENT` (queued, waiting for the driver). From there the driver's execution receipt decides the terminal state. Understand this diagram and you can work backwards from any status to see which hop it's stuck on. <DataCommandsStateDiagram lang="en" /> There are six terminal states, matching values `2`–`7` of `PointCommandStatusEnum`: `SUCCESS(2)` succeeded; `FAILED(3)` the driver explicitly failed; `TIMEOUT(4)` the application layer never received a receipt; `EXPIRED(5)` consumed only after `expireAt` had passed; `DUPLICATE(7)` blocked by the driver's dedup cache; `DEAD(6)` rejected into the dead-letter queue and no longer processed. `PENDING(0)` and `SENT(1)` are transitional. ::: info TIMEOUT currently has no producer `TIMEOUT(4)` is reserved in `PointCommandStatusEnum`, but no code in the current pipeline sets a command to this status; `SUCCESS/FAILED/EXPIRED/DUPLICATE/DEAD` are the terminal states actually produced. Read the `TIMEOUT` transition in the state-machine diagram with that in mind. ::: ## Edge cases: offline, read-only points, and failed writes Before dispatching a command, the system validates in this order: tenant consistency → device/point enabled → `rwFlag` check (for write commands) → driver online. If any check fails, the command is never dispatched. Three common "the command didn't go through" causes are worth keeping distinct: When the device or driver is **offline**, the command can still be submitted and you'll get a `commandId`, but nothing consumes it — so it usually sits at `SENT` until `EXPIRED`, or becomes `TIMEOUT` after a timeout. Whether a device is online is determined by the lease in `dc3_entity_state` plus the RabbitMQ heartbeat; you don't set it by hand. A point's read/write capability is set by its `rwFlag`, which takes the values `READ_ONLY`, `WRITE_ONLY`, and `READ_WRITE`. A write command to a read-only point is rejected outright at the validation stage. ::: danger Writes to read-only points are rejected; failed writes echo no value Calling `/point_command/write` on a point with `rwFlag=READ_ONLY` is rejected — this is a design constraint, not a temporary check. A write command is recorded as `SUCCESS` **only** when the driver's `write()` explicitly returns success. On failure the status is `FAILED` and `responseValue=null` — **a failed write echoes no value at all**, so nothing makes it look like it succeeded. When you poll and see `FAILED`, don't treat `responseValue` as the value that was written. ::: ::: info Custom commands are a separate mechanism This page covers point-level read/write commands (`dc3.e.point_command` / `PointCommandDTO`). Device-level "custom commands" use the separate `dc3.e.command` / `CommandCallDTO` namespace — don't mix the exchanges or DTOs of the two. ::: ## Troubleshooting checklist Find issues quickly by symptom. The table is a quick reference; root-cause explanations are in the text above and in the two plane documents. | Symptom | Check first | |-------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| | Devices exist but `/point_value/latest` returns empty | Whether tenant/`deviceId`/`pointId` are correct, the acquisition cycle, driver protocol logs, whether RabbitMQ is backed up | | Historical values have gaps or lag | Batch thresholds (`POINT_BATCH_SPEED`/`POINT_BATCH_INTERVAL`), RabbitMQ backlog, Data Center logs | | Command stuck at `PENDING`/`SENT` for a long time | Whether the target driver is online, whether it listens on `dc3.q.point_command.{serviceName}` | | Command becomes `EXPIRED` | Device/driver offline or slow to respond; the 10-second `expireAt` has elapsed | | Write command returns `FAILED` | Whether the point's `rwFlag` allows writes, the written value's type/range, the protocol return code | ## Further reading - [Data Plane](../architecture/data-plane) — every hop a value takes from device to hypertable, the RabbitMQ topology, and model transformations - [Command Plane](../architecture/command-plane) — the command lifecycle state machine, queue TTL/DLX, and the result-receipt channel - [First Device](../quickstart/first-device) — no device to query yet? Walk this golden path first - [API Documentation](../development/api-documentation) — how to obtain the auth headers, where to find OpenAPI/Swagger --- # Device Onboarding URL: https://docs.dc3.site/en/operation/device-onboarding <script setup> import DeviceOnboardingSelectDiagram from '../../.vitepress/theme/components/DeviceOnboardingSelectDiagram.vue' import DeviceOnboardingFlowDiagram from '../../.vitepress/theme/components/DeviceOnboardingFlowDiagram.vue' </script> # Device Onboarding Onboarding a field device into IoT DC3 takes five steps: pick a driver by protocol, build a profile and its points, create a device bound to that profile and driver, fill in the concrete point attribute values, then enable it and confirm it's online with queryable data. This page walks the full flow with the built-in `dc3-driver-virtual`, then shows how the same steps apply to real protocols. > You are here: you already understand the [Core Concepts](../introduction/concepts) (driver/profile/device/point, the > three configuration layers) and are onboarding your first device. For a faster copy-paste version, > see [First Device](../quickstart/first-device). ## Decide First: Which Driver The first decision is **which driver to use, based on the protocol the device speaks**. A driver (Driver / `dc3-driver-*`) is a protocol adapter. It knows how to talk to a class of devices and tells the Manager Center which configuration items those devices and points need. Pick the wrong protocol and the profile and points downstream won't line up. The platform ships 28 built-in drivers covering industrial fieldbus, IoT wireless, database bridging, and basic communication. The diagram maps common choices to a single driver module by protocol: <DeviceOnboardingSelectDiagram lang="en" /> ::: tip Two tips when you're unsure - **Run the virtual driver first**: `dc3-driver-virtual` generates synthetic values from configuration and needs no real device. It's the fastest way to validate the whole "profile → device → point → queryable data" chain, and it's also the template project for writing a new driver. - **Data direction decides the mode**: when the platform reads from the device (polling), use a conventional driver like `dc3-driver-virtual`. When an external system pushes data into the platform, use the reverse-listening `dc3-driver-listening-virtual` (TCP `6270` / UDP `6271`). ::: The full list of all 28 drivers (industrial / IoT / database / metering / simulation) is in [Driver Authoring](../development/driver-authoring) and the module map. The sections below follow a real onboarding flow using `dc3-driver-virtual`. ## How the Data Flows During Onboarding To know what "onboarded successfully" means, look at how a value travels from the device to a queryable location. The driver reads raw values over the protocol, normalizes them into a `PointValue`, persists them to the Data Center (Data Center / `dc3-center-data`) through RabbitMQ, and exposes them through the Gateway (Gateway / `dc3-gateway`, the single external entry point, port `8000`). <DeviceOnboardingFlowDiagram lang="en" /> So a device is successfully onboarded not when the driver starts, but **when this chain is fully connected and the device's latest point values are queryable in the Data Center**. The switches, queues, and TTLs in this data plane are covered in [Data and Commands](./data-commands). ## Step 0: Bring Up the Stack and Register the Driver Before onboarding, get the dependencies and center services running, then start the target driver: - PostgreSQL, RabbitMQ, and the core center services (auth / manager / data center) are up. - Running from local source? Load the environment variables first: `source dc3/env/dev.env.sh`. This points the local Java processes at the services Compose exposes on `localhost`. See [Environment Variables](../quickstart/environment) for details. - Start at least one driver — for example, the virtual driver. ```bash java -jar dc3-driver/dc3-driver-virtual/target/dc3-driver-virtual.jar ``` On startup, `DriverInitRunner` runs three steps: **register → `initial()` → `schedule()`**. It submits a `RegisterBO` to the Manager Center over gRPC, carrying the driver code, name, service info, tenant, and every attribute definition it declares. A failed registration is retried automatically with exponential backoff (2–30 seconds, up to 30 attempts). ::: danger `dc3.driver.code` is a stable routing identifier The driver code `dc3.driver.code` is the stable identifier for message routing and device ownership. **Don't change it after registration** — changing it is equivalent to swapping in a different driver, and any already-bound devices will lose their link. Each driver instance's code must be unique and stable. ::: If the driver doesn't appear in the driver list after a moment, check these common causes: | Symptom | Resolution | |--------------------------------------------------|----------------------------------------------------------------| | Manager Center not started | Start the Manager Center first, then restart the driver | | `CENTER_MANAGER_HOST` points to the wrong target | Check `dc3/env/dev.env(.sh)` or your IDE environment variables | | Duplicate driver code | Keep `dc3.driver.code` unique and stable | | RabbitMQ not ready | Wait for the health check to pass, then restart the driver | ## Steps 1–4: The Golden Path Onboarding The walkthrough uses the gateway HTTP API. All write endpoints go through the gateway on `:8000`, and protected endpoints require the auth headers `X-Auth-Tenant` / `X-Auth-Login` / `X-Auth-Token` (call `POST /api/v3/auth/token/salt` to get the salt, then `POST /api/v3/auth/token/generate` to get a token valid for 12 hours; see the golden-path login flow for details). Below, `$TOKEN` is the access token from login; the IDs and names in the examples are sample values. ### 1. Create a Profile Create a capability profile for devices of the same kind. The profile defines which points, commands, and events this class of devices has, and a device just reuses it. ```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}' # Response: R.ok(SuccessCode.ADD), i.e. "Added successfully"; add does not return the new entity id # When a later step needs profileId, call POST /api/v3/manager/profile/list and look it up by profileName ``` `profileShareFlag` takes a `ProfileShareTypeEnum` value (`TENANT` / `DRIVER` / `USER`) that sets the profile's sharing scope. ### 2. Create Points Under the Profile A point (Point) is a single data item. The key fields are the data type `pointTypeFlag` and the read/write direction `rwFlag`. Whether a point is writable **is set by its own `rwFlag`**, not by the command table. The optional `baseValue` / `multiple` linearly convert the raw value into an engineering value, and `unit` labels the 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 from the previous step>","valueDecimal":2,"unit":"celsius","enableFlag":true}' # Response: R.ok(SuccessCode.ADD); add does not return an id. When you need pointId, call /api/v3/manager/point/list and look it up by pointName ``` `pointTypeFlag` takes a `PointTypeEnum` value (`STRING` / `BYTE` / `SHORT` / `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN`, 8 total). `rwFlag` takes a `RwTypeEnum` value (`READ_ONLY` / `WRITE_ONLY` / `READ_WRITE`). ### 3. Create a Device and Bind It to a Profile and Driver A device (Device) is the platform mirror of one concrete field device. It **binds to one profile** (which sets its points) **and one driver** (which sets how it communicates). ```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":"<virtual driver id>", "profileId":"<profileId>","enableFlag":true}' # Response: R.ok(SuccessCode.ADD); add does not return an id. When you need deviceId, call /api/v3/manager/device/list and look it up by deviceName ``` Once the device is created, the driver picks up the change through a metadata event (`DriverMetadataListener.event(...)` receives ADD/UPDATE/DELETE) and refreshes its cache. In most cases you don't need to restart the driver. ### 4. Configure the Device's Point Attributes (the Config values for Attributes) This step is the easiest to get wrong, so keep two concepts apart: ::: info Attribute is "what exists" registered by the driver; Config is the "concrete value" filled in by the device instance - **Attribute** (`PointAttribute` / `DriverAttribute`, etc.): the protocol-layer configuration items registered from the driver's own `application.yml` **when the driver starts**. They declare which configuration items this driver's points **require** — say, the register address for Modbus, or the value range for virtual. You don't create attributes; they come with the driver's registration. - **Config** (`PointAttributeConfigDO`, etc.): the **concrete value** **this device** supplies for each attribute above — say, "for the temperature point of motor-01, the register address is 40001." This step is exactly about filling in Config. The full explanation of the three configuration layers (business-layer Param / protocol-layer Attribute / instance-layer Config) is in [Core Concepts](../introduction/concepts). ::: To write the instance value for a given attribute of a given point on a device, call `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":"<point attribute id registered by the driver>","deviceId":"<deviceId>", "pointId":"<pointId>","configValue":"40001","enableFlag":true}' # Response: R.ok(SuccessCode.ADD); add returns a uniform success code and does not return the new record id ``` `attributeId` comes from the attribute list the driver registered; `configValue` is the value this device instance supplies. Each driver declares a different attribute set — the virtual driver declares synthetic parameters like value ranges, while a Modbus driver declares protocol parameters like registers and addresses. ::: tip Calibrate points with real field parameters For industrial protocols, focus on verifying: whether the register / address / object ID / topic is correct; the data type and byte order; whether the multiplier and unit match the field; whether the read/write direction matches the device's capability; and whether the acquisition interval matches the device's performance. All of these land in `configValue`. ::: ## Step 5: After Enabling, Confirm the Device Is Online and the Data Is Queryable The endpoint of onboarding is confirming the chain is connected. After enabling the device, wait one acquisition cycle, then check in this order: status, data, logs. **Check the data first** — if you can query the latest point value, the whole chain is connected. Query this device's latest values through the gateway: ::: 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":"<deviceId>","current":1,"size":20}' ``` ```json [Response shape (sample values)] { "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` returns a `Page<PointValueVO>`. Each record contains `deviceId` / `pointId` / `driverId` / `tenantId` / `rawValue` (raw value) / `calValue` (engineering value) / `numValue` (numeric projection, nullable) / `hasLatestValue` / `createTime` / `operateTime`, with times as local date-time. To page through historical values by time window, use `POST /api/v3/data/point_value/list`. The complete read/write command chain is in [Data and Commands](./data-commands). **If there's no data, work backward along this chain.** Each hop maps onto the data-flow diagram above: 1. **Driver status**: Is the driver online? Is the device health status `ONLINE`? The status TTL the driver reports * *must be greater than the read cycle** — for a 30-second cron, the TTL should be at least 25 seconds, or the device will keep dropping offline. 2. **Driver logs**: Are there protocol connection errors (can't reach host/port, register out of range, authentication failure)? 3. **RabbitMQ**: Is the queue backed up or misbound? That would mean the driver sent the data but the Data Center didn't consume it. 4. **Data Center**: Did it receive the point value messages for this device? 5. **Tenant consistency**: Do the `tenantId` values of the device, profile, point, and attribute config all match? Cross-tenant access returns 404, not data. 6. **Attribute config**: Is `configValue` missing, or in a format the driver doesn't expect (say, an illegal string in an address field)? ::: warning Device stuck offline? Check the status TTL first The most common "enabled but no value queryable" cause is a status TTL set too small. The driver reports a heartbeat on the read cycle, and a TTL shorter than the cycle expires between two reports, marking the device offline. Set the TTL slightly larger than the read cycle. ::: Once you've run the virtual driver end to end, apply the same five steps to a real protocol. The only changes are the driver module chosen in Step 0 and the different attribute set each driver declares in Step 4. ## Further Reading - [First Device](../quickstart/first-device) — a shorter copy-paste version. Run it first, then come back here for detail. - [Data and Commands](./data-commands) — after onboarding, how to query historical values, issue read/write commands, and handle acknowledgements. - [Core Concepts](../introduction/concepts) — the driver/profile/device/point mental model and the three configuration layers (Param/Attribute/Config). - [Driver Authoring](../development/driver-authoring) — the list of 28 drivers, the SPI contract, and how to write a new protocol driver starting from `dc3-driver-virtual`. --- # Operations Manual URL: https://docs.dc3.site/en/operation/ <script setup> import OperationIndexDiagram from '../../.vitepress/theme/components/OperationIndexDiagram.vue' </script> # Operations Manual This page is the entry to the operations manual. It follows one main thread — onboard a device, see data, issue commands, receive alarms, then let AI drive operations — and points you to the right page for each step. Read it once and you get a task you can run end to end, not a list of features. > You are here: you already know the [platform positioning](../introduction/) and > the [core concepts](../introduction/concepts), and you're ready to get hands-on. If your local environment isn't up > yet, > finish the [Quick Start](../quickstart/) first. ## One main thread, five actions Day-to-day use of the platform is a linear flow. First a driver brings field devices online and you confirm that point values come back. Then you issue read and write commands and check the receipts. Finally the rule engine turns anomalies into alarms, and you can optionally hand things off to the Agentic Center for natural-language operations. Each step feeds the next — no online device means no point values, and without point values commands and alarms have nothing to act on. <OperationIndexDiagram lang="en" /> Solid lines are the task order; dashed lines point to the page that covers each action. The first four steps are the platform's core. The fifth, AI Operations, is optional. ## Recommended path Work through the pages in this order. Each step builds on the last and gives you something concrete to check. 1. Start with [Core Concepts](../introduction/concepts) to lock in the relationships among drivers, profiles, devices, points, and point values. The terminology there is used everywhere else. 2. Follow [Device Onboarding](./device-onboarding) to onboard one device. Run the full chain with `dc3-driver-virtual` first, then swap in a real protocol driver. 3. Follow [Data and Commands](./data-commands) to confirm point value collection and history queries, and to issue read/write commands and read their receipts. 4. Follow [Alarms and Notifications](./alarms) to set up rules that fire alarms when a device goes offline, a point goes out of bounds, or an event is reported. 5. If you want to drive operations in natural language with a large language model, read [Agentic Center](../ai/agentic). ### What success looks like Every step has a signal you can check yourself. Don't move on without it. ::: tip Three success signals - **Device online**: after onboarding, the device status flips to online (the heartbeat lease is still alive) instead of sitting at unknown or offline. - **Point has a value**: `POST /api/v3/data/point_value/latest` returns the latest value of the device's point, with `calValue`/`numValue` and `createTime` populated. - **Command has a receipt**: after you issue a read/write command, take the returned command ID and query `GET /api/v3/data/point_command_history/get_by_command_id`. `status` reaches a terminal state (SUCCESS, FAILED, and so on) and `responseValue` holds a result, rather than hanging on pending. ::: ::: warning A failed write command does not echo back When a write command fails, `responseValue` in the receipt is `null` and the device-side value is not echoed. While troubleshooting, trust `status` — don't read "no echo" as "not yet executed". ::: ## Runtime entry points The platform exposes a single HTTP endpoint to the outside world: the Gateway (default port `8000`, set by `DC3_GATEWAY_PORT`). It fronts the four centers — Auth, Manager, Data, and Agentic — and handles auth-header extraction and tenant-context injection in one place. During development you can bypass the gateway and hit a center directly to debug. In production, traffic always goes through the gateway. The table below is a reference index. For how to use each entry point, see its own page. | Entry point | Address / Description | Purpose | |-------------------------------------|---------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| | Gateway API | `http://localhost:8000/api/v3/...` | The only external HTTP entry point; the curl examples below all hit this | | Swagger UI | `http://localhost:8000/swagger-ui.html` | Browse the gateway-aggregated API in development (usually disabled in production) | | Direct-connect debugging per center | Auth `8300` / Manager `8400` / Data `8500` / Agentic `8600` | When debugging a single center, connect straight to its HTTP port, bypassing the gateway | | MCP / OAuth entry | `POST /mcp`, `GET /.well-known/oauth-protected-resource` | For AI Agents to reach MCP tools over OAuth 2.1 (both at the gateway root, not under `/api/v3`); see [Agentic Center](../ai/agentic) | | Web UI | The frontend source lives under `dc3-web/` in this repository | The graphical interface; its backend calls go through the Gateway too | ::: info The Web UI uses the same API entry point The graphical interface lives under `dc3-web/` in this repository and calls the same set of APIs through the Gateway. This manual describes operations in terms of API calls and curl. The matching UI entry points map one-to-one. ::: ## From login to a single command: the minimal runnable example The two snippets below walk the golden path through its minimal closed loop: get a token, then issue a read command. Login is two steps — fetch the salt, then exchange the salted password for a token (valid for 12 hours). After that, every protected call must carry the three auth headers. The example values (tenant, username, IDs) are placeholders. Replace them with your own. ::: code-group ```bash [1. Get salt + exchange for token] # Get the login salt (public endpoint; use within 5 minutes) curl -s -X POST http://localhost:8000/api/v3/auth/token/salt \ -H 'Content-Type: application/json' \ -d '{"tenant":"default","name":"dc3"}' # Exchange the salted password for an access token (valid for 12 hours) curl -s -X POST http://localhost:8000/api/v3/auth/token/generate \ -H 'Content-Type: application/json' \ -d '{"tenant":"default","name":"dc3","salt":"<salt returned in the previous step>","password":"<salted password>"}' ``` ```bash [2. Issue a read command] # Carry the three auth headers and issue a read command for a device's point curl -s -X POST http://localhost:8000/api/v3/data/point_command/read \ -H 'Content-Type: application/json' \ -H 'X-Auth-Tenant: <tenantId>' \ -H 'X-Auth-Login: dc3' \ -H 'X-Auth-Token: <token returned in the previous step>' \ -d '{"deviceId":"<deviceId>","pointId":"<pointId>"}' # The return value is the command's ID (String); use it to query the receipt in point_command_history ``` ::: ::: warning Commands are asynchronous, with a 10-second default validity A read or write command returns the command ID right away; the execution result is written back asynchronously. The command's `expireAt` defaults to `now+10s`. If a driver doesn't consume it before the timeout, it's discarded. So a returned command ID means "accepted", not "done". To see the actual result, query `point_command_history` by ID. See [Data and Commands](./data-commands) for the details. ::: ## Further reading - [Core Concepts](../introduction/concepts) — the relationships among driver, profile, device, and point, plus the three-tier configuration. Read it before you operate. - [Device Onboarding](./device-onboarding) — Step 1: run a complete onboarding once with the virtual driver. - [Data and Commands](./data-commands) — Steps 2 and 3: point value collection, history queries, and read/write command receipts. - [Alarms and Notifications](./alarms) — Step 4: rule-triggered alarms, notification channels, and the acknowledgment flow. - [Agentic Center](../ai/agentic) — optional: natural-language operations, built-in tools, and MCP integration. --- # Environment Variables Explained URL: https://docs.dc3.site/en/quickstart/environment <script setup> import EnvironmentDiagram from '../../.vitepress/theme/components/EnvironmentDiagram.vue' </script> # Environment Variables Explained IoT DC3 has two separate sets of environment files, and they target different consumers. The root `.env` feeds Docker Compose interpolation; `dc3/env/dev.env(.sh)` feeds local Java processes. By the end of this page you'll know which variable belongs in which file, why `localhost` ports differ from the ports a container sees, and which two secrets you must change before going to production. > You are here: you've already [developed locally from source](./) or brought the stack up with Compose. Next, > see [Deployment Modes and Image Sources](../guide/usage) to learn how the whole stack starts. ## Why Two Sets of Files The same variable name (say `POSTGRES_HOST`) means different things inside a container versus in a Java process on your laptop. Containers reach each other by service name on the Compose network (`dc3-postgres`). A Java process running in your IDE lives on the host, so it can only connect through the **ports Compose publishes to the host** (such as `localhost:35432`). The two file sets exist for these two non-overlapping paths. Mix them up and a local process tries to resolve a container name it can't reach, or a container tries to talk to a `localhost` it can't see. | File | Consumer | Purpose | Injection Method | |----------------------|-------------------------|------------------------------------------------------------------------------------|-------------------------------------| | `.env.example` | Docker Compose template | Copied to root `.env`; defines image registry, image tag, published ports | Compose variable interpolation | | `.env` | Docker Compose | Untracked local config, interpolated by `dc3/docker-compose*.yml` | Compose variable interpolation | | `dc3/env/dev.env` | IDE (EnvFile plugin) | Environment variables for local Java processes, **without** `export` | Read by the IDE EnvFile plugin | | `dc3/env/dev.env.sh` | Shell | Environment variables for local Java processes, with `export`, loaded via `source` | Injected into the shell environment | ::: warning The root `.env` is not injected into local Java processes The root `.env` serves Docker Compose only. It is **not** automatically passed to Java processes started by your IDE or command line. Running from source locally requires `dc3/env/dev.env(.sh)`, which points the process at the dependency ports Compose publishes on `localhost`. Setting `POSTGRES_HOST=localhost` in the root `.env` changes nothing about any container's runtime environment. ::: ## Two Isolated Paths The diagram shows how each file set takes effect along its own path. The key point: these two paths **never cross**. Compose does not read `dev.env.sh`, and local Java processes do not read the root `.env`. <EnvironmentDiagram lang="en" /> The mapping between host ports and internal ports is the core of this diagram: | Dependency | host (for local processes) | internal (for container-to-container) | |---------------|----------------------------|---------------------------------------| | PostgreSQL | `localhost:35432` | `dc3-postgres:5432` | | RabbitMQ AMQP | `localhost:35672` | `dc3-rabbitmq:5672` | | EMQX MQTT | `localhost:31883` | `dc3-emqx:1883` (conventional value) | ## How to Use ### Bring the Stack Up with Compose Create a local `.env` from the template, then bring it up with `make` (backed by `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 ``` ::: Variables in the root `.env` interpolate the compose files, for example images and published ports: ```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 does not inject every variable in `.env` into every container. Only the variables the compose files explicitly reference via `environment` or `env_file` reach a container. ### Run Locally from Source `source` the file before starting a Java process from the command line: ```bash source dc3/env/dev.env.sh ``` Without it, the local process falls back to container service names (`dc3-postgres`, `dc3-rabbitmq`, `dc3-center-manager`) or default ports, so it can't reach the dependencies on your machine. JetBrains IDEA users should use `dc3/env/dev.env` instead (same content, no `export`): install the EnvFile plugin, enable EnvFile in the Run Configuration, and add `dc3/env/dev.env`. Don't use `.env.example` directly as an IDEA environment file — it's a Compose template, not Java runtime configuration. ## Key Variables Grouped by Scenario The groups below cover what you actually need for a given scenario. The full long table is collapsed under `::: details` at the end of the page; here we list only the variables you'll really change or look up when troubleshooting. The three `Scope` values mean: `Runtime` (read by both local and in-container Java processes), `Compose only` (only the root `.env` for Compose), and `Per-process` (a single-service override). ### Security Keys (Runtime) Two secrets are the platform's root of identity. Their defaults exist only for first-run convenience and must never go to production. | Variable | Default | Purpose | |--------------------|------------------------------------------|----------------------------------------------------------------------------------| | `DC3_SECURITY_KEY` | `dc3.security.key.2026.io.github.pnoker` | Signing key the auth center uses to generate and verify login tokens | | `AUTH_HMAC_SECRET` | `io.github.pnoker.dc3` | HMAC-SHA256 key the gateway uses to sign `X-Auth-Principal` for backend services | ::: danger Change to strong random values in production, and never commit or print them `DC3_SECURITY_KEY` and `AUTH_HMAC_SECRET` ship with defaults. In production, change them to strong random values and never commit them to the repository, write them to logs, or print them as-is. When the Spring profile is `pre` or `pro` and `AUTH_HMAC_SECRET` is empty or still equals the default `io.github.pnoker.dc3`, the service throws `IllegalStateException` and refuses to start. This is an intentional security gate — don't bypass it. ::: ### PostgreSQL (Runtime) Local processes connect to `localhost:35432`; in-container processes connect to `dc3-postgres:5432`. `POSTGRES_SCHEMA` overrides the schema only inside a single-service process (such as `dc3_manager` or `dc3_data`) — don't set it globally. | Variable | Default | Scope | Purpose | |---------------------|-------------|--------------|--------------------------------------------------| | `POSTGRES_HOST` | `localhost` | Runtime | `localhost` locally, `dc3-postgres` in-container | | `POSTGRES_PORT` | `35432` | Runtime | host-published port; internal is `5432` | | `POSTGRES_USERNAME` | `dc3` | Runtime | Username | | `POSTGRES_PASSWORD` | `dc3dc3dc3` | Runtime | Password | | `POSTGRES_DB` | `dc3` | Runtime | Database name | | `POSTGRES_SCHEMA` | (unset) | Per-process | Single-service schema override | | `DC3_POSTGRES_PORT` | `35432` | Compose only | Port the container publishes to the host | ### RabbitMQ (Runtime) AMQP host port `35672`, internal `5672`. With TLS on, the internal port switches to `5671`. | Variable | Default | Scope | Purpose | |--------------------------------|-------------|--------------|--------------------------------------------------| | `RABBITMQ_HOST` | `localhost` | Runtime | `localhost` locally, `dc3-rabbitmq` in-container | | `RABBITMQ_PORT` | `35672` | Runtime | AMQP host port; internal `5672` | | `RABBITMQ_USERNAME` | `dc3` | Runtime | Username | | `RABBITMQ_PASSWORD` | `dc3dc3dc3` | Runtime | Password | | `RABBITMQ_VIRTUAL_HOST` | `dc3` | Runtime | virtual host | | `RABBITMQ_SSL_ENABLED` | `false` | Runtime | Enable TLS (uses 5671 when true) | | `DC3_RABBITMQ_PORT` | `35672` | Compose only | AMQP published port | | `DC3_RABBITMQ_MANAGEMENT_PORT` | `15672` | Compose only | Management UI published port | ### EMQX / MQTT (Runtime) MQTT broker host port `31883`. EMQX also publishes several other ports (WebSocket, Dashboard) — see the collapsed long table for the full list. | Variable | Default | Scope | Purpose | |---------------------------|-------------|--------------|---------------------------------------------------------| | `MQTT_BROKER_HOST` | `localhost` | Runtime | broker host | | `MQTT_BROKER_PORT` | `31883` | Runtime | broker port (published by EMQX; internal around `1883`) | | `MQTT_USERNAME` | `dc3` | Runtime | Username | | `MQTT_PASSWORD` | `dc3dc3dc3` | Runtime | Password | | `DC3_EMQX_MQTT_PORT` | `31883` | Compose only | MQTT published port | | `DC3_EMQX_DASHBOARD_PORT` | `18083` | Compose only | Dashboard published port | ### gRPC / facade (Runtime) Center services talk to each other through facades. Distributed deployments default to `DC3_FACADE_MODE=grpc`, and local processes point `CENTER_*_HOST` at `localhost`. | Variable | Default | Scope | Purpose | |-------------------------------|-------------|---------|-------------------------------------------------------------| | `CENTER_AUTH_HOST` | `localhost` | Runtime | Auth center host | | `CENTER_MANAGER_HOST` | `localhost` | Runtime | Manager center host | | `CENTER_DATA_HOST` | `localhost` | Runtime | Data center host | | `CENTER_AGENTIC_HOST` | `localhost` | Runtime | Agentic center host | | `DC3_FACADE_MODE` | `grpc` | Runtime | facade protocol mode | | `DC3_FACADE_GRPC_DEADLINE_MS` | `3000` | Runtime | Per-request gRPC deadline; `0` disables the client deadline | ### Gateway and Service Ports (Compose only) The gateway is the only external HTTP entry point (`8000`). The HTTP and gRPC published ports of each center are listed below. `SERVER_PORT` and `GRPC_SERVER_PORT` are single-process overrides, useful only when you run multiple services locally and need to avoid port collisions. | Variable | Default | Scope | Purpose | |----------------------------------|---------|--------------|-------------------------------------------| | `DC3_GATEWAY_PORT` | `8000` | Compose only | Gateway HTTP published port (entry point) | | `DC3_AUTH_PORT` | `8300` | Compose only | Auth center HTTP | | `DC3_MANAGER_PORT` | `8400` | Compose only | Manager center HTTP | | `DC3_DATA_PORT` | `8500` | Compose only | Data center HTTP | | `DC3_AGENTIC_PORT` | `8600` | Compose only | Agentic center HTTP | | `DC3_AUTH_GRPC_PORT` | `9300` | Compose only | Auth center gRPC | | `DC3_MANAGER_GRPC_PORT` | `9400` | Compose only | Manager center gRPC | | `DC3_DATA_GRPC_PORT` | `9500` | Compose only | Data center gRPC | | `DC3_LISTENING_VIRTUAL_TCP_PORT` | `6270` | Compose only | Listening Virtual driver TCP publish | | `DC3_LISTENING_VIRTUAL_UDP_PORT` | `6271` | Compose only | Listening Virtual driver UDP publish | | `SERVER_PORT` | (unset) | Per-process | Single-service HTTP port override | | `GRPC_SERVER_PORT` | (unset) | Per-process | Single-center gRPC port override | ::: warning `DC3_LISTENING_VIRTUAL_*_PORT` are host-published ports `DC3_LISTENING_VIRTUAL_TCP_PORT` and `DC3_LISTENING_VIRTUAL_UDP_PORT` are the ports Compose publishes to the host. The process's internal ports use `TCP_PORT` and `UDP_PORT` (Per-process). Don't confuse the two. ::: ### Agentic / AI (Runtime) The `AGENTIC_FALLBACK_OPENAI_*` group only kicks in as a fallback when `dc3_model_provider` has no usable provider configured. Conversation memory is off by default. | 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 (fill in when the endpoint requires authentication) | | `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_MEMORY_SCHEMA_INIT` | `never` | Spring AI JDBC memory table init mode (`always`/`never`/`create_if_not_exists`) | | `AGENTIC_MEMORY_ENABLED` | `false` | Whether persistent conversation memory is on | | `AGENTIC_TOOL_CALLING_ENABLED` | `true` | Whether tool calling is on | | `AGENTIC_MEMORY_MAX_MESSAGES` | `50` | Maximum messages retained per conversation window | | `AGENTIC_ATTACHMENT_STORAGE_PATH` | `dc3/data/agentic/attachments` | Attachment storage path | ::: info The table binding for `AGENTIC_MEMORY_SCHEMA_INIT` is governed by the code `AGENTIC_MEMORY_SCHEMA_INIT` defaults to `never` and is meant to control the memory table init mode. But after Compose injects it into the container, no `application*.yml` in the repository binds it to Spring AI's `initialize-schema`; the memory tables are actually pre-created by the initdb scripts. Whether setting it to `always` truly triggers automatic table creation needs to be determined from the code — don't treat it as a wired, working switch. ::: ::: danger Real API keys must not enter docs, logs, or commit history Sensitive values like `AGENTIC_FALLBACK_OPENAI_API_KEY` must never be written into docs, logs, or commit history. Configure production providers in the `dc3_model_provider` table; the fallback is just a safety net. ::: ::: info Defaults in the table come from `.env.example` and differ from the in-code fallback defaults The values above for `AGENTIC_MEMORY_ENABLED` (`false`) and `AGENTIC_ATTACHMENT_STORAGE_PATH` ( `dc3/data/agentic/attachments`) come from `.env.example`. On the `cp .env.example .env` + `source` path, these values are explicitly injected. But the in-code fallback defaults in `application-agentic.yml` differ: when the environment variables are unset, memory defaults to enabled and the attachment path is `dc3/data/upload/agentic/attachment`. When you're not on the `.env.example` path, the code takes precedence. ::: ### Batch Processing (Runtime) MQTT and point values each have a "count threshold + interval" pair. A Quartz schedule flushes the accumulated buffer all at once every `interval` seconds, where `speed = count / interval`. | Variable | Default | Purpose | |------------------------|---------|-------------------------------------------| | `MQTT_BATCH_SPEED` | `100` | MQTT batch size threshold (records/batch) | | `MQTT_BATCH_INTERVAL` | `5` | MQTT batch interval (seconds) | | `POINT_BATCH_SPEED` | `100` | Point value batch size threshold | | `POINT_BATCH_INTERVAL` | `5` | Point value batch interval (seconds) | ### Image Sources (Compose only) On mainland China networks, set `REGISTRY` to `cn` to use the Aliyun mirror. Note: the Makefile reads `REGISTRY` and Compose interpolation reads `DC3_IMAGE_REGISTRY` — each governs its own segment. | Variable | Default | Purpose | |----------------------|-------------|--------------------------------------------------------------------------------------------| | `REGISTRY` | `auto` | Makefile image-source selector (accepts only `auto`/`global`/`cn`; other values error out) | | `DC3_IMAGE_REGISTRY` | `pnoker` | Image namespace | | `DC3_IMAGE_TAG` | `2026.6` | Image tag for all services and dependencies | | `DC3_BIND_HOST` | `127.0.0.1` | Published-port bind address (`0.0.0.0` for external access) | ### Observability (Compose only / Runtime) The optional stack (EMQX, ELK, Prometheus, Grafana) comes up via `make up-optional`. Its ports and JVM parameters are below. | Variable | Default | Scope | Purpose | |----------------------|-------------------------|--------------|----------------------------------| | `GF_SERVER_ROOT_URL` | `http://localhost:3000` | Runtime | Grafana external root URL | | `DC3_GRAFANA_PORT` | `3000` | Compose only | Grafana published port | | `DC3_KIBANA_PORT` | `5601` | Compose only | Kibana published port | | `DC3_ES_JAVA_OPTS` | `-Xms512m -Xmx512m` | Runtime | Elasticsearch JVM heap | | `DC3_LS_JAVA_OPTS` | `-Xms256m -Xmx256m` | Runtime | Logstash JVM heap | | `APM_AGENT_ENABLE` | `false` | Runtime | Whether the Java APM agent is on | ::: details Full Variable Reference (collapsed) #### Security & Authentication (Runtime) | Variable | Default | Purpose | |--------------------|------------------------------------------|------------------------------------| | `DC3_SECURITY_KEY` | `dc3.security.key.2026.io.github.pnoker` | Login token signing key | | `AUTH_HMAC_SECRET` | `io.github.pnoker.dc3` | `X-Auth-Principal` HMAC-SHA256 key | #### PostgreSQL | Variable | Default | Scope | |---------------------|-------------|--------------| | `POSTGRES_HOST` | `localhost` | Runtime | | `POSTGRES_PORT` | `35432` | Runtime | | `POSTGRES_USERNAME` | `dc3` | Runtime | | `POSTGRES_PASSWORD` | `dc3dc3dc3` | Runtime | | `POSTGRES_DB` | `dc3` | Runtime | | `POSTGRES_SCHEMA` | (unset) | Per-process | | `DC3_POSTGRES_PORT` | `35432` | Compose only | #### RabbitMQ | Variable | Default | 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 | Variable | Default | 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 | Variable | Default | 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 & Service Ports | Variable | Default | 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` | (unset) | Per-process | | `GRPC_SERVER_PORT` | (unset) | Per-process | | `DC3_LISTENING_VIRTUAL_TCP_PORT` | `6270` | Compose only | | `DC3_LISTENING_VIRTUAL_UDP_PORT` | `6271` | Compose only | | `TCP_PORT` | (unset) | Per-process | | `UDP_PORT` | (unset) | Per-process | | `GATEWAY_ROUTE_AUTH_TOKEN_URI` | (unset) | Per-process | | `GATEWAY_ROUTE_AUTH_URI` | (unset) | Per-process | | `GATEWAY_ROUTE_MANAGER_URI` | (unset) | Per-process | | `GATEWAY_ROUTE_DATA_URI` | (unset) | Per-process | | `GATEWAY_ROUTE_AGENTIC_URI` | (unset) | Per-process | #### Agentic / AI (Runtime) | Variable | Default | |---------------------------------------|--------------------------------| | `AGENTIC_FALLBACK_OPENAI_BASE_URL` | `https://api.openai.com` | | `AGENTIC_FALLBACK_OPENAI_API_KEY` | (empty) | | `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` | #### Batch Processing / Images / Observability | Variable | Default | 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 | ::: ## Constraints and Common Pitfalls - Editing `.env.example` does nothing at runtime — you must `cp .env.example .env` first. - `dc3/env/dev.env` and the root `.env` serve different purposes. They are not the same file; don't copy one into the other. - Use `DC3_*_PORT` consistently for service published ports. The process internals still use Spring Boot's native names such as `SERVER_PORT` and `GRPC_SERVER_PORT`. - Per-process variables (`POSTGRES_SCHEMA`, `SERVER_PORT`, `TCP_PORT`, and so on) are single-service overrides only — don't set them globally. - The Compose application stack can use a different `NODE_ENV` than local source runs. ## Further Reading - [Develop Locally from Source](./) — the full path of bringing up the stack, logging in, and running your first device end to end - [Deployment Modes and Image Sources](../guide/usage) — how the whole stack starts and how to use `REGISTRY=cn` --- # Your First Device: End to End URL: https://docs.dc3.site/en/quickstart/first-device <script setup> import FirstDeviceDiagram from '../../.vitepress/theme/components/FirstDeviceDiagram.vue' </script> # Your First Device: End to End This page walks you through a complete data path with the built-in **virtual driver**: log in to get a token, create a profile, point, and device, configure the point's attributes, then read live values and send a write command. Each step has copy-paste commands and a "what you should see" block, so you can follow along directly. > You are here: you've [started the dependency stack](./) and read the [core concepts](../introduction/concepts). By the > end of this page you'll have **a device connected through the virtual driver, producing live point values you can read, with write commands flowing back to its writable points.** ## What This Path Looks Like The golden path is a chain of HTTP calls with front-to-back dependencies, all going through the gateway `dc3-gateway` ( `:8000`). The first two steps get a token. The next four build metadata in the Manager Center. The last two read values and send commands through the Data Center. Keep this map in mind — each step below tells you where you are in it. <FirstDeviceDiagram lang="en" /> ::: info Conventions All `id` values, tokens, and return values below are **examples**. Your environment generates snowflake IDs (long strings of digits), so replace them with the real values returned by the previous step. Every write endpoint returns the unified envelope `{ "ok": true, "code": "...", "message": "...", "data": "..." }`. Note that `add` endpoints **return only a success status — they do NOT return the new entity's ID**. When you need an ID, query it back via the matching `list` endpoint by name (each step below shows the lookup command). ::: ## Step 0: Start the Stack Bring up the database, message queue, and development stack. These two commands start PostgreSQL + RabbitMQ, then the gateway, the four centers, and the drivers (the virtual driver starts with the stack). ```bash make up-db && make up-dev ``` **What you should see**: `podman ps` lists `dc3-postgres`, `dc3-rabbitmq`, `dc3-gateway`, `dc3-center-auth/manager/data/agentic`, and several `dc3-driver-*` containers as running. The gateway is reachable at `http://localhost:8000`. ::: tip Point the dc3 CLI at the Gateway First If you use the `dc3` CLI, tell it the gateway address first (once is enough): `dc3 config set gateway http://localhost:8000`. ::: ## Steps 1–2: Log In for a Token Login takes two steps. First, fetch a **salt (use within 5 minutes)** with the tenant + username. Then submit the **plaintext password** together with the salt to exchange it for an **access token (valid 12 hours)**. After that, every protected request must carry three auth headers: `X-Auth-Tenant`, `X-Auth-Login`, `X-Auth-Token`. ::: code-group ```bash [curl] # 1) Fetch the 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: {"ok":true,"code":"...","message":"...","data":"a1b2c3d4e5"} # 2) Hash the password with the salt and exchange it for a token (see the auth docs for the hashing algorithm; PASSWORD_HASH here is an example) 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":"<PASSWORD_HASH>"}' # Example response: {"ok":true,"code":"...","message":"...","data":"<ACCESS_TOKEN>"} ``` ```bash [dc3 CLI] # The CLI wraps fetching the salt, hashing, and exchanging for a token dc3 auth login --tenant default --username dc3 # Enter the password interactively; the token is saved after login # Verify dc3 auth status dc3 auth token --header # Print X-Auth-Tenant/X-Auth-Login/X-Auth-Token ``` ::: **What you should see**: `/api/v3/auth/token/salt` returns a non-empty salt; `/api/v3/auth/token/generate` returns a long token (the `<ACCESS_TOKEN>` above). On the CLI path, `dc3 auth status` shows you logged in. ::: warning Every Subsequent Request Needs the Auth Headers To keep the curl examples short, the three headers are extracted into variables below. Set them in your shell first, using the real values from the previous step: ```bash H_TENANT='X-Auth-Tenant: default' H_LOGIN='X-Auth-Login: dc3' H_TOKEN='X-Auth-Token: <ACCESS_TOKEN>' # example ``` ::: ## Step 3: Confirm the Virtual Driver Is Registered When the virtual driver starts with `make up-dev`, it registers itself with the Manager Center. You need its `driverId` to create a device, so look it up first. ::: 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 ``` ::: **What you should see**: the list contains a driver whose `driverName` is `Virtual Driver` (with a space, from `dc3.driver.name` in `application.yml`). Its `driverCode` is the routing identifier `VirtualDriver`, and the module/service name is `dc3-driver-virtual` — three different fields. Grab its `id`, called `<DRIVER_ID>` below ( example: `92010100000000001`). The virtual driver is a **driver authoring template**: it exists for testing and bootstrapping new drivers, and produces data without any real device attached. ## Step 4: Add a Profile A profile describes what a class of devices can do. Here we create a minimal profile to hold the points. Set `profileShareFlag` to `TENANT` (shared within the tenant) and `enableFlag` to `ENABLE` (enabled). ::: 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":"Virtual Thermo Profile","profileShareFlag":"TENANT","enableFlag":"ENABLE"}' # Example response: {"ok":true,"code":"ADD","message":"Added successfully","data":"Added successfully"} # add returns only a success status, not the ID. When a later step needs profileId, look it up by name: 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":"Virtual Thermo Profile","page":{"current":1,"size":1}}' # Get profileId from records[0].id ``` ```bash [dc3 CLI] dc3 profile create --name "Virtual Thermo Profile" dc3 profile list --name "Virtual Thermo Profile" # look up profileId ``` ::: **What you should see**: `add` returns a success status (`data` is a message string, not an ID); use `profile/list` filtered by `profileName` to look it up, and read the profile ID from `records[0].id` — call it `<PROFILE_ID>` below (example: `81010100000000001`). ::: tip profileShareFlag Values `ProfileShareTypeEnum` is `TENANT` / `DRIVER` / `USER` — whether the profile is shared within the tenant, the driver, or the user. ::: ## Step 5: Add a Point A point is the data item you collect or write. **Whether it's writable is set by the point's own `rwFlag`.** Here we create a `READ_WRITE` point so we can send write commands to it later. Set `pointTypeFlag` to `FLOAT`, attach it to the profile from the previous step, and give it the unit `°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":"Temperature", "pointTypeFlag":"FLOAT", "rwFlag":"READ_WRITE", "profileId":"81010100000000001", "valueDecimal":2, "unit":"°C", "enableFlag":"ENABLE" }' # Example response: {"ok":true,"code":"ADD","message":"Added successfully","data":"Added successfully"} # add does not return the ID. When a later step needs pointId, look it up by name: 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":"Temperature","page":{"current":1,"size":1}}' # Get pointId from records[0].id ``` ```bash [dc3 CLI] dc3 point create --name "Temperature" --profile-id "81010100000000001" dc3 point list --name "Temperature" # look up pointId ``` ::: **What you should see**: `add` returns a success status; use `point/list` filtered by `pointName` to look it up, and read the point ID from `records[0].id` — call it `<POINT_ID>` below (example: `82010100000000001`). ::: tip rwFlag and pointTypeFlag Values `RwTypeEnum` is `READ_ONLY` / `WRITE_ONLY` / `READ_WRITE`; a write command against a `READ_ONLY` point is rejected. `PointTypeEnum` has 8 values: `STRING` / `BYTE` / `SHORT` / `INT` / `LONG` / `FLOAT` / `DOUBLE` / `BOOLEAN`. A point can also carry a conversion (`baseValue` / `multiple`) that linearly maps the raw value to an engineering value. ::: ## Step 6: Add a Device A device is a concrete instance bound to one profile and one driver. Use the `<DRIVER_ID>` and `<PROFILE_ID>` from above to create it. ::: 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":"Virtual Thermo Device-01", "driverId":"92010100000000001", "profileId":"81010100000000001", "enableFlag":"ENABLE" }' # Example response: {"ok":true,"code":"ADD","message":"Added successfully","data":"Added successfully"} # add does not return the ID. When a later step needs deviceId, look it up by name: 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":"Virtual Thermo Device-01","page":{"current":1,"size":1}}' # Get deviceId from records[0].id ``` ```bash [dc3 CLI] dc3 device create --name "Virtual Thermo Device-01" \ --driver-id "92010100000000001" \ --profile-id "81010100000000001" dc3 device list --name "Virtual Thermo Device-01" # look up deviceId ``` ::: **What you should see**: `add` returns a success status; use `device/list` filtered by `deviceName` to look it up, and read the device ID from `records[0].id` — call it `<DEVICE_ID>` below (example: `83010100000000001`). ## Step 7: Configure Point Attributes At startup, the driver declares **which** config items (attributes) it has. This step fills in a **concrete value** for one of those attributes, for **this point on this device** — a config. This is what actually wires the point into the driver's collection logic. The `attributeId` comes from an attribute the virtual driver registered (find it in the driver details or the attribute list), and `configValue` is the value you supply for it. ::: 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" }' # Example response: {"ok":true,"code":"ADD","message":"Added successfully","data":"Added successfully"} # add does not return the ID. The config takes effect immediately upon write, no separate lookup needed. ``` ```bash [dc3 CLI] # The CLI has no standalone attribute-config subcommand yet; use the curl above ``` ::: **What you should see**: `add` returns a success status (no ID). Once the config takes effect, the virtual driver starts producing values for that point. ::: info Where attributeId Comes From `attributeId` refers to a point attribute (`PointAttribute`) the virtual driver registered in the Manager Center. Different drivers declare different attributes — this is the "protocol-layer Attribute vs. instance-layer Config" split from the [core concepts](../introduction/concepts). The example ID is a placeholder; use the attribute ID actually registered by the virtual driver in your environment. ::: ## Step 8: Read Live Point Values Once values flow, read the latest point values from the Data Center. `/point_value/latest` filters by `deviceId` / `pointId` at the top level, takes pagination in the nested `page` object (`current` / `size`), and returns `Page<PointValueVO>`. ::: 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}}' # Example response (PointValueVO shape): # {"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 ``` ::: **What you should see**: at least one `PointValueVO` in `records`, with `rawValue` (the raw value), `calValue` (the engineering value, as a string), `numValue` (the numeric projection, nullable), and the collection time `createTime`. The value refreshes as the virtual driver keeps running. ## Step 9: Issue a Write Command Finally, send a write command to this writable point. `/point_command/write` takes `deviceId` / `pointId` / `value` and **returns a command ID (`commandId`) right away** — which means "the command was accepted," not "it ran successfully." To get the result, take the command ID and **poll** the receipt endpoint `/point_command_history/get_by_command_id` ( with that `commandId`). ::: code-group ```bash [curl] # 1) Send the write command and get a commandId immediately 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"}' # Example response: {"ok":true,"code":"...","data":"cmd_20260622_a1b2c3d4"} # 2) Poll the receipt with the 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" # Example response (PointCommandHistoryVO shape): # {"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] # Send the write dc3 point write 82010100000000001 --device-id 83010100000000001 --value 26.5 # Poll the receipt dc3 command history cmd_20260622_a1b2c3d4 ``` ::: **What you should see**: the write returns a `commandId` right away; poll the receipt until `status` reaches a terminal state. That closes the loop — you've run the full bidirectional path of "read value + write command." ::: danger Write Command Semantics: Asynchronous, Poll-Required, Failure Does Not Echo the Value - Write commands are **asynchronous**: `/point_command/write` returns a `commandId` immediately and does **not** wait for the device to finish. Poll for the result with that ID at `/point_command_history/get_by_command_id`. - Commands have a **TTL**: `PointCommandDTO.expireAt` defaults to `now + 10s`. A command not executed by then is expired. - **On failure, the written value is not echoed back**: when the receipt shows a failure, it carries no "value that was written" — so don't read "got a commandId" as "the write succeeded." - Only points whose `rwFlag` includes write permission (`WRITE_ONLY` / `READ_WRITE`) accept write commands. ::: ## Further Reading - [Device Onboarding](../operation/device-onboarding) — expand this minimal path into the full onboarding flow for a real driver - [Data and Commands](../operation/data-commands) — the full mechanics of collection persistence and read/write commands, plus receipt semantics, across the two planes - [CLI Guide](../automation/cli) — the complete `dc3` CLI command surface, with scripting and AI-integration usage --- # Local Development from Source URL: https://docs.dc3.site/en/quickstart/ <script setup> import QuickstartIndexDiagram from '../../.vitepress/theme/components/QuickstartIndexDiagram.vue' </script> # Local Development from Source This page walks you through running IoT DC3 from source: bring up PostgreSQL and RabbitMQ with Compose, point the Java processes at your local ports, build, start, and run the tests. By the end you'll have the center services running on your own machine, and you'll understand why startup has an order and why local processes can't read the root `.env` directly. > You are here because you want to develop or debug from source. If you just want to click through the shortest > end-to-end loop once, see [First Device: End to End](./first-device). To understand how the services fit together, > see [System Architecture Overview](../architecture/). ## Prerequisites Local development is "semi-containerized": the infrastructure (database, message broker) runs in containers, while the center services run as local Java processes, so you can set breakpoints and hot-restart freely. You'll need a JDK and build toolchain plus a container runtime. - **JDK 21** — the platform requires Java 21. Compiling on a lower version fails outright. - **Maven 3.9+** — the repository ships `.mvn/settings.xml` and a parallel-build configuration; use it for multi-module packaging. - **pnpm** — both the `dc3-web/` frontend and sibling `dc3-cli/` use pnpm (not npm or yarn). Skip this for backend-only work. - **Podman** — every container operation in this repository uses `podman` (`make` defaults to `podman compose`). ## Why These Five Steps The shortest path to a running local stack is five steps. Each step produces a concrete artifact, and each depends on the previous one: you need the infrastructure up before you can load the environment variables that point at it; you need the jars built before you can start the dev stack; and only once the services are up does it make sense to run tests. <QuickstartIndexDiagram lang="en" /> Each step is expanded below, with both what to do and how to verify it. ## Step 1: Start the Infrastructure `make up-db` brings up the db stack with Compose — PostgreSQL and RabbitMQ. On first startup, PostgreSQL runs the initdb scripts in filename order (extensions, common, auth, data, manager, history, agentic) and creates all the tenant, user, menu, and metadata tables. So the first launch is slower than the ones that follow. ::: code-group ```bash [Global Registry] make up-db ``` ```bash [China Mainland Registry] make up-db-cn ``` ::: The ports the containers publish to the host are fixed: PostgreSQL `localhost:35432`, RabbitMQ AMQP `localhost:35672` ( inside the containers they stay `5432` / `5672`). To verify: ```bash podman ps # you should see dc3-postgres and dc3-rabbitmq running podman exec dc3-postgres psql -U dc3 -d dc3 -c '\dt dc3_auth.*' # if it lists the auth tables, it's ready ``` ::: tip Optional Observability Stack When you need EMQX, ELK, Prometheus, or Grafana, run `make up-optional`. Local core development doesn't need them. It's best to start them only after the center services are stable, so you don't spin up too many containers at once. ::: ## Step 2: Load the Local Environment Variables ```bash source dc3/env/dev.env.sh ``` This exports the development defaults into the current shell — the database, RabbitMQ, MQTT, and gRPC hosts, among others. The part that matters here is pointing `POSTGRES_HOST=localhost`, `POSTGRES_PORT=35432`, `RABBITMQ_HOST=localhost`, `RABBITMQ_PORT=35672`, and `CENTER_AUTH_HOST/MANAGER_HOST/DATA_HOST/AGENTIC_HOST=localhost` at your local machine, so the local Java processes can reach the container ports published in Step 1. ::: warning .env Is for Compose Interpolation Only — Not for Local Java Processes The root `.env` is Compose-specific. It's only used for variable interpolation when `docker compose` parses its files ( image registry, image tag, published ports) and is **not** injected into the Java processes you start locally. When you run from source on your machine, you must `source dc3/env/dev.env.sh`. Otherwise the services fall back to in-container DNS names like `dc3-postgres:5432`, which your host can't resolve, and the connection fails immediately. In JetBrains IDEA, use the EnvFile plugin to load `dc3/env/dev.env` (the variant without `export`), or paste its key-value pairs into the run configuration's environment variables. ::: To verify: `echo $POSTGRES_PORT` should print `35432`. ## Step 3: Build ```bash make package # equivalent to mvn -s .mvn/settings.xml clean package ``` The repository already has parallel builds, enforced JDK 21 / Maven 3.9+, and Spring Java Format validation set up. The build produces the executable jar for each service module (for example, `dc3-gateway/target/dc3-gateway.jar`). This step only validates compilation and packaging — it doesn't depend on the containers from Step 1. ::: tip Fast Compile Check If you only want to confirm your changes compile, skip the full package and run `mvn -s .mvn/settings.xml -q -DskipTests compile`, which is much faster. ::: ## Step 4: Start the Dev Stack With the jars built, bring the center services up with the dev stack. `make up STACK=dev` (or the shorthand `make up-dev`) starts the Gateway, Auth, Manager, Data, Agentic, and drivers in dependency order. ::: code-group ```bash [Global Registry] make up-dev ``` ```bash [China Mainland Registry] make up-dev-cn ``` ::: Why the ordering? Because the services depend on each other: - **The Auth center must be ready first** — it holds the tenant, user, RBAC, and token-issuing logic, and every other service plus the gateway authenticate against it. - **The Gateway is the sole external HTTP entry point (8000)** — it aggregates the routes of Auth/Manager/Data/Agentic, extracts the auth headers, and injects the principal context. It can only forward correctly once the backend centers are reachable, so it comes after its dependencies. ::: details Full Startup Order and Ports In distributed mode, calls go over gRPC by default (`DC3_FACADE_MODE=grpc`, already set in dev.env). Service ports: | Service | HTTP | gRPC | |-----------------------------------------------|------|------| | Gateway / `dc3-gateway` (sole external entry) | 8000 | — | | Auth Center / `dc3-center-auth` | 8300 | 9300 | | Manager Center / `dc3-center-manager` | 8400 | 9400 | | Data Center / `dc3-center-data` | 8500 | 9500 | | Agentic Center / `dc3-center-agentic` | 8600 | — | Only the Gateway (the user entry) and listening-virtual's TCP 6270 / UDP 6271 (the device entry) are mapped to the host; all other backend ports are internal. ::: To verify: once the stack is up, run the login golden path against the gateway. Login takes two steps — first fetch the salt, then exchange the salt-hashed password for a 12-hour access token: ```bash # 1) Fetch the salt (public endpoint; use within 5 minutes) curl -s -X POST http://localhost:8000/api/v3/auth/token/salt \ -H 'Content-Type: application/json' \ -d '{"tenant":"default","name":"dc3"}' # returns the salt string (sample value) # 2) Hash the password with the salt and exchange it for a token (public endpoint, access token valid for 12 hours) curl -s -X POST http://localhost:8000/api/v3/auth/token/generate \ -H 'Content-Type: application/json' \ -d '{"tenant":"default","name":"dc3","salt":"<salt from the previous step>","password":"<salt-hashed password>"}' ``` Once you have the token, every protected endpoint goes through the gateway with the three auth headers `X-Auth-Tenant`, `X-Auth-Login`, and `X-Auth-Token`. The full "create driver → create profile → create device → read/write point" loop is covered in [First Device: End to End](./first-device). ## Step 5: Run the Tests ```bash make test # unit test suite ``` For higher-level verification: `make test-it` runs the integration tests (needs a container runtime for Testcontainers), and `make test-e2e` runs the backend E2E suite. For day-to-day development, run `make test` after a change to guard against unit-level regressions. ## Common Pitfalls - **Starting the services without `source dev.env.sh`** — the most common one. The local Java processes never see `localhost:35432` / `35672`, so they try the in-container DNS names and fail. Every new shell needs to `source` again — the variables only live in the current shell. - **Podman not running** — if `make up-db` says it can't reach the container runtime, first check the `podman` daemon or machine is started (on macOS run `podman machine start`), then confirm with `podman ps`. - **Ports in use** — startup fails when `35432` / `35672` / `8000` and friends are taken by other processes. Free the occupying process, or override the Compose published ports in the root `.env` (this only affects the container side); override the local process ports with the service-level environment variables. - **First database startup is slow or tables are incomplete** — PostgreSQL runs initdb only on the first startup of an empty volume. If it was interrupted partway and the tables are incomplete, reset the volume and start over: `make reset STACK=db` (requires `CONFIRM_RESET_VOLUMES=true`, which deletes data — use with care). ::: danger Production Secrets Must Be Replaced The `DC3_SECURITY_KEY` and `AUTH_HMAC_SECRET` in `dev.env.sh` are development defaults. In the `pre`/`pro` environments, if `AUTH_HMAC_SECRET` is empty or still equals the default `io.github.pnoker.dc3`, the Gateway fails fast and refuses to start. That's fine for local development, but before you go live you must replace it with an environment-specific random value. ::: ## Further Reading - [Environment Variables Explained](./environment) — the boundary between `.env` and `dev.env(.sh)`, and the scope and default value of each variable - [First Device: End to End](./first-device) — the shortest loop from creating a driver to reading and writing points after login - [System Architecture Overview](../architecture/) — how the five center services divide responsibilities, and how data and commands flow ---