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}