Skip to main content

netget/server/dnp3/
actions.rs

1use crate::llm::actions::protocol_trait::{ActionResult, Server};
2use crate::llm::actions::{protocol_trait::Protocol, ActionDefinition};
3use crate::protocol::{EventType, SpawnContext};
4use crate::server::ics_support::{action, parameter};
5use crate::state::AppState;
6use anyhow::{ensure, Result};
7use serde_json::{json, Value};
8use std::{
9    future::Future,
10    net::SocketAddr,
11    pin::Pin,
12    sync::{Arc, LazyLock},
13};
14#[derive(Default)]
15pub struct Dnp3Protocol;
16impl Dnp3Protocol {
17    pub fn new() -> Self {
18        Self
19    }
20}
21pub fn replies() -> Vec<ActionDefinition> {
22    vec![action("dnp3_measurements","Return typed static/class events with point kind, index, value, flags, class and optional deterministic timestamp_ms",vec![parameter("points","array","Up to 64 {kind:binary|analog|counter,index,value,flags,class:0..3,timestamp_ms}",true)],json!({"type":"dnp3_measurements","points":[{"kind":"analog","index":0,"value":12.5,"flags":1,"class":0}]})),action("dnp3_control_result","Approve or refuse the declared binary direct-operate control",vec![parameter("status","string","success, denied, not_supported or out_of_range",true)],json!({"type":"dnp3_control_result","status":"denied"}))]
23}
24pub static EVENTS: LazyLock<Vec<EventType>> = LazyLock::new(|| {
25    vec![EventType::new(
26        "dnp3_request",
27        "DNP3 outstation/master: class polls, typed points and binary direct-operate",
28        replies()[0].example.clone(),
29    )
30    .with_parameters(vec![
31        parameter("operation", "string", "poll or control", true),
32        parameter("sequence", "number", "Application sequence 0..15", true),
33        parameter("function", "number", "DNP3 application function", true),
34        parameter("classes", "array", "Classes being polled", false),
35        parameter("index", "number", "Control point index", false),
36        parameter(
37            "code",
38            "number",
39            "Binary control operation code, 1..4",
40            false,
41        ),
42        parameter("count", "number", "Control repeat count", false),
43        parameter("on_ms", "number", "On milliseconds", false),
44        parameter("off_ms", "number", "Off milliseconds", false),
45    ])
46    .with_actions(replies())]
47});
48impl Protocol for Dnp3Protocol {
49    fn protocol_name(&self) -> &'static str {
50        "DNP3"
51    }
52    fn stack_name(&self) -> &'static str {
53        "ETH>IP>TCP>DNP3"
54    }
55    fn keywords(&self) -> Vec<&'static str> {
56        vec!["dnp3"]
57    }
58    fn description(&self) -> &'static str {
59        "DNP3 outstation/master: class polls, typed points and binary direct-operate"
60    }
61    fn example_prompt(&self) -> &'static str {
62        "Simulate a DNP3 device on localhost"
63    }
64    fn group_name(&self) -> &'static str {
65        "Industrial"
66    }
67    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
68        vec![action(
69            "disconnect",
70            "Close this peer",
71            vec![],
72            json!({"type":"disconnect"}),
73        )]
74    }
75    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
76        replies()
77    }
78    fn get_event_types(&self) -> Vec<EventType> {
79        EVENTS.clone()
80    }
81
82    fn metadata(&self) -> crate::protocol::ProtocolMetadataV2 {
83        use crate::protocol::metadata::*;
84        ProtocolMetadataV2::builder().state(DevelopmentState::Experimental).privilege_requirement(PrivilegeRequirement::None).implementation("Own CRC-validated link framing and bounded transport reassembly; selected DNP3 application objects, class polls, confirmation and CROB direct-operate").llm_control("Handler supplies device values, operation approvals and refusals; client handler selects structured operations").e2e_testing("Independent peer integration plus negative and lifecycle tests in tests/server/dnp3").notes("Self-contained Rust subset; no Step Function dependency. Independent Apache-2.0 OpenDNP3 3.1.2 peer. Link addresses fixed master=1/outstation=10. Static binary G1v2, analog G30v5, counter G20v1; event G2v1/2, G32v5/7, G22v1/5. Handler-sourced class events, confirmations and last-request replay. No unsolicited reporting, SELECT/OPERATE, analog controls, serial or Secure Authentication. Deterministic handler timestamps and 10 s response deadlines. 292-byte link frames and 2048-byte app assembly.").max_inbound_bytes(crate::server::dnp3::codec::MAX_FRAME).well_known_port(20000).answers_on_failure().build()
85    }
86    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
87        let base = json!({"type":"open_server","base_stack":"dnp3","port":20000,"instruction":"Supply device decisions with structured actions"});
88        let a = replies()[0].example.clone();
89        let mut fixed = base.clone();
90        fixed["event_handlers"] = json!([{"event_pattern":"dnp3_request","handler":{"type":"static","actions":[a.clone()]}}]);
91        let code = format!("print({})", json!(json!({"actions":[a]}).to_string()));
92        let mut script = base.clone();
93        script["event_handlers"] = json!([{"event_pattern":"dnp3_request","handler":{"type":"script","language":"python","code":code}}]);
94        crate::llm::actions::StartupExamples::new(base, script, fixed)
95    }
96}
97impl Server for Dnp3Protocol {
98    fn spawn(&self, ctx: SpawnContext) -> Pin<Box<dyn Future<Output = Result<SocketAddr>> + Send>> {
99        Box::pin(crate::server::ics_support::spawn(
100            ctx,
101            Arc::new(Self),
102            super::codec::Device::default,
103            &EVENTS,
104        ))
105    }
106    fn execute_action(&self, v: Value) -> Result<ActionResult> {
107        if v["type"] == "disconnect" {
108            return Ok(ActionResult::CloseConnection);
109        }
110        ensure!(
111            replies().iter().any(|a| v["type"] == a.name),
112            "unknown device reply action"
113        );
114        ensure!(
115            crate::utils::json_budget::within_budget(&v, 4096, 1024, 16),
116            "reply exceeds JSON budget"
117        );
118        Ok(ActionResult::Custom {
119            name: v["type"].as_str().expect("validated").into(),
120            data: v,
121        })
122    }
123}