Skip to main content

netget/client/knx/
actions.rs

1use crate::llm::actions::{
2    client_trait::{Client, ClientActionResult},
3    protocol_trait::Protocol,
4    ActionDefinition, ParameterDefinition, StartupExamples,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::knx::{
8    actions::{action, parameter, write_action},
9    wire,
10};
11use crate::state::app_state::AppState;
12use anyhow::{bail, Context, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16#[derive(Default)]
17pub struct KnxClientProtocol;
18impl KnxClientProtocol {
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24fn read_action() -> ActionDefinition {
25    action(
26        "knx_group_read",
27        "Ask the bus for a group address's value; a device's answer arrives as knx_telegram with kind response.",
28        vec![parameter("group_address", "string", "Group address main/middle/sub, e.g. 1/2/4", true)],
29        json!({"type":"knx_group_read","group_address":"1/2/4"}),
30        "-> KNX read {group_address}",
31    )
32}
33
34fn disconnect_action() -> ActionDefinition {
35    action(
36        "disconnect",
37        "Close the tunnelling connection (DISCONNECT_REQUEST).",
38        vec![],
39        json!({"type":"disconnect"}),
40        "-> KNX disconnect",
41    )
42}
43
44pub fn actions() -> Vec<ActionDefinition> {
45    vec![write_action(), read_action(), disconnect_action()]
46}
47
48pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
49    EventType::new(
50        "knx_connected",
51        "The tunnel is open; telegrams on the bus now arrive as knx_telegram.",
52        read_action().example.clone(),
53    )
54    .with_parameters(vec![
55        parameter(
56            "remote_addr",
57            "string",
58            "The KNXnet/IP gateway's address",
59            true,
60        ),
61        parameter(
62            "individual_address",
63            "string",
64            "The individual address the gateway gave this tunnel",
65            true,
66        ),
67    ])
68    .with_actions(actions())
69});
70
71pub static TELEGRAM_EVENT: LazyLock<EventType> = LazyLock::new(|| {
72    EventType::new(
73        "knx_telegram",
74        "A group telegram on the bus: a write, a read, or a response to a read.",
75        read_action().example.clone(),
76    )
77    .with_parameters(vec![
78        parameter("kind", "string", "write, read or response", true),
79        parameter("source", "string", "Individual address of the sender", true),
80        parameter(
81            "destination",
82            "string",
83            "Group address, main/middle/sub",
84            true,
85        ),
86        parameter(
87            "dpt",
88            "string",
89            "The DPT configured for this address, if any",
90            false,
91        ),
92        parameter(
93            "value",
94            "any",
95            "The value decoded by that DPT, when one is configured",
96            false,
97        ),
98        parameter(
99            "interpretations",
100            "object",
101            "Every reading the value's size allows, keyed by DPT",
102            true,
103        ),
104    ])
105    .with_actions(actions())
106});
107
108impl Protocol for KnxClientProtocol {
109    fn protocol_name(&self) -> &'static str {
110        "KNX/IP"
111    }
112    fn stack_name(&self) -> &'static str {
113        "ETH>IP>UDP>KNXnet/IP"
114    }
115    fn keywords(&self) -> Vec<&'static str> {
116        vec!["knx", "knxnet/ip", "knx/ip", "knx tunnelling"]
117    }
118    fn description(&self) -> &'static str {
119        "KNXnet/IP tunnelling client: writes and reads group addresses and hears the bus"
120    }
121    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
122        actions()
123    }
124    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
125        vec![]
126    }
127    fn get_event_types(&self) -> Vec<EventType> {
128        vec![CONNECTED_EVENT.clone(), TELEGRAM_EVENT.clone()]
129    }
130    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
131        vec![ParameterDefinition {
132            name: "group_types".into(),
133            type_hint: "object".into(),
134            description: "DPT per group address, e.g. {\"1/2/3\": \"1\"}: values on those addresses are decoded and encoded by it".into(),
135            required: false,
136            example: json!({"1/2/3": "1", "1/2/4": "9.001"}),
137            default: None,
138        }]
139    }
140    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
141        use crate::protocol::metadata::*;
142        ProtocolMetadataV2::builder()
143            .state(DevelopmentState::Experimental)
144            .privilege_requirement(PrivilegeRequirement::None)
145            .implementation("The server's KNXnet/IP and cEMI codec over Tokio UDP: connect (tunnel, link layer, NAT mode), heartbeats, tunnelling requests with acks and sequence numbers, L_Data.req out and L_Data.ind/con in")
146            .llm_control("Which group addresses to write and read, and what to do with every telegram on the bus")
147            .e2e_testing("tests/client/knx: NetGet's own gateway; knxd as the gateway, observed with knxtool and answered by xknx on the same bus")
148            .notes("Tunnelling v1 over UDP only. A request is retransmitted once if its ack does not come within a second, and the tunnel closes if the second goes unanswered too. Heartbeat every 60 s. A handler chain stops after 8 follow-ups.")
149            .max_inbound_bytes(wire::MAX_FRAME)
150            .build()
151    }
152    fn example_prompt(&self) -> &'static str {
153        "Connect to the KNX/IP gateway at 192.168.1.10 and switch on the light at 1/2/3"
154    }
155    fn get_startup_examples(&self) -> StartupExamples {
156        let llm = json!({"type":"open_client","protocol":"knx","remote_addr":"127.0.0.1:3671","startup_params":{"group_types":{"1/2/3":"1"}},"instruction":"Switch the light at 1/2/3 on"});
157        let mut static_example = llm.clone();
158        static_example["event_handlers"] = json!([{"event_pattern":"knx_connected","handler":{"type":"static","actions":[{"type":"knx_group_write","group_address":"1/2/3","value":true,"dpt":"1"}]}}]);
159        let mut scripted = llm.clone();
160        scripted["event_handlers"] = json!([{"event_pattern":"knx_telegram","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[]}))"}}]);
161        StartupExamples::new(llm, scripted, static_example)
162    }
163    fn group_name(&self) -> &'static str {
164        "Industrial"
165    }
166}
167
168impl Client for KnxClientProtocol {
169    fn connect(
170        &self,
171        ctx: ConnectContext,
172    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
173    {
174        Box::pin(super::connect(ctx))
175    }
176    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
177        let name = v["type"].as_str().unwrap_or_default().to_string();
178        match name.as_str() {
179            "disconnect" => return Ok(ClientActionResult::Disconnect),
180            "knx_group_read" => {
181                wire::parse_group(
182                    v["group_address"]
183                        .as_str()
184                        .context("group_address required")?,
185                )?;
186            }
187            "knx_group_write" => {
188                wire::parse_group(
189                    v["group_address"]
190                        .as_str()
191                        .context("group_address required")?,
192                )?;
193                if let Some(dpt) = v["dpt"].as_str() {
194                    wire::encode(dpt, &v["value"])?;
195                }
196            }
197            _ => bail!("Unknown KNX/IP client action"),
198        }
199        Ok(ClientActionResult::Custom { name, data: v })
200    }
201}