···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** OwnTracks CLI - Subscribe to OwnTracks location updates over MQTT *)
77+88+open Cmdliner
99+1010+let app_name = "owntracks"
1111+1212+(** {1 Configuration} *)
1313+1414+module Config = struct
1515+ type device = { id : string; name : string }
1616+1717+ let device_codec : device Tomlt.t =
1818+ Tomlt.(
1919+ Table.(
2020+ obj (fun id name -> { id; name })
2121+ |> mem "id" string ~enc:(fun d -> d.id)
2222+ |> mem "name" string ~enc:(fun d -> d.name)
2323+ |> finish))
2424+2525+ type recorder = {
2626+ url : string option;
2727+ user : string option;
2828+ password : string option;
2929+ }
3030+3131+ let empty_recorder = { url = None; user = None; password = None }
3232+3333+ let recorder_codec : recorder Tomlt.t =
3434+ Tomlt.(
3535+ Table.(
3636+ obj (fun url user password -> { url; user; password })
3737+ |> opt_mem "url" string ~enc:(fun r -> r.url)
3838+ |> opt_mem "user" string ~enc:(fun r -> r.user)
3939+ |> opt_mem "password" string ~enc:(fun r -> r.password)
4040+ |> finish))
4141+4242+ type owntracks = {
4343+ topic : string option;
4444+ default_device : string option;
4545+ recorder : recorder;
4646+ devices : device list;
4747+ }
4848+4949+ let empty_owntracks = {
5050+ topic = None;
5151+ default_device = None;
5252+ recorder = empty_recorder;
5353+ devices = [];
5454+ }
5555+5656+ let owntracks_codec : owntracks Tomlt.t =
5757+ Tomlt.(
5858+ Table.(
5959+ obj (fun topic default_device recorder devices ->
6060+ { topic; default_device;
6161+ recorder = Option.value ~default:empty_recorder recorder;
6262+ devices = Option.value ~default:[] devices })
6363+ |> opt_mem "topic" string ~enc:(fun c -> c.topic)
6464+ |> opt_mem "default_device" string ~enc:(fun c -> c.default_device)
6565+ |> opt_mem "recorder" recorder_codec
6666+ ~enc:(fun c -> if c.recorder = empty_recorder then None else Some c.recorder)
6767+ |> opt_mem "devices" (list device_codec)
6868+ ~enc:(fun c -> match c.devices with [] -> None | ds -> Some ds)
6969+ |> finish))
7070+7171+ type t = {
7272+ owntracks : owntracks;
7373+ mqtt : Mqtte_cmd.Config_file.mqtt_config;
7474+ pool : Mqtte_cmd.Config_file.pool_config;
7575+ }
7676+7777+ let empty =
7878+ {
7979+ owntracks = empty_owntracks;
8080+ mqtt = Mqtte_cmd.Config_file.empty_mqtt_config;
8181+ pool = Mqtte_cmd.Config_file.empty_pool_config;
8282+ }
8383+8484+ let codec : t Tomlt.t =
8585+ Tomlt.(
8686+ Table.(
8787+ obj (fun owntracks mqtt pool ->
8888+ {
8989+ owntracks = Option.value ~default:empty_owntracks owntracks;
9090+ mqtt =
9191+ Option.value ~default:Mqtte_cmd.Config_file.empty_mqtt_config mqtt;
9292+ pool =
9393+ Option.value ~default:Mqtte_cmd.Config_file.empty_pool_config pool;
9494+ })
9595+ |> opt_mem "owntracks" owntracks_codec
9696+ ~enc:(fun c ->
9797+ if c.owntracks = empty_owntracks then None else Some c.owntracks)
9898+ |> opt_mem "mqtt" Mqtte_cmd.Config_file.mqtt_codec
9999+ ~enc:(fun c ->
100100+ if c.mqtt = Mqtte_cmd.Config_file.empty_mqtt_config then None
101101+ else Some c.mqtt)
102102+ |> opt_mem "pool" Mqtte_cmd.Config_file.pool_codec
103103+ ~enc:(fun c ->
104104+ if c.pool = Mqtte_cmd.Config_file.empty_pool_config then None
105105+ else Some c.pool)
106106+ |> finish))
107107+108108+ let load xdg =
109109+ match Xdge.find_config_file xdg "owntracks.toml" with
110110+ | Some path ->
111111+ (try Some (Tomlt_eio.decode_file_exn codec path)
112112+ with exn ->
113113+ Logs.warn (fun m ->
114114+ m "Failed to parse owntracks.toml: %s" (Printexc.to_string exn));
115115+ None)
116116+ | None -> None
117117+118118+ let resolve_device_name config device_id =
119119+ List.find_map
120120+ (fun d -> if d.id = device_id then Some d.name else None)
121121+ config.owntracks.devices
122122+ |> Option.value ~default:device_id
123123+124124+ let to_mqtt_config t : Mqtte_cmd.Config_file.t = { mqtt = t.mqtt; pool = t.pool }
125125+126126+ let default_toml =
127127+ {|# OwnTracks Configuration
128128+129129+[owntracks]
130130+# topic = "owntracks/#"
131131+# default_device = "My Phone"
132132+133133+# OwnTracks Recorder HTTP API settings (for historical queries)
134134+# [owntracks.recorder]
135135+# url = "https://recorder.example.com"
136136+# user = "api_user"
137137+# password = "api_secret"
138138+139139+# [[owntracks.devices]]
140140+# id = "DEVICE-UUID"
141141+# name = "Alice Phone"
142142+143143+[mqtt]
144144+host = "127.0.0.1"
145145+port = 1883
146146+# tls = false
147147+# username = "user"
148148+# password = "secret"
149149+keep_alive = 60
150150+151151+[pool]
152152+max_connections = 10
153153+idle_timeout = 60.0
154154+|}
155155+end
156156+157157+(** {1 Message Handling} *)
158158+159159+let handle_message config msg =
160160+ let open Mqtte_eio.Client in
161161+ match Owntracks.Mqtt.of_mqtt ~topic:msg.topic ~payload:msg.payload with
162162+ | Ok ot_msg -> (
163163+ match ot_msg.Owntracks.Mqtt.message with
164164+ | Owntracks.Location loc ->
165165+ let user = Option.value ~default:"unknown" ot_msg.user in
166166+ let device = Option.value ~default:"unknown" ot_msg.device in
167167+ let device_name = Config.resolve_device_name config device in
168168+ Format.printf "@[<v 0>-------------------------------------------@,";
169169+ Format.printf " Device: %s" device_name;
170170+ if device_name <> device then Format.printf " (%s)" device;
171171+ Format.printf "@, User: %s@," user;
172172+ Format.printf " Time: %s@," (Owntracks.format_timestamp loc.tst);
173173+ Format.printf " Location: %.6f, %.6f@," loc.lat loc.lon;
174174+ Option.iter (fun v -> Format.printf " Altitude: %.1f m@," v) loc.alt;
175175+ Option.iter (fun v -> Format.printf " Accuracy: +/- %.0f m@," v) loc.acc;
176176+ Option.iter (fun v -> Format.printf " Speed: %.1f km/h@," v) loc.vel;
177177+ Option.iter (fun v -> Format.printf " Battery: %d%%@," v) loc.batt;
178178+ Format.printf "-------------------------------------------@]@."
179179+ | Owntracks.Transition tr ->
180180+ let device = Option.value ~default:"unknown" ot_msg.device in
181181+ let device_name = Config.resolve_device_name config device in
182182+ Format.printf "[%s] %s region: %s@." device_name
183183+ (String.uppercase_ascii tr.t_event)
184184+ (Option.value ~default:"unknown" tr.t_desc)
185185+ | _ -> ())
186186+ | Error err -> Logs.debug (fun m -> m "Failed to parse: %s" err)
187187+188188+(** {1 Commands} *)
189189+190190+let listen_cmd ~fs =
191191+ let xdg = Xdge.create fs app_name in
192192+ let config = Config.load xdg |> Option.value ~default:Config.empty in
193193+ let mqtt_config = Config.to_mqtt_config config in
194194+195195+ let run parsed topic =
196196+ Fmt_tty.setup_std_outputs ();
197197+ Logs.set_level (Some Logs.Info);
198198+ Logs.set_reporter (Logs_fmt.reporter ());
199199+200200+ let mqtt = parsed.Mqtte_cmd.mqtt in
201201+ let conn = mqtt.connection in
202202+ let topic =
203203+ match topic with
204204+ | Some t -> t
205205+ | None ->
206206+ Option.value ~default:Owntracks.Mqtt.default_topic config.owntracks.topic
207207+ in
208208+209209+ Logs.info (fun m -> m "OwnTracks Location Listener");
210210+ Logs.info (fun m ->
211211+ m "Connecting to %s:%d%s" conn.host conn.port
212212+ (if conn.tls then " (TLS)" else ""));
213213+ Logs.info (fun m -> m "Subscribing to: %s" topic);
214214+215215+ Eio_main.run @@ fun env ->
216216+ Mirage_crypto_rng_unix.use_default ();
217217+ Eio.Switch.run @@ fun sw ->
218218+ let on_message msg = handle_message config msg in
219219+ let on_disconnect () = Logs.warn (fun m -> m "Disconnected from broker") in
220220+221221+ let net = Eio.Stdenv.net env in
222222+ let clock = Eio.Stdenv.clock env in
223223+224224+ let pool =
225225+ Mqtte_cmd.create_pool ~sw ~net ~clock ~tls:conn.tls ~insecure:conn.insecure
226226+ ~pool_config:mqtt.pool_config ()
227227+ in
228228+ let endpoint = Mqtte_cmd.endpoint conn in
229229+230230+ let client =
231231+ Mqtte_eio.Client.connect_with_pool ~sw ~clock ~on_message ~on_disconnect
232232+ ~config:mqtt.config ~pool ~endpoint ()
233233+ in
234234+235235+ Logs.info (fun m -> m "Connected to MQTT broker");
236236+ Mqtte_eio.Client.subscribe ~qos:`At_least_once [ topic ] client;
237237+ Logs.info (fun m -> m "Subscribed to %s" topic);
238238+ Logs.info (fun m -> m "Listening for location updates... (Ctrl+C to exit)");
239239+240240+ while Mqtte_eio.Client.is_connected client do
241241+ Eio.Time.sleep clock 1.0
242242+ done;
243243+244244+ Mqtte_eio.Client.disconnect client;
245245+ Logs.info (fun m -> m "Disconnected");
246246+ 0
247247+ in
248248+ let topic =
249249+ let doc = "MQTT topic (supports wildcards). Default: owntracks/#" in
250250+ let env = Cmd.Env.info "OWNTRACKS_TOPIC" ~doc in
251251+ Arg.(value & opt (some string) None & info [ "t"; "topic" ] ~docv:"TOPIC" ~doc ~env)
252252+ in
253253+ let term =
254254+ Term.(
255255+ const run
256256+ $ Mqtte_cmd.term_with_config ~app_name ~fs ~config:mqtt_config ()
257257+ $ topic)
258258+ in
259259+ let doc = "Listen for OwnTracks location updates" in
260260+ let man =
261261+ [
262262+ `S Manpage.s_description;
263263+ `P "Connects to an MQTT broker and displays OwnTracks location updates.";
264264+ `S Manpage.s_examples;
265265+ `Pre " owntracks listen -h broker.example.com --tls";
266266+ `Pre " owntracks listen -t 'owntracks/alice/#'";
267267+ ]
268268+ @ Mqtte_cmd.man_sections ~app_name
269269+ in
270270+ Cmd.v (Cmd.info "listen" ~doc ~man) term
271271+272272+let devices_cmd ~fs:_ =
273273+ let run () =
274274+ Eio_main.run @@ fun env ->
275275+ let xdg = Xdge.create (Eio.Stdenv.fs env) app_name in
276276+ (match Config.load xdg with
277277+ | Some config when config.owntracks.devices <> [] ->
278278+ Format.printf "Configured devices:@.@.";
279279+ List.iter
280280+ (fun (d : Config.device) ->
281281+ let is_default =
282282+ config.owntracks.default_device = Some d.id
283283+ || config.owntracks.default_device = Some d.name
284284+ in
285285+ Format.printf " %s%s@. ID: %s@."
286286+ d.name
287287+ (if is_default then " (default)" else "")
288288+ d.id)
289289+ config.owntracks.devices
290290+ | Some _ ->
291291+ Format.printf "No devices configured.@.";
292292+ Format.printf "Add to ~/.config/%s/owntracks.toml:@.@." app_name;
293293+ Format.printf " [[owntracks.devices]]@.";
294294+ Format.printf " id = \"DEVICE-UUID\"@.";
295295+ Format.printf " name = \"My Phone\"@."
296296+ | None ->
297297+ Format.printf "No config file. Run 'owntracks init' to create one.@.");
298298+ 0
299299+ in
300300+ let doc = "List configured device name mappings" in
301301+ Cmd.v (Cmd.info "devices" ~doc) Term.(const run $ const ())
302302+303303+(** {1 GeoJSON Output} *)
304304+305305+(** Jsont adapter for the Geojson library *)
306306+module Jsont_json : Geojson.Json with type t = Jsont.json = struct
307307+ type t = Jsont.json
308308+309309+ let find json keys =
310310+ let rec go j = function
311311+ | [] -> Some j
312312+ | k :: ks ->
313313+ match j with
314314+ | Jsont.Object (mems, _) ->
315315+ (match List.find_opt (fun ((n, _), _) -> n = k) mems with
316316+ | Some (_, v) -> go v ks
317317+ | None -> None)
318318+ | _ -> None
319319+ in
320320+ go json keys
321321+322322+ let to_string = function
323323+ | Jsont.String (s, _) -> Ok s
324324+ | _ -> Error (`Msg "expected string")
325325+326326+ let string s = Jsont.Json.string s
327327+328328+ let to_float = function
329329+ | Jsont.Number (f, _) -> Ok f
330330+ | _ -> Error (`Msg "expected number")
331331+332332+ let float f = Jsont.Json.number f
333333+334334+ let to_int = function
335335+ | Jsont.Number (f, _) -> Ok (int_of_float f)
336336+ | _ -> Error (`Msg "expected number")
337337+338338+ let int i = Jsont.Json.int i
339339+340340+ let to_list f = function
341341+ | Jsont.Array (l, _) -> Ok (List.map f l)
342342+ | _ -> Error (`Msg "expected array")
343343+344344+ let list f l = Jsont.Json.list (List.map f l)
345345+346346+ let to_array f = function
347347+ | Jsont.Array (l, _) -> Ok (Array.of_list (List.map f l))
348348+ | _ -> Error (`Msg "expected array")
349349+350350+ let array f a = Jsont.Json.list (List.map f (Array.to_list a))
351351+352352+ let to_obj = function
353353+ | Jsont.Object (mems, _) -> Ok (List.map (fun ((n, _), v) -> (n, v)) mems)
354354+ | _ -> Error (`Msg "expected object")
355355+356356+ let obj l = Jsont.Json.object' (List.map (fun (n, v) -> Jsont.Json.mem (Jsont.Json.name n) v) l)
357357+358358+ let null = Jsont.Json.null ()
359359+ let is_null = function Jsont.Null _ -> true | _ -> false
360360+end
361361+362362+module Geo = Geojson.Make (Jsont_json)
363363+364364+module Geojson_output = struct
365365+ let pos_of_loc (loc : Owntracks.location) =
366366+ Geo.Geometry.Position.v ?altitude:loc.alt ~lng:loc.lon ~lat:loc.lat ()
367367+368368+ let props ~device_name ~timestamp ~time ?accuracy ?speed ?battery ?tracker_id () =
369369+ let open Jsont.Json in
370370+ let add n f opt acc = match opt with Some v -> (n, f v) :: acc | None -> acc in
371371+ [
372372+ ("name", string device_name);
373373+ ("timestamp", int timestamp);
374374+ ("time", string time)
375375+ ]
376376+ |> add "accuracy" number accuracy
377377+ |> add "speed" number speed
378378+ |> add "battery" int battery
379379+ |> add "tracker_id" string tracker_id
380380+ |> Jsont_json.obj
381381+382382+ (** Convert a location to GeoJSON Feature with Point geometry *)
383383+ let point_feature ~device_name (loc : Owntracks.location) : Geo.t =
384384+ let point = Geo.Geometry.Point.v (pos_of_loc loc) in
385385+ let geom = Geo.Geometry.v (Point point) in
386386+ let properties = Some (props ~device_name ~timestamp:loc.tst
387387+ ~time:(Owntracks.format_timestamp loc.tst)
388388+ ?accuracy:loc.acc ?speed:loc.vel ?battery:loc.batt ?tracker_id:loc.tid ()) in
389389+ let feature = Geo.Feature.v ?properties geom in
390390+ Geo.v (Feature feature)
391391+392392+ (** Convert a list of locations to GeoJSON Feature with LineString geometry *)
393393+ let linestring_feature ~device_name (locs : Owntracks.location list) : Geo.t =
394394+ let sorted = List.sort (fun a b -> Int.compare a.Owntracks.tst b.Owntracks.tst) locs in
395395+ let positions = Array.of_list (List.map pos_of_loc sorted) in
396396+ let line = Geo.Geometry.LineString.v positions in
397397+ let geom = Geo.Geometry.v (LineString line) in
398398+ let start_time = match sorted with [] -> 0 | h :: _ -> h.tst in
399399+ let end_time = match List.rev sorted with [] -> 0 | h :: _ -> h.tst in
400400+ let properties = Some (Jsont_json.obj [
401401+ ("name", Jsont.Json.string device_name);
402402+ ("points", Jsont.Json.int (List.length sorted));
403403+ ("start_time", Jsont.Json.string (Owntracks.format_timestamp start_time));
404404+ ("end_time", Jsont.Json.string (Owntracks.format_timestamp end_time));
405405+ ]) in
406406+ let feature = Geo.Feature.v ?properties geom in
407407+ Geo.v (Feature feature)
408408+409409+ let to_string geo =
410410+ let json = Geo.to_json geo in
411411+ match Jsont_bytesrw.encode_string Jsont.json json with
412412+ | Ok s -> s
413413+ | Error e -> failwith e
414414+end
415415+416416+(** {1 OwnTracks Recorder HTTP API} *)
417417+418418+module Recorder = struct
419419+ (** Query the OwnTracks Recorder HTTP API for historical locations.
420420+ API: GET /api/0/locations?user=USER&device=DEVICE&from=YYYY-MM-DD&to=YYYY-MM-DD *)
421421+422422+ let location_of_json (json : Jsont.json) : Owntracks.location option =
423423+ let get_float key =
424424+ match json with
425425+ | Jsont.Object (mems, _) ->
426426+ List.find_map (fun ((k, _), v) ->
427427+ if k = key then
428428+ match v with
429429+ | Jsont.Number (f, _) -> Some f
430430+ | _ -> None
431431+ else None) mems
432432+ | _ -> None
433433+ in
434434+ let get_int key = Option.map int_of_float (get_float key) in
435435+ let get_string key =
436436+ match json with
437437+ | Jsont.Object (mems, _) ->
438438+ List.find_map (fun ((k, _), v) ->
439439+ if k = key then
440440+ match v with
441441+ | Jsont.String (s, _) -> Some s
442442+ | _ -> None
443443+ else None) mems
444444+ | _ -> None
445445+ in
446446+ match (get_float "lat", get_float "lon", get_int "tst") with
447447+ | (Some lat, Some lon, Some tst) ->
448448+ Some {
449449+ Owntracks.lat; lon; tst;
450450+ tid = get_string "tid";
451451+ alt = get_float "alt";
452452+ acc = get_float "acc";
453453+ vel = get_float "vel";
454454+ cog = get_float "cog";
455455+ batt = get_int "batt";
456456+ bs = get_int "bs";
457457+ conn = get_string "conn";
458458+ t = get_string "t";
459459+ m = get_int "m";
460460+ poi = get_string "poi";
461461+ inregions = [];
462462+ addr = get_string "addr";
463463+ topic = get_string "topic";
464464+ }
465465+ | _ -> None
466466+467467+ let parse_locations_json json_str : Owntracks.location list =
468468+ match Jsont_bytesrw.decode_string Jsont.json json_str with
469469+ | Error _ -> []
470470+ | Ok json ->
471471+ match json with
472472+ | Jsont.Array (items, _) ->
473473+ List.filter_map location_of_json items
474474+ | Jsont.Object (mems, _) ->
475475+ (* Sometimes the API returns { "data": [...] } *)
476476+ (match List.find_opt (fun ((k, _), _) -> k = "data") mems with
477477+ | Some (_, Jsont.Array (items, _)) -> List.filter_map location_of_json items
478478+ | _ -> [])
479479+ | _ -> []
480480+481481+ let parse_string_list json_str : string list =
482482+ match Jsont_bytesrw.decode_string Jsont.json json_str with
483483+ | Error _ -> []
484484+ | Ok json ->
485485+ match json with
486486+ | Jsont.Object (mems, _) ->
487487+ (* API returns { "results": ["user1", "user2", ...] } *)
488488+ (match List.find_opt (fun ((k, _), _) -> k = "results") mems with
489489+ | Some (_, Jsont.Array (items, _)) ->
490490+ List.filter_map (function
491491+ | Jsont.String (s, _) -> Some s
492492+ | _ -> None) items
493493+ | _ -> [])
494494+ | Jsont.Array (items, _) ->
495495+ List.filter_map (function
496496+ | Jsont.String (s, _) -> Some s
497497+ | _ -> None) items
498498+ | _ -> []
499499+500500+ let list_users ~sw env ~verbose_http ~recorder_url ?auth () : string list =
501501+ let url = Printf.sprintf "%s/api/0/list" recorder_url in
502502+ if verbose_http then begin
503503+ Logs.set_level (Some Logs.Debug);
504504+ Logs.set_reporter (Logs_fmt.reporter ())
505505+ end;
506506+ let session = Requests.create ~sw env in
507507+ let headers = match auth with
508508+ | Some (username, password) ->
509509+ Requests.Headers.empty |> Requests.Headers.basic ~username ~password
510510+ | None -> Requests.Headers.empty
511511+ in
512512+ let response = Requests.get ~headers session url in
513513+ if Requests.Response.ok response then begin
514514+ let body = Requests.Response.body response |> Eio.Flow.read_all in
515515+ parse_string_list body
516516+ end else begin
517517+ Format.eprintf "HTTP error: %d@." (Requests.Response.status_code response);
518518+ []
519519+ end
520520+521521+ let list_devices ~sw env ~verbose_http ~recorder_url ~user ?auth () : string list =
522522+ let url = Printf.sprintf "%s/api/0/list?user=%s" recorder_url (Uri.pct_encode user) in
523523+ if verbose_http then begin
524524+ Logs.set_level (Some Logs.Debug);
525525+ Logs.set_reporter (Logs_fmt.reporter ())
526526+ end;
527527+ let session = Requests.create ~sw env in
528528+ let headers = match auth with
529529+ | Some (username, password) ->
530530+ Requests.Headers.empty |> Requests.Headers.basic ~username ~password
531531+ | None -> Requests.Headers.empty
532532+ in
533533+ let response = Requests.get ~headers session url in
534534+ if Requests.Response.ok response then begin
535535+ let body = Requests.Response.body response |> Eio.Flow.read_all in
536536+ parse_string_list body
537537+ end else begin
538538+ Format.eprintf "HTTP error: %d@." (Requests.Response.status_code response);
539539+ []
540540+ end
541541+542542+ let fetch_locations ~sw env ~verbose_http ~recorder_url ~user ~device ~from_date ~to_date ?auth () : Owntracks.location list =
543543+ let url = Printf.sprintf "%s/api/0/locations?user=%s&device=%s&from=%s&to=%s"
544544+ recorder_url
545545+ (Uri.pct_encode user)
546546+ (Uri.pct_encode device)
547547+ from_date
548548+ to_date
549549+ in
550550+ Format.eprintf "Fetching from %s...@." url;
551551+ (* Set up verbose logging if requested *)
552552+ if verbose_http then begin
553553+ Logs.set_level (Some Logs.Debug);
554554+ Logs.set_reporter (Logs_fmt.reporter ())
555555+ end;
556556+ let session = Requests.create ~sw env in
557557+ let headers = match auth with
558558+ | Some (username, password) ->
559559+ Requests.Headers.empty |> Requests.Headers.basic ~username ~password
560560+ | None -> Requests.Headers.empty
561561+ in
562562+ let response = Requests.get ~headers session url in
563563+ if Requests.Response.ok response then begin
564564+ let body = Requests.Response.body response |> Eio.Flow.read_all in
565565+ parse_locations_json body
566566+ end else begin
567567+ Format.eprintf "HTTP error: %d@." (Requests.Response.status_code response);
568568+ []
569569+ end
570570+end
571571+572572+let geojson_cmd ~fs =
573573+ let xdg = Xdge.create fs app_name in
574574+ let config = Config.load xdg |> Option.value ~default:Config.empty in
575575+ let mqtt_config = Config.to_mqtt_config config in
576576+577577+ (* Helper to get today's date as YYYY-MM-DD *)
578578+ let today () =
579579+ let now = Unix.gettimeofday () in
580580+ let tm = Unix.gmtime now in
581581+ Printf.sprintf "%04d-%02d-%02d" (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday
582582+ in
583583+584584+ let run parsed topic device duration track from_date to_date user recorder_url recorder_user recorder_password verbose_http =
585585+ let device = match device with Some _ -> device | None -> config.owntracks.default_device in
586586+ let recorder_url = match recorder_url with Some _ -> recorder_url | None -> config.owntracks.recorder.url in
587587+ let recorder_user = match recorder_user with Some _ -> recorder_user | None -> config.owntracks.recorder.user in
588588+ let recorder_password = match recorder_password with Some _ -> recorder_password | None -> config.owntracks.recorder.password in
589589+590590+ (* If --from is specified, use HTTP API instead of MQTT *)
591591+ match from_date with
592592+ | Some from_date ->
593593+ let to_date = Option.value to_date ~default:(today ()) in
594594+ (* Default user from config recorder.user, then "owntracks" *)
595595+ let user = match user with
596596+ | Some u -> u
597597+ | None -> Option.value config.owntracks.recorder.user ~default:"owntracks"
598598+ in
599599+ (* Default device from config default_device, then "phone" *)
600600+ let device = Option.value device ~default:(Option.value config.owntracks.default_device ~default:"phone") in
601601+ (match recorder_url with
602602+ | None ->
603603+ Format.eprintf "Error: --recorder-url or recorder_url config required for historical queries@.";
604604+ 1
605605+ | Some recorder_url ->
606606+ let auth = match (recorder_user, recorder_password) with
607607+ | (Some u, Some p) -> Some (u, p)
608608+ | _ -> None
609609+ in
610610+ Eio_main.run @@ fun env ->
611611+ Mirage_crypto_rng_unix.use_default ();
612612+ Eio.Switch.run @@ fun sw ->
613613+ let locations = Recorder.fetch_locations ~sw env ~verbose_http ~recorder_url ~user ~device ~from_date ~to_date ?auth () in
614614+ match locations with
615615+ | [] ->
616616+ Format.eprintf "No locations found for %s/%s from %s to %s@." user device from_date to_date;
617617+ 1
618618+ | locs ->
619619+ let device_name = Config.resolve_device_name config device in
620620+ let json = Geojson_output.linestring_feature ~device_name locs in
621621+ print_endline (Geojson_output.to_string json);
622622+ 0)
623623+ | None ->
624624+ (* Use MQTT for real-time data *)
625625+ let mqtt = parsed.Mqtte_cmd.mqtt in
626626+ let conn = mqtt.connection in
627627+ let topic =
628628+ Option.value topic
629629+ ~default:(Option.value ~default:Owntracks.Mqtt.default_topic config.owntracks.topic)
630630+ in
631631+ let result = ref None in
632632+ let track_points = ref [] in
633633+ let track_device_name = ref "unknown" in
634634+635635+ Eio_main.run @@ fun env ->
636636+ Mirage_crypto_rng_unix.use_default ();
637637+ Eio.Switch.run @@ fun sw ->
638638+ let net = Eio.Stdenv.net env in
639639+ let clock = Eio.Stdenv.clock env in
640640+641641+ let on_message msg =
642642+ let open Mqtte_eio.Client in
643643+ match Owntracks.Mqtt.of_mqtt ~topic:msg.topic ~payload:msg.payload with
644644+ | Ok ot_msg -> (
645645+ match ot_msg.Owntracks.Mqtt.message with
646646+ | Owntracks.Location loc ->
647647+ let msg_device = Option.value ~default:"unknown" ot_msg.device in
648648+ let matches = match device with
649649+ | None -> true
650650+ | Some d -> d = msg_device || d = Config.resolve_device_name config msg_device
651651+ in
652652+ if matches then begin
653653+ let device_name = Config.resolve_device_name config msg_device in
654654+ if track then begin
655655+ track_device_name := device_name;
656656+ track_points := loc :: !track_points
657657+ end else if Option.is_none !result then
658658+ result := Some (Geojson_output.point_feature ~device_name loc)
659659+ end
660660+ | _ -> ())
661661+ | Error _ -> ()
662662+ in
663663+ let on_disconnect () = () in
664664+665665+ let pool =
666666+ Mqtte_cmd.create_pool ~sw ~net ~clock ~tls:conn.tls ~insecure:conn.insecure
667667+ ~pool_config:mqtt.pool_config ()
668668+ in
669669+ let endpoint = Mqtte_cmd.endpoint conn in
670670+671671+ let client =
672672+ Mqtte_eio.Client.connect_with_pool ~sw ~clock ~on_message ~on_disconnect
673673+ ~config:mqtt.config ~pool ~endpoint ()
674674+ in
675675+676676+ Mqtte_eio.Client.subscribe ~qos:`At_least_once [ topic ] client;
677677+678678+ if track then begin
679679+ (* Track mode: collect points for the full duration *)
680680+ let deadline = Eio.Time.now clock +. duration in
681681+ Format.eprintf "Collecting track for %.0f seconds...@." duration;
682682+ while Eio.Time.now clock < deadline && Mqtte_eio.Client.is_connected client do
683683+ Eio.Time.sleep clock 0.5
684684+ done;
685685+ Mqtte_eio.Client.disconnect client;
686686+ match !track_points with
687687+ | [] ->
688688+ Format.eprintf "No locations received within %.0f seconds@." duration;
689689+ 1
690690+ | points ->
691691+ let json = Geojson_output.linestring_feature ~device_name:!track_device_name points in
692692+ print_endline (Geojson_output.to_string json);
693693+ 0
694694+ end else begin
695695+ (* Single point mode: wait for first location then exit immediately *)
696696+ let deadline = Eio.Time.now clock +. duration in
697697+ while Option.is_none !result && Eio.Time.now clock < deadline && Mqtte_eio.Client.is_connected client do
698698+ Eio.Time.sleep clock 0.1
699699+ done;
700700+ Mqtte_eio.Client.disconnect client;
701701+ match !result with
702702+ | Some json ->
703703+ print_endline (Geojson_output.to_string json);
704704+ 0
705705+ | None ->
706706+ Format.eprintf "No location received within %.0f seconds@." duration;
707707+ 1
708708+ end
709709+ in
710710+ let topic =
711711+ let doc = "MQTT topic (supports wildcards). Default: owntracks/#" in
712712+ Arg.(value & opt (some string) None & info [ "t"; "topic" ] ~docv:"TOPIC" ~doc)
713713+ in
714714+ let device =
715715+ let doc = "Filter by device name or ID. Defaults to default_device from config." in
716716+ Arg.(value & opt (some string) None & info [ "d"; "device" ] ~docv:"DEVICE" ~doc)
717717+ in
718718+ let duration =
719719+ let doc = "Duration in seconds to wait for location (single point) or collect track data (with --track)" in
720720+ Arg.(value & opt float 30.0 & info [ "duration" ] ~docv:"SECONDS" ~doc)
721721+ in
722722+ let track =
723723+ let doc = "Collect a track (LineString) instead of a single point. \
724724+ Use --duration to set collection duration (e.g., --duration 86400 for 24h)." in
725725+ Arg.(value & flag & info [ "track" ] ~doc)
726726+ in
727727+ let from_date =
728728+ let doc = "Start date for historical query (YYYY-MM-DD). Uses HTTP API instead of MQTT." in
729729+ Arg.(value & opt (some string) None & info [ "from" ] ~docv:"DATE" ~doc)
730730+ in
731731+ let to_date =
732732+ let doc = "End date for historical query (YYYY-MM-DD). Defaults to today." in
733733+ Arg.(value & opt (some string) None & info [ "to" ] ~docv:"DATE" ~doc)
734734+ in
735735+ let user =
736736+ let doc = "OwnTracks user for HTTP API queries. Default: owntracks" in
737737+ Arg.(value & opt (some string) None & info [ "user" ] ~docv:"USER" ~doc)
738738+ in
739739+ let recorder_url =
740740+ let doc = "OwnTracks Recorder URL (e.g., https://recorder.example.com). \
741741+ Can also be set in config as [owntracks.recorder] url." in
742742+ Arg.(value & opt (some string) None & info [ "recorder-url" ] ~docv:"URL" ~doc)
743743+ in
744744+ let recorder_user =
745745+ let doc = "User for HTTP Basic Auth to OwnTracks Recorder. \
746746+ Can also be set in config as [owntracks.recorder] user." in
747747+ Arg.(value & opt (some string) None & info [ "recorder-user" ] ~docv:"USER" ~doc)
748748+ in
749749+ let recorder_password =
750750+ let doc = "Password for HTTP Basic Auth to OwnTracks Recorder. \
751751+ Can also be set in config as [owntracks.recorder] password." in
752752+ Arg.(value & opt (some string) None & info [ "recorder-password" ] ~docv:"PASS" ~doc)
753753+ in
754754+ let verbose_http =
755755+ let doc = "Enable verbose HTTP-level logging for debugging recorder requests." in
756756+ Arg.(value & flag & info [ "verbose-http" ] ~doc)
757757+ in
758758+ let term =
759759+ Term.(
760760+ const run
761761+ $ Mqtte_cmd.term_with_config ~app_name ~fs ~config:mqtt_config ()
762762+ $ topic $ device $ duration $ track $ from_date $ to_date $ user $ recorder_url
763763+ $ recorder_user $ recorder_password $ verbose_http)
764764+ in
765765+ let doc = "Output device location as GeoJSON" in
766766+ let man =
767767+ [
768768+ `S Manpage.s_description;
769769+ `P "Connects to MQTT and outputs location data as GeoJSON.";
770770+ `P "By default, outputs the first location as a GeoJSON Feature with Point geometry. \
771771+ With $(b,--track), collects locations for the specified duration and outputs a \
772772+ LineString showing the movement path.";
773773+ `P "With $(b,--from), queries the OwnTracks Recorder HTTP API for historical data \
774774+ instead of using MQTT. Requires $(b,--recorder-url) or recorder_url in config.";
775775+ `S Manpage.s_examples;
776776+ `Pre " owntracks geojson";
777777+ `Pre " owntracks geojson -d 'My Phone' --duration 60";
778778+ `Pre " owntracks geojson --track --duration 3600 # 1 hour track";
779779+ `Pre " owntracks geojson --from 2024-01-12 --to 2024-01-13 # historical";
780780+ `Pre " owntracks geojson --from 2024-01-12 --recorder-url https://recorder.example.com";
781781+ ]
782782+ in
783783+ Cmd.v (Cmd.info "geojson" ~doc ~man) term
784784+785785+let recorder_cmd ~fs =
786786+ let xdg = Xdge.create fs app_name in
787787+ let config = Config.load xdg |> Option.value ~default:Config.empty in
788788+789789+ let run recorder_url recorder_user recorder_password user verbose_http =
790790+ let recorder_url = match recorder_url with Some _ -> recorder_url | None -> config.owntracks.recorder.url in
791791+ let recorder_user = match recorder_user with Some _ -> recorder_user | None -> config.owntracks.recorder.user in
792792+ let recorder_password = match recorder_password with Some _ -> recorder_password | None -> config.owntracks.recorder.password in
793793+794794+ match recorder_url with
795795+ | None ->
796796+ Format.eprintf "Error: --recorder-url or [owntracks.recorder] url config required@.";
797797+ 1
798798+ | Some recorder_url ->
799799+ let auth = match (recorder_user, recorder_password) with
800800+ | (Some u, Some p) -> Some (u, p)
801801+ | _ -> None
802802+ in
803803+ Eio_main.run @@ fun env ->
804804+ Mirage_crypto_rng_unix.use_default ();
805805+ Eio.Switch.run @@ fun sw ->
806806+ match user with
807807+ | None ->
808808+ (* List all users *)
809809+ let users = Recorder.list_users ~sw env ~verbose_http ~recorder_url ?auth () in
810810+ (match users with
811811+ | [] ->
812812+ Format.printf "No users found (or unable to access recorder).@.";
813813+ 1
814814+ | users ->
815815+ Format.printf "Users on recorder:@.";
816816+ List.iter (fun u -> Format.printf " %s@." u) users;
817817+ 0)
818818+ | Some user ->
819819+ (* List devices for user *)
820820+ let devices = Recorder.list_devices ~sw env ~verbose_http ~recorder_url ~user ?auth () in
821821+ (match devices with
822822+ | [] ->
823823+ Format.printf "No devices found for user '%s'.@." user;
824824+ 1
825825+ | devices ->
826826+ Format.printf "Devices for user '%s':@." user;
827827+ List.iter (fun d -> Format.printf " %s@." d) devices;
828828+ 0)
829829+ in
830830+ let recorder_url =
831831+ let doc = "OwnTracks Recorder URL (e.g., https://recorder.example.com)." in
832832+ Arg.(value & opt (some string) None & info [ "recorder-url" ] ~docv:"URL" ~doc)
833833+ in
834834+ let recorder_user =
835835+ let doc = "User for HTTP Basic Auth to OwnTracks Recorder." in
836836+ Arg.(value & opt (some string) None & info [ "recorder-user" ] ~docv:"USER" ~doc)
837837+ in
838838+ let recorder_password =
839839+ let doc = "Password for HTTP Basic Auth to OwnTracks Recorder." in
840840+ Arg.(value & opt (some string) None & info [ "recorder-password" ] ~docv:"PASS" ~doc)
841841+ in
842842+ let user =
843843+ let doc = "List devices for this user. If omitted, lists all users." in
844844+ Arg.(value & opt (some string) None & info [ "user" ] ~docv:"USER" ~doc)
845845+ in
846846+ let verbose_http =
847847+ let doc = "Enable verbose HTTP-level logging." in
848848+ Arg.(value & flag & info [ "verbose-http" ] ~doc)
849849+ in
850850+ let term =
851851+ Term.(const run $ recorder_url $ recorder_user $ recorder_password $ user $ verbose_http)
852852+ in
853853+ let doc = "List users and devices from OwnTracks Recorder" in
854854+ let man =
855855+ [
856856+ `S Manpage.s_description;
857857+ `P "Query the OwnTracks Recorder HTTP API to list available users and devices.";
858858+ `P "Without $(b,--user), lists all users. With $(b,--user), lists devices for that user.";
859859+ `S Manpage.s_examples;
860860+ `Pre " owntracks recorder # list all users";
861861+ `Pre " owntracks recorder --user avsm # list devices for user 'avsm'";
862862+ ]
863863+ in
864864+ Cmd.v (Cmd.info "recorder" ~doc ~man) term
865865+866866+let init_cmd ~fs:_ =
867867+ let run force =
868868+ Eio_main.run @@ fun env ->
869869+ let xdg = Xdge.create (Eio.Stdenv.fs env) app_name in
870870+ let path = Eio.Path.(Xdge.config_dir xdg / "owntracks.toml") in
871871+ let exists = Eio.Path.is_file path in
872872+ if exists && not force then begin
873873+ Format.printf "Config exists: %s@.Use --force to overwrite.@."
874874+ (Eio.Path.native_exn path);
875875+ 1
876876+ end
877877+ else begin
878878+ Eio.Path.save ~create:(`Or_truncate 0o644) path Config.default_toml;
879879+ Format.printf "%s: %s@."
880880+ (if exists then "Overwrote" else "Created")
881881+ (Eio.Path.native_exn path);
882882+ 0
883883+ end
884884+ in
885885+ let force = Arg.(value & flag & info [ "f"; "force" ] ~doc:"Overwrite existing") in
886886+ let doc = "Create default configuration file" in
887887+ Cmd.v (Cmd.info "init" ~doc) Term.(const run $ force)
888888+889889+(** {1 Main} *)
890890+891891+let () =
892892+ let main_cmd ~fs =
893893+ let doc = "OwnTracks location tracking CLI" in
894894+ let man =
895895+ [
896896+ `S Manpage.s_description;
897897+ `P "Subscribe to OwnTracks location updates over MQTT.";
898898+ `S "CONFIGURATION";
899899+ `P (Printf.sprintf "Settings are stored in ~/.config/%s/owntracks.toml:" app_name);
900900+ `Pre
901901+ {|[owntracks]
902902+topic = "owntracks/#"
903903+[[owntracks.devices]]
904904+id = "DEVICE-UUID"
905905+name = "My Phone"
906906+907907+[mqtt]
908908+host = "mqtt.example.com"
909909+port = 8883
910910+tls = true
911911+username = "user"
912912+password = "secret"|};
913913+ `P "Run $(b,owntracks init) to create a config file.";
914914+ ]
915915+ in
916916+ let default = Term.(ret (const (`Help (`Pager, None)))) in
917917+ Cmd.group (Cmd.info app_name ~version:"0.1.0" ~doc ~man) ~default
918918+ [ listen_cmd ~fs; geojson_cmd ~fs; recorder_cmd ~fs; devices_cmd ~fs; init_cmd ~fs ]
919919+ in
920920+ Eio_main.run @@ fun env ->
921921+ exit (Cmd.eval' (main_cmd ~fs:(Eio.Stdenv.fs env)))
-121
bin/owntracks_example.ml
···11-(** OwnTracks MQTT Location Subscriber
22-33- This example connects to an MQTT broker and subscribes to OwnTracks
44- location messages, pretty-printing the received data.
55-66- Usage:
77- owntracks-subscriber [OPTIONS]
88-99- Options can also be set via environment variables:
1010- MQTT_HOST - MQTT broker hostname (default: 127.0.0.1)
1111- MQTT_PORT - MQTT broker port (default: 1883, or 8883 with --tls)
1212- MQTT_TLS - Enable TLS (set to any value)
1313- MQTT_USER - MQTT username (optional)
1414- MQTT_PASSWORD - MQTT password (optional)
1515- MQTT_CLIENT_ID - MQTT client ID (optional)
1616-1717- See vendor/git/recorder for the OwnTracks Recorder reference implementation.
1818-*)
1919-2020-open Cmdliner
2121-2222-let topic =
2323- let doc = "OwnTracks topic to subscribe to. Supports MQTT wildcards." in
2424- let env = Cmd.Env.info "OT_TOPIC" ~doc in
2525- Arg.(value & opt string "owntracks/#" &
2626- info ["t"; "topic"] ~docv:"TOPIC" ~doc ~env)
2727-2828-let run mqtt topic =
2929- Fmt_tty.setup_std_outputs ();
3030- Logs.set_level (Some Logs.Info);
3131- Logs.set_reporter (Logs_fmt.reporter ());
3232-3333- let conn = mqtt.Mqtte_eio.Cmd.connection in
3434- let config = mqtt.Mqtte_eio.Cmd.config in
3535- let pool_config = mqtt.Mqtte_eio.Cmd.pool_config in
3636-3737- Logs.info (fun m -> m "OwnTracks MQTT Subscriber");
3838- Logs.info (fun m -> m "Connecting to %s:%d%s" conn.host conn.port
3939- (if conn.tls then " (TLS)" else ""));
4040- Logs.info (fun m -> m "Subscribing to: %s" topic);
4141-4242- Eio_main.run @@ fun env ->
4343- Mirage_crypto_rng_unix.use_default ();
4444- Eio.Switch.run @@ fun sw ->
4545-4646- let on_message msg =
4747- let open Mqtte_eio.Client in
4848- let payload_with_topic =
4949- let payload = msg.payload in
5050- if String.length payload > 0 && payload.[0] = '{' then
5151- let topic_json = Printf.sprintf "{\"topic\":%S," msg.topic in
5252- topic_json ^ String.sub payload 1 (String.length payload - 1)
5353- else
5454- payload
5555- in
5656- match Owntracks.decode_message payload_with_topic with
5757- | Ok message ->
5858- Format.printf "%a@." Owntracks.pp_message message
5959- | Error err ->
6060- Logs.warn (fun m -> m "Failed to parse message on [%s]: %s"
6161- msg.topic err);
6262- Logs.debug (fun m -> m "Raw payload: %s" msg.payload)
6363- in
6464-6565- let on_disconnect () =
6666- Logs.warn (fun m -> m "Disconnected from broker")
6767- in
6868-6969- let net = Eio.Stdenv.net env in
7070- let clock = Eio.Stdenv.clock env in
7171-7272- let pool = Mqtte_eio.Cmd.create_pool ~sw ~net ~clock
7373- ~tls:conn.tls ~insecure:conn.insecure ~pool_config () in
7474- let endpoint = Mqtte_eio.Cmd.endpoint conn in
7575-7676- let client = Mqtte_eio.Client.connect_with_pool
7777- ~sw
7878- ~clock
7979- ~on_message
8080- ~on_disconnect
8181- ~config
8282- ~pool
8383- ~endpoint
8484- ()
8585- in
8686-8787- Logs.info (fun m -> m "Connected to MQTT broker");
8888-8989- Mqtte_eio.Client.subscribe ~qos:`At_least_once [topic] client;
9090- Logs.info (fun m -> m "Subscribed to %s" topic);
9191-9292- Logs.info (fun m -> m "Listening for OwnTracks location updates...");
9393- Logs.info (fun m -> m "(Press Ctrl+C to exit)");
9494-9595- while Mqtte_eio.Client.is_connected client do
9696- Eio.Time.sleep clock 1.0
9797- done;
9898-9999- Mqtte_eio.Client.disconnect client;
100100- Logs.info (fun m -> m "Disconnected")
101101-102102-let term =
103103- Term.(const run $ Mqtte_eio.Cmd.term $ topic)
104104-105105-let cmd =
106106- let doc = "Subscribe to OwnTracks location messages over MQTT" in
107107- let man = [
108108- `S Manpage.s_description;
109109- `P "Connects to an MQTT broker and subscribes to OwnTracks location \
110110- messages, pretty-printing the received data.";
111111- `S Manpage.s_examples;
112112- `Pre " owntracks-subscriber -h broker.example.com -p 1883 -t 'owntracks/#'";
113113- `Pre " owntracks-subscriber -h secure.example.com --tls -u user";
114114- `Pre " MQTT_HOST=broker.example.com owntracks-subscriber";
115115- `S Manpage.s_bugs;
116116- `P "Report bugs at https://github.com/example/mqtt-eio/issues";
117117- ] in
118118- let info = Cmd.info "owntracks-subscriber" ~version:"0.1.0" ~doc ~man in
119119- Cmd.v info term
120120-121121-let () = exit (Cmd.eval cmd)
+13-3
dune-project
···991010(package
1111 (name owntracks)
1212- (synopsis "OwnTracks message types and JSON codecs")
1313- (description "Types and codecs for parsing OwnTracks MQTT location messages using jsont")
1212+ (synopsis "OwnTracks message types, JSON codecs, and MQTT client")
1313+ (description "Types and codecs for parsing OwnTracks MQTT location messages using jsont, with an MQTT client for subscribing to location updates")
1414 (depends
1515 (ocaml (>= 5.1))
1616- jsont))
1616+ jsont
1717+ geojson
1818+ requests
1919+ (mqtte (>= 0.1))
2020+ (eio (>= 1.0))
2121+ (eio_main (>= 1.0))
2222+ xdge
2323+ tomlt
2424+ (cmdliner (>= 1.2))
2525+ (logs (>= 0.7))
2626+ (fmt (>= 0.9))))
+108-5
lib/owntracks.ml
···86868787(** Location message codec. *)
8888let location_jsont : location Jsont.t =
8989- let make tid tst lat lon alt acc vel cog batt bs conn t m poi inregions addr topic =
8989+ let make _type tid tst lat lon alt acc vel cog batt bs conn t m poi inregions addr topic =
9090+ ignore _type;
9091 { tid; tst; lat; lon; alt; acc; vel; cog; batt; bs; conn; t; m; poi;
9192 inregions = Option.value ~default:[] inregions; addr; topic }
9293 in
9394 Jsont.Object.map ~kind:"location" make
9595+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "location")
9496 |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun l -> l.tid)
9597 |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.tst)
9698 |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun l -> l.lat)
···114116115117(** Transition message codec. *)
116118let transition_jsont : transition Jsont.t =
117117- let make tid tst lat lon acc event desc wtst =
119119+ let make _type tid tst lat lon acc event desc wtst =
120120+ ignore _type;
118121 { t_tid = tid; t_tst = tst; t_lat = lat; t_lon = lon; t_acc = acc;
119122 t_event = event; t_desc = desc; t_wtst = wtst }
120123 in
121124 Jsont.Object.map ~kind:"transition" make
125125+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "transition")
122126 |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun t -> t.t_tid)
123127 |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun t -> t.t_tst)
124128 |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun t -> t.t_lat)
···132136133137(** Waypoint message codec. *)
134138let waypoint_jsont : waypoint Jsont.t =
135135- let make tst lat lon rad desc =
139139+ let make _type tst lat lon rad desc =
140140+ ignore _type;
136141 { w_tst = tst; w_lat = lat; w_lon = lon; w_rad = rad; w_desc = desc }
137142 in
138143 Jsont.Object.map ~kind:"waypoint" make
144144+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "waypoint")
139145 |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun w -> w.w_tst)
140146 |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun w -> w.w_lat)
141147 |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun w -> w.w_lon)
···146152147153(** Card message codec. *)
148154let card_jsont : card Jsont.t =
149149- let make name face tid =
155155+ let make _type name face tid =
156156+ ignore _type;
150157 { c_name = name; c_face = face; c_tid = tid }
151158 in
152159 Jsont.Object.map ~kind:"card" make
160160+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "card")
153161 |> Jsont.Object.opt_mem "name" Jsont.string ~enc:(fun c -> c.c_name)
154162 |> Jsont.Object.opt_mem "face" Jsont.string ~enc:(fun c -> c.c_face)
155163 |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun c -> c.c_tid)
···158166159167(** LWT message codec. *)
160168let lwt_jsont : lwt Jsont.t =
161161- let make tst = { lwt_tst = tst } in
169169+ let make _type tst =
170170+ ignore _type;
171171+ { lwt_tst = tst }
172172+ in
162173 Jsont.Object.map ~kind:"lwt" make
174174+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "lwt")
163175 |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.lwt_tst)
164176 |> Jsont.Object.skip_unknown
165177 |> Jsont.Object.finish
···326338 (format_timestamp l.lwt_tst)
327339 | Unknown (typ, _) ->
328340 Format.fprintf ppf "Unknown message type: %s" typ
341341+342342+(** {1:mqtt MQTT Integration} *)
343343+344344+(** MQTT integration for OwnTracks messages.
345345+346346+ This module provides helpers for parsing MQTT messages into OwnTracks
347347+ types and constructing topic patterns for subscriptions. *)
348348+module Mqtt = struct
349349+350350+ (** {2:types Types} *)
351351+352352+ (** An MQTT message received from a broker. *)
353353+ type mqtt_message = {
354354+ topic : string;
355355+ payload : string;
356356+ qos : [ `At_most_once | `At_least_once | `Exactly_once ];
357357+ retain : bool;
358358+ }
359359+360360+ (** An OwnTracks message with its source topic and parsed user/device. *)
361361+ type t = {
362362+ topic : string;
363363+ user : string option;
364364+ device : string option;
365365+ message : message;
366366+ }
367367+368368+ (** {2:parsing Parsing} *)
369369+370370+ (** Parse an MQTT message into an OwnTracks message.
371371+372372+ This function:
373373+ - Extracts user/device from the topic if it follows OwnTracks conventions
374374+ - Injects the topic into the JSON payload for location messages
375375+ - Decodes the JSON payload into the appropriate OwnTracks message type
376376+377377+ Returns [Error] if the payload cannot be parsed as valid OwnTracks JSON. *)
378378+ let of_mqtt_message (msg : mqtt_message) : (t, string) result =
379379+ let user, device =
380380+ match parse_topic msg.topic with
381381+ | Some (u, d) -> (Some u, Some d)
382382+ | None -> (None, None)
383383+ in
384384+ let payload_with_topic =
385385+ let payload = msg.payload in
386386+ if String.length payload > 0 && payload.[0] = '{' then
387387+ let topic_json = Printf.sprintf "{\"topic\":%S," msg.topic in
388388+ topic_json ^ String.sub payload 1 (String.length payload - 1)
389389+ else
390390+ payload
391391+ in
392392+ match decode_message payload_with_topic with
393393+ | Ok message -> Ok { topic = msg.topic; user; device; message }
394394+ | Error e -> Error e
395395+396396+ (** Parse a raw MQTT message (topic + payload) into an OwnTracks message.
397397+398398+ Convenience function that creates an [mqtt_message] with default QoS
399399+ and retain settings. *)
400400+ let of_mqtt ~topic ~payload : (t, string) result =
401401+ of_mqtt_message { topic; payload; qos = `At_least_once; retain = false }
402402+403403+ (** {2:topics Topic Helpers} *)
404404+405405+ (** Default OwnTracks wildcard topic that matches all users and devices. *)
406406+ let default_topic = "owntracks/#"
407407+408408+ (** Create a topic pattern for a specific user's devices.
409409+410410+ Returns [owntracks/user/#] to match all devices for that user. *)
411411+ let user_topic user = Printf.sprintf "owntracks/%s/#" user
412412+413413+ (** Create a topic pattern for a specific user and device.
414414+415415+ Returns [owntracks/user/device] for exact matching. *)
416416+ let device_topic ~user ~device = Printf.sprintf "owntracks/%s/%s" user device
417417+418418+ (** {2:pretty_printing Pretty Printing} *)
419419+420420+ (** Pretty-print an OwnTracks MQTT message. *)
421421+ let pp ppf msg =
422422+ Format.fprintf ppf "@[<v 0>";
423423+ begin match msg.user, msg.device with
424424+ | Some user, Some device ->
425425+ Format.fprintf ppf "User: %s / Device: %s@," user device
426426+ | _ ->
427427+ Format.fprintf ppf "Topic: %s@," msg.topic
428428+ end;
429429+ pp_message ppf msg.message;
430430+ Format.fprintf ppf "@]"
431431+end
+12-2
owntracks.opam
···11# This file is generated by dune, edit dune-project instead
22opam-version: "2.0"
33-synopsis: "OwnTracks message types and JSON codecs"
33+synopsis: "OwnTracks message types, JSON codecs, and MQTT client"
44description:
55- "Types and codecs for parsing OwnTracks MQTT location messages using jsont"
55+ "Types and codecs for parsing OwnTracks MQTT location messages using jsont, with an MQTT client for subscribing to location updates"
66maintainer: ["anil@recoil.org"]
77authors: ["Anil Madhavapeddy"]
88license: "ISC"
···1010 "dune" {>= "3.20"}
1111 "ocaml" {>= "5.1"}
1212 "jsont"
1313+ "geojson"
1414+ "requests"
1515+ "mqtte" {>= "0.1"}
1616+ "eio" {>= "1.0"}
1717+ "eio_main" {>= "1.0"}
1818+ "xdge"
1919+ "tomlt"
2020+ "cmdliner" {>= "1.2"}
2121+ "logs" {>= "0.7"}
2222+ "fmt" {>= "0.9"}
1323 "odoc" {with-doc}
1424]
1525build: [