16 · 自动化 API 方法速查
相关:第 15 章 自动化 API 总览 · 第 19 章 账号、角色与权限
这一章是什么
第 15 章讲了自动化 API 是干什么的、怎么打开工程。这一章是完整的方法清单 ——
IProjectAutomation 一共 132 个方法(另有 6 个实现类辅助成员 / 异步方法,见下表标注;合计 138 条),按"你大概想干什么"分成 16 组列在这里。
用法:先在左边找到你的需求(比如"我要批量建点"),再跳到对应分组看方法名和参数。 每个方法都写了"干什么"和"什么时候用",不用去翻源码。
所有方法都返回
AutomationResult({ Ok, Message, Data }),失败不抛异常。 所以每次调用都要看Ok,别假设一定成功。详见第 15 章。
16 组方法一览
| 分组 | 方法数 | 一句话 |
|---|---|---|
| 1. 打开与生命周期 | 5 | 打开/新建/切换工程,挂编译器 |
| 2. 感知查询 | 14 | 只读:看看工程里有什么 |
| 3. 画面 | 5 | 建/删/改名/复制画面、改尺寸 |
| 4. 元素 | 17 | 加/删/移动控件、绑定、改属性、分组对齐 |
| 5. 点表 | 9 | 加点、内部变量、窗口变量、导入导出 |
| 6. 通道与设备 | 8 | 通道、设备、连接参数、只读诊断 |
| 7. 脚本组织 | 14 | 全局/画面/元素脚本、脚本模块 |
| 8. 报警配置 | 5 | 按点配四级限值 |
| 9. 历史记录 | 5 | 按点配记录规则 |
| 10. 配方 | 10 | 配方增删改、导入导出 |
| 11. 报表 | 5 | 历史聚合/报警事件报表定义 + 查询 |
| 12. 多语言 | 12 | 译表、抽取、CSV 导入导出 |
| 13. 工程设置 | 15 | 启动配置、发布信息、通知配置、外观预设、皮肤 |
| 14. 持久化与整包 | 5 | 校验、保存、导出/导入、加密封包 |
| 15. 脚本编译与函数目录 | 3 | 只编不跑、编译整个工程、函数目录 |
| 16. 北向数据转发 | 6 | 出站推送 / 入站监听回写规则 |
1. 打开与生命周期
什么时候用:程序刚起来、要接上一个工程的时候。这一步不做,后面所有方法都用不了。
| 方法 | 干什么 |
|---|---|
ProjectAutomation.Open(dir) 静态 |
打开工程目录,返回实例。缺 project.json 会抛异常(不是返回失败) |
CreateBlank(dir, name) 静态 |
在空目录里落一份最小空白工程。已有工程则拒绝,绝不覆盖 |
LoadProject(dir) |
运行中换工程。目录里没有 project.json 就拒绝 |
CreateProject(dir, name) |
运行中新建空工程并切过去。目录已有工程则拒绝 |
AttachCompiler(compiler) |
注入脚本编译校验器,打开工程后必须调一次,否则 CheckScript/BuildScripts 都返回"编译器未接入" |
using Hmi.Automation;
// ① 打开(这一步会抛异常,包 try)
ProjectAutomation auto;
try { auto = ProjectAutomation.Open(@"D:\我的工程"); }
catch (Exception ex) { Console.WriteLine($"打不开:{ex.Message}"); return; }
// ② 标记来源(进审计,方便日后查是谁改的)
auto.Source = "MyTool";
// ③ 挂编译器(不挂的话脚本校验用不了)
auto.AttachCompiler(new RoslynScriptChecker(auto.Loaded));
// ④ 干完活保存
var r = auto.Save();
if (!r.Ok) Console.WriteLine($"保存失败:{r.Message}");
ProjectDir/Loaded/ProjectSwitched/Audit这四个成员不在接口里,但实现类ProjectAutomation上有: 当前工程目录、内存工程实例、换工程事件、审计记录。
换工程时必须重挂编译器
LoadProject / CreateProject 成功后会触发 ProjectSwitched 事件,并把内部编译器置空
(因为旧编译器指向的是旧工程)。宿主收到事件后要重新 AttachCompiler:
auto.ProjectSwitched += dir =>
{
auto.AttachCompiler(new RoslynScriptChecker(auto.Loaded)); // 用新工程重挂
Console.WriteLine($"已切到工程:{dir}");
};
2. 感知查询(只读)
什么时候用:想知道"这个工程里有什么" —— 有哪些画面、哪些点、哪些通道、支持哪些协议。 这一组全部只读,不产生任何副作用,可以放心随便调。
| 方法 | 返回什么 |
|---|---|
GetProject() |
工程元信息:名/版本/启动画面/说明 |
ListScreens() |
所有画面名 + 尺寸/元素数概览 |
GetScreen(name) |
一个画面的完整模型 |
ListElements(screen) |
画面里的元素:名/类型/几何/绑定数 |
ListTags() |
所有点位定义 |
GetTag(name) |
一个点的定义 |
ListTagTypes() |
可选的点数据类型 + 每种类型哪些字段有意义(占几寄存器/字节序是否有意义/是否可配量程) |
ListChannels() |
所有通道/设备 |
ListProtocols() |
可用协议(内置 + 已加载插件)+ 地址语法提示 |
DescribeProtocol(protocolId) |
一个协议支持哪些连接方式 + 各自参数清单 |
DescribeElementProps(type, elementName?) |
某元素类型的可写属性清单(名/类型/当前值/说明) |
DescribeTypeSchema(typeName) |
查嵌套复杂类型的字段结构(名/类型/枚举合法值/默认值/是否必填)。DescribeElementProps 只照到元素顶层属性,这里补它照不到的(Button.Action / Trend.Series / RealtimeTable.Columns / NavBar.Items / ScriptFragment / 报警限值项) |
ListElementTypes() |
所有可创建的元件类型(46 种)+ 中文名/分类/一句话用途。含 ListBox / Table / RealtimeTable 这类现成控件 —— 别再用矩形拼列表 |
GetProjectTree() |
结构化「工程树」总览:画面→元素、点表规模、通道→设备、配方、报警/历史条数、报表、脚本组织、多语言。给 AI「先鸟瞰再钻某一枝」用(逐点/逐元素细节仍走各自的 list_/get_) |
ListTagTypes 是干什么的
给"新建点"向导用的。你不需要在前端硬编码"浮点占 2 个寄存器、布尔占 1 位"这类规则 —— 调它,后端会把每种类型的字段语义告诉你,前端照着渲染就行。后端加新类型,前端零改动。
DescribeProtocol 是干什么的
给"设备向导"用的。力控式流程是:选协议 → 选网口/串口 → 按参数清单生成表单。
DescribeProtocol("ModbusTcp") 会告诉你"支持 TCP,需要 host 和 port 两个参数"。
没声明连接方式的协议,兜底成"仅 TCP(host+port)"。
DescribeElementProps —— 改属性前必须先调它
这是最容易踩的坑:SetElementProps 是按属性名反射写入的,名字写错了不会报错,直接静默跳过。
用户 2026-09-23 实际报过这个错:
set_element_props → 失败:FontWeight(该元素无此可写属性);TextAlignment(…)
正确的名字是 Bold 和 TextAlign(不是 FontWeight / TextAlignment)。
// ✅ 正确流程:先拿真名,再写
var props = auto.DescribeElementProps("Button", "启动按钮");
// → Data 里列出:Text / Background / Foreground / Bold / FontSize / Action ...
auto.SetElementProps("主画面", "启动按钮", new Dictionary<string, object?>
{
["Text"] = "开始",
["Bold"] = true,
});
⚠ 大小写差异是合法的(比较时忽略大小写),所以
Fontsize也能用。 但"词不一样"(FontWeight ≠ Bold)就是错的。现在写错的报错会指到正确的名字上,比以前只回一句"没有这个属性"好多了。
3. 画面
什么时候用:要程序化地建画面、删画面、改画面尺寸。
| 方法 | 干什么 |
|---|---|
CreateScreen(name, width=1280, height=720) |
新建画面 |
DeleteScreen(name) |
删画面 |
RenameScreen(oldName, newName) |
改名 |
SetScreenProps(name, width?, height?, background?, gridSize?, isMaster?, master?) |
改画面属性。传 null 的字段不动 |
DuplicateScreen(srcName, newName) |
复制整张画面(深拷贝控件树 + 属性/绑定/事件),连源画面的窗口变量一并复制到新名下并改写绑定 |
auto.CreateScreen("监控总览", 1920, 1080);
auto.SetScreenProps("监控总览", background: "#1E1E1E", gridSize: 10);
auto.RenameScreen("监控总览", "总览");
isMaster/master是母版画面相关:母版画面上的元素会出现在所有引用它的画面上。
4. 元素
什么时候用:在画面上摆控件、改控件外观、把控件绑到点上。
| 方法 | 干什么 |
|---|---|
AddElement(screen, type, name, x, y, w, h) |
加元素 |
RemoveElement(screen, name) |
删元素 |
MoveElement(screen, name, x?, y?, w?, h?, rotation?) |
改几何。传 null 的不动 |
SetElementProps(screen, name, props) |
改非几何属性(文本/颜色/字号/动作…) |
SetBinding(screen, name, property, tag, format?, when?, map?, twoWay?) |
设/改一条绑定(按 property 覆盖同名绑定) |
RemoveBinding(screen, name, property) |
删一条绑定 |
ApplyStyle(screen, name, presetId) |
套预置样式。按元素类型分流,并校验 Id 属于该类型的合法集(内置预设镜像 ∪ Button 的自定义皮肤;非法 → Fail 并列出合法值),通过后记进元素的 StylePreset。合法 Id 先调 ListStylePresets() 查 |
ReorderElement(screen, name, how) |
调图层次序:top / bottom / up / down |
GetElement(screen, elementName) |
读单个元素的完整模型(类型/属性/绑定/事件),比 GetScreen 省上下文 |
RenameElement(screen, oldName, newName) |
元素改名,保留其绑定/事件/属性(优于删了重建) |
DuplicateElement(screen, srcName, newName, dx=20, dy=20, count=1) |
复制元素(深拷贝含绑定/事件),按 dx/dy 偏移摆放;count>1 连续复制并自动加序号 |
SetElementsProps(screen, elementNames, props) |
批量改多个元素的同一组属性。先整体校验元素都在(缺则整批拒绝并列出缺哪些),再逐个套用 |
GroupElements(screen, elementNames) |
成组(扁平分组,不嵌套),至少 2 个;成员原本属别的组也一并并入新组。data.GroupId 是新组标识 |
UngroupElements(screen, elementNames) |
解组:传任一成员即解散它所在的整组(清空该组所有成员的 GroupId) |
AlignElements(screen, elementNames, mode) |
对齐到统一基准(以选中元素外包围盒为参照)。mode:left/right/top/bottom/hcenter/vcenter。只改 X/Y,不改尺寸;至少 2 个 |
DistributeElements(screen, elementNames, direction) |
在某方向均匀分布。direction:horizontal/vertical,首尾锁定令相邻边到边间隙相等。只改 X/Y;至少 3 个 |
ValidateElement(elementType, props) |
干跑校验:逐条判断属性能否落上(可写属性名/值可转/闭集枚举合法),不改工程。建元素前预检,避免「存进去了却不生效」 |
AddElement 支持哪些类型
46 种,别手抄 —— 调 ListElementTypes() 拿权威清单(返回 {Count, Types:[{Type,Name,Group,Desc}]})。
基础图形 Rectangle Ellipse Line Polygon Arrow Curve Text;
显示类 ValueDisplay Indicator Bar Gauge LED ProgressBar Slider Clock Tank;
交互类 Button GraphicButton Label Edit CheckBox RadioButton ComboBox Switch UserLogin;
列表与表格类 ListBox Table RealtimeTable RecipeTable AlarmList NavTree NavBar TabControl;
布局装饰类 GroupBox Image Video WebView Animation Timer Report AlarmBanner Trend Pipe Valve Pump SafetySign。
权威可创建集 = 后端
ElementModel上的[JsonDerivedType]判别器(当前 46 个), 由list_element_types现算现给 —— 后端加元件,这里和前端图元库会一起跟上。 ⚠ 做列表/表格别用矩形拼 ——ListBox/Table/RealtimeTable是现成的。
几何 / 属性 / 绑定 三条路别走混
| 你要改什么 | 用哪个方法 |
|---|---|
| 位置、大小、旋转 | MoveElement |
| 文本、颜色、字号、按钮动作 | SetElementProps |
| 绑到哪个点 | SetBinding |
⚠
Name和Bindings是受保护字段,不能经SetElementProps改。 改名用画面级方法,改绑定用SetBinding/RemoveBinding。
SetBinding 的四个可选参数
| 参数 | 意思 |
|---|---|
format |
显示格式,如 {0:F1} 保留一位小数 |
when |
条件表达式,满足才显示/生效 |
map |
值映射,如 {"0":"停止","1":"运行"} |
twoWay |
双向:元素上改值也写回点(仅按钮/输入类有意义) |
auto.SetBinding("主画面", "温度显示", "Value", "Oven.Temp",
format: "{0:F1}", map: null, twoWay: false);
5. 点表
什么时候用:批量导入点、建内部变量、建窗口变量。
| 方法 | 干什么 |
|---|---|
AddTag(TagDef) |
加点。Id 自动取当前点数(保持 0 连续) |
AddInternalVariable(name, type?, initialValue?, writable?, unit?, desc?) |
加全局内部变量(无 PLC 采集,可绑定/脚本读写) |
AddScreenVariable(screen, name, ...) |
加窗口变量(只在那张画面有效) |
ScreenVariables(screen) |
取某画面的全部窗口变量(按短名排序) |
GlobalVariables() |
取全部全局内部变量 |
UpdateTag(name, edit) |
改点定义(按名定位,Id/Name 不在此改) |
RemoveTag(name) |
删点。删后自动重排剩余点 Id 为 0..N-1 |
ExportTags() |
导出整张点表为 JSON 文本(data.Json 是内容,data.Count 是点数)。真实落文件由调用方做(自动化 API 不做文件 I/O) |
ImportTags(json) |
从 JSON 文本(单个点或点数组)导入并按点名合并(同名覆盖)。全有或全无:先在候选副本上合并 + 校验(点名唯一、窗口变量归属画面已存在、连接变量目标须已存在),全通过才整体换入并重排 Id 为 0..N-1;任一条不合法则点表原封不动 |
⭐ 点 Id 必须从 0 连续 —— 这是最容易翻车的地方
实时库用 Id 当数组下标,所以Id 必须 0、1、2… 连续无跳号。
Save() 会先校验,跳号就直接拒绝保存。
批量加点时,不要自己指定 Id,让 AddTag 自动取:
// ✅ 正确:Id 自动取,连续
foreach (var d in defs)
auto.AddTag(new TagDef { Name = d.Name, DataType = TagDataType.Float, /* ... */ });
// ❌ 错误:自己从 100 开始编 → Save() 直接报"点 Id 必须从 0 连续无重复"
删点会重排 Id,所以别把 Id 存下来长期用。要引用点,一律用点名。 绑定、配方、报警配置全都按名引用,不受重排影响。
全局内部变量 vs 窗口变量
| 全局内部变量 | 窗口变量 | |
|---|---|---|
| 作用范围 | 整个工程 | 只在指定画面 |
| 同名变量 | 全局唯一 | 不同画面可各有一份 |
| 落库点名 | 就叫 变量名 |
自动加前缀 → 画面名.变量名 |
auto.AddInternalVariable("系统运行状态", TagDataType.Bool, initialValue: 0);
auto.AddScreenVariable("主画面", "当前选中项", TagDataType.Int);
// 窗口变量在实时库里的真名带前缀,查询时注意
foreach (var v in auto.ScreenVariables("主画面"))
Console.WriteLine($"{v.Name}"); // 主画面.当前选中项
窗口变量为什么要加前缀:实时库的点名必须全局唯一。两张画面各有"选中项"时, 不加前缀就撞名了。加前缀是自动的,你不用管,但读值时要写全名。
6. 通道与设备
什么时候用:配置"从哪台 PLC 采数据"。通道是物理链路(一个网口/串口),设备挂在通道下面。
| 方法 | 干什么 |
|---|---|
AddChannel(ChannelDef) |
加通道 |
UpdateChannel(id, name?, driverName?, scanIntervalMs?, timeoutMs?, retryCount?, writeQueueCapacity?) |
改通道。传 null 的不动;写队列上限必须大于 0 |
RemoveChannel(id) |
删通道(通道下的设备一起没了) |
AddDevice(channelId, DeviceDef?) |
加设备 |
UpdateDevice(channelId, deviceId, DeviceDef?) |
改设备配置 |
RemoveDevice(channelId, deviceId) |
删设备 |
TestChannel(channelName) |
只读连通测试(只探测,不下发)。channelName 须是已存在的通道名 |
ReadTagOnce(tagName) |
单次读一个点的当前值(读一次,不订阅、不写)。tagName 须是已存在的点名 |
⚠
TestChannel/ReadTagOnce是「只组态不控 PLC」硬边界下的只读 seam。 组态态无头宿主默认不接真实设备,此时两者只做组态侧存在性校验,然后明确返回「未接入在线诊断」 —— 绝不伪造连通结果或点值。接真实设备要宿主侧注入IDiagnosticReadProvider(见AttachDiagnosticReader)。
⚠ 设备 Id 不可改
UpdateDevice 是整体替换可配置字段(描述/站号/连接方式与参数/采集周期/故障恢复),
但 Id 不可改 —— 点表是按 DeviceId 引用设备的,换了 Id 会让已有点全变成孤儿。
载荷里带了不同的 Id 会被直接拒绝。
AddDevice 的 device 参数可以为 null
这不是笔误 —— 路由侧从载荷里反序列化时,载荷没带 device 字段就是 null。
实现里显式判空并回一个可读的失败信息(而不是抛空引用)。
7. 脚本组织
什么时候用:程序化地写脚本 —— 全局脚本、画面脚本、元素事件脚本、脚本模块。
7.1 工程级脚本(全局)
| 方法 | 干什么 |
|---|---|
GetProjectScripts() |
读工程级脚本:OnStartup / OnShutdown / Conditions / StartupCalls / ShutdownCalls / Periodics |
SetProjectScripts(scripts) |
整体保存。缺的字段保留原值(保护 Periodics 不被老前端抹掉) |
ListProjectPeriodics() |
列后台定时脚本(含触发方式 At/IntervalMs 与中文描述 Trigger) |
SetProjectPeriodic(name, intervalMs, code?, remark?, at?) |
加/改一条后台定时脚本 |
RemoveProjectPeriodic(name) |
删一条 |
后台定时脚本 = 软件一启动就常驻执行,和当前在哪个画面无关。适合做后台轮询、数据采集汇总这类事。
触发方式二选一: - 按间隔 ——
intervalMs > 0(如SetProjectPeriodic("巡检", 2000, code)); - 按时刻 ——at非空,墙钟走到表上的时刻才跑(如SetProjectPeriodic("整点存盘", 0, code, null, "*:00")即每小时整点)。at语法 = 逗号分隔的HH:mm,HH可用*表示每个小时:*:00每小时整点、*:30每半点、08:00,20:00每天早八晚八。两者互斥:填了
at,intervalMs会被归一为 0。at传null= 不改现有触发方式(老调用方安全), 传空串 = 显式改回按间隔。画面周期脚本不支持at(画面没打开就不会跑,与"到点就跑"语义冲突)。
7.2 画面脚本
| 方法 | 干什么 |
|---|---|
GetScreenScripts(screen) |
读画面脚本:OnEnter / OnExit / Periodic |
SetScreenScripts(screen, scripts) |
整体保存 |
画面脚本只在当前打开这张画面时才跑。切走了就停。
7.3 元素事件脚本
| 方法 | 干什么 |
|---|---|
GetElementEvents(screen, elementName) |
读元素的事件脚本集 |
SetElementEvents(screen, elementName, events) |
整体保存(会校验事件名合法) |
元素事件是 12 个固定字面量:
Click/DoubleClick/MouseDown/MouseUp/RightClick/RightDoubleClick/RightMouseDown/RightMouseUp/MouseEnter/MouseHover/MouseMove/MouseLeave。 写别的名字会被拒。详见第 12 章。
7.4 脚本模块(易语言式程序集)
| 方法 | 干什么 |
|---|---|
ListModules() |
列模块(名/备注/函数数) |
GetModule(name) |
取一个模块的完整内容 |
AddModule(name) |
新建空模块 |
SaveModule(name, module) |
整体保存 |
DeleteModule(name) |
删模块 |
模块 = 你自己写的一堆子程序打包在一起,别的脚本可以调。 名字和画面名相同的模块 = 那个画面的程序集,随窗口生死。
8. 报警配置
什么时候用:给某个点配上"超过多少就报警"。
| 方法 | 干什么 |
|---|---|
ListAlarms() |
列所有点的报警配置概览 |
GetAlarm(tagName) |
取一个点的完整配置(该点没配置返回失败) |
SetAlarm(config) |
设/改(同点名整体覆盖)。限值越出点量程照收,成功消息里带 ⚠ 告警 |
RemoveAlarm(tagName) |
删配置 |
SetAlarmsBulk(configs) |
批量设/改(一次多条,省逐条往返)。全有或全无:任一条不合法则整批拒绝、现有配置不动;全合法则逐条按点名去重覆盖 |
模拟量四级 + 开关量
| 点类型 | 配什么 |
|---|---|
| 模拟量(温度/压力…) | 四级限值 HiHi / Hi / Lo / LoLo —— 配了才检测 |
| 开关量 | Digital(0 或 1 触发) |
校验规则:点必须存在、级别名合法(Info/Warn/Alarm/Critical)、回差/延时非负。
auto.SetAlarm(new AlarmConfigModel
{
TagName = "Oven.Temp",
Group = "烤箱", // 分组:报警窗按它过滤
Deadband = 2, // 回差:升到 100 报警后,要降回 98 才恢复(防在 100 附近抖动)
DelaySeconds = 3, // 延时秒:持续超限 3 秒才算真报警(滤瞬时毛刺)
// 四级限值:每一级是个**对象**,不是数字。不配就留 null(该级不检测)。
HiHi = new AnalogLimitModel { Limit = 120, Level = "Critical", Message = "温度超高高限" },
Hi = new AnalogLimitModel { Limit = 100, Level = "Alarm" },
Lo = new AnalogLimitModel { Limit = 20, Level = "Alarm" },
LoLo = new AnalogLimitModel { Limit = 10, Level = "Critical" },
});
⚠ 三个最容易写错的地方
| 写错的样子 | 正确写法 | 说明 |
|---|---|---|
HiHi = 120 |
HiHi = new AnalogLimitModel { Limit = 120 } |
HiHi/Hi/Lo/LoLo 是 AnalogLimitModel?,不是 double |
DelayMs = 3000 |
DelaySeconds = 3 |
真名是 DelaySeconds,单位是秒不是毫秒 |
Level = "Warning" |
Level = "Warn" |
枚举里没有 Warning |
AnalogLimitModel 就三个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
Limit |
double |
限值(工程量) |
Level |
string |
Info / Warn / Alarm / Critical,默认 "Alarm" |
Message |
string |
报警文本,留空则运行期用默认模板 |
开关量报警(Digital)同理,是 DigitalAlarmModel:字段 Trigger(long,不是 bool)、Level、Message。
回差(Deadband) 是防抖动用的:超 100 报高,但必须降到 98 以下才恢复。 延时(DelaySeconds) 是防毛刺用的:瞬时超一下不算,得持续超一段时间。 详见第 14 章。
9. 历史记录
什么时候用:给某个点配"值变了就记一笔"或"每 5 秒记一笔"。
| 方法 | 干什么 |
|---|---|
ListHistoryRules() |
列所有点的历史规则 |
GetHistoryRule(tagName) |
取一个点的规则(该点没配置返回失败) |
SetHistoryRule(rule) |
设/改(同点名整体覆盖) |
RemoveHistoryRule(tagName) |
删规则 |
SetHistoryRulesBulk(rules) |
批量设/改(一次多条)。全有或全无:任一条不合法则整批拒绝;全合法则逐条按点名去重覆盖 |
三种记录模式
| 模式 | 什么时候记 |
|---|---|
OnChange |
值变化才记(带死区,变化小于死区不记) |
Timed |
每 N 毫秒记一次(不管变没变),周期看 TimedIntervalMs |
Both |
两者都要 |
auto.SetHistoryRule(new HistoryConfigModel
{
TagName = "Oven.Temp",
Enabled = true, // 默认就是 true
Mode = "OnChange", // ⚠ 是**字符串**,不是枚举
Deadband = 0.5, // 变化小于 0.5℃ 不记,省空间
TimedIntervalMs = 1000, // Timed / Both 模式下每 1 秒记一次
RetentionDays = 90, // 保留 90 天,超期自动清;0 = 不自动清理
});
⚠
HistoryConfigModel的Mode是string("OnChange"/"Timed"/"Both")—— 写Mode = TagHistoryRule.OnChange或HistoryRecordMode.OnChange都编不过。为什么会这样:
HistoryConfigModel是工程模型(要和磁盘上project.json的字符串对得上), 而TagHistoryRule(带HistoryRecordMode枚举)是Hmi.Historian的运行期模型。 两者靠HistoryConfigMapper桥接。报警的Level也是同一个套路(工程侧字符串、运行侧枚举)。校验:点必须存在、模式名合法、死区非负、保留天数非负; 定时周期只在
Timed/Both模式下要求 > 0 ——OnChange模式根本不读它(记不记只看变化)。
10. 配方
什么时候用:管理"一套参数组合"(比如不同产品的温度/时间/压力配方)。
| 方法 | 干什么 |
|---|---|
ListRecipes() |
列配方(名/备注/项数) |
GetRecipe(name) |
取完整内容(含各项) |
CreateRecipe(name, remark?) |
建空配方(名工程内唯一) |
DeleteRecipe(name) |
删 |
SetRecipeItem(recipe, tagName, value, remark?) |
设一项点目标(点名 → 目标值) |
SetRecipeElementItem(recipe, elementTarget, value, remark?) |
设一项编辑框目标(窗口.组件[.属性] → 目标值) |
RemoveRecipeItem(recipe, target) |
删一项(target 可以是点名或组件目标) |
SetRecipeRemark(name, remark) |
设备注 |
ExportRecipe(name) |
导出为 JSON 文本 |
ImportRecipes(json) |
从 JSON 文本导入并合并(同名覆盖) |
两种配方项
| 类型 | 目标是 | 例子 |
|---|---|---|
| 点目标 | 点名 | Oven.Temp → 180 |
| 编辑框目标 | 窗口.组件[.属性] |
参数画面.温度输入.Text → 180 |
配方下发是运行态的事(要真写进 PLC),不在自动化 API 里 —— 这里只管配方的定义。
ExportRecipe 只返回文本,不落文件
它返回的 Data 是 JSON 字符串。要真的存成文件、写进 U 盘,由宿主侧做
(自动化 API 刻意不做文件 I/O,保持纯粹)。
var r = auto.ExportRecipe("产品A");
if (r.Ok) File.WriteAllText(@"D:\配方导出.json", r.Data!.ToString()!);
11. 报表
什么时候用:定义"每天出一张温度曲线报表"这类东西。
| 方法 | 干什么 |
|---|---|
ListReports() |
列报表定义(名/标题/源/窗口/点数) |
GetReport(name) |
取完整定义 |
SetReport(report) |
设/改(同名整体覆盖) |
RemoveReport(name) |
删 |
QueryReport(name, fromUtc?, toUtc?) |
查一张真实表格(历史聚合 / 报警事件)。fromUtc/toUtc 都给则用绝对范围(ISO 8601),否则用定义里的近 N 分钟窗口。纯只读;未接入报表数据源时返回「未接入」而非伪造行值 |
两种报表源
| 源 | 数据从哪来 | 校验什么 |
|---|---|---|
Historian |
历史库,按聚合函数算 | 引用的点必须存在 + 聚合函数名合法 |
Alarms |
报警事件 | 级别名合法 |
运行期真正生成报表走的是
ReportGenerator,不在自动化 API 里 —— 这里只管定义。
12. 多语言
什么时候用:工程要做成中英双语、中日双语。
| 方法 | 干什么 |
|---|---|
ListLanguages() |
列语言:默认(源)语言 + 目标语言 + 译条数 |
GetI18n() |
读整份多语言表 |
SetI18n(table) |
整份覆盖(null 按空表处理) |
AddLanguage(code) |
加目标语言(如 en / ja) |
SetDefaultLanguage(code) |
设默认(源)语言 |
ExtractTranslations() |
从所有画面抽取可翻译文本,补进译表 |
SetTranslation(sourceText, language, translation) |
设某源文本在某语言下的译文 |
GetTranslation(sourceText, language) |
查译文(缺译回退原文) |
ExportTranslationsCsv() |
导成 CSV 文本(Excel 可编辑) |
ImportTranslationsCsv(csv) |
从 CSV 导入译文 |
RemoveLanguage(code) |
删一个目标语言:从语言列表移除,并清掉每条译条里该语言的译文(源文本保留)。默认(源)语言不可删 |
RemoveTranslation(sourceText, language?) |
删译文(与 SetTranslation 对称):language 留空 = 删整条译条;给定语言 = 只清该语言下的译文 |
典型流程
auto.AddLanguage("en"); // ① 加目标语言
auto.ExtractTranslations(); // ② 从画面里把所有中文抽出来
var csv = auto.ExportTranslationsCsv(); // ③ 导出 CSV 交给翻译
// ...人工翻译,改 CSV...
auto.ImportTranslationsCsv(translatedCsv); // ④ 导回来
源文本是 key:译表按"中文原文"匹配。所以原文改了,译文就对不上了 —— 改文案后要重新抽取核对。
13. 工程设置
什么时候用:配启动画面、配发布信息、配邮件通知、配按钮皮肤、配数据库连接。
13.1 启动配置
| 方法 | 干什么 |
|---|---|
GetStartupConfig() |
读:启动画面 + 启动/退出时要调的全局子程序清单 |
SetStartupConfig(startScreen?, startupCalls?, shutdownCalls?) |
写。三个参数给了才改(null = 不动) |
⚠ 清单里的子程序必须真的存在 —— 认不出会直接拒绝,不静默丢弃。 启动画面也必须存在。
13.2 交付发布配置
| 方法 | 干什么 |
|---|---|
GetPublishProfile() |
读:exe 文件名/图标/产品信息(含留空回退值) |
SetPublishProfile(exeName?, productName?, company?, fileVersion?, copyright?, description?, iconFile?, readmeNote?, author?) |
写。每个字段给了才改(空串 = 清空回退默认) |
GetProjectBuild() |
读「生成信息」—— 同一份配置的另一种叫法 |
SetProjectBuild(productName?, fileName?, version?, company?, author?, copyright?, description?) |
写「生成信息」,内部映射到 PublishProfile |
SetProjectInfo(name?, description?) |
改工程元信息:工程名 / 说明。两者都给了才改(null = 不动;说明给空串 = 清空)。不含 Version(那是文件格式版本,非产品版本)与 StartScreen(走 SetStartupConfig);产品版本/作者走 PublishProfile |
为什么有两套:前端不同地方用了不同的词表。
SetProjectBuild的FileName↔SetPublishProfile的ExeName,Version↔FileVersion。 底层是同一份数据,改哪个都行。
13.3 通知配置(邮件)
| 方法 | 干什么 |
|---|---|
GetNotifyConfig() |
读配置。口令只回"有没有",不回明文 |
SetNotifyConfig(config?, password?, clearPassword?) |
写(整份替换) |
SendTestMailAsync(to?, ct?) |
发一封测试邮件 |
// 口令语义:三态
auto.SetNotifyConfig(config); // password=null → 不改口令
auto.SetNotifyConfig(config, password: "smtp口令"); // 设新口令
auto.SetNotifyConfig(config, clearPassword: true); // 删掉本机保存的口令
⭐
password = null的意思是"不改",不是"设成空" —— 因为工程文件里不存明文口令(存在本机),所以"不改"必须靠这个语义区分。⭐ 测试邮件不受总开关限制 —— 配置配到一半正是最需要验证的时候。 收件地址留空则用配置里第一个启用的收件人。
13.4 自定义按钮皮肤
| 方法 | 干什么 |
|---|---|
ListStylePresets() |
列内置外观预设的 Id/中文名/分类(button/indicator/bar 三类),供 ApplyStyle 选 presetId。先查再写 |
ListButtonSkins() |
列皮肤(Id/名字/分组 + 完整 Svg/States) |
AddButtonSkin(skin) |
加。Id 工程内唯一;SVG 非空且不含活动内容(脚本/事件) |
UpdateButtonSkin(skin) |
整体覆盖(按 Id 定位) |
RemoveButtonSkin(id) |
删。已引用它的按钮回退到显式外观 |
⚠ SVG 里不许有脚本/事件 —— 会被安全校验拦下。这是防 XSS。
13.5 数据库连接(脚本 Db.* 的连接名来源)
| 方法 | 干什么 |
|---|---|
ListDbConnections() |
读:连接清单 + 可用驱动清单(Available 标该驱动是否已接通) |
SetDbConnection(conn) |
写:新增 / 修改一条连接(按连接名匹配,忽略大小写:同名 = 改,否则 = 新增) |
DeleteDbConnection(name) |
删一条连接(不动物理 .db 文件 —— 那是用户数据) |
auto.SetDbConnection(new DbConnectionDef { Name = "记录库", Provider = "sqlite" });
// 之后脚本里就能用:Db.Exec("记录库", "CREATE TABLE IF NOT EXISTS t(a TEXT)");
⭐
Password留空 = 保持原密码,不是清空 —— 读接口从不回明文(只回HasPassword),编辑时留空是常态。 ⭐ 当前只内置 SQLite:Provider填mysql/postgres也存得下,但驱动未接,运行态会明确报「驱动未接入」。 SQLite 的File是相对工程data/的文件名(留空用「连接名.db」;:memory:走内存库)。
14. 持久化与整包
什么时候用:保存、校验、备份、还原、加密分发。
| 方法 | 干什么 |
|---|---|
Validate() |
整份工程校验(见下) |
Save() |
校验 10 项全过才写回目录(任一项红都不写) |
ExportProject(archivePath) |
导出 .hmiproj 归档(先保存;排除运行态历史库产物) |
ImportProject(archivePath, targetDir, overwrite?) |
从归档还原到目标目录(不改当前工程;目标非空需 overwrite) |
PackProject(packagePath, password?) |
封成加密运行封包 .hmipkg(先保存) |
Validate() 具体查什么
Data 回 [{Check, Ok, Details}] 逐项清单,全部跑完再汇总(不首错即返)。
10 项全部是阻断项 —— 任一项红则 Ok=false、Save() 拒写。
| 检查项 | 说明 |
|---|---|
| 点 Id 连续 | 必须 0、1、2… 无断裂无重复 |
| 点名唯一 | |
| 画面键名一致 | 字典键与 Name 必须一致 |
| 元素名画面内唯一 | |
| 绑定引用存在且双向可写 | 引用点必须存在;TwoWay 要求目标点 Writable |
| 配方名唯一 | |
| 配方项引用有效 | 组件项:目标画面/组件存在;点项:点名存在 |
| 连接变量有效 | 见下 |
| 报警限值在量程内 | 限值越出点工程量程 → 运行期永不触发;写入口 SetAlarm(s) 直接拒 |
| 点字段与类型相符 | 与类型无关的字段被填(Bool 点的 Decimals/量程、单寄存器点的 ByteOrder)→ 运行期不生效;写入口 AddTag/UpdateTag 直接拒 |
最后两项 2026-09-29 从「只告警」改为硬校验(用户拍板:不为存量工程保留兼容)。 这两类配置运行期必然失效 —— 放行等于在工程里留一个"看着配了、实际不动作"的坑。
连接变量(MCGS 式)的三条规则
| 规则 | 违反时 |
|---|---|
| 内部变量不该设连接变量 | 报错 |
| 不能连自己 | 报错 |
| 连接目标必须是存在的全局内部变量 | 报错 |
连接变量是干嘛的:把一个采集点连到一个全局内部变量上。 典型用途是"我想在界面上手动改这个值,但也要 PLC 能写" —— 两边映射到同一个变量。
.hmiproj vs .hmipkg
.hmiproj |
.hmipkg |
|
|---|---|---|
| 用途 | 备份/还原/整包分发 | 加密运行封包 |
| 内容 | 完整工程(可再编辑) | 加密后的运行包 |
| 口令 | 无 | 空 = 内置密钥(混淆级);非空 = 口令保护 |
⚠
.hmipkg的"内置密钥"只是混淆级,不是强加密 —— 别拿它当保密手段。
15. 脚本编译与函数目录
什么时候用:做脚本编辑器 —— 用户点"编译"时你替他编一下,看有没有错。
| 方法 | 干什么 |
|---|---|
CheckScript(code, screen?, asCondition?) |
只编不跑一段脚本。需已 AttachCompiler |
BuildScripts() |
编译校验整份工程的所有脚本(「编译工程」按钮用) |
ListFunctionCatalog() |
列内置脚本函数库目录(15 库 / 176 函数,含签名 + 中文说明 + 插入片段) |
// 编辑器里用户点了"编译"
var r = auto.CheckScript(userCode, screen: "主画面");
if (!r.Ok) ShowError(r.Message); // 编译错误信息
asCondition: true= 按布尔条件式校验(条件脚本必须能转成 bool)。
ListFunctionCatalog 和 第 13 章 同源
章 13 里列的那 15 个库、176 个函数,就是这个方法返回的内容。 组态器里「快速插入函数」面板调的也是它。所以后端加一个函数,文档和面板都要跟着更新。
16. 北向数据转发
什么时候用:把工程里的点往外推(出站:给 MES / 上级 SCADA 读),或接别处推来的值 (入站:监听一个端口,收到就写回内部变量)。
| 方法 | 干什么 |
|---|---|
ListForwards() |
列所有转发规则(名/启用/方向/协议/点数/出入站端点/鉴权模式) |
GetForward(name) |
取一个规则的完整定义。不存在返回失败 |
SetForward(forward) |
设/改(同名整体覆盖) |
RemoveForward(name) |
删规则 |
SetForwardSecret(authKeyRef, value) |
写入入站鉴权密钥的真值到本机 %APPDATA%(不进工程、不置脏)。value 为空 = 清除 |
ClearForwardSecret(authKeyRef) |
清除某入站鉴权密钥(本机) |
⚠ 三条安全纪律(违反直接拒绝)
| 纪律 | 为什么 |
|---|---|
| 入站监听非回环 + 无鉴权 → 拒绝 | 对外开一个无鉴权的写入口 = 把 PLC 交给整个局域网 |
入站写点范围 IncludeChannel(写真实 PLC 通道点)一期拒绝 |
触及「组态不写 PLC」的红线,阶段六才开 |
| 密钥永不回吐 | SetForwardSecret 的返回信息、GetForward 都只回 AuthKeyRef,不含真密钥 |
密钥为什么不进工程:工程目录是要拷给别人的。真密钥存本机
%APPDATA%, 工程里只留一个引用名(AuthKeyRef)—— 与邮件口令同一套路。
完整调用示例:批量生成 100 个点 + 一张画面
把前面几组串起来,做一个真实场景:给一条产线生成 100 个温度点,并做一张显示画面。
using Hmi.Automation;
using Hmi.Core.Models;
using Hmi.Project.Model;
using Hmi.Project.History;
var auto = ProjectAutomation.Open(@"D:\产线工程");
auto.Source = "导入工具";
// ① 建通道 + 设备(100 个点都挂在这台 PLC 上)
auto.AddChannel(new ChannelDef
{
Id = 0, Name = "产线PLC", DriverName = "ModbusTcp",
ScanIntervalMs = 1000, TimeoutMs = 3000, RetryCount = 3,
WriteQueueCapacity = 4096,
});
auto.AddDevice(0, new DeviceDef
{
Id = 0, Name = "1号炉",
Description = "1号加热炉",
StationAddress = 1, // 从站号(不是 StationNo)
Connection = new ConnectionDef // 连接参数挂在 Connection 里(不是 Props)
{
Kind = "Tcp",
Params = new Dictionary<string, string> { ["host"] = "192.168.1.10", ["port"] = "502" },
},
});
// ② 批量加点(Id 自动取,不要自己编)
for (int i = 1; i <= 100; i++)
{
var r = auto.AddTag(new TagDef
{
Name = $"1号炉.Temp{i:D2}",
Type = TagDataType.Float, // 字段名是 Type(不是 DataType)
DeviceId = 0,
// 地址是**纯十进制字符串**。Float 占 2 个寄存器,所以每个点间隔 2。
Address = (40001 + (i - 1) * 2).ToString(), // 40001 / 40003 / 40005 …
Unit = "℃",
Desc = $"第 {i} 区温度",
});
if (!r.Ok) { Console.WriteLine($"第 {i} 个点失败:{r.Message}"); break; }
}
// ③ 建画面 + 摆几个显示框
auto.CreateScreen("1号炉总览", 1920, 1080);
for (int i = 1; i <= 8; i++) // 前 8 个点上画面
{
auto.AddElement("1号炉总览", "ValueDisplay", $"温度{i}", 40, 40 + (i - 1) * 60, 200, 40);
auto.SetBinding("1号炉总览", $"温度{i}", "Value", $"1号炉.Temp{i:D2}", format: "{0:F1}");
}
// ④ 给每个点配历史记录(值变就记,保留 30 天)
for (int i = 1; i <= 100; i++)
auto.SetHistoryRule(new HistoryConfigModel
{
TagName = $"1号炉.Temp{i:D2}",
Mode = "OnChange", // ⚠ 字符串,不是枚举
Deadband = 0.5,
RetentionDays = 30,
});
// ⑤ 校验 + 保存
var v = auto.Validate();
if (!v.Ok) { Console.WriteLine($"校验没过:{v.Message}"); return; }
Console.WriteLine(auto.Save().Message);
// ⑥ 导出备份
auto.ExportProject(@"D:\备份\产线工程.hmiproj");
这个示例里埋了几个坑,注意看:
- 地址必须留宽度 —— Float 占 2 个寄存器,所以地址是
40001 / 40003 / 40005…。 连着写40001 / 40002 / 40003会互相覆盖。 - 地址是纯十进制字符串 —— 解析器走
long.TryParse,写"40001+2"会抛"地址无法解析"。 要算偏移就在 C# 里先算好再ToString()。 AddTag不传 Id —— 让它自动取,保证 0 连续。- 绑定点名用
{i:D2}补零 —— 因为点名就是Temp01这种格式,和AddTag时一致。 - 每步都查
Ok—— 失败不抛异常,不查就静默错过去了。 ExportProject内部会先Save()一次 —— 所以第 ⑤ 步的 Save 主要是为了拿校验结果。ExportProject内部会先 Save 一次 —— 所以第 ⑤ 步的 Save 其实是给校验用的。
七个最容易踩的坑
| # | 坑 | 怎么避 |
|---|---|---|
| 1 | 点 Id 跳号 → Save() 拒绝 |
别自己指定 Id,让 AddTag 自动取;删点后别复用旧 Id |
| 2 | 属性名写错 → 静默跳过,以为改成功了 | 改前先 DescribeElementProps 拿真名 |
| 3 | 忘了 AttachCompiler → 脚本校验全返回"编译器未接入" |
打开工程后立刻挂;ProjectSwitched 后重挂 |
| 4 | 不看 Ok → 失败了也不知道 |
每个调用都查 Ok,!Ok 时打 Message |
| 5 | SetElementProps 传空表 → 被拒 |
空/null 会明确拒绝,不静默当"改成功" |
| 6 | 用 Id 引用点 → 删点重排后全错 | 一律用点名引用 |
| 7 | 忘了 Save() → 改了半天磁盘上没变 |
一组操作做完统一 Validate() + Save() |
方法清单以
src/Hmi.Automation/IProjectAutomation.cs为准。 发现手册缺了方法,回头补上。