///|
/// Kind of operator command accepted by the control plane.
pub(all) enum DeviceCommandKind {
WriteCoilCommand
WriteRegisterCommand
WriteCoilsCommand
WriteRegistersCommand
ReadVerificationCommand
ClearAlarmCommand
RestartDeviceCommand
CustomCommand
} derive(Debug, Eq)
///|
/// Lifecycle of a command from admission to audit completion.
pub(all) enum DeviceCommandState {
Queued
Validating
Dispatched
Acknowledged
Rejected
TimedOut
FailedCommand
CancelledCommand
} derive(Debug, Eq)
///|
/// A typed key/value argument supplied by a UI, rule, or automation.
pub(all) struct CommandArgument {
key : String
value : String
}
///|
pub fn CommandArgument::new(
key : String,
value : String,
) -> Result[CommandArgument, ModbusError] {
if key == "" {
Err(InvalidData)
} else {
Ok({ key, value })
}
}
///|
pub fn CommandArgument::key(self : CommandArgument) -> String {
self.key
}
///|
pub fn CommandArgument::value(self : CommandArgument) -> String {
self.value
}
///|
pub fn CommandArgument::as_int(
self : CommandArgument,
) -> Result[Int, ModbusError] {
Ok(@string.parse_int(self.value)) catch {
_ => Err(InvalidData)
}
}
///|
pub fn CommandArgument::as_bool(
self : CommandArgument,
) -> Result[Bool, ModbusError] {
if self.value == "true" || self.value == "1" {
Ok(true)
} else if self.value == "false" || self.value == "0" {
Ok(false)
} else {
Err(InvalidData)
}
}
///|
/// Definition and authorization context for one device operation.
pub(all) struct DeviceCommand {
id : String
device_id : String
unit_id : Byte
kind : DeviceCommandKind
function : Byte
address : UInt16
quantity : Int
arguments : Array[CommandArgument]
principal : String
reason : String
timeout : Int
mut state : DeviceCommandState
mut created_at : Int
}
///|
pub fn DeviceCommand::new(
id : String,
device_id : String,
unit_id : Byte,
kind : DeviceCommandKind,
function : Byte,
address : UInt16,
quantity : Int,
principal : String,
reason : String,
created_at : Int,
timeout? : Int = 3000,
) -> Result[DeviceCommand, ModbusError] {
if id == "" || device_id == "" || principal == "" || reason == "" {
return Err(InvalidData)
}
if !is_valid_unit_id(unit_id) ||
function == 0 ||
quantity < 1 ||
timeout < 1 ||
created_at < 0 {
return Err(InvalidData)
}
Ok({
id,
device_id,
unit_id,
kind,
function,
address,
quantity,
arguments: [],
principal,
reason,
timeout,
state: Queued,
created_at,
})
}
///|
pub fn DeviceCommand::id(self : DeviceCommand) -> String {
self.id
}
///|
pub fn DeviceCommand::device_id(self : DeviceCommand) -> String {
self.device_id
}
///|
pub fn DeviceCommand::unit_id(self : DeviceCommand) -> Byte {
self.unit_id
}
///|
pub fn DeviceCommand::kind(self : DeviceCommand) -> DeviceCommandKind {
self.kind
}
///|
pub fn DeviceCommand::function(self : DeviceCommand) -> Byte {
self.function
}
///|
pub fn DeviceCommand::address(self : DeviceCommand) -> UInt16 {
self.address
}
///|
pub fn DeviceCommand::quantity(self : DeviceCommand) -> Int {
self.quantity
}
///|
pub fn DeviceCommand::principal(self : DeviceCommand) -> String {
self.principal
}
///|
pub fn DeviceCommand::reason(self : DeviceCommand) -> String {
self.reason
}
///|
pub fn DeviceCommand::timeout(self : DeviceCommand) -> Int {
self.timeout
}
///|
pub fn DeviceCommand::state(self : DeviceCommand) -> DeviceCommandState {
self.state
}
///|
pub fn DeviceCommand::created_at(self : DeviceCommand) -> Int {
self.created_at
}
///|
pub fn DeviceCommand::argument_count(self : DeviceCommand) -> Int {
self.arguments.length()
}
///|
pub fn DeviceCommand::argument(
self : DeviceCommand,
key : String,
) -> CommandArgument? {
for argument in self.arguments {
if argument.key() == key {
return Some(argument)
}
}
None
}
///|
pub fn DeviceCommand::add_argument(
self : DeviceCommand,
argument : CommandArgument,
) -> Result[Unit, ModbusError] {
for existing in self.arguments {
if existing.key() == argument.key() {
return Err(InvalidData)
}
}
if self.arguments.length() >= 16 {
return Err(CapacityExceeded)
}
self.arguments.push(argument)
Ok(())
}
///|
pub fn DeviceCommand::set_state(
self : DeviceCommand,
state : DeviceCommandState,
) -> Result[Unit, ModbusError] {
match (self.state, state) {
(Queued, Validating) | (Queued, CancelledCommand) => ()
(Validating, Dispatched)
| (Validating, Rejected)
| (Validating, CancelledCommand) => ()
(Dispatched, Acknowledged)
| (Dispatched, TimedOut)
| (Dispatched, FailedCommand) => ()
(_, _) => return Err(Busy)
}
self.state = state
Ok(())
}
///|
pub fn DeviceCommand::is_terminal(self : DeviceCommand) -> Bool {
match self.state {
Acknowledged | Rejected | TimedOut | FailedCommand | CancelledCommand =>
true
_ => false
}
}
///|
pub fn DeviceCommand::is_write(self : DeviceCommand) -> Bool {
match self.kind {
WriteCoilCommand
| WriteRegisterCommand
| WriteCoilsCommand
| WriteRegistersCommand => true
_ => false
}
}
///|
pub fn DeviceCommand::verify_address(self : DeviceCommand) -> Bool {
self.address.to_int() + self.quantity <= 65536 && self.quantity <= 123
}
///|
/// Build a PDU for commands represented directly by standard Modbus writes.
pub fn DeviceCommand::request(
self : DeviceCommand,
) -> Result[Frame, ModbusError] {
if !self.verify_address() {
return Err(InvalidAddress)
}
match self.kind {
WriteCoilCommand => {
let argument = match self.argument("value") {
Some(value) => value
None => return Err(InvalidData)
}
let value = match argument.as_bool() {
Ok(value) => value
Err(error) => return Err(error)
}
let data : Array[Byte] = [
(self.address >> 8).to_byte(),
self.address.to_byte(),
if value {
255
} else {
0
},
0,
]
Ok(Frame::new(self.unit_id, 5, data))
}
WriteRegisterCommand => {
let argument = match self.argument("value") {
Some(value) => value
None => return Err(InvalidData)
}
let value = match argument.as_int() {
Ok(value) => value
Err(error) => return Err(error)
}
if value < 0 || value > 65535 {
return Err(InvalidData)
}
let data : Array[Byte] = [
(self.address >> 8).to_byte(),
self.address.to_byte(),
(value / 256).to_byte(),
value.to_byte(),
]
Ok(Frame::new(self.unit_id, 6, data))
}
WriteCoilsCommand => {
let bytes = match self.argument("bytes") {
Some(value) => value.value()
None => return Err(InvalidData)
}
let parsed = command_parse_hex(bytes)
if parsed.length() == 0 || parsed.length() > 250 {
return Err(InvalidByteCount)
}
let data : Array[Byte] = [
(self.address >> 8).to_byte(),
self.address.to_byte(),
(self.quantity / 256).to_byte(),
self.quantity.to_byte(),
parsed.length().to_byte(),
]
for byte in parsed {
data.push(byte)
}
Ok(Frame::new(self.unit_id, 15, data))
}
WriteRegistersCommand => {
let bytes = match self.argument("bytes") {
Some(value) => value.value()
None => return Err(InvalidData)
}
let parsed = command_parse_hex(bytes)
if parsed.length() == 0 ||
parsed.length() > 246 ||
parsed.length() != self.quantity * 2 {
return Err(InvalidByteCount)
}
let data : Array[Byte] = [
(self.address >> 8).to_byte(),
self.address.to_byte(),
(self.quantity / 256).to_byte(),
self.quantity.to_byte(),
parsed.length().to_byte(),
]
for byte in parsed {
data.push(byte)
}
Ok(Frame::new(self.unit_id, 16, data))
}
ReadVerificationCommand => {
let data : Array[Byte] = [
(self.address >> 8).to_byte(),
self.address.to_byte(),
(self.quantity / 256).to_byte(),
self.quantity.to_byte(),
]
Ok(Frame::new(self.unit_id, self.function, data))
}
_ => Err(Unsupported)
}
}
///|
fn command_parse_hex(value : String) -> Array[Byte] {
let result : Array[Byte] = []
let mut high : Byte? = None
for character in value.to_array() {
let byte = character.to_int().to_byte()
let nibble = match command_hex_nibble(byte) {
Some(value) => value
None => continue
}
match high {
None => high = Some(nibble)
Some(first) => {
result.push(first * 16 + nibble)
high = None
}
}
}
result
}
///|
fn command_hex_nibble(value : Byte) -> Byte? {
if value >= 48 && value <= 57 {
Some(value - 48)
} else if value >= 65 && value <= 70 {
Some(value - 55)
} else if value >= 97 && value <= 102 {
Some(value - 87)
} else {
None
}
}
///|
pub fn device_command_kind_name(kind : DeviceCommandKind) -> String {
match kind {
WriteCoilCommand => "write-coil"
WriteRegisterCommand => "write-register"
WriteCoilsCommand => "write-coils"
WriteRegistersCommand => "write-registers"
ReadVerificationCommand => "read-verification"
ClearAlarmCommand => "clear-alarm"
RestartDeviceCommand => "restart-device"
CustomCommand => "custom"
}
}
///|
pub fn device_command_state_name(state : DeviceCommandState) -> String {
match state {
Queued => "queued"
Validating => "validating"
Dispatched => "dispatched"
Acknowledged => "acknowledged"
Rejected => "rejected"
TimedOut => "timed-out"
FailedCommand => "failed"
CancelledCommand => "cancelled"
}
}
///|
/// Result returned by a transport or a simulated execution backend.
pub(all) struct CommandExecution {
command_id : String
state : DeviceCommandState
started_at : Int
finished_at : Int
response : Frame?
error : ModbusError?
message : String
}
///|
pub fn CommandExecution::new(
command_id : String,
state : DeviceCommandState,
started_at : Int,
finished_at : Int,
response? : Frame = Frame::new(0, 0, []),
error? : ModbusError = Incomplete,
message? : String = "",
) -> Result[CommandExecution, ModbusError] {
if command_id == "" || started_at < 0 || finished_at < started_at {
return Err(InvalidData)
}
let selected_response = if response.unit_id == 0 {
None
} else {
Some(response)
}
let selected_error = if state == Acknowledged { None } else { Some(error) }
Ok({
command_id,
state,
started_at,
finished_at,
response: selected_response,
error: selected_error,
message,
})
}
///|
pub fn CommandExecution::command_id(self : CommandExecution) -> String {
self.command_id
}
///|
pub fn CommandExecution::state(self : CommandExecution) -> DeviceCommandState {
self.state
}
///|
pub fn CommandExecution::started_at(self : CommandExecution) -> Int {
self.started_at
}
///|
pub fn CommandExecution::finished_at(self : CommandExecution) -> Int {
self.finished_at
}
///|
pub fn CommandExecution::duration(self : CommandExecution) -> Int {
self.finished_at - self.started_at
}
///|
pub fn CommandExecution::response(self : CommandExecution) -> Frame? {
self.response
}
///|
pub fn CommandExecution::error(self : CommandExecution) -> ModbusError? {
self.error
}
///|
pub fn CommandExecution::message(self : CommandExecution) -> String {
self.message
}
///|
/// Immutable audit event for compliance exports.
pub(all) struct CommandAuditEntry {
sequence : Int
command_id : String
principal : String
device_id : String
operation : String
state : DeviceCommandState
timestamp : Int
detail : String
}
///|
pub fn CommandAuditEntry::new(
sequence : Int,
command : DeviceCommand,
state : DeviceCommandState,
timestamp : Int,
detail? : String = "",
) -> Result[CommandAuditEntry, ModbusError] {
if sequence < 1 || timestamp < 0 {
return Err(InvalidData)
}
Ok({
sequence,
command_id: command.id(),
principal: command.principal(),
device_id: command.device_id(),
operation: device_command_kind_name(command.kind()),
state,
timestamp,
detail,
})
}
///|
pub fn CommandAuditEntry::sequence(self : CommandAuditEntry) -> Int {
self.sequence
}
///|
pub fn CommandAuditEntry::command_id(self : CommandAuditEntry) -> String {
self.command_id
}
///|
pub fn CommandAuditEntry::principal(self : CommandAuditEntry) -> String {
self.principal
}
///|
pub fn CommandAuditEntry::device_id(self : CommandAuditEntry) -> String {
self.device_id
}
///|
pub fn CommandAuditEntry::operation(self : CommandAuditEntry) -> String {
self.operation
}
///|
pub fn CommandAuditEntry::state(self : CommandAuditEntry) -> DeviceCommandState {
self.state
}
///|
pub fn CommandAuditEntry::timestamp(self : CommandAuditEntry) -> Int {
self.timestamp
}
///|
pub fn CommandAuditEntry::detail(self : CommandAuditEntry) -> String {
self.detail
}
///|
/// Central queue and audit log used by a control-plane service.
pub(all) struct CommandCenter {
queue : Array[DeviceCommand]
completed : Array[CommandExecution]
audit : Array[CommandAuditEntry]
max_queue : Int
max_audit : Int
mut sequence : Int
}
///|
pub fn CommandCenter::new(
max_queue? : Int = 1024,
max_audit? : Int = 8192,
) -> Result[CommandCenter, ModbusError] {
if max_queue < 1 || max_audit < 1 {
Err(InvalidData)
} else {
Ok({
queue: [],
completed: [],
audit: [],
max_queue,
max_audit,
sequence: 0,
})
}
}
///|
pub fn CommandCenter::queue_count(self : CommandCenter) -> Int {
self.queue.length()
}
///|
pub fn CommandCenter::completed_count(self : CommandCenter) -> Int {
self.completed.length()
}
///|
pub fn CommandCenter::audit_count(self : CommandCenter) -> Int {
self.audit.length()
}
///|
pub fn CommandCenter::submit(
self : CommandCenter,
command : DeviceCommand,
timestamp : Int,
) -> Result[Unit, ModbusError] {
if timestamp < 0 || self.queue.length() >= self.max_queue {
return Err(CapacityExceeded)
}
match command.set_state(Validating) {
Err(error) => return Err(error)
Ok(_) => ()
}
match command.request() {
Err(error) => {
ignore(command.set_state(Rejected))
return Err(error)
}
Ok(_) => ()
}
ignore(command.set_state(Queued))
self.queue.push(command)
self.record_audit(command, Queued, timestamp, "admitted")
Ok(())
}
///|
pub fn CommandCenter::next(
self : CommandCenter,
timestamp : Int,
) -> Result[DeviceCommand, ModbusError] {
if timestamp < 0 {
return Err(InvalidData)
}
if self.queue.length() == 0 {
return Err(Incomplete)
}
let best = command_best_index(self.queue)
let command = self.queue.remove(best)
ignore(command.set_state(Dispatched))
self.record_audit(command, Dispatched, timestamp, "dispatched")
Ok(command)
}
///|
fn command_best_index(queue : Array[DeviceCommand]) -> Int {
let mut best = 0
for i in 1.. command_priority(queue[best].kind()) {
best = i
}
}
best
}
///|
fn command_priority(kind : DeviceCommandKind) -> Int {
match kind {
RestartDeviceCommand | ClearAlarmCommand => 4
WriteCoilCommand
| WriteRegisterCommand
| WriteCoilsCommand
| WriteRegistersCommand => 3
ReadVerificationCommand => 2
CustomCommand => 1
}
}
///|
/// Complete a dispatched command and retain its result for inspection.
pub fn CommandCenter::finish(
self : CommandCenter,
command : DeviceCommand,
execution : CommandExecution,
) -> Result[Unit, ModbusError] {
if execution.command_id() != command.id() {
return Err(UnitMismatch)
}
self.completed.push(execution)
self.record_audit(
command,
execution.state(),
execution.finished_at(),
execution.message(),
)
Ok(())
}
///|
pub fn CommandCenter::cancel(
self : CommandCenter,
command_id : String,
timestamp : Int,
) -> Result[Unit, ModbusError] {
if command_id == "" || timestamp < 0 {
return Err(InvalidData)
}
for i in 0.. Unit {
if self.audit.length() >= self.max_audit {
ignore(self.audit.remove(0))
}
self.sequence += 1
match
CommandAuditEntry::new(self.sequence, command, state, timestamp, detail~) {
Ok(entry) => self.audit.push(entry)
Err(_) => ()
}
}
///|
pub fn CommandCenter::find_audit(
self : CommandCenter,
command_id : String,
) -> Array[CommandAuditEntry] {
let result : Array[CommandAuditEntry] = []
for entry in self.audit {
if entry.command_id() == command_id {
result.push(entry)
}
}
result
}
///|
pub fn CommandCenter::recent(
self : CommandCenter,
limit : Int,
) -> Result[Array[CommandAuditEntry], ModbusError] {
if limit < 1 {
return Err(InvalidQuantity)
}
let result : Array[CommandAuditEntry] = []
let start = if self.audit.length() > limit {
self.audit.length() - limit
} else {
0
}
for i in start.. Array[Int] {
let counts : Array[Int] = [0, 0, 0, 0, 0, 0, 0, 0]
for entry in self.audit {
let index = match entry.state() {
Queued => 0
Validating => 1
Dispatched => 2
Acknowledged => 3
Rejected => 4
TimedOut => 5
FailedCommand => 6
CancelledCommand => 7
}
counts[index] += 1
}
counts
}