Skip to main content

netget/server/knx/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, Context, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12use super::wire;
13
14#[derive(Default)]
15pub struct KnxProtocol;
16impl KnxProtocol {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
23    Parameter {
24        name: name.into(),
25        type_hint: kind.into(),
26        description: description.into(),
27        required,
28    }
29}
30
31pub fn action(
32    name: &str,
33    description: &str,
34    parameters: Vec<Parameter>,
35    example: Value,
36    log: &str,
37) -> ActionDefinition {
38    ActionDefinition {
39        name: name.into(),
40        description: description.into(),
41        parameters,
42        example,
43        log_template: Some(LogTemplate::new().with_info(log)),
44    }
45}
46
47fn value_params() -> Vec<Parameter> {
48    vec![
49        parameter("value", "any", "The value: true/false, a number or text, as its DPT takes", true),
50        parameter("dpt", "string", &format!("Datapoint type, e.g. 9.001; may be omitted when group_types configures the address. Supported: {}", wire::DPT_NOTE), false),
51    ]
52}
53
54fn response_action() -> ActionDefinition {
55    action(
56        "knx_group_response",
57        "Answer the group read with the address's current value (sent on the bus from this gateway's address).",
58        value_params(),
59        json!({"type":"knx_group_response","value":21.5,"dpt":"9.001"}),
60        "-> KNX response {value}",
61    )
62}
63
64pub fn write_action() -> ActionDefinition {
65    let mut params = vec![parameter(
66        "group_address",
67        "string",
68        "Group address main/middle/sub, e.g. 1/2/3",
69        true,
70    )];
71    params.extend(value_params());
72    action(
73        "knx_group_write",
74        "Put a GroupValueWrite on the bus to every tunnel (e.g. an actuator's status feedback).",
75        params,
76        json!({"type":"knx_group_write","group_address":"1/2/10","value":true,"dpt":"1"}),
77        "-> KNX write {group_address} = {value}",
78    )
79}
80
81fn ignore_action() -> ActionDefinition {
82    action(
83        "knx_ignore",
84        "No device reacts to this telegram; nothing further goes on the bus.",
85        vec![],
86        json!({"type":"knx_ignore"}),
87        "-> KNX ignore",
88    )
89}
90
91fn telegram_params() -> Vec<Parameter> {
92    vec![
93        parameter(
94            "source",
95            "string",
96            "Individual address of the sender (the tunnel's)",
97            true,
98        ),
99        parameter(
100            "destination",
101            "string",
102            "Group address, main/middle/sub",
103            true,
104        ),
105        parameter(
106            "dpt",
107            "string",
108            "The DPT configured for this address, if any",
109            false,
110        ),
111        parameter(
112            "value",
113            "any",
114            "The value decoded by that DPT, when one is configured",
115            false,
116        ),
117        parameter(
118            "interpretations",
119            "object",
120            "Every reading the value's size allows, keyed by DPT, when none is configured",
121            true,
122        ),
123    ]
124}
125
126pub static TELEGRAM_EVENT: LazyLock<EventType> = LazyLock::new(|| {
127    let mut params = vec![parameter(
128        "kind",
129        "string",
130        "write, or response (a device answering a read)",
131        true,
132    )];
133    params.extend(telegram_params());
134    EventType::new(
135        "knx_group_telegram",
136        "A tunnel put a group value on the bus (GroupValueWrite or GroupValueResponse). Other tunnels already received it.",
137        ignore_action().example.clone(),
138    )
139    .with_parameters(params)
140    .with_actions(vec![ignore_action(), write_action()])
141});
142
143pub static READ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
144    EventType::new(
145        "knx_group_read",
146        "A tunnel asked for a group address's value (GroupValueRead). Answer with knx_group_response, or stay silent if no device holds it.",
147        response_action().example.clone(),
148    )
149    .with_parameters(vec![
150        parameter("source", "string", "Individual address of the reader", true),
151        parameter("destination", "string", "Group address read, main/middle/sub", true),
152        parameter("dpt", "string", "The DPT configured for this address, if any", false),
153    ])
154    .with_actions(vec![response_action(), ignore_action(), write_action()])
155});
156
157/// Check a value action; returns the destination (when the action names one) and the data.
158pub fn value_of(action: &Value, configured: Option<&str>) -> Result<wire::Data> {
159    let dpt = action["dpt"]
160        .as_str()
161        .or(configured)
162        .context("dpt required: the address has no configured type")?;
163    wire::encode(dpt, &action["value"])
164}
165
166impl Protocol for KnxProtocol {
167    fn protocol_name(&self) -> &'static str {
168        "KNX/IP"
169    }
170    fn stack_name(&self) -> &'static str {
171        "ETH>IP>UDP>KNXnet/IP"
172    }
173    fn description(&self) -> &'static str {
174        "KNXnet/IP tunnelling gateway: tunnels connect, group writes are routed between them, and the handler plays every device on the bus"
175    }
176    fn keywords(&self) -> Vec<&'static str> {
177        vec![
178            "knx",
179            "knxnet/ip",
180            "knx/ip",
181            "eib",
182            "building automation",
183            "knx tunnelling",
184        ]
185    }
186    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
187        vec![]
188    }
189    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
190        vec![response_action(), write_action(), ignore_action()]
191    }
192    fn get_event_types(&self) -> Vec<EventType> {
193        vec![TELEGRAM_EVENT.clone(), READ_EVENT.clone()]
194    }
195    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
196        vec![
197            ParameterDefinition {
198                name: "group_types".into(),
199                type_hint: "object".into(),
200                description: "DPT per group address, e.g. {\"1/2/3\": \"1\", \"1/2/4\": \"9.001\"}: values on those addresses are decoded and encoded by it".into(),
201                required: false,
202                example: json!({"1/2/3": "1", "1/2/4": "9.001"}),
203                default: None,
204            },
205            ParameterDefinition {
206                name: "individual_address".into(),
207                type_hint: "string".into(),
208                description: "This gateway's own individual address, the source of the telegrams it puts on the bus".into(),
209                required: false,
210                example: json!("1.1.250"),
211                default: Some(json!(super::DEFAULT_ADDRESS)),
212            },
213            ParameterDefinition {
214                name: "max_tunnels".into(),
215                type_hint: "number".into(),
216                description: "How many tunnelling connections at once (1..=64)".into(),
217                required: false,
218                example: json!(8),
219                default: Some(json!(super::DEFAULT_MAX_TUNNELS)),
220            },
221        ]
222    }
223    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
224        use crate::protocol::metadata::*;
225        ProtocolMetadataV2::builder()
226            .state(DevelopmentState::Experimental)
227            .privilege_requirement(PrivilegeRequirement::None)
228            .well_known_udp_port(3671)
229            .implementation("Hand-written KNXnet/IP over Tokio UDP: search, description, connect (tunnel, link layer), connection state, disconnect, tunnelling requests and acks with sequence checking; cEMI L_Data.req/con/ind; group telegrams routed between tunnels; values decoded by DPT")
230            .llm_control("Every group read's answer and any telegram a device sends in reaction; the handler is every device on the bus")
231            .e2e_testing("tests/server/knx: raw frames and bounds; xknx (Python) and knxd (C++, connecting as a tunnel client with -b ipt:, driven by knxtool) as independent clients")
232            .notes("Tunnelling only: no routing (multicast), no device management, no secure tunnelling, no tunnelling v2 over TCP. Telegrams NetGet sends are not retransmitted. Frames are capped at 512 bytes; a tunnel silent for 120 s is dropped, as the specification's heartbeat requires. A handler failure on a read sends nothing — an invented value would be a device that does not exist.")
233            .deliberately_silent()
234            .max_inbound_bytes(wire::MAX_FRAME)
235            .build()
236    }
237    fn example_prompt(&self) -> &'static str {
238        "KNX/IP gateway where 1/2/3 is a light switch and 1/2/4 a temperature of 21.5 °C"
239    }
240    fn get_startup_examples(&self) -> StartupExamples {
241        let llm = json!({"type":"open_server","base_stack":"knx","port":3671,"startup_params":{"group_types":{"1/2/3":"1","1/2/4":"9.001"}},"instruction":"1/2/3 is a light; 1/2/4 reads 21.5 °C"});
242        let mut scripted = llm.clone();
243        scripted["event_handlers"] = json!([{"event_pattern":"knx_group_read","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\na={'type':'knx_group_response','value':21.5} if e['destination']=='1/2/4' else {'type':'knx_ignore'}\nprint(json.dumps({'actions':[a]}))"}}]);
244        let mut static_example = llm.clone();
245        static_example["event_handlers"] = json!([{"event_pattern":"knx_group_read","handler":{"type":"static","actions":[{"type":"knx_group_response","value":21.5,"dpt":"9.001"}]}}]);
246        StartupExamples::new(llm, scripted, static_example)
247    }
248    fn group_name(&self) -> &'static str {
249        "Industrial"
250    }
251}
252
253impl Server for KnxProtocol {
254    fn spawn(
255        &self,
256        ctx: SpawnContext,
257    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
258    {
259        Box::pin(super::spawn(ctx))
260    }
261    fn execute_action(&self, v: Value) -> Result<ActionResult> {
262        let name = v["type"].as_str().unwrap_or_default().to_string();
263        match name.as_str() {
264            "knx_ignore" => {}
265            "knx_group_response" => {
266                if let Some(dpt) = v["dpt"].as_str() {
267                    wire::encode(dpt, &v["value"])?;
268                }
269            }
270            "knx_group_write" => {
271                wire::parse_group(
272                    v["group_address"]
273                        .as_str()
274                        .context("group_address required")?,
275                )?;
276                if let Some(dpt) = v["dpt"].as_str() {
277                    wire::encode(dpt, &v["value"])?;
278                }
279            }
280            _ => bail!("Unknown KNX/IP server action"),
281        }
282        Ok(ActionResult::Custom { name, data: v })
283    }
284}