Skip to main content

netget/client/dnp3/
actions.rs

1use crate::llm::actions::client_trait::{Client, ClientActionResult};
2use crate::llm::actions::{protocol_trait::Protocol, ActionDefinition};
3use crate::protocol::{ConnectContext, EventType};
4use crate::server::ics_support::{action, parameter};
5use crate::state::AppState;
6use anyhow::Result;
7use serde_json::{json, Value};
8use std::{future::Future, net::SocketAddr, pin::Pin, sync::LazyLock};
9#[derive(Default)]
10pub struct Dnp3ClientProtocol;
11impl Dnp3ClientProtocol {
12    pub fn new() -> Self {
13        Self
14    }
15}
16pub fn requests() -> Vec<ActionDefinition> {
17    vec![action("dnp3_poll","Read classes (0 static, 1/2/3 events)",vec![parameter("classes","array","One to four class numbers 0..3",true)],json!({"type":"dnp3_poll","classes":[0]})),action("dnp3_control","One CROB direct-operate, 16-bit index; code values 1 pulse on,2 pulse off,3 latch on,4 latch off",vec![parameter("index","number","Output index 0..65535",true),parameter("code","number","CROB code, low nibble 0..4",true),parameter("count","number","Repeat count 1..255",true),parameter("on_ms","number","On duration, 32-bit milliseconds",true),parameter("off_ms","number","Off duration, 32-bit milliseconds",true)],json!({"type":"dnp3_control","index":0,"code":3,"count":1,"on_ms":0,"off_ms":0})),action("disconnect","Close master link",vec![],json!({"type":"disconnect"}))]
18}
19pub static EVENTS: LazyLock<Vec<EventType>> = LazyLock::new(|| {
20    vec![
21        EventType::new(
22            "dnp3_connected",
23            "Transport ready for device operations",
24            requests()[0].example.clone(),
25        )
26        .with_parameters(vec![parameter(
27            "remote_addr",
28            "string",
29            "Address of the connected remote device",
30            true,
31        )])
32        .with_actions(requests()),
33        EventType::new(
34            "dnp3_response",
35            "Device operation result or protocol status",
36            requests()[0].example.clone(),
37        )
38        .with_parameters(vec![
39            parameter(
40                "success",
41                "boolean",
42                "Whether the device accepted the operation",
43                false,
44            ),
45            parameter("status", "number", "Protocol error status, if any", false),
46            parameter("value", "object", "Decoded response fields", false),
47        ])
48        .with_actions(requests()),
49    ]
50});
51impl Protocol for Dnp3ClientProtocol {
52    fn protocol_name(&self) -> &'static str {
53        "DNP3"
54    }
55    fn stack_name(&self) -> &'static str {
56        "ETH>IP>TCP>DNP3"
57    }
58    fn keywords(&self) -> Vec<&'static str> {
59        vec!["dnp3"]
60    }
61    fn description(&self) -> &'static str {
62        "DNP3 outstation/master: class polls, typed points and binary direct-operate"
63    }
64    fn example_prompt(&self) -> &'static str {
65        "Connect to a DNP3 device on localhost"
66    }
67    fn group_name(&self) -> &'static str {
68        "Industrial"
69    }
70    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
71        requests()
72    }
73    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
74        vec![]
75    }
76    fn get_event_types(&self) -> Vec<EventType> {
77        EVENTS.clone()
78    }
79
80    fn metadata(&self) -> crate::protocol::ProtocolMetadataV2 {
81        use crate::protocol::metadata::*;
82        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/client/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).build()
83    }
84    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
85        let base = json!({"type":"open_client","protocol":"dnp3","remote_addr":"127.0.0.1:20000","instruction":"Supply device decisions with structured actions"});
86        let a = requests()[0].example.clone();
87        let mut fixed = base.clone();
88        fixed["event_handlers"] = json!([{"event_pattern":"dnp3_connected","handler":{"type":"static","actions":[a.clone()]}}]);
89        let code = format!("print({})", json!(json!({"actions":[a]}).to_string()));
90        let mut script = base.clone();
91        script["event_handlers"] = json!([{"event_pattern":"dnp3_connected","handler":{"type":"script","language":"python","code":code}}]);
92        crate::llm::actions::StartupExamples::new(base, script, fixed)
93    }
94}
95impl Client for Dnp3ClientProtocol {
96    fn connect(
97        &self,
98        ctx: ConnectContext,
99    ) -> Pin<Box<dyn Future<Output = Result<SocketAddr>> + Send>> {
100        Box::pin(super::connect(ctx))
101    }
102    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
103        if v["type"] == "disconnect" {
104            return Ok(ClientActionResult::Disconnect);
105        }
106        crate::server::dnp3::codec::validate(&v)?;
107        Ok(ClientActionResult::Custom {
108            name: v["type"]
109                .as_str()
110                .ok_or_else(|| anyhow::anyhow!("action type"))?
111                .into(),
112            data: v,
113        })
114    }
115}