MQTT 3.1 and 5 in OCaml using Eio
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add mqtte.cmd subpackage with XDG config and cmdliner support

- Split cmdliner terms into separate mqtte.cmd findlib subpackage
- Add TOML config file support via tomlt library
- Integrate with xdge for XDG Base Directory paths
- Config precedence: CLI args > env vars > config file > defaults
- Add man page documentation helpers (man_environment, man_config_file)
- Add init-config and show-config subcommands for config management
- Consolidate V5 pubx write functions to reduce duplication

Config file location: ~/.config/{app_name}/config.toml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+784 -205
+2
dune-project
··· 24 24 ca-certs 25 25 (cmdliner (>= 1.2)) 26 26 tls 27 + xdge 28 + tomlt 27 29 (logs (>= 0.7)) 28 30 (fmt (>= 0.9)) 29 31 (alcotest :with-test)
+1 -1
examples/dune
··· 2 2 (names publisher subscriber) 3 3 (public_names mqtte-publisher mqtte-subscriber) 4 4 (package mqtte) 5 - (libraries mqtte mqtte_eio eio_main logs.fmt fmt.tty)) 5 + (libraries mqtte mqtte_eio mqtte_cmd eio_main logs.fmt fmt.tty))
+4
lib/cmd/dune
··· 1 + (library 2 + (name mqtte_cmd) 3 + (public_name mqtte.cmd) 4 + (libraries mqtte mqtte_eio xdge tomlt tomlt.eio eio conpool ca-certs tls cmdliner logs fmt))
+545
lib/cmd/mqtte_cmd.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Cmdliner terms for MQTT configuration with XDG and TOML support 7 + 8 + This module provides Cmdliner terms and argument definitions for configuring 9 + MQTT connections from the command line, environment variables, or TOML 10 + configuration files stored in XDG directories. *) 11 + 12 + open Cmdliner 13 + 14 + let src = Logs.Src.create "mqtt.cmd" ~doc:"MQTT Cmdliner" 15 + 16 + module Log = (val Logs.src_log src : Logs.LOG) 17 + 18 + (** {1 TOML Configuration} *) 19 + 20 + module Config_file = struct 21 + type mqtt_config = { 22 + host : string option; 23 + port : int option; 24 + tls : bool option; 25 + insecure : bool option; 26 + client_id : string option; 27 + clean_session : bool option; 28 + keep_alive : int option; 29 + username : string option; 30 + password : string option; 31 + protocol_version : string option; 32 + } 33 + 34 + let empty_mqtt_config = 35 + { 36 + host = None; 37 + port = None; 38 + tls = None; 39 + insecure = None; 40 + client_id = None; 41 + clean_session = None; 42 + keep_alive = None; 43 + username = None; 44 + password = None; 45 + protocol_version = None; 46 + } 47 + 48 + type pool_config = { 49 + min_connections : int option; 50 + max_connections : int option; 51 + idle_timeout : float option; 52 + } 53 + 54 + let empty_pool_config = 55 + { min_connections = None; max_connections = None; idle_timeout = None } 56 + 57 + type t = { mqtt : mqtt_config; pool : pool_config } 58 + 59 + let empty = { mqtt = empty_mqtt_config; pool = empty_pool_config } 60 + 61 + let mqtt_codec : mqtt_config Tomlt.t = 62 + Tomlt.( 63 + Table.( 64 + obj 65 + (fun host port tls insecure client_id clean_session keep_alive 66 + username password protocol_version -> 67 + { 68 + host; 69 + port; 70 + tls; 71 + insecure; 72 + client_id; 73 + clean_session; 74 + keep_alive; 75 + username; 76 + password; 77 + protocol_version; 78 + }) 79 + |> opt_mem "host" string ~enc:(fun c -> c.host) 80 + |> opt_mem "port" int ~enc:(fun c -> c.port) 81 + |> opt_mem "tls" bool ~enc:(fun c -> c.tls) 82 + |> opt_mem "insecure" bool ~enc:(fun c -> c.insecure) 83 + |> opt_mem "client_id" string ~enc:(fun c -> c.client_id) 84 + |> opt_mem "clean_session" bool ~enc:(fun c -> c.clean_session) 85 + |> opt_mem "keep_alive" int ~enc:(fun c -> c.keep_alive) 86 + |> opt_mem "username" string ~enc:(fun c -> c.username) 87 + |> opt_mem "password" string ~enc:(fun c -> c.password) 88 + |> opt_mem "protocol_version" string ~enc:(fun c -> c.protocol_version) 89 + |> finish)) 90 + 91 + let pool_codec : pool_config Tomlt.t = 92 + Tomlt.( 93 + Table.( 94 + obj (fun min_connections max_connections idle_timeout -> 95 + { min_connections; max_connections; idle_timeout }) 96 + |> opt_mem "min_connections" int ~enc:(fun c -> c.min_connections) 97 + |> opt_mem "max_connections" int ~enc:(fun c -> c.max_connections) 98 + |> opt_mem "idle_timeout" float ~enc:(fun c -> c.idle_timeout) 99 + |> finish)) 100 + 101 + let codec : t Tomlt.t = 102 + Tomlt.( 103 + Table.( 104 + obj (fun mqtt pool -> 105 + { 106 + mqtt = Option.value ~default:empty_mqtt_config mqtt; 107 + pool = Option.value ~default:empty_pool_config pool; 108 + }) 109 + |> opt_mem "mqtt" mqtt_codec 110 + ~enc:(fun c -> 111 + if c.mqtt = empty_mqtt_config then None else Some c.mqtt) 112 + |> opt_mem "pool" pool_codec 113 + ~enc:(fun c -> 114 + if c.pool = empty_pool_config then None else Some c.pool) 115 + |> finish)) 116 + 117 + let load_from_path path = 118 + try 119 + let config = Tomlt_eio.decode_path_exn codec ~fs:path "" in 120 + Log.debug (fun m -> m "Loaded config from %s" (Eio.Path.native_exn path)); 121 + Some config 122 + with 123 + | Eio.Io _ -> 124 + Log.debug (fun m -> 125 + m "Config file not found: %s" (Eio.Path.native_exn path)); 126 + None 127 + | exn -> 128 + Log.warn (fun m -> 129 + m "Error loading config %s: %s" (Eio.Path.native_exn path) 130 + (Printexc.to_string exn)); 131 + None 132 + 133 + let load xdg = 134 + match Xdge.find_config_file xdg "config.toml" with 135 + | Some path -> load_from_path path 136 + | None -> 137 + Log.debug (fun m -> m "No config.toml found in XDG config directories"); 138 + None 139 + end 140 + 141 + (** {1 Connection Options} *) 142 + 143 + type connection = { host : string; port : int; tls : bool; insecure : bool } 144 + 145 + type t = { 146 + connection : connection; 147 + config : Mqtte_eio.Client.config; 148 + pool_config : Conpool.Config.t; 149 + } 150 + 151 + (** {1 Internal Helpers} *) 152 + 153 + let or_else opt default = match opt with Some v -> v | None -> default 154 + 155 + let parse_version = function 156 + | "3.1.1" -> `V3_1_1 157 + | "5" | "5.0" -> `V5_0 158 + | _ -> `V3_1_1 159 + 160 + (** Build credentials from username/password options *) 161 + let make_credentials username password = 162 + match (username, password) with 163 + | Some u, Some p -> Some (`Username_password (u, p)) 164 + | Some u, None -> Some (`Username u) 165 + | None, _ -> None 166 + 167 + (** {1 Cmdliner Terms} *) 168 + 169 + let host_term ~file_config = 170 + let default = or_else file_config.Config_file.mqtt.host "127.0.0.1" in 171 + let doc = "MQTT broker hostname or IP address." in 172 + let env = Cmd.Env.info "MQTT_HOST" ~doc in 173 + Arg.(value & opt string default & info [ "h"; "host" ] ~docv:"HOST" ~doc ~env) 174 + 175 + let port_term ~file_config = 176 + let default = or_else file_config.Config_file.mqtt.port 1883 in 177 + let doc = "MQTT broker port." in 178 + let env = Cmd.Env.info "MQTT_PORT" ~doc in 179 + Arg.(value & opt int default & info [ "p"; "port" ] ~docv:"PORT" ~doc ~env) 180 + 181 + let tls_term ~file_config = 182 + let config_default = or_else file_config.Config_file.mqtt.tls false in 183 + let doc = 184 + "Use TLS for the connection. Port defaults to 8883 if not specified." 185 + in 186 + let env = Cmd.Env.info "MQTT_TLS" ~doc in 187 + let arg = Arg.(value & flag & info [ "tls" ] ~doc ~env) in 188 + Term.(const (fun flag -> flag || config_default) $ arg) 189 + 190 + let insecure_term ~file_config = 191 + let config_default = or_else file_config.Config_file.mqtt.insecure false in 192 + let doc = 193 + "Skip TLS certificate verification (allows expired/self-signed \ 194 + certificates)." 195 + in 196 + Term.(const (fun flag -> flag || config_default) $ Arg.(value & flag & info [ "insecure" ] ~doc)) 197 + 198 + let client_id_term ~file_config = 199 + let default = file_config.Config_file.mqtt.client_id in 200 + let doc = 201 + "MQTT client identifier. If not specified, a random ID is generated." 202 + in 203 + let env = Cmd.Env.info "MQTT_CLIENT_ID" ~doc in 204 + Arg.(value & opt (some string) default & info [ "client-id" ] ~docv:"ID" ~doc ~env) 205 + 206 + let clean_session_term ~file_config = 207 + let config_default = or_else file_config.Config_file.mqtt.clean_session false in 208 + let doc = 209 + "Start with a clean session (discard any previous session state)." 210 + in 211 + Term.(const (fun flag -> flag || config_default) $ Arg.(value & flag & info [ "clean-session" ] ~doc)) 212 + 213 + let keep_alive_term ~file_config = 214 + let default = or_else file_config.Config_file.mqtt.keep_alive 60 in 215 + let doc = "Keep-alive interval in seconds. Use 0 to disable." in 216 + Arg.(value & opt int default & info [ "keep-alive" ] ~docv:"SECONDS" ~doc) 217 + 218 + let protocol_version_term ~file_config = 219 + let default = 220 + match file_config.Config_file.mqtt.protocol_version with 221 + | Some v -> parse_version v 222 + | None -> `V3_1_1 223 + in 224 + let doc = "MQTT protocol version to use." in 225 + let versions = [ ("3.1.1", `V3_1_1); ("5", `V5_0); ("5.0", `V5_0) ] in 226 + Arg.(value & opt (enum versions) default & info [ "mqtt-version" ] ~docv:"VERSION" ~doc) 227 + 228 + let username_term ~file_config = 229 + let default = file_config.Config_file.mqtt.username in 230 + let doc = "MQTT username for authentication." in 231 + let env = Cmd.Env.info "MQTT_USER" ~doc in 232 + Arg.(value & opt (some string) default & info [ "u"; "username" ] ~docv:"USER" ~doc ~env) 233 + 234 + let password_term ~file_config = 235 + let default = file_config.Config_file.mqtt.password in 236 + let doc = "MQTT password for authentication." in 237 + let env = Cmd.Env.info "MQTT_PASSWORD" ~doc in 238 + Arg.(value & opt (some string) default & info [ "password" ] ~docv:"PASS" ~doc ~env) 239 + 240 + (** {1 Combined Terms} *) 241 + 242 + let connection_term ~file_config = 243 + let make host port tls insecure = 244 + let port = if port = 1883 && tls then 8883 else port in 245 + { host; port; tls; insecure } 246 + in 247 + Term.( 248 + const make 249 + $ host_term ~file_config 250 + $ port_term ~file_config 251 + $ tls_term ~file_config 252 + $ insecure_term ~file_config) 253 + 254 + let config_term ~file_config = 255 + let make client_id clean_session keep_alive version username password = 256 + let client_id = 257 + match client_id with 258 + | Some id -> id 259 + | None -> Printf.sprintf "mqtt-eio-%d" (Random.int 100000) 260 + in 261 + let credentials = make_credentials username password in 262 + Mqtte_eio.Client. 263 + { 264 + client_id; 265 + version; 266 + clean_session; 267 + keep_alive; 268 + credentials; 269 + will = None; 270 + } 271 + in 272 + Term.( 273 + const make 274 + $ client_id_term ~file_config 275 + $ clean_session_term ~file_config 276 + $ keep_alive_term ~file_config 277 + $ protocol_version_term ~file_config 278 + $ username_term ~file_config 279 + $ password_term ~file_config) 280 + 281 + let pool_config_term ~file_config = 282 + let default_max = 283 + or_else file_config.Config_file.pool.max_connections 284 + (Conpool.Config.max_connections_per_endpoint Conpool.Config.default) 285 + in 286 + let default_idle = 287 + or_else file_config.Config_file.pool.idle_timeout 288 + (Conpool.Config.max_idle_time Conpool.Config.default) 289 + in 290 + let max_size = 291 + let doc = "Maximum number of connections per endpoint." in 292 + Arg.( 293 + value & opt int default_max 294 + & info [ "pool-max-connections" ] ~docv:"N" ~doc) 295 + in 296 + let idle_timeout = 297 + let doc = "Idle connection timeout in seconds." in 298 + Arg.( 299 + value & opt float default_idle 300 + & info [ "pool-idle-timeout" ] ~docv:"SECONDS" ~doc) 301 + in 302 + Term.( 303 + const (fun max_connections_per_endpoint max_idle_time -> 304 + Conpool.Config.make ~max_connections_per_endpoint ~max_idle_time ()) 305 + $ max_size $ idle_timeout) 306 + 307 + (** {1 Main Term} *) 308 + 309 + type parsed = { mqtt : t; xdg : Xdge.t; xdg_config : Xdge.Cmd.t } 310 + 311 + let term ~app_name ~fs () = 312 + (* Create XDG context to read config file defaults at term construction time. 313 + The xdg_term from Xdge.Cmd will provide runtime XDG paths with CLI override. *) 314 + let xdg = Xdge.create fs app_name in 315 + let file_config = 316 + match Config_file.load xdg with Some c -> c | None -> Config_file.empty 317 + in 318 + (* Xdge.Cmd.term provides XDG paths with command-line override support *) 319 + let xdg_term = Xdge.Cmd.term app_name fs ~dirs:[ `Config ] () in 320 + let make (xdg, xdg_config) connection config pool_config = 321 + { mqtt = { connection; config; pool_config }; xdg; xdg_config } 322 + in 323 + Term.( 324 + const make $ xdg_term 325 + $ connection_term ~file_config 326 + $ config_term ~file_config 327 + $ pool_config_term ~file_config) 328 + 329 + (** {1 Connection Pool Helpers} *) 330 + 331 + let create_pool ~sw ~net ~clock ~tls ?(insecure = false) ~pool_config () = 332 + let tls_config = 333 + if tls then 334 + let authenticator = 335 + if insecure then fun ?ip:_ ~host:_ _ -> Ok None 336 + else 337 + match Ca_certs.authenticator () with 338 + | Ok auth -> auth 339 + | Error (`Msg msg) -> 340 + failwith ("Failed to load CA certificates: " ^ msg) 341 + in 342 + match Tls.Config.client ~authenticator () with 343 + | Ok config -> Some config 344 + | Error (`Msg msg) -> failwith ("Failed to create TLS config: " ^ msg) 345 + else None 346 + in 347 + Conpool.create ~sw ~net ~clock ?tls:tls_config ~config:pool_config () 348 + 349 + let endpoint conn = Conpool.Endpoint.make ~host:conn.host ~port:conn.port 350 + 351 + (** {1 XDG Access} *) 352 + 353 + let xdg ~app_name ~fs = Xdge.create fs app_name 354 + 355 + (** {1 Documentation Helpers} *) 356 + 357 + let man_config_file ~app_name = 358 + let config_path = Printf.sprintf "~/.config/%s/config.toml" app_name in 359 + [ 360 + `S Manpage.s_files; 361 + `P 362 + (Printf.sprintf 363 + "Configuration can be stored in a TOML file at %s. Command-line \ 364 + options take precedence over environment variables, which take \ 365 + precedence over the configuration file." 366 + config_path); 367 + `Pre 368 + {|# Example configuration file 369 + [mqtt] 370 + host = "mqtt.example.com" 371 + port = 8883 372 + tls = true 373 + client_id = "my-client" 374 + clean_session = true 375 + keep_alive = 60 376 + username = "user" 377 + password = "secret" 378 + protocol_version = "5.0" 379 + 380 + [pool] 381 + max_connections = 10 382 + idle_timeout = 60.0|}; 383 + `P 384 + "All fields are optional. Missing fields use default values or can be \ 385 + overridden via environment variables or command-line options."; 386 + ] 387 + 388 + let man_environment ~app_name = 389 + let app_upper = String.uppercase_ascii app_name in 390 + [ 391 + `S Manpage.s_environment; 392 + `P "The following environment variables affect the program:"; 393 + `I 394 + ( "MQTT_HOST", 395 + "MQTT broker hostname or IP address. Overrides config file, \ 396 + overridden by --host." ); 397 + `I 398 + ("MQTT_PORT", "MQTT broker port. Overrides config file, overridden by --port."); 399 + `I ("MQTT_TLS", "Enable TLS if set to any value. Overridden by --tls."); 400 + `I 401 + ( "MQTT_CLIENT_ID", 402 + "Client identifier. Overrides config file, overridden by --client-id." 403 + ); 404 + `I 405 + ( "MQTT_USER", 406 + "Username for authentication. Overrides config file, overridden by \ 407 + --username." ); 408 + `I 409 + ( "MQTT_PASSWORD", 410 + "Password for authentication. Overrides config file, overridden by \ 411 + --password." ); 412 + `P 413 + (Printf.sprintf 414 + "XDG Base Directory variables (%s_CONFIG_DIR, XDG_CONFIG_HOME) \ 415 + control where the configuration file is searched." 416 + app_upper); 417 + ] 418 + 419 + let man_sections ~app_name = 420 + man_environment ~app_name @ man_config_file ~app_name 421 + 422 + (** {1 Default Configuration} *) 423 + 424 + let default_config_toml = 425 + {|# MQTT Client Configuration 426 + # Place this file at ~/.config/<app_name>/config.toml 427 + 428 + [mqtt] 429 + # Broker connection settings 430 + host = "127.0.0.1" 431 + port = 1883 432 + # tls = false 433 + # insecure = false 434 + 435 + # Client settings 436 + # client_id = "my-client" # Random ID generated if not set 437 + # clean_session = false 438 + keep_alive = 60 439 + 440 + # Authentication (optional) 441 + # username = "user" 442 + # password = "secret" 443 + 444 + # Protocol version: "3.1.1" or "5.0" 445 + protocol_version = "3.1.1" 446 + 447 + [pool] 448 + # Connection pool settings 449 + max_connections = 10 450 + idle_timeout = 60.0 451 + |} 452 + 453 + let write_default_config xdg = 454 + let config_dir = Xdge.config_dir xdg in 455 + let config_path = Eio.Path.(config_dir / "config.toml") in 456 + try 457 + if Eio.Path.is_file config_path then 458 + Error 459 + (Printf.sprintf "Config file already exists: %s" 460 + (Eio.Path.native_exn config_path)) 461 + else begin 462 + Eio.Path.save ~create:(`Or_truncate 0o644) config_path default_config_toml; 463 + Ok (Eio.Path.native_exn config_path) 464 + end 465 + with exn -> Error (Printexc.to_string exn) 466 + 467 + let config_path xdg = 468 + let config_dir = Xdge.config_dir xdg in 469 + Eio.Path.(config_dir / "config.toml") 470 + 471 + (** {1 Init Config Subcommand} *) 472 + 473 + let init_config_term ~app_name ~fs = 474 + let run force = 475 + let xdg = Xdge.create fs app_name in 476 + let path = config_path xdg in 477 + let exists = Eio.Path.is_file path in 478 + if exists && not force then begin 479 + Fmt.pr "Config file already exists: %s@." (Eio.Path.native_exn path); 480 + Fmt.pr "Use --force to overwrite.@."; 481 + `Ok 1 482 + end 483 + else begin 484 + (try 485 + Eio.Path.save ~create:(`Or_truncate 0o644) path default_config_toml; 486 + if exists then 487 + Fmt.pr "Overwrote config file: %s@." (Eio.Path.native_exn path) 488 + else Fmt.pr "Created config file: %s@." (Eio.Path.native_exn path); 489 + Fmt.pr "@.Edit this file to configure your MQTT connection.@." 490 + with exn -> 491 + Fmt.pr "Error writing config: %s@." (Printexc.to_string exn)); 492 + `Ok 0 493 + end 494 + in 495 + let force = 496 + let doc = "Overwrite existing configuration file." in 497 + Arg.(value & flag & info [ "f"; "force" ] ~doc) 498 + in 499 + Term.(ret (const run $ force)) 500 + 501 + let init_config_cmd ~app_name ~fs = 502 + let doc = "Initialize a default configuration file" in 503 + let man = 504 + [ 505 + `S Manpage.s_description; 506 + `P 507 + (Printf.sprintf 508 + "Creates a default configuration file at ~/.config/%s/config.toml \ 509 + with commented examples of all available options." 510 + app_name); 511 + `P 512 + "If a configuration file already exists, use --force to overwrite it."; 513 + ] 514 + in 515 + let info = Cmd.info "init-config" ~doc ~man in 516 + Cmd.v info (init_config_term ~app_name ~fs) 517 + 518 + (** {1 Show Config Subcommand} *) 519 + 520 + let show_config_term ~app_name ~fs = 521 + let run () = 522 + let xdg = Xdge.create fs app_name in 523 + let path = config_path xdg in 524 + Fmt.pr "Configuration file: %s@.@." (Eio.Path.native_exn path); 525 + if Eio.Path.is_file path then begin 526 + Fmt.pr "Current contents:@."; 527 + Fmt.pr "─────────────────@."; 528 + let content = Eio.Path.load path in 529 + Fmt.pr "%s@." content 530 + end 531 + else Fmt.pr "File does not exist. Run 'init-config' to create it.@."; 532 + `Ok 0 533 + in 534 + Term.(ret (const run $ const ())) 535 + 536 + let show_config_cmd ~app_name ~fs = 537 + let doc = "Show configuration file location and contents" in 538 + let man = 539 + [ 540 + `S Manpage.s_description; 541 + `P "Shows the path to the configuration file and its current contents."; 542 + ] 543 + in 544 + let info = Cmd.info "show-config" ~doc ~man in 545 + Cmd.v info (show_config_term ~app_name ~fs)
+211
lib/cmd/mqtte_cmd.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Cmdliner terms for MQTT configuration with XDG and TOML support 7 + 8 + This module provides Cmdliner terms and argument definitions for configuring 9 + MQTT connections from the command line, environment variables, or TOML 10 + configuration files stored in XDG directories. 11 + 12 + {2 Configuration Precedence} 13 + 14 + Configuration values are resolved in the following order (highest to lowest 15 + priority): 16 + + Command-line arguments 17 + + Environment variables 18 + + TOML configuration file ([config.toml] in XDG config directory) 19 + + Default values 20 + 21 + {2 Configuration File Format} 22 + 23 + The configuration file should be named [config.toml] and placed in the XDG 24 + config directory for your application (e.g., [~/.config/myapp/config.toml]). 25 + 26 + Example configuration file: 27 + {v 28 + [mqtt] 29 + host = "mqtt.example.com" 30 + port = 8883 31 + tls = true 32 + client_id = "my-client" 33 + clean_session = true 34 + keep_alive = 60 35 + username = "user" 36 + password = "secret" 37 + protocol_version = "5.0" 38 + 39 + [pool] 40 + min_connections = 1 41 + max_connections = 10 42 + idle_timeout = 300.0 43 + v} *) 44 + 45 + (** {1 Connection Options} *) 46 + 47 + type connection = { host : string; port : int; tls : bool; insecure : bool } 48 + (** Connection parameters parsed from command line, environment, or config file. *) 49 + 50 + (** {1 Combined Configuration} *) 51 + 52 + type t = { 53 + connection : connection; 54 + config : Mqtte_eio.Client.config; 55 + pool_config : Conpool.Config.t; 56 + } 57 + (** Complete MQTT configuration parsed from all sources. *) 58 + 59 + (** {1 Cmdliner Terms} *) 60 + 61 + type parsed = { mqtt : t; xdg : Xdge.t; xdg_config : Xdge.Cmd.t } 62 + (** Result of parsing command-line arguments, including MQTT configuration and 63 + XDG context for accessing application directories. *) 64 + 65 + val term : 66 + app_name:string -> fs:Eio.Fs.dir_ty Eio.Path.t -> unit -> parsed Cmdliner.Term.t 67 + (** [term ~app_name ~fs ()] creates a Cmdliner term that parses MQTT 68 + configuration from command line arguments, environment variables, and TOML 69 + config files. 70 + 71 + @param app_name 72 + The application name used for XDG directory naming (e.g., ["myapp"] will 73 + look for config in [~/.config/myapp/config.toml]) 74 + @param fs The Eio filesystem for reading config files 75 + 76 + {3 Generated Command-line Flags} 77 + 78 + {b Connection:} 79 + - [-h], [--host HOST]: MQTT broker hostname (env: [MQTT_HOST]) 80 + - [-p], [--port PORT]: MQTT broker port (env: [MQTT_PORT]) 81 + - [--tls]: Use TLS connection (env: [MQTT_TLS]) 82 + - [--insecure]: Skip TLS certificate verification 83 + 84 + {b Client:} 85 + - [--client-id ID]: Client identifier (env: [MQTT_CLIENT_ID]) 86 + - [--clean-session]: Start with clean session 87 + - [--keep-alive SECONDS]: Keep-alive interval 88 + - [--mqtt-version VERSION]: Protocol version (3.1.1 or 5.0) 89 + - [-u], [--username USER]: Authentication username (env: [MQTT_USER]) 90 + - [--password PASS]: Authentication password (env: [MQTT_PASSWORD]) 91 + 92 + {b Connection Pool:} 93 + - [--pool-min-connections N]: Minimum pool connections 94 + - [--pool-max-connections N]: Maximum pool connections 95 + - [--pool-idle-timeout SECONDS]: Idle connection timeout *) 96 + 97 + (** {1 Connection Pool Helpers} *) 98 + 99 + val create_pool : 100 + sw:Eio.Switch.t -> 101 + net:_ Eio.Net.t -> 102 + clock:_ Eio.Time.clock -> 103 + tls:bool -> 104 + ?insecure:bool -> 105 + pool_config:Conpool.Config.t -> 106 + unit -> 107 + Conpool.t 108 + (** [create_pool ~sw ~net ~clock ~tls ?insecure ~pool_config ()] creates a 109 + connection pool with optional TLS configuration. 110 + 111 + @param sw Switch for resource management 112 + @param net Network interface 113 + @param clock Clock for timeouts 114 + @param tls Whether to use TLS 115 + @param insecure Skip certificate verification (default: [false]) 116 + @param pool_config Connection pool configuration *) 117 + 118 + val endpoint : connection -> Conpool.Endpoint.t 119 + (** [endpoint conn] creates a Conpool endpoint from connection parameters. *) 120 + 121 + (** {1 XDG Access} *) 122 + 123 + val xdg : app_name:string -> fs:Eio.Fs.dir_ty Eio.Path.t -> Xdge.t 124 + (** [xdg ~app_name ~fs] creates an XDG context for the application. 125 + 126 + This can be used to access other XDG directories (data, cache, state, etc.) 127 + for your application. *) 128 + 129 + (** {1 Configuration File} *) 130 + 131 + module Config_file : sig 132 + type t 133 + (** Parsed TOML configuration. *) 134 + 135 + val load : Xdge.t -> t option 136 + (** [load xdg] attempts to load [config.toml] from the XDG config directory. 137 + Returns [None] if the file doesn't exist or cannot be parsed. *) 138 + 139 + val load_from_path : Eio.Fs.dir_ty Eio.Path.t -> t option 140 + (** [load_from_path path] loads configuration from a specific file path. 141 + Returns [None] if the file doesn't exist or cannot be parsed. *) 142 + end 143 + 144 + (** {1 Man Page Documentation} 145 + 146 + These functions return Cmdliner man page blocks that can be included in your 147 + application's man page to document the configuration system. *) 148 + 149 + val man_environment : app_name:string -> Cmdliner.Manpage.block list 150 + (** [man_environment ~app_name] returns man page blocks documenting the 151 + environment variables that affect MQTT configuration. 152 + 153 + Include these in your command's [~man] parameter: 154 + {[ 155 + let man = [ `S Manpage.s_description; ... ] @ Mqtte_cmd.man_environment ~app_name 156 + ]} *) 157 + 158 + val man_config_file : app_name:string -> Cmdliner.Manpage.block list 159 + (** [man_config_file ~app_name] returns man page blocks documenting the 160 + configuration file format and location. 161 + 162 + Includes a complete example configuration file. *) 163 + 164 + val man_sections : app_name:string -> Cmdliner.Manpage.block list 165 + (** [man_sections ~app_name] returns both environment and config file 166 + documentation. Equivalent to [man_environment @ man_config_file]. *) 167 + 168 + (** {1 Configuration File Management} *) 169 + 170 + val config_path : Xdge.t -> Eio.Fs.dir_ty Eio.Path.t 171 + (** [config_path xdg] returns the path where the configuration file should be 172 + located ([~/.config/{app_name}/config.toml]). *) 173 + 174 + val default_config_toml : string 175 + (** A default configuration file template with commented examples of all 176 + available options. *) 177 + 178 + val write_default_config : Xdge.t -> (string, string) result 179 + (** [write_default_config xdg] writes a default configuration file to the XDG 180 + config directory. 181 + 182 + @return [Ok path] with the path to the created file on success 183 + @return [Error msg] if the file already exists or writing failed *) 184 + 185 + (** {1 Subcommands} 186 + 187 + These functions create Cmdliner subcommands that can be added to your 188 + application to help users manage configuration files. 189 + 190 + Example usage: 191 + {[ 192 + let main_cmd = Cmd.group info [ 193 + your_main_term; 194 + Mqtte_cmd.init_config_cmd ~app_name ~fs; 195 + Mqtte_cmd.show_config_cmd ~app_name ~fs; 196 + ] 197 + ]} *) 198 + 199 + val init_config_cmd : 200 + app_name:string -> fs:Eio.Fs.dir_ty Eio.Path.t -> int Cmdliner.Cmd.t 201 + (** [init_config_cmd ~app_name ~fs] creates an [init-config] subcommand that 202 + writes a default configuration file to the XDG config directory. 203 + 204 + Supports [--force] flag to overwrite existing configuration. 205 + Returns exit code 0 on success, 1 if file exists and --force not given. *) 206 + 207 + val show_config_cmd : 208 + app_name:string -> fs:Eio.Fs.dir_ty Eio.Path.t -> int Cmdliner.Cmd.t 209 + (** [show_config_cmd ~app_name ~fs] creates a [show-config] subcommand that 210 + displays the configuration file path and its contents if it exists. 211 + Returns exit code 0. *)
+18 -30
lib/core/v5.ml
··· 791 791 P.write_fixed_header writer `PUBLISH flags (String.length body); 792 792 P.write_string writer body 793 793 794 - let write_pubx_payload ~packet_id ~reason_code ~properties = 795 - P.to_string (fun w -> 796 - P.write_uint16_be w packet_id; 797 - if reason_code <> `Success || properties <> [] then begin 798 - P.write_uint8 w (Reason_code.to_int reason_code); 799 - if properties <> [] then Property.write_properties w properties 800 - end) 801 - 802 - let write_puback writer (p : Puback.t) = 794 + let write_pubx writer packet_type ~flags ~packet_id ~reason_code ~properties = 803 795 let payload = 804 - write_pubx_payload ~packet_id:p.packet_id ~reason_code:p.reason_code 805 - ~properties:p.properties 796 + P.to_string (fun w -> 797 + P.write_uint16_be w packet_id; 798 + if reason_code <> `Success || properties <> [] then begin 799 + P.write_uint8 w (Reason_code.to_int reason_code); 800 + if properties <> [] then Property.write_properties w properties 801 + end) 806 802 in 807 - P.write_fixed_header writer `PUBACK 0 (String.length payload); 803 + P.write_fixed_header writer packet_type flags (String.length payload); 808 804 P.write_string writer payload 805 + 806 + let write_puback writer (p : Puback.t) = 807 + write_pubx writer `PUBACK ~flags:0 ~packet_id:p.packet_id 808 + ~reason_code:p.reason_code ~properties:p.properties 809 809 810 810 let write_pubrec writer (p : Pubrec.t) = 811 - let payload = 812 - write_pubx_payload ~packet_id:p.packet_id ~reason_code:p.reason_code 813 - ~properties:p.properties 814 - in 815 - P.write_fixed_header writer `PUBREC 0 (String.length payload); 816 - P.write_string writer payload 811 + write_pubx writer `PUBREC ~flags:0 ~packet_id:p.packet_id 812 + ~reason_code:p.reason_code ~properties:p.properties 817 813 818 814 let write_pubrel writer (p : Pubrel.t) = 819 - let payload = 820 - write_pubx_payload ~packet_id:p.packet_id ~reason_code:p.reason_code 821 - ~properties:p.properties 822 - in 823 - P.write_fixed_header writer `PUBREL 0x02 (String.length payload); 824 - P.write_string writer payload 815 + write_pubx writer `PUBREL ~flags:0x02 ~packet_id:p.packet_id 816 + ~reason_code:p.reason_code ~properties:p.properties 825 817 826 818 let write_pubcomp writer (p : Pubcomp.t) = 827 - let payload = 828 - write_pubx_payload ~packet_id:p.packet_id ~reason_code:p.reason_code 829 - ~properties:p.properties 830 - in 831 - P.write_fixed_header writer `PUBCOMP 0 (String.length payload); 832 - P.write_string writer payload 819 + write_pubx writer `PUBCOMP ~flags:0 ~packet_id:p.packet_id 820 + ~reason_code:p.reason_code ~properties:p.properties 833 821 834 822 let write_subscribe writer (s : Subscribe.t) = 835 823 let payload =
-172
lib/eio/cmd.ml
··· 1 - (*--------------------------------------------------------------------------- 2 - Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 - SPDX-License-Identifier: ISC 4 - ---------------------------------------------------------------------------*) 5 - 6 - (** Cmdliner terms for MQTT configuration 7 - 8 - This module provides Cmdliner terms and argument definitions for configuring 9 - MQTT connections from the command line. *) 10 - 11 - open Cmdliner 12 - 13 - (** {1 Connection Options} *) 14 - 15 - let host = 16 - let doc = "MQTT broker hostname or IP address." in 17 - let env = Cmd.Env.info "MQTT_HOST" ~doc in 18 - Arg.( 19 - value & opt string "127.0.0.1" & info [ "h"; "host" ] ~docv:"HOST" ~doc ~env) 20 - 21 - let port = 22 - let doc = "MQTT broker port." in 23 - let env = Cmd.Env.info "MQTT_PORT" ~doc in 24 - Arg.(value & opt int 1883 & info [ "p"; "port" ] ~docv:"PORT" ~doc ~env) 25 - 26 - let tls = 27 - let doc = 28 - "Use TLS for the connection. Port defaults to 8883 if not specified." 29 - in 30 - let env = Cmd.Env.info "MQTT_TLS" ~doc in 31 - Arg.(value & flag & info [ "tls" ] ~doc ~env) 32 - 33 - let insecure = 34 - let doc = 35 - "Skip TLS certificate verification (allows expired/self-signed \ 36 - certificates)." 37 - in 38 - Arg.(value & flag & info [ "insecure" ] ~doc) 39 - 40 - (** {1 Client Options} *) 41 - 42 - let client_id = 43 - let doc = 44 - "MQTT client identifier. If not specified, a random ID is generated." 45 - in 46 - let env = Cmd.Env.info "MQTT_CLIENT_ID" ~doc in 47 - Arg.( 48 - value & opt (some string) None & info [ "client-id" ] ~docv:"ID" ~doc ~env) 49 - 50 - let clean_session = 51 - let doc = 52 - "Start with a clean session (discard any previous session state)." 53 - in 54 - Arg.(value & flag & info [ "clean-session" ] ~doc) 55 - 56 - let keep_alive = 57 - let doc = "Keep-alive interval in seconds. Use 0 to disable." in 58 - Arg.(value & opt int 60 & info [ "keep-alive" ] ~docv:"SECONDS" ~doc) 59 - 60 - (** {1 Protocol Options} *) 61 - 62 - let protocol_version = 63 - let doc = "MQTT protocol version to use." in 64 - let versions = [ ("3.1.1", `V3_1_1); ("5", `V5_0); ("5.0", `V5_0) ] in 65 - Arg.( 66 - value 67 - & opt (enum versions) `V3_1_1 68 - & info [ "mqtt-version" ] ~docv:"VERSION" ~doc) 69 - 70 - (** {1 Authentication Options} *) 71 - 72 - let username = 73 - let doc = "MQTT username for authentication." in 74 - let env = Cmd.Env.info "MQTT_USER" ~doc in 75 - Arg.( 76 - value 77 - & opt (some string) None 78 - & info [ "u"; "username" ] ~docv:"USER" ~doc ~env) 79 - 80 - let password = 81 - let doc = "MQTT password for authentication." in 82 - let env = Cmd.Env.info "MQTT_PASSWORD" ~doc in 83 - Arg.( 84 - value & opt (some string) None & info [ "password" ] ~docv:"PASS" ~doc ~env) 85 - 86 - (** {1 Combined Connection Parameters} *) 87 - 88 - type connection = { host : string; port : int; tls : bool; insecure : bool } 89 - (** Type for connection parameters parsed from command line *) 90 - 91 - type t = { 92 - connection : connection; 93 - config : Client.config; 94 - pool_config : Conpool.Config.t; 95 - } 96 - (** Type for all MQTT configuration parsed from command line *) 97 - 98 - (** Build credentials from username/password options *) 99 - let make_credentials username password = 100 - match (username, password) with 101 - | Some u, Some p -> Some (`Username_password (u, p)) 102 - | Some u, None -> Some (`Username u) 103 - | None, _ -> None 104 - 105 - (** Term that parses connection parameters (host, port, tls, and insecure) *) 106 - let connection_term = 107 - let make host port tls insecure = 108 - let port = if port = 1883 && tls then 8883 else port in 109 - { host; port; tls; insecure } 110 - in 111 - Term.(const make $ host $ port $ tls $ insecure) 112 - 113 - (** Term that parses the full MQTT client configuration *) 114 - let config_term = 115 - let make client_id clean_session keep_alive version username password = 116 - let client_id = 117 - match client_id with 118 - | Some id -> id 119 - | None -> Printf.sprintf "mqtt-eio-%d" (Random.int 100000) 120 - in 121 - let credentials = make_credentials username password in 122 - Client. 123 - { 124 - client_id; 125 - version; 126 - clean_session; 127 - keep_alive; 128 - credentials; 129 - will = None; 130 - } 131 - in 132 - Term.( 133 - const make $ client_id $ clean_session $ keep_alive $ protocol_version 134 - $ username $ password) 135 - 136 - (** Term that parses both connection and config together *) 137 - let term = 138 - let make connection config pool_config = 139 - { connection; config; pool_config } 140 - in 141 - Term.(const make $ connection_term $ config_term $ Conpool.Cmd.config) 142 - 143 - (** {1 Connection Pool Helpers} *) 144 - 145 - (** Create a connection pool with optional TLS configuration. 146 - 147 - @param sw Switch for resource management 148 - @param net Network interface 149 - @param clock Clock for timeouts 150 - @param tls Whether to use TLS 151 - @param insecure Skip certificate verification (default false) 152 - @param pool_config Connection pool configuration *) 153 - let create_pool ~sw ~net ~clock ~tls ?(insecure = false) ~pool_config () = 154 - let tls_config = 155 - if tls then 156 - let authenticator = 157 - if insecure then fun ?ip:_ ~host:_ _ -> Ok None 158 - else 159 - match Ca_certs.authenticator () with 160 - | Ok auth -> auth 161 - | Error (`Msg msg) -> 162 - failwith ("Failed to load CA certificates: " ^ msg) 163 - in 164 - match Tls.Config.client ~authenticator () with 165 - | Ok config -> Some config 166 - | Error (`Msg msg) -> failwith ("Failed to create TLS config: " ^ msg) 167 - else None 168 - in 169 - Conpool.create ~sw ~net ~clock ?tls:tls_config ~config:pool_config () 170 - 171 - (** Create a Conpool endpoint from connection parameters *) 172 - let endpoint conn = Conpool.Endpoint.make ~host:conn.host ~port:conn.port
+1 -1
lib/eio/dune
··· 1 1 (library 2 2 (name mqtte_eio) 3 3 (public_name mqtte.eio) 4 - (libraries mqtte bytesrw bytesrw-eio eio conpool ca-certs logs fmt cmdliner)) 4 + (libraries mqtte bytesrw bytesrw-eio eio conpool logs fmt))
-1
lib/eio/mqtte_eio.ml
··· 11 11 module Client = Client 12 12 module Transport = Transport 13 13 module Protocol = Protocol 14 - module Cmd = Cmd
+2
mqtte.opam
··· 18 18 "ca-certs" 19 19 "cmdliner" {>= "1.2"} 20 20 "tls" 21 + "xdge" 22 + "tomlt" 21 23 "logs" {>= "0.7"} 22 24 "fmt" {>= "0.9"} 23 25 "alcotest" {with-test}