1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub static IPP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16 EventType::new(
17 "ipp_connected",
18 "IPP client initialized and ready to send print operations",
19 json!({"type": "get_printer_attributes"}),
20 )
21 .with_parameters(vec![Parameter {
22 name: "printer_uri".to_string(),
23 type_hint: "string".to_string(),
24 description: "IPP printer URI".to_string(),
25 required: true,
26 }])
27});
28
29pub static IPP_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
31 EventType::new(
32 "ipp_response_received",
33 "IPP operation response received from printer",
34 json!({"type": "get_job_attributes", "job_id": 123}),
35 )
36 .with_parameters(vec![
37 Parameter {
38 name: "operation".to_string(),
39 type_hint: "string".to_string(),
40 description:
41 "IPP operation name (get_printer_attributes, print_job, get_job_attributes)"
42 .to_string(),
43 required: true,
44 },
45 Parameter {
46 name: "success".to_string(),
47 type_hint: "boolean".to_string(),
48 description: "Whether the operation succeeded".to_string(),
49 required: true,
50 },
51 Parameter {
52 name: "response".to_string(),
53 type_hint: "object".to_string(),
54 description: "Response data from the printer".to_string(),
55 required: true,
56 },
57 ])
58});
59
60pub struct IppClientProtocol;
62
63impl IppClientProtocol {
64 pub fn new() -> Self {
65 Self
66 }
67}
68
69impl Protocol for IppClientProtocol {
71 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
72 vec![ParameterDefinition {
73 name: "printer_path".to_string(),
74 description: "Path to the printer on the server (e.g., /printers/test-printer)"
75 .to_string(),
76 type_hint: "string".to_string(),
77 required: false,
78 example: json!("/printers/test-printer"),
79 default: None,
80 }]
81 }
82 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
83 vec![
84 ActionDefinition {
85 name: "get_printer_attributes".to_string(),
86 description: "Query printer capabilities and status".to_string(),
87 parameters: vec![],
88 example: json!({
89 "type": "get_printer_attributes"
90 }),
91 log_template: None,
92 },
93 ActionDefinition {
94 name: "print_job".to_string(),
95 description: "Submit a print job to the printer".to_string(),
96 parameters: vec![
97 Parameter {
98 name: "job_name".to_string(),
99 type_hint: "string".to_string(),
100 description: "Name/title for the print job".to_string(),
101 required: true,
102 },
103 Parameter {
104 name: "document_format".to_string(),
105 type_hint: "string".to_string(),
106 description:
107 "MIME type of the document (e.g., application/pdf, text/plain)"
108 .to_string(),
109 required: false,
110 },
111 Parameter {
112 name: "document_data".to_string(),
113 type_hint: "string".to_string(),
114 description: "Document content. Plain text unless you set \
115 encoding=\"base64\", which is how you send a PDF or any \
116 other binary document. It is never guessed."
117 .to_string(),
118 required: true,
119 },
120 Parameter {
121 name: "encoding".to_string(),
122 type_hint: "string".to_string(),
123 description: "How to read document_data: \"utf8\" (default, the text \
124 itself) or \"base64\". Say which; the two are not \
125 distinguishable from the content."
126 .to_string(),
127 required: false,
128 },
129 ],
130 example: json!({
131 "type": "print_job",
132 "job_name": "Test Document",
133 "document_format": "text/plain",
134 "document_data": "Hello, Printer!\n"
135 }),
136 log_template: None,
137 },
138 ActionDefinition {
139 name: "get_job_attributes".to_string(),
140 description: "Query status and details of a specific print job".to_string(),
141 parameters: vec![Parameter {
142 name: "job_id".to_string(),
143 type_hint: "number".to_string(),
144 description: "Job ID returned from print_job operation".to_string(),
145 required: true,
146 }],
147 example: json!({
148 "type": "get_job_attributes",
149 "job_id": 123
150 }),
151 log_template: None,
152 },
153 ActionDefinition {
154 name: "disconnect".to_string(),
155 description: "Disconnect from the IPP printer".to_string(),
156 parameters: vec![],
157 example: json!({
158 "type": "disconnect"
159 }),
160 log_template: None,
161 },
162 ]
163 }
164 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
165 vec![
166 ActionDefinition {
167 name: "wait_for_more".to_string(),
168 description: "Do nothing and wait for the next IPP response. The correct answer \
169 when what arrived needs no follow-up -- without it the model has to \
170 invent an action it does not want."
171 .to_string(),
172 parameters: vec![],
173 example: json!({
174 "type": "wait_for_more"
175 }),
176 log_template: None,
177 },
178 ActionDefinition {
179 name: "get_printer_attributes".to_string(),
180 description: "Query printer capabilities in response to previous operation"
181 .to_string(),
182 parameters: vec![],
183 example: json!({
184 "type": "get_printer_attributes"
185 }),
186 log_template: None,
187 },
188 ActionDefinition {
189 name: "get_job_attributes".to_string(),
190 description: "Query job status after submitting a print job".to_string(),
191 parameters: vec![Parameter {
192 name: "job_id".to_string(),
193 type_hint: "number".to_string(),
194 description: "Job ID to query".to_string(),
195 required: true,
196 }],
197 example: json!({
198 "type": "get_job_attributes",
199 "job_id": 123
200 }),
201 log_template: None,
202 },
203 ]
204 }
205 fn protocol_name(&self) -> &'static str {
206 "IPP"
207 }
208 fn get_event_types(&self) -> Vec<EventType> {
209 vec![
210 EventType::new(
211 "ipp_connected",
212 "Triggered when IPP client is initialized",
213 json!({"type": "get_printer_attributes"}),
214 ),
215 EventType::new(
216 "ipp_response_received",
217 "Triggered when IPP client receives a response from the printer",
218 json!({"type": "get_job_attributes", "job_id": 123}),
219 ),
220 ]
221 }
222 fn stack_name(&self) -> &'static str {
223 "ETH>IP>TCP>HTTP>IPP"
224 }
225 fn keywords(&self) -> Vec<&'static str> {
226 vec![
227 "ipp",
228 "ipp client",
229 "internet printing protocol",
230 "print",
231 "printer",
232 ]
233 }
234 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
235 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
236
237 ProtocolMetadataV2::builder()
238 .state(DevelopmentState::Experimental)
239 .implementation("ipp crate 5.3 with AsyncIppClient")
240 .llm_control("Full control over print operations: get-printer-attributes, print-job, get-job-attributes")
241 .e2e_testing("CUPS test server or local IPP printer")
242 .build()
243 }
244 fn description(&self) -> &'static str {
245 "IPP client for printing and querying print jobs"
246 }
247 fn example_prompt(&self) -> &'static str {
248 "Connect to ipp://localhost:631/printers/test-printer and query its capabilities"
249 }
250 fn group_name(&self) -> &'static str {
251 "File & Print"
252 }
253 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
254 use crate::llm::actions::StartupExamples;
255 use serde_json::json;
256
257 StartupExamples::new(
258 json!({
260 "type": "open_client",
261 "remote_addr": "localhost:631",
262 "base_stack": "ipp",
263 "startup_params": {
264 "printer_path": "/printers/test-printer"
265 },
266 "instruction": "Query printer capabilities and submit a test print job"
267 }),
268 json!({
270 "type": "open_client",
271 "remote_addr": "localhost:631",
272 "base_stack": "ipp",
273 "startup_params": {
274 "printer_path": "/printers/test-printer"
275 },
276 "event_handlers": [{
277 "event_pattern": "ipp_response_received",
278 "handler": {
279 "type": "script",
280 "language": "python",
281 "code": "<ipp_client_handler>"
282 }
283 }]
284 }),
285 json!({
287 "type": "open_client",
288 "remote_addr": "localhost:631",
289 "base_stack": "ipp",
290 "startup_params": {
291 "printer_path": "/printers/test-printer"
292 },
293 "event_handlers": [
294 {
295 "event_pattern": "ipp_connected",
296 "handler": {
297 "type": "static",
298 "actions": [{
299 "type": "get_printer_attributes"
300 }]
301 }
302 },
303 {
304 "event_pattern": "ipp_response_received",
305 "handler": {
306 "type": "static",
307 "actions": [{
308 "type": "disconnect"
309 }]
310 }
311 }
312 ]
313 }),
314 )
315 }
316}
317
318impl Client for IppClientProtocol {
320 fn connect(
321 &self,
322 ctx: crate::protocol::ConnectContext,
323 ) -> std::pin::Pin<
324 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
325 > {
326 Box::pin(async move {
327 use crate::client::ipp::IppClient;
328
329 let printer_path = ctx
341 .startup_params
342 .as_ref()
343 .map(|p| p.get_optional_string("printer_path"))
344 .transpose()?
345 .flatten()
346 .filter(|s| !s.trim().is_empty());
347
348 IppClient::connect_with_llm_actions(
349 ctx.remote_addr,
350 printer_path,
351 ctx.llm_client,
352 ctx.state,
353 ctx.status_tx,
354 ctx.client_id,
355 )
356 .await
357 })
358 }
359 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
360 let action_type = action
361 .get("type")
362 .and_then(|v| v.as_str())
363 .context("Missing 'type' field in action")?;
364
365 match action_type {
366 "get_printer_attributes" => Ok(ClientActionResult::Custom {
367 name: "ipp_get_printer_attributes".to_string(),
368 data: json!({}),
369 }),
370 "print_job" => {
371 let job_name = action
372 .get("job_name")
373 .and_then(|v| v.as_str())
374 .context("Missing 'job_name' field")?
375 .to_string();
376
377 let document_format = action
378 .get("document_format")
379 .and_then(|v| v.as_str())
380 .map(|s| s.to_string());
381
382 let document_data = action
383 .get("document_data")
384 .and_then(|v| v.as_str())
385 .context("Missing 'document_data' field")?;
386
387 let data_bytes = match action
399 .get("encoding")
400 .and_then(|v| v.as_str())
401 .map(str::trim)
402 .unwrap_or("utf8")
403 .to_ascii_lowercase()
404 .as_str()
405 {
406 "utf8" | "utf-8" | "text" | "plain" => document_data.as_bytes().to_vec(),
407 "base64" | "b64" => {
408 use base64::{engine::general_purpose, Engine as _};
409 general_purpose::STANDARD
410 .decode(document_data)
411 .map_err(|e| {
412 anyhow::anyhow!(
413 "print_job 'document_data' is not valid base64: {e}. Either fix \
414 the encoding or omit the 'encoding' field to send the string \
415 as text."
416 )
417 })?
418 }
419 other => {
420 return Err(anyhow::anyhow!(
421 "print_job 'encoding' must be \"utf8\" or \"base64\", got {other:?}"
422 ))
423 }
424 };
425
426 Ok(ClientActionResult::Custom {
427 name: "ipp_print_job".to_string(),
428 data: json!({
429 "job_name": job_name,
430 "document_format": document_format,
431 "document_data": data_bytes,
432 }),
433 })
434 }
435 "get_job_attributes" => {
436 let job_id = action
441 .get("job_id")
442 .and_then(|v| v.as_i64())
443 .context("Missing or invalid 'job_id' field")?;
444 if !(1..=i32::MAX as i64).contains(&job_id) {
445 return Err(anyhow::anyhow!(
446 "job_id {job_id} is not an IPP job-id (1-2147483647, RFC 8011 §5.3.2). \
447 Use the job-id the printer returned from print_job."
448 ));
449 }
450 let job_id = job_id as i32;
451
452 Ok(ClientActionResult::Custom {
453 name: "ipp_get_job_attributes".to_string(),
454 data: json!({
455 "job_id": job_id,
456 }),
457 })
458 }
459 "disconnect" => Ok(ClientActionResult::Disconnect),
460 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
464 _ => Err(anyhow::anyhow!(
465 "Unknown IPP client action: {}",
466 action_type
467 )),
468 }
469 }
470}