# 动作

> 动作编辑器：标识与位置、脚本块、脚本可以读什么写什么、每一条校验提示、“运行”这条命令和它的日志、上一次运行是谁触发的、什么会运行这个动作、启用和禁用、复制和删除，以及运行的那些上限。

动作是一段 JavaScript 语句块，它读地址空间，写内部存储值变量和点位。动作是 Logic 的写入通路：它们可以按需运行（运行）、由一个[触发器](logic-triggers)运行、由一个接在它们上面的仪表画面[按钮](component-button)运行，或者在一次 Process 运行记录开始或完成时，经由一条 [Logic 动作命令](process-actions#logic-action-commands)运行。在 Logic 那一栏 Actions 一节下面选中一个动作，或者在它所在文件夹或所属那一节的根的表格里按它那一行上的**打开**，就能打开它的编辑器（用“新建…”>“新建动作”造出来的动作不会被打开，而是在那张表格里突出显示，见[新建与复制](logic#creating-and-copying)）；读要**查看 Logic**，编辑要**配置 Logic**，运行要**操作 Logic**。

标题栏显示动作标记、名称、那一行“一段你按需运行、或者由触发器替你运行的脚本，用来写入变量和点位。”，动作关着的时候还有一枚**已禁用**的胶囊。表单那一列里放着**标识**、**脚本**和**使用者**；右边那条竖栏放着**最近一次运行**。

## 标识 {#identity}

| 字段 | 它是什么 | 取值 / 默认值 | 影响 |
| --- | --- | --- | --- |
| 名称 | 显示名称，也是一次运行被记进日志时所用的那个标签。 | 自由文本；新动作叫 `ActionN`；名字留空则存成 `Action`。 | 每一次按键都自动保存。动作是按身份被引用的，所以重命名什么都不用改写，也弄不坏什么。 |
| 位置行 | 这个动作归在哪里，用文字写出来：`Actions / Startup / Walk setpoint`。 | 只读。 | 动作没有地址；这一行是一个位置，绝不是拿去写进表达式的东西。要改就用树里的**移动到**。 |
| 说明（可选） | 自由文本。 | 默认为空。 | 没有影响。 |

## 脚本 {#script}

**脚本**卡片就是那个[脚本编辑器](logic-expressions#the-script-editor)，带一句提示“JavaScript 语句（let、if、for、函数）。写内部存储值变量用赋值（Variables.Setpoint = 21.5;），写点位用 Connector.Driver.Device.Tag.Write(value)。读取不做质量校验：请先检查 .State()，并在做算术之前用 ?? 兜住缺口。”脚本每一次按键都自动保存，每半秒校验一次；提示出现在那句提示下面。校验从不执行脚本。

脚本就是普通的严格模式 JavaScript（局部变量、`if`、循环、函数、`Math`、`Date`、`JSON`），跑在和表达式同一个[地址空间](logic-address-space)、同一套[可读面](logic-expressions#the-read-surface-of-a-point)之上，只有两处不同。读取**不**过质量关卡：一个空档读作 `null`，所以请检查 `.State()`，并用 `??` 把算术挡住；处理一次不良读数正是动作的用武之地。而且脚本可以读表达式变量（读它们已发布的值）；只有表达式不能引用它们。

实时点位（点位、变量、报警、Process 的通道）是点位本身，不是它的值，所以请通过成员读它的值：`if (Variables.Start.ValueToBool())`、`Variables.Level.ValueToReal() > 80`、`if (Alarms.High.IsActive)`。光写在 `if`、`while`、`do`、`for`、`?:` 的条件里，作为 `!`、`&&`、`||` 的操作数，或写在比较、算术（包括 `+=` 和其他复合赋值）里，它要么始终被视为真，要么永远读不到它的值：`if (Variables.Start) { … }` 不管变量是什么值，每次执行都会执行它的代码块。校验会拒绝这种写法，并说出应该改读哪个成员（[校验拒绝什么](#what-validation-refuses)）。传给函数的点位，以及点位的任何成员，都保持原样。对光写点位做 `switch`、或 `case` 写一个点位（请读 `.Value`），`??` 的左边（请读 `.Value`），以及模板字符串里的占位 `${…}`（请读 `.ValueToString()`），也都一样。对变量的复合赋值（`Variables.Total += 1`、`-=`、`||=` 等）会在写入之前把变量读成点位，所以会被拒绝，并给出能用的普通赋值：`Variables.Total = Variables.Total.ValueToReal() + 1`。

### 一个动作可以写什么 {#what-an-action-may-write}

恰好有两种写法能改变现场，而且两种都走那条记进日志的[写入出口](logic#writing-values)：

| 写入 | 形式 | 目标 |
| --- | --- | --- |
| 内部存储值赋值 | `Variables.Setpoint = 21.5;` 或 `Variables.Forno.Setpoint = 21.5;` | 一个内部存储值变量，文件夹层级也在内。值会被强制转换成这个变量的输出类型，而且在变量是掉电保持时被标记为需要保存。它当场就生效，所以同一段脚本的下一行读到的就是它刚写进去的东西。 |
| 点位写入 | `Connector.Simulated.Pump.Cmd.Write(true);` 或 `…Cmd.WriteAndWait(true);` | 一个实时点位。这个值是工程值：它会倒着走完这个点位的[转换链](connector-value-pipeline)，变成设备要的那个原始值，随后出口把它交给那台设备自己的下发队列，欠这台设备的那些值在那里按到达的先后一个一个发出去。 |

一个点位带着两种写入写法，脚本按自己要不要那个回音来挑：

| 写法 | 参数 | 省掉时 | 返回 | 等不等现场设备 |
| --- | --- | --- | --- | --- |
| `Write(value)` | 那个值。 | 没有什么可省。 | 什么都不返回。 | 不等。值交给设备的队列，下一行就接着跑。 |
| `WriteAndWait(value, timeoutMs, continueOnFailure)` | 那个值、为它等多少毫秒，以及失败之后要不要接着跑。 | `timeoutMs`：改用驱动自己的等待时长。`continueOnFailure`：`true`。 | 只有现场设备收下了这个值才返回 `true`；其余一切返回 `false`，被更新的值替换掉也算在内。 | 等，直到回执到了，或者等待时长用完。 |

这个等发生在动作通道上，所以一台慢设备拖住的只是排在这一个后面的那些动作，而且它绝不会把写入撤回来：一个没能按时得到确认的值仍在路上，它的回执落地时照样记进日志。答复说的就是这件事，一字不差：“‘Cmd’在 2000 ms 内没有得到确认，该值仍在发往设备的途中。”`continueOnFailure` 省掉或者写成 `true` 时，这个写法答 `false`，脚本接着跑；写成 `false` 时，运行就在那一行结束，点出目标和原因。

**没有任何一次写入自己就能中止一段脚本。** 一次在任何东西发出去之前就被拒绝的写入，和一次被现场设备拒绝或者始终没有回音的写入，都只是它自己失败：脚本其余部分照跑，这次运行收尾时算作一次带着失败写入的成功运行。也没有什么会被回滚，所以一次失败的写入之前已经生效的仍然生效，而诊断日志把每一次写入按来源“动作”记成“从旧值到新值”。一次拒绝会用下面这些说法读出来：

- “对‘X’的写入被驳回：这个值不是有限数。”（NaN 或无穷）
- “‘Tag’没有在连接器里落地。”（一台已禁用的设备，或者一个 Connector 没有带着的点位）
- “‘value’无法为‘Tag’完成转换。”
- “‘X’被‘`<holder>`’写保护。”（一次配方运行或者一个联锁占着这个信号源）
- “未写入‘Cmd’：设备未连接。”（这台设备已经知道是趴着的，所以没有给它排任何东西）
- “对‘Tag’的写入失败：Connector 没有在运行。”
- “找不到变量‘X’。”以及“‘Variables.X’是表达式变量：它由计算得出，永远不被写入。”
- 拒绝来自线路那头时，现场设备或者它的驱动答了什么，就照它自己的说法写什么。

一个在设备的队列里被同一点位上更新的写入赶超掉的值**不算**失败：“‘Cmd’在发送前已被更新的值替换。”说的正是发生过的事，现场设备被告知的是更新的那个值，而这件事除了对一次 `WriteAndWait` 之外哪儿都不报：它答 `false`，因为出发的不是这个值。

真正会让一次运行结束的是：脚本自身的错误（语法错误、指不到任何东西的地址、一次异常）、一次写入失败了的 `WriteAndWait(…, false)`，以及[运行](#run)那一节里的那些上限。

运行本身报得出哪些失败，看的是谁等在那里。一次在任何东西发出去之前就被拒绝的 `Write`，是在脚本还跑着的时候被拒绝的，所以这次运行把它算进去，记录条也说出来；一次写入在现场设备事后才拒绝的 `Write`，答复远在运行结束之后才到，所以扛着它的是[事件](events)控制台里那个动作名字下面的记录，永远不是这次运行。一次 `WriteAndWait` 留下来等了答复，所以答复往哪边倒，它都算进去。

## 校验拒绝什么 {#what-validation-refuses}

| 提示 | 原因 |
| --- | --- |
| “必须填写脚本。” | 编辑器是空的。 |
| “脚本错误：…” | 这段文字解析不了。 |
| “地址必须是静态的：‘Root’用一个计算出来的地址段访问。” | `Variables[name]` 或者任何非字面量的段。 |
| “地址‘…’不完整。” | 一处太短、点不到一个点位的引用。 |
| “没有找到‘X’。” | 一次读取，或者一个赋值目标，什么都解析不出来。 |
| “‘X’是一个值：请不加 .Value 直接读取。” | 在作为值本身进入脚本的点位上读取实时点位的成员（`.Value`、`.ValueToBool()`、`.State()` 等）：`System.StartTime.Value`，或 `Process.Oven_soak.Oven_1.Procedure.Running.ValueToBool()`。消息里写的是脚本中用到的那个成员。 |
| “‘X’是点位本身，而不是它的值：作为条件时它始终被视为真。请用 X.ValueToBool() 读取它的值。” | 在判断真假的地方光写的实时点位：`if`、`while`、`do`、`for`、`?:` 的条件，或 `!`、`&&`、`\|\|` 的操作数（`if (Variables.Start)`）。对报警，消息写的是 `.IsActive`。 |
| “‘X’是点位本身，而不是它的值：比较或计算永远读不到它的值。请用 X.ValueToReal() 读取它的值。” | 在比较或算术里光写的实时点位（`Variables.Level > 80`、`total += Variables.Level`）。对 `==`、`!=`、`===`、`!==`，消息写的是 `.Value`；对报警，写的是 `.IsActive`。对光写点位做 `switch`，或 `case` 写一个点位，得到的是带 `.Value` 的那句消息。 |
| “‘X’是点位本身，而不是它的值：它永远不为空，所以 ?? 总是保留它。请用 X.Value 读取它的值。” | 在 `??` 左边光写的实时点位（`Variables.Level ?? 0`）。对报警，消息写的是 `.IsActive`。 |
| “‘X’是点位本身，而不是它的值：写进文本里也永远显示不出它的值。请用 X.ValueToString() 读取它的值。” | 在模板字符串的占位 `${…}` 里光写的实时点位。对报警，消息写的是 `.IsActive`。 |
| “‘+=’在写入之前把 X 读成点位本身，而不是它的值。请用 X = X.ValueToReal() + 1。” | 对变量的复合赋值：`+=`、`-=`、`*=`、`/=`、`%=`、`**=`、位运算的那些、`&&=`、`\|\|=` 和 `??=`。消息写出所用的运算符，以及按它的意思工作的普通赋值，右边保持原样：算术用 `.ValueToReal()`，位运算用 `.ValueToInteger()`，`&&=` 和 `\|\|=` 用 `.ValueToBool()`，`??=` 用 `.Value`。 |
| “请直接给变量本身赋值：Variables.Name = value。” | `=` 左边点的是一个成员，或者比一个变量的地址还深。 |
| “‘X’是表达式变量：它由计算得出，永远不被写入。” | 给一个表达式变量赋值。 |
| “‘Connector’下的点位是只读的。写点位请用 Connector.Driver.Device.Tag.Write(value)。” | 给 Variables 以外的任何根赋值（Process 有它自己的提示）。 |
| “动作里 Process 的点位是只读的；脚本不支持运行命令和预选写入。” | 给一个 Process 点位赋值，或者对它调 `Write`。 |
| “动作里不能调用 Process 的运行命令；命令点位只提供只读的就绪状态值。” | 调用 `Process.<Model>.<Unit>.Commands.Start()` 或者任何别的命令类点位；一个 `Commands` 点位只读得出这条命令此刻能不能执行。 |
| “点位不支持 ++ 和 -- 运算符。请用 Variables.Name = Variables.Name.ValueToReal() + 1。” | `Variables.N++`。 |
| “Write(value) 就是点位写入：Connector.Driver.Device.Tag.Write(value)。” | 对一个不是点位地址的东西调了 `Write`。这句话点的是当时用的那个写法的名字，所以 `WriteAndWait` 得到的是同一句，两处都换成它自己的名字。 |
| “WriteAndWait 的等待时间必须是毫秒数：WriteAndWait(value, 2000)。” | 第二个参数写出来了，而且不是一个数。 |
| “WriteAndWait 的失败后继续必须是 true 或 false：WriteAndWait(value, 2000, false)。” | 第三个参数写出来了，而且不是 `true` 也不是 `false`。 |
| “WriteAndWait 最多接受三个参数：值、以毫秒计的等待时间，以及失败后是否继续。” | 出现了第四个参数。 |

`WriteAndWait` 的这三句判的是文字本身写了什么。一个编辑器光靠读读不出结论的参数（一个变量、一个算出来的表达式），要等这次调用真的发生时才判，那时同样这句话会让运行在那一行结束。

JavaScript 的局部变量（`let x = 1; x = x + 1;`）不受这些规则约束。

## 运行 {#run}

**运行**是一个打开着的动作上那条命令栏里排在最前的命令；它只对拥有**操作 Logic** 的角色画出来，工作站只读时带着“运行时已停止”置灰（工程锁定不会把它置灰：运行是操作，不是配置）。角色同时还拥有“配置 Logic”时，编辑器里的草稿会先保存下来，运行执行的是保存之后的那份定义；没有“配置”时，运行执行的是已经落盘的那份定义（那些字段本来就是只读的，所以两者绝不会不一样）。

每一次运行都在**动作通道**上、用一个全新的脚本引擎执行，所以两次运行之间不会有状态漏过去，两段脚本也绝不会同时跑：它们一次一个，按被要求的先后跑。这条通道不是那 100 毫秒的扫描周期。一段脚本跑着的时候，表达式、报警、确认以及从别处来的写入都照常进行，所以一次扫描周期可能落在一段脚本中间，同一段脚本里的两次读取也可能看到不同的周期；换来的是，一个停下来等设备的动作只耽误排在它后面的那些动作，工作站里别的什么都不耽误。

一次运行超过 500 毫秒（“脚本已中止：它超过了 500 ms 的时间上限（是不是死循环？）。”）、100000 条语句或者 4 MB 内存就被中止，每一种都有一条点名那个上限的提示。那 500 毫秒量的是脚本在跑的时间：一次 `WriteAndWait` 在等设备的整段时间里计数是停着的，等完再接着算，所以一段正确的脚本绝不会为慢设备背锅。已禁用的动作根本不跑就失败（“该动作已禁用。”）；运行时已停止时答的是“逻辑运行时没有在运行。”

每一次写入都过去了时，记录条报告“运行正常，用时 12 ms。”；脚本自己跑完了而有写入没成时，报告“运行正常，用时 12 ms，1 次写入失败：Cmd：未写入‘Cmd’：设备未连接。”（从第二次失败起是“运行正常，用时 12 ms，3 次写入失败，第一次：Cmd：未写入‘Cmd’：设备未连接。”）；运行本身收场不好时，报告“运行失败：`<reason>`”。

失败的那次运行会到[事件](events)控制台，记在这个动作的名字下面，并连同是谁发起的：命令按的是“手动”，仪表画面按钮是 Dashboard，代理是 MCP，排程运行的是那个触发器的名字，Process 运行记录是 `Process · <unit> · <action>`。它第一次被记为一条警告，在它以同一个原因反复失败期间被汇总，恢复时再记一条，而那一条恢复的记录，是一次成功的运行在那里唯一写下的东西。一次被拒绝的写入也照同样的路、记在同样的名字下面到达控制台，而被点名的是那个动作、不是那次运行，道理是一样的：现场设备的答复可能在运行结束好几秒之后才到，那时已经没有哪次运行还能失败了。单纯成功的一次运行，以及它写下的每一个值，只写进[详细捕获](events-console#detailed-capture)：一个每次扫描周期都写的脚本会产出成千上万个值，而一个被它们填满的控制台，是一个谁也读不到工作站要闻的控制台。

**最近一次运行**那条竖栏显示这个动作上一次是什么时候跑的（或者“从未运行。”）、“用时：N 毫秒”、是谁触发的（见下文）、放在一条提醒里的最近一次错误、单独放在一条提醒里的那次运行写失败的那些写入（和记录条上带的是同一句话，所以一次成功了却仍然把某个设定值留在原处的运行，在这里也照样说出来），以及那句提示“运行都在动作通道上执行，一次一个。只有失败的运行才会进入事件控制台；成功的运行以及它写入的每一个值，都留给详细捕获。触发器按计划调用动作。”这条竖栏属于活着的那个动作：一次自动保存绝不会把它抹空；把运行时重启一次会。

### 触发来源 {#fired-by}

在用时下面，“触发来源：…”这一行说明最近一次是什么运行了这个动作：

| 是什么运行了它 | 这一行写着 |
| --- | --- |
| 本页上的**运行** | “触发来源：在本页运行” |
| 仪表画面上的一个按钮 | “触发来源：仪表画面上的按钮” |
| 代理（`action_run`） | “触发来源：代理” |
| 一个触发器 | 触发器的名称：“触发来源：Heater off when too hot”。 |
| 一次 Process 运行记录里的一条 Logic 动作命令 | 单元和动作组：“触发来源：Process · Oven 1 · After soak”。 |

## 使用者 {#used-by}

**使用者**卡片列出工作站上运行这个动作的一切（“工作站上运行这个动作的一切。”），在打开动作时读取：触发它的每一条 Process 命令、绑在它上面的每一个触发器、运行它的每一个仪表画面按钮，各占一行。

| 种类 | 位置 | 名称 |
| --- | --- | --- |
| **Process 命令** | 模型。 | 动作组和时机：`After soak · 完成`。 |
| **触发器** | 空。 | 触发器，放在它的文件夹下面。 |
| **按钮** | 仪表画面。 | 按钮上的文字。 |

什么都没有时，它写“工作站上没有任何东西运行这个动作。”；引用读不出来时，写“这个动作可能仍在使用中。删除或重命名它之前，请再试一次。”并带一个**重试**。这张卡片只读，所以每个角色都看得到，工作站被工程锁定时也一样。停用或删除一个动作之前先读它：一条动作已经不在的 Process 命令什么都不运行，并在它自己的页面上说明。

## 读取触发它的那次运行记录 {#reading-the-run-that-fired-it}

一次 Process 运行记录在完成时触发的动作，从单元的 [LastRun](process-addresses#lastrun) 分支读取那次运行记录，动作运行时这一支已经装着它；开始时触发的动作，从 `Procedure.Metadata` 读取正在开始的那次运行记录的元数据。Process 的点位读作普通值，后面不跟 `.Value`，这一点和变量不同；运行记录里没有的点位读作 `null`，所以要先判断：

```js
if (Process.Oven_soak.Oven_1.LastRun.Verdict === "Ok") {
  Variables.Last_serial = Process.Oven_soak.Oven_1.LastRun.Metadata.Serial;
}
const peak = Process.Oven_soak.Oven_1.LastRun.Evaluations.Soak_check.Temperature_in_range.MaxValue;
if (peak !== null) {
  Variables.Last_peak = peak;
}
```

地址要整段写出来，和脚本里的每个地址一样：地址不能用一个变量拼出来。

## 命令 {#commands}

| 命令 | 作用 | 何时置灰 | 何时不画出来 |
| --- | --- | --- | --- |
| 运行 | 保存草稿（有“配置 Logic”时）并把一次运行排进队列。 | 运行时已停止。 | 角色没有“操作 Logic”。 |
| 启用 / 禁用 | 翻转“已启用”；已禁用的动作拒绝运行，并让绑在它上面的每一个触发器空转，触发它的 Process 命令每次都被拒绝，并显示需要复核。 | 工作站被锁住或只读。 | 角色没有“配置 Logic”。 |
| 复制 | 把这个动作复制到 Actions 根下面一个空闲的名字上，副本不会被打开，而是在这个根的表格里突出显示（见[新建与复制](logic#creating-and-copying)）；副本没有运行记录，也没有触发器绑在它上面。 | 工作站被锁住或只读。 | 角色没有“配置 Logic”。 |
| 删除动作 | 先问“要删除‘X’吗？这会删除该动作。 此操作无法撤销。”绑在它上面的触发器会变成没有绑定，而不是跟着一起被删掉；运行它的 Process 命令会留下来，什么都不运行，并显示需要复核。 | 工作站被锁住或只读。 | 角色没有“配置 Logic”。 |

Actions 根的面板在“数值”那一列里列出每一个动作和它的状态：“已启用 · 上次运行 `<when>`”，上一次运行失败时加“· 错误”，运行成功了而它的某次写入没成时加“· 写入失败”，否则是“已禁用”。

## 动作不做什么 {#what-actions-do-not-do}

它们不下发 Process 的运行命令、不预选一个流程、也不给运行记录加备注（那些点位是只读的就绪值和预选值）；它们不确认报警；它们不并行运行；它们两次运行之间不留状态，也不共用函数；而且它们不由数值变化或条件触发，只由“运行”、一个触发器的定时间隔、一个仪表画面按钮、代理或者一次 Process 运行记录的命令触发。Process 运行记录从不等它触发的动作，动作的结果也从不写进运行记录。
