···300300 let doc = "List configured device name mappings" in
301301 Cmd.v (Cmd.info "devices" ~doc) Term.(const run $ const ())
302302303303-(** {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-416303(** {1 OwnTracks Recorder HTTP API} *)
417304418305module 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
306306+ (** HTTP client for the OwnTracks Recorder API.
466307467467- 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- | _ -> []
308308+ Uses the parsing functions from Owntracks.Recorder. *)
499309500310 let list_users ~sw env ~verbose_http ~recorder_url ?auth () : string list =
501311 let url = Printf.sprintf "%s/api/0/list" recorder_url in
···512322 let response = Requests.get ~headers session url in
513323 if Requests.Response.ok response then begin
514324 let body = Requests.Response.body response |> Eio.Flow.read_all in
515515- parse_string_list body
325325+ Owntracks.Recorder.parse_string_list body
516326 end else begin
517327 Format.eprintf "HTTP error: %d@." (Requests.Response.status_code response);
518328 []
···533343 let response = Requests.get ~headers session url in
534344 if Requests.Response.ok response then begin
535345 let body = Requests.Response.body response |> Eio.Flow.read_all in
536536- parse_string_list body
346346+ Owntracks.Recorder.parse_string_list body
537347 end else begin
538348 Format.eprintf "HTTP error: %d@." (Requests.Response.status_code response);
539349 []
···562372 let response = Requests.get ~headers session url in
563373 if Requests.Response.ok response then begin
564374 let body = Requests.Response.body response |> Eio.Flow.read_all in
565565- parse_locations_json body
375375+ Owntracks.Recorder.parse_locations_json body
566376 end else begin
567377 Format.eprintf "HTTP error: %d@." (Requests.Response.status_code response);
568378 []
···617427 1
618428 | locs ->
619429 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);
430430+ let json = Owntracks.Geojson_output.linestring_feature ~device_name locs in
431431+ print_endline (Owntracks.Geojson_output.to_string json);
622432 0)
623433 | None ->
624434 (* Use MQTT for real-time data *)
···655465 track_device_name := device_name;
656466 track_points := loc :: !track_points
657467 end else if Option.is_none !result then
658658- result := Some (Geojson_output.point_feature ~device_name loc)
468468+ result := Some (Owntracks.Geojson_output.point_feature ~device_name loc)
659469 end
660470 | _ -> ())
661471 | Error _ -> ()
···688498 Format.eprintf "No locations received within %.0f seconds@." duration;
689499 1
690500 | points ->
691691- let json = Geojson_output.linestring_feature ~device_name:!track_device_name points in
692692- print_endline (Geojson_output.to_string json);
501501+ let json = Owntracks.Geojson_output.linestring_feature ~device_name:!track_device_name points in
502502+ print_endline (Owntracks.Geojson_output.to_string json);
693503 0
694504 end else begin
695505 (* Single point mode: wait for first location then exit immediately *)
···700510 Mqtte_eio.Client.disconnect client;
701511 match !result with
702512 | Some json ->
703703- print_endline (Geojson_output.to_string json);
513513+ print_endline (Owntracks.Geojson_output.to_string json);
704514 0
705515 | None ->
706516 Format.eprintf "No location received within %.0f seconds@." duration;
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 The jsont programmers. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** GeoJSON codec using jsont.
77+88+ See {{:https://datatracker.ietf.org/doc/html/rfc7946}RFC 7946} for the
99+ GeoJSON specification. This module provides types and jsont codecs for
1010+ encoding and decoding GeoJSON data. *)
1111+1212+type float_array = float array
1313+1414+let float_array_jsont ~kind = Jsont.array ~kind Jsont.number
1515+1616+type 'a garray = 'a array
1717+1818+let garray = Jsont.array
1919+2020+(** Bounding box. *)
2121+module Bbox = struct
2222+ type t = float_array
2323+2424+ let jsont = float_array_jsont ~kind:"Bbox"
2525+end
2626+2727+(** Position coordinates [longitude, latitude] or [longitude, latitude, altitude]. *)
2828+module Position = struct
2929+ type t = float_array
3030+3131+ let jsont = float_array_jsont ~kind:"Position"
3232+3333+ let v ?altitude ~lng ~lat () =
3434+ match altitude with
3535+ | Some alt -> [| lng; lat; alt |]
3636+ | None -> [| lng; lat |]
3737+3838+ let lng t = t.(0)
3939+ let lat t = t.(1)
4040+ let altitude t = if Array.length t > 2 then Some t.(2) else None
4141+end
4242+4343+(** Internal wrapper for GeoJSON objects with bbox and unknown fields. *)
4444+module Geojson_object = struct
4545+ type 'a t = { type' : 'a; bbox : Bbox.t option; unknown : Jsont.json }
4646+4747+ let make type' bbox unknown = { type'; bbox; unknown }
4848+ let type' o = o.type'
4949+ let bbox o = o.bbox
5050+ let unknown o = o.unknown
5151+5252+ let finish_jsont map =
5353+ map
5454+ |> Jsont.Object.opt_mem "bbox" Bbox.jsont ~enc:bbox
5555+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
5656+ |> Jsont.Object.finish
5757+5858+ let geometry ~kind coordinates =
5959+ Jsont.Object.map ~kind make
6060+ |> Jsont.Object.mem "coordinates" coordinates ~enc:type'
6161+ |> finish_jsont
6262+end
6363+6464+(** Point geometry. *)
6565+module Point = struct
6666+ type t = Position.t
6767+6868+ let jsont = Geojson_object.geometry ~kind:"Point" Position.jsont
6969+ let v pos = Geojson_object.make pos None (Jsont.Json.object' [])
7070+end
7171+7272+(** MultiPoint geometry. *)
7373+module Multi_point = struct
7474+ type t = Position.t garray
7575+7676+ let jsont =
7777+ Geojson_object.geometry ~kind:"MultiPoint" (garray Position.jsont)
7878+end
7979+8080+(** LineString geometry. *)
8181+module Line_string = struct
8282+ type t = Position.t garray
8383+8484+ let jsont =
8585+ Geojson_object.geometry ~kind:"LineString" (garray Position.jsont)
8686+8787+ let v positions = Geojson_object.make positions None (Jsont.Json.object' [])
8888+end
8989+9090+(** MultiLineString geometry. *)
9191+module Multi_line_string = struct
9292+ type t = Line_string.t garray
9393+9494+ let jsont =
9595+ Geojson_object.geometry ~kind:"LineString" (garray (garray Position.jsont))
9696+end
9797+9898+(** Polygon geometry. *)
9999+module Polygon = struct
100100+ type t = Line_string.t garray
101101+102102+ let jsont =
103103+ Geojson_object.geometry ~kind:"Polygon" (garray (garray Position.jsont))
104104+end
105105+106106+(** MultiPolygon geometry. *)
107107+module Multi_polygon = struct
108108+ type t = Polygon.t garray
109109+110110+ let jsont =
111111+ Geojson_object.geometry ~kind:"MultiPolygon"
112112+ (garray (garray (garray Position.jsont)))
113113+end
114114+115115+(** Main GeoJSON module with all geometry types, features, and collections. *)
116116+module Geojson = struct
117117+ type 'a object' = 'a Geojson_object.t
118118+119119+ type geometry =
120120+ [ `Point of Point.t object'
121121+ | `Multi_point of Multi_point.t object'
122122+ | `Line_string of Line_string.t object'
123123+ | `Multi_line_string of Multi_line_string.t object'
124124+ | `Polygon of Polygon.t object'
125125+ | `Multi_polygon of Multi_polygon.t object'
126126+ | `Geometry_collection of geometry_collection object' ]
127127+128128+ and geometry_collection = geometry list
129129+130130+ (** Feature with optional id, geometry, and properties. *)
131131+ module Feature = struct
132132+ type id = [ `Number of float | `String of string ]
133133+134134+ type t = {
135135+ id : id option;
136136+ geometry : geometry option;
137137+ properties : Jsont.json option;
138138+ }
139139+140140+ let make id geometry properties = { id; geometry; properties }
141141+142142+ let make_geojson_object id geometry properties =
143143+ Geojson_object.make (make id geometry properties)
144144+145145+ let id f = f.id
146146+ let geometry f = f.geometry
147147+ let properties f = f.properties
148148+149149+ type collection = t object' list
150150+151151+ let v ?properties geometry =
152152+ let obj = { id = None; geometry = Some geometry; properties } in
153153+ Geojson_object.make obj None (Jsont.Json.object' [])
154154+ end
155155+156156+ type t =
157157+ [ `Feature of Feature.t object'
158158+ | `Feature_collection of Feature.collection object'
159159+ | geometry ]
160160+161161+ let point v = `Point v
162162+ let multi_point v = `Multi_point v
163163+ let line_string v = `Line_string v
164164+ let multi_line_string v = `Multi_line_string v
165165+ let polygon v = `Polygon v
166166+ let multi_polygon v = `Multi_polygon v
167167+ let geometry_collection vs = `Geometry_collection vs
168168+ let feature v = `Feature v
169169+ let feature_collection vs = `Feature_collection vs
170170+171171+ let feature_id_jsont =
172172+ let number =
173173+ let dec = Jsont.Base.dec (fun n -> `Number n) in
174174+ let enc = Jsont.Base.enc (function `Number n -> n | _ -> assert false) in
175175+ Jsont.Base.number (Jsont.Base.map ~enc ~dec ())
176176+ in
177177+ let string =
178178+ let dec = Jsont.Base.dec (fun n -> `String n) in
179179+ let enc = Jsont.Base.enc (function `String n -> n | _ -> assert false) in
180180+ Jsont.Base.string (Jsont.Base.map ~enc ~dec ())
181181+ in
182182+ let enc = function `Number _ -> number | `String _ -> string in
183183+ Jsont.any ~kind:"id" ~dec_number:number ~dec_string:string ~enc ()
184184+185185+ let case_map obj dec = Jsont.Object.Case.map (Jsont.kind obj) obj ~dec
186186+187187+ let rec geometry_jsont =
188188+ lazy
189189+ begin
190190+ let case_point = case_map Point.jsont point in
191191+ let case_multi_point = case_map Multi_point.jsont multi_point in
192192+ let case_line_string = case_map Line_string.jsont line_string in
193193+ let case_multi_line_string =
194194+ case_map Multi_line_string.jsont multi_line_string
195195+ in
196196+ let case_polygon = case_map Polygon.jsont polygon in
197197+ let case_multi_polygon = case_map Multi_polygon.jsont multi_polygon in
198198+ let case_geometry_collection =
199199+ case_map (Lazy.force geometry_collection_jsont) geometry_collection
200200+ in
201201+ let enc_case = function
202202+ | `Point v -> Jsont.Object.Case.value case_point v
203203+ | `Multi_point v -> Jsont.Object.Case.value case_multi_point v
204204+ | `Line_string v -> Jsont.Object.Case.value case_line_string v
205205+ | `Multi_line_string v ->
206206+ Jsont.Object.Case.value case_multi_line_string v
207207+ | `Polygon v -> Jsont.Object.Case.value case_polygon v
208208+ | `Multi_polygon v -> Jsont.Object.Case.value case_multi_polygon v
209209+ | `Geometry_collection v ->
210210+ Jsont.Object.Case.value case_geometry_collection v
211211+ in
212212+ let cases =
213213+ Jsont.Object.Case.
214214+ [
215215+ make case_point;
216216+ make case_multi_point;
217217+ make case_line_string;
218218+ make case_multi_line_string;
219219+ make case_polygon;
220220+ make case_multi_polygon;
221221+ make case_geometry_collection;
222222+ ]
223223+ in
224224+ Jsont.Object.map ~kind:"Geometry object" Fun.id
225225+ |> Jsont.Object.case_mem "type" Jsont.string ~enc:Fun.id ~enc_case cases
226226+ ~tag_to_string:Fun.id ~tag_compare:String.compare
227227+ |> Jsont.Object.finish
228228+ end
229229+230230+ and feature_jsont : Feature.t object' Jsont.t Lazy.t =
231231+ lazy
232232+ begin
233233+ let case_feature = case_map (Lazy.force case_feature_jsont) Fun.id in
234234+ let enc_case v = Jsont.Object.Case.value case_feature v in
235235+ let cases = Jsont.Object.Case.[ make case_feature ] in
236236+ Jsont.Object.map ~kind:"Feature" Fun.id
237237+ |> Jsont.Object.case_mem "type" Jsont.string ~enc:Fun.id ~enc_case cases
238238+ ~tag_to_string:Fun.id ~tag_compare:String.compare
239239+ |> Jsont.Object.finish
240240+ end
241241+242242+ and case_feature_jsont : Feature.t object' Jsont.t Lazy.t =
243243+ lazy
244244+ begin
245245+ Jsont.Object.map ~kind:"Feature" Feature.make_geojson_object
246246+ |> Jsont.Object.opt_mem "id" feature_id_jsont ~enc:(fun o ->
247247+ Feature.id (Geojson_object.type' o))
248248+ |> Jsont.Object.mem "geometry"
249249+ (Jsont.option (Jsont.rec' geometry_jsont))
250250+ ~enc:(fun o -> Feature.geometry (Geojson_object.type' o))
251251+ |> Jsont.Object.mem "properties" (Jsont.option Jsont.json_object)
252252+ ~enc:(fun o -> Feature.properties (Geojson_object.type' o))
253253+ |> Geojson_object.finish_jsont
254254+ end
255255+256256+ and geometry_collection_jsont =
257257+ lazy
258258+ begin
259259+ Jsont.Object.map ~kind:"GeometryCollection" Geojson_object.make
260260+ |> Jsont.Object.mem "geometries"
261261+ (Jsont.list (Jsont.rec' geometry_jsont))
262262+ ~enc:Geojson_object.type'
263263+ |> Geojson_object.finish_jsont
264264+ end
265265+266266+ and feature_collection_json =
267267+ lazy
268268+ begin
269269+ Jsont.Object.map ~kind:"FeatureCollection" Geojson_object.make
270270+ |> Jsont.Object.mem "features"
271271+ Jsont.(list (Jsont.rec' feature_jsont))
272272+ ~enc:Geojson_object.type'
273273+ |> Geojson_object.finish_jsont
274274+ end
275275+276276+ and jsont : t Jsont.t Lazy.t =
277277+ lazy
278278+ begin
279279+ let case_point = case_map Point.jsont point in
280280+ let case_multi_point = case_map Multi_point.jsont multi_point in
281281+ let case_line_string = case_map Line_string.jsont line_string in
282282+ let case_multi_line_string =
283283+ case_map Multi_line_string.jsont multi_line_string
284284+ in
285285+ let case_polygon = case_map Polygon.jsont polygon in
286286+ let case_multi_polygon = case_map Multi_polygon.jsont multi_polygon in
287287+ let case_geometry_collection =
288288+ case_map (Lazy.force geometry_collection_jsont) geometry_collection
289289+ in
290290+ let case_feature =
291291+ case_map (Lazy.force case_feature_jsont) feature
292292+ in
293293+ let case_feature_collection =
294294+ case_map (Lazy.force feature_collection_json) feature_collection
295295+ in
296296+ let enc_case = function
297297+ | `Point v -> Jsont.Object.Case.value case_point v
298298+ | `Multi_point v -> Jsont.Object.Case.value case_multi_point v
299299+ | `Line_string v -> Jsont.Object.Case.value case_line_string v
300300+ | `Multi_line_string v ->
301301+ Jsont.Object.Case.value case_multi_line_string v
302302+ | `Polygon v -> Jsont.Object.Case.value case_polygon v
303303+ | `Multi_polygon v -> Jsont.Object.Case.value case_multi_polygon v
304304+ | `Geometry_collection v ->
305305+ Jsont.Object.Case.value case_geometry_collection v
306306+ | `Feature v -> Jsont.Object.Case.value case_feature v
307307+ | `Feature_collection v ->
308308+ Jsont.Object.Case.value case_feature_collection v
309309+ in
310310+ let cases =
311311+ Jsont.Object.Case.
312312+ [
313313+ make case_point;
314314+ make case_multi_point;
315315+ make case_line_string;
316316+ make case_multi_line_string;
317317+ make case_polygon;
318318+ make case_multi_polygon;
319319+ make case_geometry_collection;
320320+ make case_feature;
321321+ make case_feature_collection;
322322+ ]
323323+ in
324324+ Jsont.Object.map ~kind:"GeoJSON" Fun.id
325325+ |> Jsont.Object.case_mem "type" Jsont.string ~enc:Fun.id ~enc_case cases
326326+ ~tag_to_string:Fun.id ~tag_compare:String.compare
327327+ |> Jsont.Object.finish
328328+ end
329329+330330+ let jsont = Lazy.force jsont
331331+332332+ let to_string t =
333333+ match Jsont_bytesrw.encode_string jsont t with
334334+ | Ok s -> s
335335+ | Error e -> failwith e
336336+end
337337+338338+(** Convenience module for working with GeoJSON geometry types. *)
339339+module Geometry = struct
340340+ module Position = Position
341341+ module Point = Point
342342+ module LineString = Line_string
343343+end
+278
lib/geojson.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 The jsont programmers. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** GeoJSON codec using jsont.
77+88+ This module provides types and {{:https://erratique.ch/software/jsont}jsont}
99+ codecs for encoding and decoding GeoJSON data according to
1010+ {{:https://datatracker.ietf.org/doc/html/rfc7946}RFC 7946}.
1111+1212+ {1:overview Overview}
1313+1414+ GeoJSON is a format for encoding geographic data structures. It supports
1515+ the following geometry types:
1616+ - {!Point} - a single position
1717+ - {!Line_string} - an array of positions forming a line
1818+ - {!Polygon} - an array of linear rings (closed line strings)
1919+ - {!Multi_point}, {!Multi_line_string}, {!Multi_polygon} - collections
2020+ - Geometry collections
2121+2222+ These geometries can be wrapped in {!Geojson.Feature} objects with
2323+ associated properties, or grouped into feature collections.
2424+2525+ {1:example Example}
2626+2727+ Creating a simple point feature:
2828+ {[
2929+ let pos = Position.v ~lng:(-122.4194) ~lat:37.7749 () in
3030+ let point = Point.v pos in
3131+ let feature = Geojson.Feature.v (`Point point) in
3232+ let geojson = `Feature feature in
3333+ Geojson.to_string geojson
3434+ ]} *)
3535+3636+(** {1:primitives Primitive Types} *)
3737+3838+(** Bounding box as an array of coordinates.
3939+4040+ A bounding box is represented as an array of 4 or 6 numbers:
4141+ - 2D: [\[west, south, east, north\]]
4242+ - 3D: [\[west, south, min-altitude, east, north, max-altitude\]] *)
4343+module Bbox : sig
4444+ type t = float array
4545+ (** The type for bounding boxes. *)
4646+4747+ val jsont : t Jsont.t
4848+ (** [jsont] is a JSON codec for bounding boxes. *)
4949+end
5050+5151+(** Geographic position coordinates.
5252+5353+ Positions are represented as arrays of 2 or 3 numbers:
5454+ - 2D: [\[longitude, latitude\]]
5555+ - 3D: [\[longitude, latitude, altitude\]]
5656+5757+ Note that the order is longitude first, then latitude, following the
5858+ GeoJSON specification (which differs from the common lat/lon convention). *)
5959+module Position : sig
6060+ type t = float array
6161+ (** The type for positions. *)
6262+6363+ val jsont : t Jsont.t
6464+ (** [jsont] is a JSON codec for positions. *)
6565+6666+ val v : ?altitude:float -> lng:float -> lat:float -> unit -> t
6767+ (** [v ~lng ~lat ()] creates a 2D position.
6868+ [v ~altitude ~lng ~lat ()] creates a 3D position with altitude. *)
6969+7070+ val lng : t -> float
7171+ (** [lng t] returns the longitude (first element). *)
7272+7373+ val lat : t -> float
7474+ (** [lat t] returns the latitude (second element). *)
7575+7676+ val altitude : t -> float option
7777+ (** [altitude t] returns the altitude if present (third element). *)
7878+end
7979+8080+(** {1:internal Internal Types} *)
8181+8282+(** Internal wrapper for GeoJSON objects with optional bounding box.
8383+8484+ All GeoJSON objects can have an optional bounding box and may contain
8585+ unknown JSON members that are preserved during round-tripping. *)
8686+module Geojson_object : sig
8787+ type 'a t
8888+ (** The type for GeoJSON objects wrapping a value of type ['a]. *)
8989+9090+ val type' : 'a t -> 'a
9191+ (** [type' o] returns the wrapped value (the geometry coordinates or
9292+ feature data). *)
9393+9494+ val bbox : 'a t -> Bbox.t option
9595+ (** [bbox o] returns the optional bounding box. *)
9696+end
9797+9898+(** {1:geometry Geometry Types} *)
9999+100100+(** Point geometry - a single position.
101101+102102+ A Point represents a single location in coordinate space. *)
103103+module Point : sig
104104+ type t = Position.t
105105+ (** The type for point coordinates. *)
106106+107107+ val jsont : t Geojson_object.t Jsont.t
108108+ (** [jsont] is a JSON codec for Point geometry objects. *)
109109+110110+ val v : Position.t -> t Geojson_object.t
111111+ (** [v pos] creates a Point geometry from a position. *)
112112+end
113113+114114+(** MultiPoint geometry - a collection of positions.
115115+116116+ A MultiPoint represents multiple locations. *)
117117+module Multi_point : sig
118118+ type t = Position.t array
119119+ (** The type for multipoint coordinates. *)
120120+121121+ val jsont : t Geojson_object.t Jsont.t
122122+ (** [jsont] is a JSON codec for MultiPoint geometry objects. *)
123123+end
124124+125125+(** LineString geometry - an ordered sequence of positions forming a line.
126126+127127+ A LineString must have at least two positions. It represents a path
128128+ through coordinate space. *)
129129+module Line_string : sig
130130+ type t = Position.t array
131131+ (** The type for linestring coordinates. *)
132132+133133+ val jsont : t Geojson_object.t Jsont.t
134134+ (** [jsont] is a JSON codec for LineString geometry objects. *)
135135+136136+ val v : Position.t array -> t Geojson_object.t
137137+ (** [v positions] creates a LineString geometry from an array of positions. *)
138138+end
139139+140140+(** MultiLineString geometry - a collection of line strings.
141141+142142+ Represents multiple paths or lines. *)
143143+module Multi_line_string : sig
144144+ type t = Line_string.t array
145145+ (** The type for multilinestring coordinates. *)
146146+147147+ val jsont : t Geojson_object.t Jsont.t
148148+ (** [jsont] is a JSON codec for MultiLineString geometry objects. *)
149149+end
150150+151151+(** Polygon geometry - an area bounded by linear rings.
152152+153153+ A polygon is represented as an array of linear rings. The first ring
154154+ is the exterior boundary; subsequent rings are holes. Each ring must
155155+ have at least four positions, with the first and last being identical
156156+ (a closed ring). *)
157157+module Polygon : sig
158158+ type t = Line_string.t array
159159+ (** The type for polygon coordinates (array of linear rings). *)
160160+161161+ val jsont : t Geojson_object.t Jsont.t
162162+ (** [jsont] is a JSON codec for Polygon geometry objects. *)
163163+end
164164+165165+(** MultiPolygon geometry - a collection of polygons.
166166+167167+ Represents multiple areas, possibly with holes. *)
168168+module Multi_polygon : sig
169169+ type t = Polygon.t array
170170+ (** The type for multipolygon coordinates. *)
171171+172172+ val jsont : t Geojson_object.t Jsont.t
173173+ (** [jsont] is a JSON codec for MultiPolygon geometry objects. *)
174174+end
175175+176176+(** {1:geojson Main GeoJSON Module} *)
177177+178178+(** Main GeoJSON types including features and geometry collections.
179179+180180+ This module provides the complete GeoJSON type hierarchy as defined
181181+ in {{:https://datatracker.ietf.org/doc/html/rfc7946}RFC 7946}. *)
182182+module Geojson : sig
183183+184184+ (** {1:types Type Definitions} *)
185185+186186+ type 'a object' = 'a Geojson_object.t
187187+ (** Alias for GeoJSON object wrapper. *)
188188+189189+ (** All geometry types as a polymorphic variant. *)
190190+ type geometry =
191191+ [ `Point of Point.t object'
192192+ | `Multi_point of Multi_point.t object'
193193+ | `Line_string of Line_string.t object'
194194+ | `Multi_line_string of Multi_line_string.t object'
195195+ | `Polygon of Polygon.t object'
196196+ | `Multi_polygon of Multi_polygon.t object'
197197+ | `Geometry_collection of geometry_collection object' ]
198198+199199+ and geometry_collection = geometry list
200200+ (** A collection of geometry objects. *)
201201+202202+ (** {1:features Features}
203203+204204+ A Feature object represents a spatially bounded entity with
205205+ associated properties. *)
206206+ module Feature : sig
207207+208208+ type id = [ `Number of float | `String of string ]
209209+ (** Feature identifiers can be either numbers or strings. *)
210210+211211+ type t
212212+ (** The type for feature data (id, geometry, properties). *)
213213+214214+ val id : t -> id option
215215+ (** [id f] returns the optional feature identifier. *)
216216+217217+ val geometry : t -> geometry option
218218+ (** [geometry f] returns the optional geometry. A feature may have
219219+ null geometry. *)
220220+221221+ val properties : t -> Jsont.json option
222222+ (** [properties f] returns the optional properties JSON object.
223223+ Properties can be any JSON object. *)
224224+225225+ type collection = t object' list
226226+ (** A FeatureCollection is a list of features. *)
227227+228228+ val v : ?properties:Jsont.json -> geometry -> t object'
229229+ (** [v ?properties geometry] creates a Feature with the given geometry
230230+ and optional properties JSON object. *)
231231+ end
232232+233233+ (** {1:toplevel Top-level GeoJSON Type} *)
234234+235235+ type t =
236236+ [ `Feature of Feature.t object'
237237+ | `Feature_collection of Feature.collection object'
238238+ | geometry ]
239239+ (** The type for any GeoJSON object. A valid GeoJSON document can be:
240240+ - A {!Feature} with geometry and properties
241241+ - A FeatureCollection containing multiple features
242242+ - Any geometry type directly *)
243243+244244+ (** {1:constructors Constructors}
245245+246246+ These constructors wrap geometry objects in the appropriate variant. *)
247247+248248+ val point : Point.t object' -> [> `Point of Point.t object' ]
249249+ val multi_point : Multi_point.t object' -> [> `Multi_point of Multi_point.t object' ]
250250+ val line_string : Line_string.t object' -> [> `Line_string of Line_string.t object' ]
251251+ val multi_line_string : Multi_line_string.t object' -> [> `Multi_line_string of Multi_line_string.t object' ]
252252+ val polygon : Polygon.t object' -> [> `Polygon of Polygon.t object' ]
253253+ val multi_polygon : Multi_polygon.t object' -> [> `Multi_polygon of Multi_polygon.t object' ]
254254+ val geometry_collection : geometry_collection object' -> [> `Geometry_collection of geometry_collection object' ]
255255+ val feature : Feature.t object' -> [> `Feature of Feature.t object' ]
256256+ val feature_collection : Feature.collection object' -> [> `Feature_collection of Feature.collection object' ]
257257+258258+ (** {1:codec Encoding and Decoding} *)
259259+260260+ val jsont : t Jsont.t
261261+ (** [jsont] is a JSON codec for GeoJSON objects. Handles all GeoJSON
262262+ types including features, feature collections, and all geometry types. *)
263263+264264+ val to_string : t -> string
265265+ (** [to_string t] encodes [t] as a minified JSON string.
266266+ @raise Failure if encoding fails. *)
267267+end
268268+269269+(** {1:convenience Convenience Modules} *)
270270+271271+(** Convenience aliases for common geometry operations.
272272+273273+ Provides shorter names for frequently used modules. *)
274274+module Geometry : sig
275275+ module Position = Position
276276+ module Point = Point
277277+ module LineString = Line_string
278278+end
+168
lib/owntracks.ml
···429429 pp_message ppf msg.message;
430430 Format.fprintf ppf "@]"
431431end
432432+433433+(** {1:recorder OwnTracks Recorder HTTP API} *)
434434+435435+(** Query the OwnTracks Recorder HTTP API for historical locations.
436436+437437+ The OwnTracks Recorder provides an HTTP API for querying historical
438438+ location data. This module provides functions to list users, list
439439+ devices for a user, and fetch historical locations.
440440+441441+ API endpoints:
442442+ - [GET /api/0/list] - List all users
443443+ - [GET /api/0/list?user=USER] - List devices for a user
444444+ - [GET /api/0/locations?user=USER&device=DEVICE&from=YYYY-MM-DD&to=YYYY-MM-DD] - Fetch locations *)
445445+module Recorder = struct
446446+447447+ (** {2:types Types} *)
448448+449449+ (** Authentication credentials for HTTP Basic Auth. *)
450450+ type auth = { username : string; password : string }
451451+452452+ (** {2:parsing JSON Parsing} *)
453453+454454+ (** Parse a location from a JSON object. *)
455455+ let location_of_json (json : Jsont.json) : location option =
456456+ let get_float key =
457457+ match json with
458458+ | Jsont.Object (mems, _) ->
459459+ List.find_map (fun ((k, _), v) ->
460460+ if k = key then
461461+ match v with
462462+ | Jsont.Number (f, _) -> Some f
463463+ | _ -> None
464464+ else None) mems
465465+ | _ -> None
466466+ in
467467+ let get_int key = Option.map int_of_float (get_float key) in
468468+ let get_string key =
469469+ match json with
470470+ | Jsont.Object (mems, _) ->
471471+ List.find_map (fun ((k, _), v) ->
472472+ if k = key then
473473+ match v with
474474+ | Jsont.String (s, _) -> Some s
475475+ | _ -> None
476476+ else None) mems
477477+ | _ -> None
478478+ in
479479+ match (get_float "lat", get_float "lon", get_int "tst") with
480480+ | (Some lat, Some lon, Some tst) ->
481481+ Some {
482482+ lat; lon; tst;
483483+ tid = get_string "tid";
484484+ alt = get_float "alt";
485485+ acc = get_float "acc";
486486+ vel = get_float "vel";
487487+ cog = get_float "cog";
488488+ batt = get_int "batt";
489489+ bs = get_int "bs";
490490+ conn = get_string "conn";
491491+ t = get_string "t";
492492+ m = get_int "m";
493493+ poi = get_string "poi";
494494+ inregions = [];
495495+ addr = get_string "addr";
496496+ topic = get_string "topic";
497497+ }
498498+ | _ -> None
499499+500500+ (** Parse a list of locations from a JSON string.
501501+502502+ Handles both array format and object with "data" key. *)
503503+ let parse_locations_json json_str : location list =
504504+ match Jsont_bytesrw.decode_string Jsont.json json_str with
505505+ | Error _ -> []
506506+ | Ok json ->
507507+ match json with
508508+ | Jsont.Array (items, _) ->
509509+ List.filter_map location_of_json items
510510+ | Jsont.Object (mems, _) ->
511511+ (* Sometimes the API returns { "data": [...] } *)
512512+ (match List.find_opt (fun ((k, _), _) -> k = "data") mems with
513513+ | Some (_, Jsont.Array (items, _)) -> List.filter_map location_of_json items
514514+ | _ -> [])
515515+ | _ -> []
516516+517517+ (** Parse a list of strings from a JSON response.
518518+519519+ Handles both array format and object with "results" key. *)
520520+ let parse_string_list json_str : string list =
521521+ match Jsont_bytesrw.decode_string Jsont.json json_str with
522522+ | Error _ -> []
523523+ | Ok json ->
524524+ match json with
525525+ | Jsont.Object (mems, _) ->
526526+ (* API returns { "results": ["user1", "user2", ...] } *)
527527+ (match List.find_opt (fun ((k, _), _) -> k = "results") mems with
528528+ | Some (_, Jsont.Array (items, _)) ->
529529+ List.filter_map (function
530530+ | Jsont.String (s, _) -> Some s
531531+ | _ -> None) items
532532+ | _ -> [])
533533+ | Jsont.Array (items, _) ->
534534+ List.filter_map (function
535535+ | Jsont.String (s, _) -> Some s
536536+ | _ -> None) items
537537+ | _ -> []
538538+end
539539+540540+(** {1:geojson_output GeoJSON Output} *)
541541+542542+(** Convert OwnTracks locations to GeoJSON format.
543543+544544+ This module provides functions to convert location data into GeoJSON
545545+ Point and LineString features for use in mapping applications. *)
546546+module Geojson_output = struct
547547+ open Geojson
548548+549549+ (** Convert a location to a GeoJSON position. *)
550550+ let pos_of_loc (loc : location) =
551551+ Geometry.Position.v ?altitude:loc.alt ~lng:loc.lon ~lat:loc.lat ()
552552+553553+ (** Create GeoJSON properties object for a location. *)
554554+ let props ~device_name ~timestamp ~time ?accuracy ?speed ?battery ?tracker_id () =
555555+ let open Jsont.Json in
556556+ let add n f opt acc = match opt with Some v -> (n, f v) :: acc | None -> acc in
557557+ [
558558+ ("name", string device_name);
559559+ ("timestamp", int timestamp);
560560+ ("time", string time)
561561+ ]
562562+ |> add "accuracy" number accuracy
563563+ |> add "speed" number speed
564564+ |> add "battery" int battery
565565+ |> add "tracker_id" string tracker_id
566566+ |> fun mems -> Jsont.Json.object' (List.map (fun (n, v) -> Jsont.Json.mem (Jsont.Json.name n) v) mems)
567567+568568+ (** Convert a location to a GeoJSON Feature with Point geometry. *)
569569+ let point_feature ~device_name (loc : location) : Geojson.t =
570570+ let point = Geometry.Point.v (pos_of_loc loc) in
571571+ let geom : Geojson.geometry = `Point point in
572572+ let properties = Some (props ~device_name ~timestamp:loc.tst
573573+ ~time:(format_timestamp loc.tst)
574574+ ?accuracy:loc.acc ?speed:loc.vel ?battery:loc.batt ?tracker_id:loc.tid ()) in
575575+ let feature = Geojson.Feature.v ?properties geom in
576576+ `Feature feature
577577+578578+ (** Convert a list of locations to a GeoJSON Feature with LineString geometry.
579579+580580+ Locations are sorted by timestamp before creating the linestring. *)
581581+ let linestring_feature ~device_name (locs : location list) : Geojson.t =
582582+ let sorted = List.sort (fun a b -> Int.compare a.tst b.tst) locs in
583583+ let positions = Array.of_list (List.map pos_of_loc sorted) in
584584+ let line = Geometry.LineString.v positions in
585585+ let geom : Geojson.geometry = `Line_string line in
586586+ let start_time = match sorted with [] -> 0 | h :: _ -> h.tst in
587587+ let end_time = match List.rev sorted with [] -> 0 | h :: _ -> h.tst in
588588+ let properties = Some (Jsont.Json.object' [
589589+ Jsont.Json.mem (Jsont.Json.name "name") (Jsont.Json.string device_name);
590590+ Jsont.Json.mem (Jsont.Json.name "points") (Jsont.Json.int (List.length sorted));
591591+ Jsont.Json.mem (Jsont.Json.name "start_time") (Jsont.Json.string (format_timestamp start_time));
592592+ Jsont.Json.mem (Jsont.Json.name "end_time") (Jsont.Json.string (format_timestamp end_time));
593593+ ]) in
594594+ let feature = Geojson.Feature.v ?properties geom in
595595+ `Feature feature
596596+597597+ (** Encode a GeoJSON value to a JSON string. *)
598598+ let to_string = Geojson.to_string
599599+end
+452
lib/owntracks.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** OwnTracks message types and JSON codecs.
77+88+ This module provides types and {{:https://erratique.ch/software/jsont}jsont}
99+ codecs for parsing {{:https://owntracks.org/}OwnTracks} MQTT messages.
1010+ OwnTracks is an open-source location tracking application that publishes
1111+ location data over MQTT.
1212+1313+ {1:overview Overview}
1414+1515+ OwnTracks publishes several message types:
1616+ - {!location} - GPS coordinates, accuracy, speed, battery, etc.
1717+ - {!transition} - Region entry/exit events
1818+ - {!waypoint} - Monitored region definitions
1919+ - {!card} - User information for display
2020+ - {!lwt} - Last Will and Testament (disconnect notification)
2121+2222+ Messages are published to MQTT topics in the format [owntracks/user/device].
2323+2424+ {1:example Example}
2525+2626+ Decoding a location message:
2727+ {[
2828+ let json = {|{"_type":"location","lat":51.5,"lon":-0.1,"tst":1234567890}|} in
2929+ match Owntracks.decode_message json with
3030+ | Ok (Location loc) ->
3131+ Printf.printf "Location: %.4f, %.4f\n" loc.lat loc.lon
3232+ | Ok _ -> print_endline "Other message type"
3333+ | Error e -> Printf.printf "Error: %s\n" e
3434+ ]}
3535+3636+ See {{:https://owntracks.org/booklet/tech/json/}OwnTracks JSON format}
3737+ for the complete specification. *)
3838+3939+(** {1:types Message Types} *)
4040+4141+(** Location message - the primary OwnTracks message type.
4242+4343+ Published when the device reports its location. Contains GPS coordinates,
4444+ accuracy, altitude, speed, heading, and various device state information.
4545+4646+ Required fields are [lat], [lon], and [tst]. All other fields are optional
4747+ and may not be present depending on device capabilities and settings. *)
4848+type location = {
4949+ tid : string option;
5050+ (** Tracker ID - a short identifier (typically 2 characters) configured
5151+ in the app. Used to identify the device in a compact way. *)
5252+5353+ tst : int;
5454+ (** Timestamp as Unix epoch (seconds since 1970-01-01 00:00:00 UTC).
5555+ This is when the location was recorded by the device. *)
5656+5757+ lat : float;
5858+ (** Latitude in decimal degrees. Range: -90 to +90. *)
5959+6060+ lon : float;
6161+ (** Longitude in decimal degrees. Range: -180 to +180. *)
6262+6363+ alt : float option;
6464+ (** Altitude above sea level in meters. May be negative for locations
6565+ below sea level. *)
6666+6767+ acc : float option;
6868+ (** Horizontal accuracy (radius) in meters. Indicates the confidence
6969+ interval for the reported position. *)
7070+7171+ vel : float option;
7272+ (** Velocity (speed) in km/h. Only present when the device is moving. *)
7373+7474+ cog : float option;
7575+ (** Course over ground (heading) in degrees from true north (0-360).
7676+ Indicates the direction of travel. *)
7777+7878+ batt : int option;
7979+ (** Battery level as percentage (0-100). *)
8080+8181+ bs : int option;
8282+ (** Battery status:
8383+ - [0] = unknown
8484+ - [1] = unplugged
8585+ - [2] = charging
8686+ - [3] = full *)
8787+8888+ conn : string option;
8989+ (** Connection type:
9090+ - ["w"] = WiFi
9191+ - ["m"] = Mobile/cellular
9292+ - ["o"] = Offline *)
9393+9494+ t : string option;
9595+ (** Trigger - what caused this location report:
9696+ - ["p"] = Ping (response to request)
9797+ - ["c"] = Circular region event
9898+ - ["b"] = Beacon event
9999+ - ["r"] = Response to reportLocation
100100+ - ["u"] = Manual/user-initiated
101101+ - ["t"] = Timer-based
102102+ - ["v"] = Monitoring mode change *)
103103+104104+ m : int option;
105105+ (** Monitoring mode:
106106+ - [0] = Quiet (no location reporting)
107107+ - [1] = Manual (only when requested)
108108+ - [2] = Significant changes only
109109+ - [3] = Move mode (frequent updates) *)
110110+111111+ poi : string option;
112112+ (** Point of Interest - name of a waypoint if the device is currently
113113+ at a defined location. *)
114114+115115+ inregions : string list;
116116+ (** List of region names the device is currently inside. May be empty
117117+ if not inside any monitored regions. *)
118118+119119+ addr : string option;
120120+ (** Reverse-geocoded address. This is typically added by the OwnTracks
121121+ Recorder server, not the device itself. *)
122122+123123+ topic : string option;
124124+ (** MQTT topic this message was published to. Added during parsing,
125125+ not present in the original JSON. *)
126126+}
127127+128128+(** Transition event - published when entering or leaving a monitored region.
129129+130130+ Transitions are triggered by geofences (circular regions) or beacons
131131+ configured in the OwnTracks app. *)
132132+type transition = {
133133+ t_tid : string option;
134134+ (** Tracker ID of the device. *)
135135+136136+ t_tst : int;
137137+ (** Timestamp when the transition occurred. *)
138138+139139+ t_lat : float;
140140+ (** Latitude where the transition was detected. *)
141141+142142+ t_lon : float;
143143+ (** Longitude where the transition was detected. *)
144144+145145+ t_acc : float option;
146146+ (** Accuracy of the position in meters. *)
147147+148148+ t_event : string;
149149+ (** Event type: ["enter"] when entering a region, ["leave"] when leaving. *)
150150+151151+ t_desc : string option;
152152+ (** Description/name of the region. *)
153153+154154+ t_wtst : int option;
155155+ (** Timestamp of the waypoint definition that triggered this transition. *)
156156+}
157157+158158+(** Waypoint definition - describes a monitored circular region.
159159+160160+ Waypoints define geofences that trigger {!transition} events when
161161+ the device enters or leaves them. *)
162162+type waypoint = {
163163+ w_tst : int;
164164+ (** Timestamp when the waypoint was created or last modified. *)
165165+166166+ w_lat : float;
167167+ (** Latitude of the region center. *)
168168+169169+ w_lon : float;
170170+ (** Longitude of the region center. *)
171171+172172+ w_rad : int;
173173+ (** Radius of the circular region in meters. *)
174174+175175+ w_desc : string;
176176+ (** Description/name of the waypoint. *)
177177+}
178178+179179+(** Card message - provides user information for display.
180180+181181+ Cards allow users to share their name and photo with others tracking
182182+ their location. The tracker ID must match the location message's [tid]
183183+ to associate the card with the correct user. *)
184184+type card = {
185185+ c_name : string option;
186186+ (** Full name of the user. *)
187187+188188+ c_face : string option;
189189+ (** Base64-encoded image (typically JPEG or PNG). *)
190190+191191+ c_tid : string option;
192192+ (** Tracker ID that this card belongs to. Must match the [tid] in
193193+ location messages to be associated correctly. *)
194194+}
195195+196196+(** LWT (Last Will and Testament) message.
197197+198198+ Published automatically by the MQTT broker when a client disconnects
199199+ unexpectedly. This allows subscribers to know when a device has gone
200200+ offline. *)
201201+type lwt = {
202202+ lwt_tst : int;
203203+ (** Timestamp of the disconnection. *)
204204+}
205205+206206+(** All OwnTracks message types as a variant. *)
207207+type message =
208208+ | Location of location
209209+ (** A location update from the device. *)
210210+ | Transition of transition
211211+ (** A region entry/exit event. *)
212212+ | Waypoint of waypoint
213213+ (** A waypoint/region definition. *)
214214+ | Card of card
215215+ (** User information card. *)
216216+ | Lwt of lwt
217217+ (** Client disconnection notification. *)
218218+ | Unknown of string * Jsont.json
219219+ (** Unknown message type. Contains the [_type] value and raw JSON
220220+ for messages that don't match known types. *)
221221+222222+(** {1:codecs JSON Codecs}
223223+224224+ These codecs can be used with jsont for encoding and decoding individual
225225+ message types. For most use cases, {!decode_message} is more convenient. *)
226226+227227+val location_jsont : location Jsont.t
228228+(** JSON codec for location messages. *)
229229+230230+val transition_jsont : transition Jsont.t
231231+(** JSON codec for transition messages. *)
232232+233233+val waypoint_jsont : waypoint Jsont.t
234234+(** JSON codec for waypoint messages. *)
235235+236236+val card_jsont : card Jsont.t
237237+(** JSON codec for card messages. *)
238238+239239+val lwt_jsont : lwt Jsont.t
240240+(** JSON codec for LWT messages. *)
241241+242242+(** {1:decoding Decoding} *)
243243+244244+val decode_message : string -> (message, string) result
245245+(** [decode_message json_str] decodes a JSON string into an OwnTracks message.
246246+247247+ The message type is determined by the ["_type"] field in the JSON:
248248+ - ["location"] -> {!Location}
249249+ - ["transition"] -> {!Transition}
250250+ - ["waypoint"] or ["waypoints"] -> {!Waypoint}
251251+ - ["card"] -> {!Card}
252252+ - ["lwt"] -> {!Lwt}
253253+ - Other values -> {!Unknown}
254254+255255+ Returns [Error] with an error message if the JSON is malformed or
256256+ missing required fields. *)
257257+258258+(** {1:formatting Formatting and Display} *)
259259+260260+val format_timestamp : int -> string
261261+(** [format_timestamp tst] formats a Unix timestamp as an ISO 8601 string
262262+ in UTC timezone.
263263+264264+ Example: [format_timestamp 1234567890] returns ["2009-02-13 23:31:30 UTC"]. *)
265265+266266+val parse_topic : string -> (string * string) option
267267+(** [parse_topic topic] extracts the user and device from an OwnTracks topic.
268268+269269+ OwnTracks topics follow the pattern [owntracks/user/device].
270270+271271+ Returns [Some (user, device)] if the topic matches, [None] otherwise. *)
272272+273273+val pp_location : Format.formatter -> location -> unit
274274+(** [pp_location ppf loc] pretty-prints a location message. *)
275275+276276+val pp_transition : Format.formatter -> transition -> unit
277277+(** [pp_transition ppf tr] pretty-prints a transition message. *)
278278+279279+val pp_message : Format.formatter -> message -> unit
280280+(** [pp_message ppf msg] pretty-prints any OwnTracks message. *)
281281+282282+(** {1:mqtt MQTT Integration} *)
283283+284284+(** MQTT integration for OwnTracks messages.
285285+286286+ This module provides helpers for parsing MQTT messages into OwnTracks
287287+ types and constructing MQTT topic patterns for subscriptions.
288288+289289+ {2 Topic Format}
290290+291291+ OwnTracks uses the topic pattern [owntracks/{user}/{device}] where:
292292+ - [{user}] is typically a username or identifier
293293+ - [{device}] identifies the specific device (phone, tablet, etc.)
294294+295295+ Use {!Mqtt.default_topic} to subscribe to all OwnTracks messages, or
296296+ {!Mqtt.user_topic} / {!Mqtt.device_topic} for filtered subscriptions. *)
297297+module Mqtt : sig
298298+299299+ (** {1:types Types} *)
300300+301301+ type mqtt_message = {
302302+ topic : string;
303303+ payload : string;
304304+ qos : [ `At_most_once | `At_least_once | `Exactly_once ];
305305+ retain : bool;
306306+ }
307307+ (** Raw MQTT message with topic, payload, QoS level, and retain flag. *)
308308+309309+ type t = {
310310+ topic : string;
311311+ user : string option;
312312+ device : string option;
313313+ message : message;
314314+ }
315315+ (** Parsed OwnTracks message with extracted user/device information. *)
316316+317317+ (** {1:parsing Parsing} *)
318318+319319+ val of_mqtt_message : mqtt_message -> (t, string) result
320320+ (** [of_mqtt_message msg] parses an MQTT message into an OwnTracks message.
321321+322322+ Extracts user and device from the topic if it follows the OwnTracks
323323+ convention ([owntracks/user/device]). The topic is also injected into
324324+ the message payload for location messages.
325325+326326+ Returns [Error] if the payload is not valid OwnTracks JSON. *)
327327+328328+ val of_mqtt : topic:string -> payload:string -> (t, string) result
329329+ (** [of_mqtt ~topic ~payload] is a convenience function for parsing
330330+ MQTT messages without constructing an {!mqtt_message} record.
331331+332332+ Equivalent to calling {!of_mqtt_message} with default QoS and
333333+ retain settings. *)
334334+335335+ (** {1:topics Topic Helpers} *)
336336+337337+ val default_topic : string
338338+ (** [default_topic] is ["owntracks/#"], a wildcard topic that matches
339339+ all OwnTracks messages from all users and devices. *)
340340+341341+ val user_topic : string -> string
342342+ (** [user_topic user] returns ["owntracks/{user}/#"], matching all
343343+ devices for a specific user. *)
344344+345345+ val device_topic : user:string -> device:string -> string
346346+ (** [device_topic ~user ~device] returns ["owntracks/{user}/{device}"],
347347+ matching a specific device. *)
348348+349349+ (** {1:formatting Pretty Printing} *)
350350+351351+ val pp : Format.formatter -> t -> unit
352352+ (** [pp ppf msg] pretty-prints an OwnTracks MQTT message with user/device
353353+ information. *)
354354+end
355355+356356+(** {1:recorder OwnTracks Recorder API} *)
357357+358358+(** JSON parsing for the OwnTracks Recorder HTTP API.
359359+360360+ The {{:https://github.com/owntracks/recorder}OwnTracks Recorder} is a
361361+ server that stores location history and provides an HTTP API for
362362+ querying it.
363363+364364+ This module provides functions to parse JSON responses from the
365365+ Recorder API. The actual HTTP client implementation is left to the
366366+ application.
367367+368368+ {2 API Endpoints}
369369+370370+ The Recorder provides these endpoints:
371371+ - [GET /api/0/list] - List all users
372372+ - [GET /api/0/list?user=USER] - List devices for a user
373373+ - [GET /api/0/locations?user=USER&device=DEVICE&from=DATE&to=DATE] -
374374+ Fetch location history *)
375375+module Recorder : sig
376376+377377+ (** {1:types Types} *)
378378+379379+ type auth = {
380380+ username : string;
381381+ password : string;
382382+ }
383383+ (** HTTP Basic Authentication credentials. *)
384384+385385+ (** {1:parsing JSON Parsing} *)
386386+387387+ val location_of_json : Jsont.json -> location option
388388+ (** [location_of_json json] attempts to parse a JSON object as a location.
389389+390390+ Returns [Some location] if the JSON contains at least [lat], [lon],
391391+ and [tst] fields; [None] otherwise. *)
392392+393393+ val parse_locations_json : string -> location list
394394+ (** [parse_locations_json json_str] parses a JSON response containing
395395+ location data.
396396+397397+ Handles two response formats:
398398+ - Array format: [\[{...}, {...}, ...\]]
399399+ - Object format: [{"data": \[{...}, {...}, ...\]}]
400400+401401+ Returns an empty list if parsing fails or no valid locations found. *)
402402+403403+ val parse_string_list : string -> string list
404404+ (** [parse_string_list json_str] parses a JSON response containing a
405405+ list of strings (e.g., usernames or device names).
406406+407407+ Handles two response formats:
408408+ - Array format: [\["a", "b", ...\]]
409409+ - Object format: [{"results": \["a", "b", ...\]}]
410410+411411+ Returns an empty list if parsing fails. *)
412412+end
413413+414414+(** {1:geojson GeoJSON Output} *)
415415+416416+(** Convert OwnTracks locations to GeoJSON format.
417417+418418+ This module provides functions to convert location data into
419419+ {{:https://geojson.org/}GeoJSON} Point and LineString features
420420+ for use in mapping applications.
421421+422422+ The output is compatible with tools like Leaflet, MapLibre, QGIS,
423423+ and geojson.io. *)
424424+module Geojson_output : sig
425425+426426+ val point_feature : device_name:string -> location -> Geojson.Geojson.t
427427+ (** [point_feature ~device_name loc] creates a GeoJSON Feature with
428428+ Point geometry from a single location.
429429+430430+ The feature properties include:
431431+ - [name]: the device name
432432+ - [timestamp]: Unix timestamp
433433+ - [time]: formatted timestamp string
434434+ - [accuracy]: horizontal accuracy (if available)
435435+ - [speed]: velocity in km/h (if available)
436436+ - [battery]: battery percentage (if available)
437437+ - [tracker_id]: tracker ID (if available) *)
438438+439439+ val linestring_feature : device_name:string -> location list -> Geojson.Geojson.t
440440+ (** [linestring_feature ~device_name locs] creates a GeoJSON Feature
441441+ with LineString geometry from a list of locations.
442442+443443+ Locations are sorted by timestamp before creating the line. The
444444+ feature properties include:
445445+ - [name]: the device name
446446+ - [points]: number of positions in the line
447447+ - [start_time]: formatted timestamp of first point
448448+ - [end_time]: formatted timestamp of last point *)
449449+450450+ val to_string : Geojson.Geojson.t -> string
451451+ (** [to_string geojson] encodes the GeoJSON value as a JSON string. *)
452452+end