···11-(** OwnTracks message types and JSON codecs using jsont.
22-33- This module provides types and codecs for parsing OwnTracks MQTT messages.
44- OwnTracks is an open-source location tracking app that publishes location
55- data over MQTT.
66-77- Message types include:
88- - Location updates with coordinates, altitude, speed, etc.
99- - Transition events for entering/leaving regions
1010- - Waypoint definitions
1111- - Cards with user information
1212-1313- See {{:https://owntracks.org/booklet/tech/json/}OwnTracks JSON format}
1414- and the vendored recorder in vendor/git/recorder for reference. *)
1515-1616-(** {1:types Message Types} *)
1717-1818-(** Location message - the primary OwnTracks message type.
1919-2020- Published when the device reports its location. Contains GPS coordinates,
2121- accuracy, altitude, speed, heading, and various device state information. *)
2222-type location = {
2323- tid : string option; (** Tracker ID (2 chars, configurable) *)
2424- tst : int; (** Timestamp (Unix epoch) *)
2525- lat : float; (** Latitude *)
2626- lon : float; (** Longitude *)
2727- alt : float option; (** Altitude in meters *)
2828- acc : float option; (** Horizontal accuracy in meters *)
2929- vel : float option; (** Velocity in km/h *)
3030- cog : float option; (** Course over ground (heading) in degrees *)
3131- batt : int option; (** Battery level percentage (0-100) *)
3232- bs : int option; (** Battery status: 0=unknown, 1=unplugged, 2=charging, 3=full *)
3333- conn : string option; (** Connection type: w=wifi, m=mobile, o=offline *)
3434- t : string option; (** Trigger: p=ping, c=circular region, b=beacon, r=response, u=manual, t=timer, v=monitoring *)
3535- m : int option; (** Monitoring mode: 0=quiet, 1=manual, 2=significant, 3=move *)
3636- poi : string option; (** Point of Interest name if at a defined waypoint *)
3737- inregions : string list; (** List of regions the device is currently in *)
3838- addr : string option; (** Reverse-geocoded address (added by recorder) *)
3939- topic : string option; (** MQTT topic (added by recorder) *)
4040-}
4141-4242-(** Transition event - published when entering or leaving a region. *)
4343-type transition = {
4444- t_tid : string option; (** Tracker ID *)
4545- t_tst : int; (** Timestamp *)
4646- t_lat : float; (** Latitude *)
4747- t_lon : float; (** Longitude *)
4848- t_acc : float option; (** Accuracy *)
4949- t_event : string; (** "enter" or "leave" *)
5050- t_desc : string option; (** Region description *)
5151- t_wtst : int option; (** Waypoint timestamp *)
5252-}
5353-5454-(** Waypoint definition - describes a monitored region. *)
5555-type waypoint = {
5656- w_tst : int; (** Timestamp *)
5757- w_lat : float; (** Latitude of center *)
5858- w_lon : float; (** Longitude of center *)
5959- w_rad : int; (** Radius in meters *)
6060- w_desc : string; (** Description/name *)
6161-}
6262-6363-(** Card message - provides user information for display. *)
6464-type card = {
6565- c_name : string option; (** Full name *)
6666- c_face : string option; (** Base64-encoded image *)
6767- c_tid : string option; (** Tracker ID (must match location tid) *)
6868-}
6969-7070-(** LWT (Last Will and Testament) message - published when client disconnects. *)
7171-type lwt = {
7272- lwt_tst : int; (** Timestamp *)
7373-}
7474-7575-(** All OwnTracks message types. *)
7676-type message =
7777- | Location of location
7878- | Transition of transition
7979- | Waypoint of waypoint
8080- | Card of card
8181- | Lwt of lwt
8282- | Unknown of string * Jsont.json
8383- (** Unknown message type with the _type value and raw JSON *)
8484-8585-(** {1:codecs JSON Codecs} *)
8686-8787-(** Location message codec. *)
8888-let location_jsont : location Jsont.t =
8989- let make _type tid tst lat lon alt acc vel cog batt bs conn t m poi inregions addr topic =
9090- ignore _type;
9191- { tid; tst; lat; lon; alt; acc; vel; cog; batt; bs; conn; t; m; poi;
9292- inregions = Option.value ~default:[] inregions; addr; topic }
9393- in
9494- Jsont.Object.map ~kind:"location" make
9595- |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "location")
9696- |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun l -> l.tid)
9797- |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.tst)
9898- |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun l -> l.lat)
9999- |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun l -> l.lon)
100100- |> Jsont.Object.opt_mem "alt" Jsont.number ~enc:(fun l -> l.alt)
101101- |> Jsont.Object.opt_mem "acc" Jsont.number ~enc:(fun l -> l.acc)
102102- |> Jsont.Object.opt_mem "vel" Jsont.number ~enc:(fun l -> l.vel)
103103- |> Jsont.Object.opt_mem "cog" Jsont.number ~enc:(fun l -> l.cog)
104104- |> Jsont.Object.opt_mem "batt" Jsont.int ~enc:(fun l -> l.batt)
105105- |> Jsont.Object.opt_mem "bs" Jsont.int ~enc:(fun l -> l.bs)
106106- |> Jsont.Object.opt_mem "conn" Jsont.string ~enc:(fun l -> l.conn)
107107- |> Jsont.Object.opt_mem "t" Jsont.string ~enc:(fun l -> l.t)
108108- |> Jsont.Object.opt_mem "m" Jsont.int ~enc:(fun l -> l.m)
109109- |> Jsont.Object.opt_mem "poi" Jsont.string ~enc:(fun l -> l.poi)
110110- |> Jsont.Object.opt_mem "inregions" (Jsont.list Jsont.string)
111111- ~enc:(fun l -> match l.inregions with [] -> None | xs -> Some xs)
112112- |> Jsont.Object.opt_mem "addr" Jsont.string ~enc:(fun l -> l.addr)
113113- |> Jsont.Object.opt_mem "topic" Jsont.string ~enc:(fun l -> l.topic)
114114- |> Jsont.Object.skip_unknown
115115- |> Jsont.Object.finish
116116-117117-(** Transition message codec. *)
118118-let transition_jsont : transition Jsont.t =
119119- let make _type tid tst lat lon acc event desc wtst =
120120- ignore _type;
121121- { t_tid = tid; t_tst = tst; t_lat = lat; t_lon = lon; t_acc = acc;
122122- t_event = event; t_desc = desc; t_wtst = wtst }
123123- in
124124- Jsont.Object.map ~kind:"transition" make
125125- |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "transition")
126126- |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun t -> t.t_tid)
127127- |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun t -> t.t_tst)
128128- |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun t -> t.t_lat)
129129- |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun t -> t.t_lon)
130130- |> Jsont.Object.opt_mem "acc" Jsont.number ~enc:(fun t -> t.t_acc)
131131- |> Jsont.Object.mem "event" Jsont.string ~enc:(fun t -> t.t_event)
132132- |> Jsont.Object.opt_mem "desc" Jsont.string ~enc:(fun t -> t.t_desc)
133133- |> Jsont.Object.opt_mem "wtst" Jsont.int ~enc:(fun t -> t.t_wtst)
134134- |> Jsont.Object.skip_unknown
135135- |> Jsont.Object.finish
136136-137137-(** Waypoint message codec. *)
138138-let waypoint_jsont : waypoint Jsont.t =
139139- let make _type tst lat lon rad desc =
140140- ignore _type;
141141- { w_tst = tst; w_lat = lat; w_lon = lon; w_rad = rad; w_desc = desc }
142142- in
143143- Jsont.Object.map ~kind:"waypoint" make
144144- |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "waypoint")
145145- |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun w -> w.w_tst)
146146- |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun w -> w.w_lat)
147147- |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun w -> w.w_lon)
148148- |> Jsont.Object.mem "rad" Jsont.int ~enc:(fun w -> w.w_rad)
149149- |> Jsont.Object.mem "desc" Jsont.string ~enc:(fun w -> w.w_desc)
150150- |> Jsont.Object.skip_unknown
151151- |> Jsont.Object.finish
152152-153153-(** Card message codec. *)
154154-let card_jsont : card Jsont.t =
155155- let make _type name face tid =
156156- ignore _type;
157157- { c_name = name; c_face = face; c_tid = tid }
158158- in
159159- Jsont.Object.map ~kind:"card" make
160160- |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "card")
161161- |> Jsont.Object.opt_mem "name" Jsont.string ~enc:(fun c -> c.c_name)
162162- |> Jsont.Object.opt_mem "face" Jsont.string ~enc:(fun c -> c.c_face)
163163- |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun c -> c.c_tid)
164164- |> Jsont.Object.skip_unknown
165165- |> Jsont.Object.finish
11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
1665167167-(** LWT message codec. *)
168168-let lwt_jsont : lwt Jsont.t =
169169- let make _type tst =
170170- ignore _type;
171171- { lwt_tst = tst }
172172- in
173173- Jsont.Object.map ~kind:"lwt" make
174174- |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "lwt")
175175- |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.lwt_tst)
176176- |> Jsont.Object.skip_unknown
177177- |> Jsont.Object.finish
178178-179179-(** {1:decoding Decoding} *)
180180-181181-(** Extract the _type field from a generic JSON object. *)
182182-let extract_type = function
183183- | Jsont.Object (members, _) ->
184184- List.find_map (fun ((name, _), value) ->
185185- if name = "_type" then
186186- match value with Jsont.String (s, _) -> Some s | _ -> None
187187- else None
188188- ) members
189189- | _ -> None
190190-191191-(** Decode an OwnTracks JSON message from a string.
192192-193193- Returns the appropriate message type based on the "_type" field.
194194- Unknown message types are returned as [Unknown (type_name, json)]. *)
195195-let decode_message (json_str : string) : (message, string) result =
196196- let ( let* ) = Result.bind in
197197- let decode_as jsont wrap =
198198- Result.map wrap (Jsont_bytesrw.decode_string jsont json_str)
199199- in
200200- try
201201- let* json = Jsont_bytesrw.decode_string Jsont.json json_str in
202202- match extract_type json with
203203- | Some "location" -> decode_as location_jsont (fun l -> Location l)
204204- | Some "transition" -> decode_as transition_jsont (fun t -> Transition t)
205205- | Some "waypoint" | Some "waypoints" -> decode_as waypoint_jsont (fun w -> Waypoint w)
206206- | Some "card" -> decode_as card_jsont (fun c -> Card c)
207207- | Some "lwt" -> decode_as lwt_jsont (fun l -> Lwt l)
208208- | Some other -> Ok (Unknown (other, json))
209209- | None -> Ok (Unknown ("", json))
210210- with exn ->
211211- Error (Printexc.to_string exn)
212212-213213-(** {1:formatting Pretty Printing} *)
214214-215215-(** Format a code string using a lookup table. *)
216216-let pp_code_map ~unknown codes ppf = function
217217- | Some s ->
218218- let display = List.assoc_opt s codes |> Option.value ~default:s in
219219- Format.pp_print_string ppf display
220220- | None -> Format.pp_print_string ppf unknown
221221-222222-(** Format connection type as human-readable string. *)
223223-let pp_conn =
224224- pp_code_map ~unknown:"Unknown"
225225- ["w", "WiFi"; "m", "Mobile"; "o", "Offline"]
226226-227227-(** Format trigger type as human-readable string. *)
228228-let pp_trigger =
229229- pp_code_map ~unknown:"Unknown"
230230- ["p", "Ping"; "c", "Circular region"; "b", "Beacon"; "r", "Response";
231231- "u", "Manual"; "t", "Timer"; "v", "Monitoring"]
232232-233233-(** Format timestamp as ISO 8601 string. *)
234234-let format_timestamp tst =
235235- let t = Unix.gmtime (float_of_int tst) in
236236- Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d UTC"
237237- (t.Unix.tm_year + 1900) (t.Unix.tm_mon + 1) t.Unix.tm_mday
238238- t.Unix.tm_hour t.Unix.tm_min t.Unix.tm_sec
239239-240240-(** Parse user and device from OwnTracks topic.
241241- Topic format: owntracks/user/device *)
242242-let parse_topic topic =
243243- match String.split_on_char '/' topic with
244244- | _ :: user :: device :: _ -> Some (user, device)
245245- | _ -> None
246246-247247-(** Pretty-print a location message. *)
248248-let pp_location ppf (loc : location) =
249249- Format.fprintf ppf "@[<v 0>";
250250- Format.fprintf ppf "-------------------------------------------@,";
251251-252252- (* User/device from topic *)
253253- begin match loc.topic with
254254- | Some topic ->
255255- begin match parse_topic topic with
256256- | Some (user, device) ->
257257- Format.fprintf ppf " User: %s / %s" user device;
258258- Option.iter (fun tid -> Format.fprintf ppf " [%s]" tid) loc.tid;
259259- Format.fprintf ppf "@,"
260260- | None ->
261261- Format.fprintf ppf " Topic: %s@," topic
262262- end
263263- | None ->
264264- Option.iter (fun tid ->
265265- Format.fprintf ppf " Tracker: %s@," tid
266266- ) loc.tid
267267- end;
268268-269269- Format.fprintf ppf " Time: %s@," (format_timestamp loc.tst);
270270- Format.fprintf ppf " Location: %.6f, %.6f@," loc.lat loc.lon;
271271-272272- Option.iter (fun alt ->
273273- Format.fprintf ppf " Altitude: %.1f m@," alt
274274- ) loc.alt;
275275-276276- Option.iter (fun acc ->
277277- Format.fprintf ppf " Accuracy: +/- %.0f m@," acc
278278- ) loc.acc;
279279-280280- Option.iter (fun vel ->
281281- Format.fprintf ppf " Speed: %.1f km/h@," vel
282282- ) loc.vel;
283283-284284- Option.iter (fun cog ->
285285- Format.fprintf ppf " Heading: %.0f deg@," cog
286286- ) loc.cog;
287287-288288- Option.iter (fun batt ->
289289- Format.fprintf ppf " Battery: %d%%@," batt
290290- ) loc.batt;
291291-292292- Format.fprintf ppf " Conn: %a@," pp_conn loc.conn;
293293-294294- Option.iter (fun _ ->
295295- Format.fprintf ppf " Trigger: %a@," pp_trigger loc.t
296296- ) loc.t;
297297-298298- Option.iter (fun poi ->
299299- Format.fprintf ppf " POI: %s@," poi
300300- ) loc.poi;
301301-302302- if loc.inregions <> [] then
303303- Format.fprintf ppf " Regions: %s@," (String.concat ", " loc.inregions);
304304-305305- Option.iter (fun addr ->
306306- Format.fprintf ppf " Address: %s@," addr
307307- ) loc.addr;
308308-309309- Format.fprintf ppf "-------------------------------------------@]"
310310-311311-(** Pretty-print a transition message. *)
312312-let pp_transition ppf (tr : transition) =
313313- Format.fprintf ppf "@[<v 0>";
314314- Format.fprintf ppf "-------------------------------------------@,";
315315- Format.fprintf ppf " Event: %s@," (String.uppercase_ascii tr.t_event);
316316- Option.iter (fun desc ->
317317- Format.fprintf ppf " Region: %s@," desc
318318- ) tr.t_desc;
319319- Option.iter (fun tid ->
320320- Format.fprintf ppf " Tracker: %s@," tid
321321- ) tr.t_tid;
322322- Format.fprintf ppf " Time: %s@," (format_timestamp tr.t_tst);
323323- Format.fprintf ppf " Location: %.6f, %.6f@," tr.t_lat tr.t_lon;
324324- Format.fprintf ppf "-------------------------------------------@]"
325325-326326-(** Pretty-print any OwnTracks message. *)
327327-let pp_message ppf = function
328328- | Location loc -> pp_location ppf loc
329329- | Transition tr -> pp_transition ppf tr
330330- | Waypoint wp ->
331331- Format.fprintf ppf "Waypoint: %s at (%.6f, %.6f) radius %dm"
332332- wp.w_desc wp.w_lat wp.w_lon wp.w_rad
333333- | Card c ->
334334- Format.fprintf ppf "Card: %s"
335335- (Option.value ~default:"(no name)" c.c_name)
336336- | Lwt l ->
337337- Format.fprintf ppf "LWT: client disconnected at %s"
338338- (format_timestamp l.lwt_tst)
339339- | Unknown (typ, _) ->
340340- 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
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
66+module Location = Owntracks_location
77+module Transition = Owntracks_transition
88+module Waypoint = Owntracks_waypoint
99+module Card = Owntracks_card
1010+module Lwt = Owntracks_lwt
1111+module Message = Owntracks_message
1212+module Mqtt = Owntracks_mqtt
1313+module Recorder = Owntracks_recorder
1414+module Geojson = Owntracks_geojson_output
+41-406
lib/owntracks.mli
···1313 {1:overview Overview}
14141515 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)
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)
21212222 Messages are published to MQTT topics in the format [owntracks/user/device].
23232424 {1:example Example}
25252626- Decoding a location message:
2626+ Decoding a location message using jsont_bytesrw:
2727 {[
2828 let json = {|{"_type":"location","lat":51.5,"lon":-0.1,"tst":1234567890}|} in
2929- match Owntracks.decode_message json with
2929+ match Jsont_bytesrw.decode_string Owntracks.Message.jsont json with
3030 | Ok (Location loc) ->
3131- Printf.printf "Location: %.4f, %.4f\n" loc.lat loc.lon
3131+ Printf.printf "Location: %.4f, %.4f\n"
3232+ (Owntracks.Location.lat loc) (Owntracks.Location.lon loc)
3233 | Ok _ -> print_endline "Other message type"
3334 | Error e -> Printf.printf "Error: %s\n" e
3435 ]}
35363637 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.
3838+ for the complete specification.
15939160160- 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. *)
4040+ {1:modules Module Structure}
16541166166- w_lat : float;
167167- (** Latitude of the region center. *)
4242+ Each message type is defined in its own module with an abstract [type t]:
16843169169- w_lon : float;
170170- (** Longitude of the region center. *)
4444+ - {!Location} - Location messages with GPS coordinates
4545+ - {!Transition} - Region entry/exit events
4646+ - {!Waypoint} - Waypoint/geofence definitions
4747+ - {!Card} - User information cards
4848+ - {!Lwt} - Last Will and Testament messages
4949+ - {!Message} - Variant type encompassing all message types
17150172172- w_rad : int;
173173- (** Radius of the circular region in meters. *)
5151+ Additional modules for integration:
17452175175- w_desc : string;
176176- (** Description/name of the waypoint. *)
177177-}
5353+ - {!Mqtt} - MQTT message parsing and topic helpers
5454+ - {!Recorder} - OwnTracks Recorder HTTP API parsing
5555+ - {!Geojson} - Convert locations to GeoJSON format *)
17856179179-(** Card message - provides user information for display.
5757+(** {1:types Message Types} *)
18058181181- 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. *)
5959+(** Location message - the primary OwnTracks message type. *)
6060+module Location = Owntracks_location
18761188188- c_face : string option;
189189- (** Base64-encoded image (typically JPEG or PNG). *)
6262+(** Transition event - region entry/exit. *)
6363+module Transition = Owntracks_transition
19064191191- 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-}
6565+(** Waypoint definition - monitored circular region. *)
6666+module Waypoint = Owntracks_waypoint
19567196196-(** LWT (Last Will and Testament) message.
6868+(** Card message - user information for display. *)
6969+module Card = Owntracks_card
19770198198- 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-}
7171+(** LWT (Last Will and Testament) message. *)
7272+module Lwt = Owntracks_lwt
2057320674(** 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. *)
7575+module Message = Owntracks_message
22176222222-(** {1:codecs JSON Codecs}
7777+(** {1:integration Integration Modules} *)
22378224224- These codecs can be used with jsont for encoding and decoding individual
225225- message types. For most use cases, {!decode_message} is more convenient. *)
7979+(** MQTT integration for OwnTracks messages. *)
8080+module Mqtt = Owntracks_mqtt
22681227227-val location_jsont : location Jsont.t
228228-(** JSON codec for location messages. *)
8282+(** OwnTracks Recorder HTTP API codecs. *)
8383+module Recorder = Owntracks_recorder
22984230230-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} *)
8585+(** Convert OwnTracks locations to GeoJSON format. *)
8686+module Geojson = Owntracks_geojson_output
33687337337- 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
+42
lib/owntracks_card.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = {
77+ name : string option;
88+ face : string option;
99+ tid : string option;
1010+}
1111+1212+let v ?name ?face ?tid () = { name; face; tid }
1313+1414+let name t = t.name
1515+let face t = t.face
1616+let tid t = t.tid
1717+1818+let jsont_bare : t Jsont.t =
1919+ let make name face tid = { name; face; tid } in
2020+ Jsont.Object.map ~kind:"card" make
2121+ |> Jsont.Object.opt_mem "name" Jsont.string ~enc:(fun c -> c.name)
2222+ |> Jsont.Object.opt_mem "face" Jsont.string ~enc:(fun c -> c.face)
2323+ |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun c -> c.tid)
2424+ |> Jsont.Object.skip_unknown
2525+ |> Jsont.Object.finish
2626+2727+let jsont : t Jsont.t =
2828+ let make _type name face tid =
2929+ ignore _type;
3030+ { name; face; tid }
3131+ in
3232+ Jsont.Object.map ~kind:"card" make
3333+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "card")
3434+ |> Jsont.Object.opt_mem "name" Jsont.string ~enc:(fun c -> c.name)
3535+ |> Jsont.Object.opt_mem "face" Jsont.string ~enc:(fun c -> c.face)
3636+ |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun c -> c.tid)
3737+ |> Jsont.Object.skip_unknown
3838+ |> Jsont.Object.finish
3939+4040+let pp ppf card =
4141+ Format.fprintf ppf "Card: %s"
4242+ (Option.value ~default:"(no name)" card.name)
+53
lib/owntracks_card.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Card message type for OwnTracks.
77+88+ @canonical Owntracks.Card
99+1010+ Provides user information for display. Cards allow users to share
1111+ their name and photo with others tracking their location. The
1212+ tracker ID must match the location message's tid to associate the
1313+ card with the correct user. *)
1414+1515+type t
1616+(** The type for card messages. *)
1717+1818+(** {1 Constructors} *)
1919+2020+val v :
2121+ ?name:string ->
2222+ ?face:string ->
2323+ ?tid:string ->
2424+ unit ->
2525+ t
2626+(** [v ()] creates a card message with optional fields. *)
2727+2828+(** {1 Accessors} *)
2929+3030+val name : t -> string option
3131+(** [name card] returns the full name of the user, if present. *)
3232+3333+val face : t -> string option
3434+(** [face card] returns the Base64-encoded image (typically JPEG or PNG),
3535+ if present. *)
3636+3737+val tid : t -> string option
3838+(** [tid card] returns the tracker ID that this card belongs to. Must
3939+ match the tid in location messages to be associated correctly. *)
4040+4141+(** {1 JSON Codec} *)
4242+4343+val jsont : t Jsont.t
4444+(** [jsont] is a JSON codec for card messages.
4545+ Expects the ["_type"] field to be ["card"]. *)
4646+4747+val jsont_bare : t Jsont.t
4848+(** [jsont_bare] is a JSON codec that doesn't require the ["_type"] field. *)
4949+5050+(** {1 Pretty Printing} *)
5151+5252+val pp : Format.formatter -> t -> unit
5353+(** [pp ppf card] pretty-prints a card message. *)
+59
lib/owntracks_geojson_output.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+open Geojson
77+88+let pos_of_loc loc =
99+ Geometry.Position.v
1010+ ?altitude:(Owntracks_location.alt loc)
1111+ ~lng:(Owntracks_location.lon loc)
1212+ ~lat:(Owntracks_location.lat loc)
1313+ ()
1414+1515+let props ~device_name ~timestamp ~time ?accuracy ?speed ?battery ?tracker_id () =
1616+ let open Jsont.Json in
1717+ let add n f opt acc = match opt with Some v -> (n, f v) :: acc | None -> acc in
1818+ [
1919+ ("name", string device_name);
2020+ ("timestamp", int timestamp);
2121+ ("time", string time)
2222+ ]
2323+ |> add "accuracy" number accuracy
2424+ |> add "speed" number speed
2525+ |> add "battery" int battery
2626+ |> add "tracker_id" string tracker_id
2727+ |> fun mems -> Jsont.Json.object' (List.map (fun (n, v) -> Jsont.Json.mem (Jsont.Json.name n) v) mems)
2828+2929+let point_feature ~device_name loc : Geojson.t =
3030+ let point = Geometry.Point.v (pos_of_loc loc) in
3131+ let geom : Geojson.geometry = `Point point in
3232+ let tst = Owntracks_location.tst loc in
3333+ let properties = Some (props ~device_name ~timestamp:tst
3434+ ~time:(Owntracks_location.format_timestamp tst)
3535+ ?accuracy:(Owntracks_location.acc loc)
3636+ ?speed:(Owntracks_location.vel loc)
3737+ ?battery:(Owntracks_location.batt loc)
3838+ ?tracker_id:(Owntracks_location.tid loc) ()) in
3939+ let feature = Geojson.Feature.v ?properties geom in
4040+ `Feature feature
4141+4242+let linestring_feature ~device_name locs : Geojson.t =
4343+ let sorted = List.sort (fun a b ->
4444+ Int.compare (Owntracks_location.tst a) (Owntracks_location.tst b)) locs in
4545+ let positions = Array.of_list (List.map pos_of_loc sorted) in
4646+ let line = Geometry.LineString.v positions in
4747+ let geom : Geojson.geometry = `Line_string line in
4848+ let start_time = match sorted with [] -> 0 | h :: _ -> Owntracks_location.tst h in
4949+ let end_time = match List.rev sorted with [] -> 0 | h :: _ -> Owntracks_location.tst h in
5050+ let properties = Some (Jsont.Json.object' [
5151+ Jsont.Json.mem (Jsont.Json.name "name") (Jsont.Json.string device_name);
5252+ Jsont.Json.mem (Jsont.Json.name "points") (Jsont.Json.int (List.length sorted));
5353+ Jsont.Json.mem (Jsont.Json.name "start_time") (Jsont.Json.string (Owntracks_location.format_timestamp start_time));
5454+ Jsont.Json.mem (Jsont.Json.name "end_time") (Jsont.Json.string (Owntracks_location.format_timestamp end_time));
5555+ ]) in
5656+ let feature = Geojson.Feature.v ?properties geom in
5757+ `Feature feature
5858+5959+let to_string = Geojson.to_string
+42
lib/owntracks_geojson_output.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Convert OwnTracks locations to GeoJSON format.
77+88+ @canonical Owntracks.Geojson
99+1010+ This module provides functions to convert location data into
1111+ {{:https://geojson.org/}GeoJSON} Point and LineString features
1212+ for use in mapping applications.
1313+1414+ The output is compatible with tools like Leaflet, MapLibre, QGIS,
1515+ and geojson.io. *)
1616+1717+val point_feature : device_name:string -> Owntracks_location.t -> Geojson.Geojson.t
1818+(** [point_feature ~device_name loc] creates a GeoJSON Feature with
1919+ Point geometry from a single location.
2020+2121+ The feature properties include:
2222+ - [name]: the device name
2323+ - [timestamp]: Unix timestamp
2424+ - [time]: formatted timestamp string
2525+ - [accuracy]: horizontal accuracy (if available)
2626+ - [speed]: velocity in km/h (if available)
2727+ - [battery]: battery percentage (if available)
2828+ - [tracker_id]: tracker ID (if available) *)
2929+3030+val linestring_feature : device_name:string -> Owntracks_location.t list -> Geojson.Geojson.t
3131+(** [linestring_feature ~device_name locs] creates a GeoJSON Feature
3232+ with LineString geometry from a list of locations.
3333+3434+ Locations are sorted by timestamp before creating the line. The
3535+ feature properties include:
3636+ - [name]: the device name
3737+ - [points]: number of positions in the line
3838+ - [start_time]: formatted timestamp of first point
3939+ - [end_time]: formatted timestamp of last point *)
4040+4141+val to_string : Geojson.Geojson.t -> string
4242+(** [to_string geojson] encodes the GeoJSON value as a JSON string. *)
+182
lib/owntracks_location.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = {
77+ tid : string option;
88+ tst : int;
99+ lat : float;
1010+ lon : float;
1111+ alt : float option;
1212+ acc : float option;
1313+ vel : float option;
1414+ cog : float option;
1515+ batt : int option;
1616+ bs : int option;
1717+ conn : string option;
1818+ t : string option;
1919+ m : int option;
2020+ poi : string option;
2121+ inregions : string list;
2222+ addr : string option;
2323+ topic : string option;
2424+}
2525+2626+let v ?tid ~tst ~lat ~lon ?alt ?acc ?vel ?cog ?batt ?bs ?conn ?t ?m ?poi
2727+ ?(inregions = []) ?addr ?topic () =
2828+ { tid; tst; lat; lon; alt; acc; vel; cog; batt; bs; conn; t; m; poi;
2929+ inregions; addr; topic }
3030+3131+let tid t = t.tid
3232+let tst t = t.tst
3333+let lat t = t.lat
3434+let lon t = t.lon
3535+let alt t = t.alt
3636+let acc t = t.acc
3737+let vel t = t.vel
3838+let cog t = t.cog
3939+let batt t = t.batt
4040+let bs t = t.bs
4141+let conn t = t.conn
4242+let trigger t = t.t
4343+let monitoring_mode t = t.m
4444+let poi t = t.poi
4545+let inregions t = t.inregions
4646+let addr t = t.addr
4747+let topic t = t.topic
4848+4949+let with_topic topic t = { t with topic = Some topic }
5050+5151+let jsont : t Jsont.t =
5252+ let make _type tid tst lat lon alt acc vel cog batt bs conn t m poi
5353+ inregions addr topic =
5454+ ignore _type;
5555+ { tid; tst; lat; lon; alt; acc; vel; cog; batt; bs; conn; t; m; poi;
5656+ inregions = Option.value ~default:[] inregions; addr; topic }
5757+ in
5858+ Jsont.Object.map ~kind:"location" make
5959+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "location")
6060+ |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun l -> l.tid)
6161+ |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.tst)
6262+ |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun l -> l.lat)
6363+ |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun l -> l.lon)
6464+ |> Jsont.Object.opt_mem "alt" Jsont.number ~enc:(fun l -> l.alt)
6565+ |> Jsont.Object.opt_mem "acc" Jsont.number ~enc:(fun l -> l.acc)
6666+ |> Jsont.Object.opt_mem "vel" Jsont.number ~enc:(fun l -> l.vel)
6767+ |> Jsont.Object.opt_mem "cog" Jsont.number ~enc:(fun l -> l.cog)
6868+ |> Jsont.Object.opt_mem "batt" Jsont.int ~enc:(fun l -> l.batt)
6969+ |> Jsont.Object.opt_mem "bs" Jsont.int ~enc:(fun l -> l.bs)
7070+ |> Jsont.Object.opt_mem "conn" Jsont.string ~enc:(fun l -> l.conn)
7171+ |> Jsont.Object.opt_mem "t" Jsont.string ~enc:(fun l -> l.t)
7272+ |> Jsont.Object.opt_mem "m" Jsont.int ~enc:(fun l -> l.m)
7373+ |> Jsont.Object.opt_mem "poi" Jsont.string ~enc:(fun l -> l.poi)
7474+ |> Jsont.Object.opt_mem "inregions" (Jsont.list Jsont.string)
7575+ ~enc:(fun l -> match l.inregions with [] -> None | xs -> Some xs)
7676+ |> Jsont.Object.opt_mem "addr" Jsont.string ~enc:(fun l -> l.addr)
7777+ |> Jsont.Object.opt_mem "topic" Jsont.string ~enc:(fun l -> l.topic)
7878+ |> Jsont.Object.skip_unknown
7979+ |> Jsont.Object.finish
8080+8181+let jsont_bare : t Jsont.t =
8282+ let make tid tst lat lon alt acc vel cog batt bs conn t m poi
8383+ inregions addr topic =
8484+ { tid; tst; lat; lon; alt; acc; vel; cog; batt; bs; conn; t; m; poi;
8585+ inregions = Option.value ~default:[] inregions; addr; topic }
8686+ in
8787+ Jsont.Object.map ~kind:"location" make
8888+ |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun l -> l.tid)
8989+ |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.tst)
9090+ |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun l -> l.lat)
9191+ |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun l -> l.lon)
9292+ |> Jsont.Object.opt_mem "alt" Jsont.number ~enc:(fun l -> l.alt)
9393+ |> Jsont.Object.opt_mem "acc" Jsont.number ~enc:(fun l -> l.acc)
9494+ |> Jsont.Object.opt_mem "vel" Jsont.number ~enc:(fun l -> l.vel)
9595+ |> Jsont.Object.opt_mem "cog" Jsont.number ~enc:(fun l -> l.cog)
9696+ |> Jsont.Object.opt_mem "batt" Jsont.int ~enc:(fun l -> l.batt)
9797+ |> Jsont.Object.opt_mem "bs" Jsont.int ~enc:(fun l -> l.bs)
9898+ |> Jsont.Object.opt_mem "conn" Jsont.string ~enc:(fun l -> l.conn)
9999+ |> Jsont.Object.opt_mem "t" Jsont.string ~enc:(fun l -> l.t)
100100+ |> Jsont.Object.opt_mem "m" Jsont.int ~enc:(fun l -> l.m)
101101+ |> Jsont.Object.opt_mem "poi" Jsont.string ~enc:(fun l -> l.poi)
102102+ |> Jsont.Object.opt_mem "inregions" (Jsont.list Jsont.string)
103103+ ~enc:(fun l -> match l.inregions with [] -> None | xs -> Some xs)
104104+ |> Jsont.Object.opt_mem "addr" Jsont.string ~enc:(fun l -> l.addr)
105105+ |> Jsont.Object.opt_mem "topic" Jsont.string ~enc:(fun l -> l.topic)
106106+ |> Jsont.Object.skip_unknown
107107+ |> Jsont.Object.finish
108108+109109+let format_timestamp tst =
110110+ let t = Unix.gmtime (float_of_int tst) in
111111+ Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d UTC"
112112+ (t.Unix.tm_year + 1900) (t.Unix.tm_mon + 1) t.Unix.tm_mday
113113+ t.Unix.tm_hour t.Unix.tm_min t.Unix.tm_sec
114114+115115+let pp_code_map ~unknown codes ppf = function
116116+ | Some s ->
117117+ let display = List.assoc_opt s codes |> Option.value ~default:s in
118118+ Format.pp_print_string ppf display
119119+ | None -> Format.pp_print_string ppf unknown
120120+121121+let pp_conn =
122122+ pp_code_map ~unknown:"Unknown"
123123+ ["w", "WiFi"; "m", "Mobile"; "o", "Offline"]
124124+125125+let pp_trigger =
126126+ pp_code_map ~unknown:"Unknown"
127127+ ["p", "Ping"; "c", "Circular region"; "b", "Beacon"; "r", "Response";
128128+ "u", "Manual"; "t", "Timer"; "v", "Monitoring"]
129129+130130+let parse_topic topic =
131131+ match String.split_on_char '/' topic with
132132+ | _ :: user :: device :: _ -> Some (user, device)
133133+ | _ -> None
134134+135135+let pp ppf loc =
136136+ Format.fprintf ppf "@[<v 0>";
137137+ Format.fprintf ppf "-------------------------------------------@,";
138138+ begin match loc.topic with
139139+ | Some topic ->
140140+ begin match parse_topic topic with
141141+ | Some (user, device) ->
142142+ Format.fprintf ppf " User: %s / %s" user device;
143143+ Option.iter (fun tid -> Format.fprintf ppf " [%s]" tid) loc.tid;
144144+ Format.fprintf ppf "@,"
145145+ | None ->
146146+ Format.fprintf ppf " Topic: %s@," topic
147147+ end
148148+ | None ->
149149+ Option.iter (fun tid ->
150150+ Format.fprintf ppf " Tracker: %s@," tid
151151+ ) loc.tid
152152+ end;
153153+ Format.fprintf ppf " Time: %s@," (format_timestamp loc.tst);
154154+ Format.fprintf ppf " Location: %.6f, %.6f@," loc.lat loc.lon;
155155+ Option.iter (fun alt ->
156156+ Format.fprintf ppf " Altitude: %.1f m@," alt
157157+ ) loc.alt;
158158+ Option.iter (fun acc ->
159159+ Format.fprintf ppf " Accuracy: +/- %.0f m@," acc
160160+ ) loc.acc;
161161+ Option.iter (fun vel ->
162162+ Format.fprintf ppf " Speed: %.1f km/h@," vel
163163+ ) loc.vel;
164164+ Option.iter (fun cog ->
165165+ Format.fprintf ppf " Heading: %.0f deg@," cog
166166+ ) loc.cog;
167167+ Option.iter (fun batt ->
168168+ Format.fprintf ppf " Battery: %d%%@," batt
169169+ ) loc.batt;
170170+ Format.fprintf ppf " Conn: %a@," pp_conn loc.conn;
171171+ Option.iter (fun _ ->
172172+ Format.fprintf ppf " Trigger: %a@," pp_trigger loc.t
173173+ ) loc.t;
174174+ Option.iter (fun poi ->
175175+ Format.fprintf ppf " POI: %s@," poi
176176+ ) loc.poi;
177177+ if loc.inregions <> [] then
178178+ Format.fprintf ppf " Regions: %s@," (String.concat ", " loc.inregions);
179179+ Option.iter (fun addr ->
180180+ Format.fprintf ppf " Address: %s@," addr
181181+ ) loc.addr;
182182+ Format.fprintf ppf "-------------------------------------------@]"
+146
lib/owntracks_location.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Location message type for OwnTracks.
77+88+ @canonical Owntracks.Location
99+1010+ The primary OwnTracks message type, published when the device reports
1111+ its location. Contains GPS coordinates, accuracy, altitude, speed,
1212+ heading, and various device state information.
1313+1414+ Required fields are latitude, longitude, and timestamp. All other
1515+ fields are optional and may not be present depending on device
1616+ capabilities and settings. *)
1717+1818+type t
1919+(** The type for location messages. *)
2020+2121+(** {1 Constructors} *)
2222+2323+val v :
2424+ ?tid:string ->
2525+ tst:int ->
2626+ lat:float ->
2727+ lon:float ->
2828+ ?alt:float ->
2929+ ?acc:float ->
3030+ ?vel:float ->
3131+ ?cog:float ->
3232+ ?batt:int ->
3333+ ?bs:int ->
3434+ ?conn:string ->
3535+ ?t:string ->
3636+ ?m:int ->
3737+ ?poi:string ->
3838+ ?inregions:string list ->
3939+ ?addr:string ->
4040+ ?topic:string ->
4141+ unit ->
4242+ t
4343+(** [v ~tst ~lat ~lon ()] creates a location with the required fields.
4444+ Optional fields can be provided as labeled arguments. *)
4545+4646+(** {1 Accessors} *)
4747+4848+val tid : t -> string option
4949+(** [tid loc] returns the tracker ID - a short identifier (typically 2
5050+ characters) configured in the app. *)
5151+5252+val tst : t -> int
5353+(** [tst loc] returns the timestamp as Unix epoch (seconds since
5454+ 1970-01-01 00:00:00 UTC). *)
5555+5656+val lat : t -> float
5757+(** [lat loc] returns the latitude in decimal degrees. Range: -90 to +90. *)
5858+5959+val lon : t -> float
6060+(** [lon loc] returns the longitude in decimal degrees. Range: -180 to +180. *)
6161+6262+val alt : t -> float option
6363+(** [alt loc] returns the altitude above sea level in meters, if present. *)
6464+6565+val acc : t -> float option
6666+(** [acc loc] returns the horizontal accuracy (radius) in meters, if present. *)
6767+6868+val vel : t -> float option
6969+(** [vel loc] returns the velocity (speed) in km/h, if present. *)
7070+7171+val cog : t -> float option
7272+(** [cog loc] returns the course over ground (heading) in degrees from
7373+ true north (0-360), if present. *)
7474+7575+val batt : t -> int option
7676+(** [batt loc] returns the battery level as percentage (0-100), if present. *)
7777+7878+val bs : t -> int option
7979+(** [bs loc] returns the battery status, if present:
8080+ - [0] = unknown
8181+ - [1] = unplugged
8282+ - [2] = charging
8383+ - [3] = full *)
8484+8585+val conn : t -> string option
8686+(** [conn loc] returns the connection type, if present:
8787+ - ["w"] = WiFi
8888+ - ["m"] = Mobile/cellular
8989+ - ["o"] = Offline *)
9090+9191+val trigger : t -> string option
9292+(** [trigger loc] returns what caused this location report, if present:
9393+ - ["p"] = Ping (response to request)
9494+ - ["c"] = Circular region event
9595+ - ["b"] = Beacon event
9696+ - ["r"] = Response to reportLocation
9797+ - ["u"] = Manual/user-initiated
9898+ - ["t"] = Timer-based
9999+ - ["v"] = Monitoring mode change *)
100100+101101+val monitoring_mode : t -> int option
102102+(** [monitoring_mode loc] returns the monitoring mode, if present:
103103+ - [0] = Quiet (no location reporting)
104104+ - [1] = Manual (only when requested)
105105+ - [2] = Significant changes only
106106+ - [3] = Move mode (frequent updates) *)
107107+108108+val poi : t -> string option
109109+(** [poi loc] returns the Point of Interest name if the device is
110110+ currently at a defined location. *)
111111+112112+val inregions : t -> string list
113113+(** [inregions loc] returns the list of region names the device is
114114+ currently inside. May be empty. *)
115115+116116+val addr : t -> string option
117117+(** [addr loc] returns the reverse-geocoded address, if present.
118118+ Typically added by the OwnTracks Recorder server. *)
119119+120120+val topic : t -> string option
121121+(** [topic loc] returns the MQTT topic this message was published to,
122122+ if present. Added during parsing. *)
123123+124124+(** {1 Modifiers} *)
125125+126126+val with_topic : string -> t -> t
127127+(** [with_topic topic loc] returns a new location with the topic set. *)
128128+129129+(** {1 JSON Codec} *)
130130+131131+val jsont : t Jsont.t
132132+(** [jsont] is a JSON codec for location messages.
133133+ Expects the ["_type"] field to be ["location"]. *)
134134+135135+val jsont_bare : t Jsont.t
136136+(** [jsont_bare] is a JSON codec that doesn't require the ["_type"] field.
137137+ Use this for parsing recorder API responses which omit the type field. *)
138138+139139+(** {1 Pretty Printing} *)
140140+141141+val pp : Format.formatter -> t -> unit
142142+(** [pp ppf loc] pretty-prints a location message. *)
143143+144144+val format_timestamp : int -> string
145145+(** [format_timestamp tst] formats a Unix timestamp as an ISO 8601 string
146146+ in UTC timezone. *)
+32
lib/owntracks_lwt.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = { tst : int }
77+88+let v ~tst = { tst }
99+1010+let tst t = t.tst
1111+1212+let jsont_bare : t Jsont.t =
1313+ let make tst = { tst } in
1414+ Jsont.Object.map ~kind:"lwt" make
1515+ |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.tst)
1616+ |> Jsont.Object.skip_unknown
1717+ |> Jsont.Object.finish
1818+1919+let jsont : t Jsont.t =
2020+ let make _type tst =
2121+ ignore _type;
2222+ { tst }
2323+ in
2424+ Jsont.Object.map ~kind:"lwt" make
2525+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "lwt")
2626+ |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.tst)
2727+ |> Jsont.Object.skip_unknown
2828+ |> Jsont.Object.finish
2929+3030+let pp ppf lwt =
3131+ Format.fprintf ppf "LWT: client disconnected at %s"
3232+ (Owntracks_location.format_timestamp lwt.tst)
+39
lib/owntracks_lwt.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** LWT (Last Will and Testament) message type for OwnTracks.
77+88+ @canonical Owntracks.Lwt
99+1010+ Published automatically by the MQTT broker when a client disconnects
1111+ unexpectedly. This allows subscribers to know when a device has gone
1212+ offline. *)
1313+1414+type t
1515+(** The type for LWT messages. *)
1616+1717+(** {1 Constructors} *)
1818+1919+val v : tst:int -> t
2020+(** [v ~tst] creates an LWT message with the given timestamp. *)
2121+2222+(** {1 Accessors} *)
2323+2424+val tst : t -> int
2525+(** [tst lwt] returns the timestamp of the disconnection. *)
2626+2727+(** {1 JSON Codec} *)
2828+2929+val jsont : t Jsont.t
3030+(** [jsont] is a JSON codec for LWT messages.
3131+ Expects the ["_type"] field to be ["lwt"]. *)
3232+3333+val jsont_bare : t Jsont.t
3434+(** [jsont_bare] is a JSON codec that doesn't require the ["_type"] field. *)
3535+3636+(** {1 Pretty Printing} *)
3737+3838+val pp : Format.formatter -> t -> unit
3939+(** [pp ppf lwt] pretty-prints an LWT message. *)
+64
lib/owntracks_message.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t =
77+ | Location of Owntracks_location.t
88+ | Transition of Owntracks_transition.t
99+ | Waypoint of Owntracks_waypoint.t
1010+ | Card of Owntracks_card.t
1111+ | Lwt of Owntracks_lwt.t
1212+ | Unknown of string
1313+1414+let location l = Location l
1515+let transition t = Transition t
1616+let waypoint w = Waypoint w
1717+let card c = Card c
1818+let lwt l = Lwt l
1919+2020+let jsont : t Jsont.t =
2121+ let case_location =
2222+ Jsont.Object.Case.map "location" Owntracks_location.jsont_bare ~dec:location
2323+ in
2424+ let case_transition =
2525+ Jsont.Object.Case.map "transition" Owntracks_transition.jsont_bare ~dec:transition
2626+ in
2727+ let case_waypoint =
2828+ Jsont.Object.Case.map "waypoint" Owntracks_waypoint.jsont_bare ~dec:waypoint
2929+ in
3030+ let case_waypoints =
3131+ Jsont.Object.Case.map "waypoints" Owntracks_waypoint.jsont_bare ~dec:waypoint
3232+ in
3333+ let case_card =
3434+ Jsont.Object.Case.map "card" Owntracks_card.jsont_bare ~dec:card
3535+ in
3636+ let case_lwt =
3737+ Jsont.Object.Case.map "lwt" Owntracks_lwt.jsont_bare ~dec:lwt
3838+ in
3939+ let enc_case = function
4040+ | Location l -> Jsont.Object.Case.value case_location l
4141+ | Transition t -> Jsont.Object.Case.value case_transition t
4242+ | Waypoint w -> Jsont.Object.Case.value case_waypoint w
4343+ | Card c -> Jsont.Object.Case.value case_card c
4444+ | Lwt l -> Jsont.Object.Case.value case_lwt l
4545+ | Unknown _ -> assert false (* Cannot encode Unknown *)
4646+ in
4747+ let cases = Jsont.Object.Case.[
4848+ make case_location; make case_transition;
4949+ make case_waypoint; make case_waypoints;
5050+ make case_card; make case_lwt
5151+ ] in
5252+ Jsont.Object.map ~kind:"message" Fun.id
5353+ |> Jsont.Object.case_mem "_type" Jsont.string ~enc:Fun.id ~enc_case cases
5454+ |> Jsont.Object.skip_unknown
5555+ |> Jsont.Object.finish
5656+5757+let pp ppf = function
5858+ | Location loc -> Owntracks_location.pp ppf loc
5959+ | Transition tr -> Owntracks_transition.pp ppf tr
6060+ | Waypoint wp -> Owntracks_waypoint.pp ppf wp
6161+ | Card c -> Owntracks_card.pp ppf c
6262+ | Lwt l -> Owntracks_lwt.pp ppf l
6363+ | Unknown typ ->
6464+ Format.fprintf ppf "Unknown message type: %s" typ
+45
lib/owntracks_message.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** OwnTracks message variant type.
77+88+ @canonical Owntracks.Message
99+1010+ All OwnTracks message types as a single variant. Use {!jsont} with
1111+ {{:https://erratique.ch/software/jsont}jsont_bytesrw} to decode
1212+ messages from JSON strings. *)
1313+1414+type t =
1515+ | Location of Owntracks_location.t
1616+ (** A location update from the device. *)
1717+ | Transition of Owntracks_transition.t
1818+ (** A region entry/exit event. *)
1919+ | Waypoint of Owntracks_waypoint.t
2020+ (** A waypoint/region definition. *)
2121+ | Card of Owntracks_card.t
2222+ (** User information card. *)
2323+ | Lwt of Owntracks_lwt.t
2424+ (** Client disconnection notification. *)
2525+ | Unknown of string
2626+ (** Unknown message type. Contains the ["_type"] value. *)
2727+(** The type for OwnTracks messages. *)
2828+2929+(** {1 JSON Codec} *)
3030+3131+val jsont : t Jsont.t
3232+(** [jsont] is a JSON codec for OwnTracks messages.
3333+3434+ The message type is determined by the ["_type"] field in the JSON:
3535+ - ["location"] -> [Location]
3636+ - ["transition"] -> [Transition]
3737+ - ["waypoint"] or ["waypoints"] -> [Waypoint]
3838+ - ["card"] -> [Card]
3939+ - ["lwt"] -> [Lwt]
4040+ - Other values -> [Unknown] *)
4141+4242+(** {1 Pretty Printing} *)
4343+4444+val pp : Format.formatter -> t -> unit
4545+(** [pp ppf msg] pretty-prints any OwnTracks message. *)
+68
lib/owntracks_mqtt.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Mqtt_message = struct
77+ type t = {
88+ topic : string;
99+ payload : string;
1010+ qos : [ `At_most_once | `At_least_once | `Exactly_once ];
1111+ retain : bool;
1212+ }
1313+end
1414+1515+type t = {
1616+ topic : string;
1717+ user : string option;
1818+ device : string option;
1919+ message : Owntracks_message.t;
2020+}
2121+2222+let topic t = t.topic
2323+let user t = t.user
2424+let device t = t.device
2525+let message t = t.message
2626+2727+let parse_topic topic =
2828+ match String.split_on_char '/' topic with
2929+ | _ :: user :: device :: _ -> Some (user, device)
3030+ | _ -> None
3131+3232+let of_mqtt_message (msg : Mqtt_message.t) : (t, string) result =
3333+ let user, device =
3434+ match parse_topic msg.topic with
3535+ | Some (u, d) -> (Some u, Some d)
3636+ | None -> (None, None)
3737+ in
3838+ let payload_with_topic =
3939+ let payload = msg.payload in
4040+ if String.length payload > 0 && payload.[0] = '{' then
4141+ let topic_json = Printf.sprintf "{\"topic\":%S," msg.topic in
4242+ topic_json ^ String.sub payload 1 (String.length payload - 1)
4343+ else
4444+ payload
4545+ in
4646+ match Jsont_bytesrw.decode_string Owntracks_message.jsont payload_with_topic with
4747+ | Ok message -> Ok { topic = msg.topic; user; device; message }
4848+ | Error e -> Error e
4949+5050+let of_mqtt ~topic ~payload : (t, string) result =
5151+ of_mqtt_message { Mqtt_message.topic; payload; qos = `At_least_once; retain = false }
5252+5353+let default_topic = "owntracks/#"
5454+5555+let user_topic user = Printf.sprintf "owntracks/%s/#" user
5656+5757+let device_topic ~user ~device = Printf.sprintf "owntracks/%s/%s" user device
5858+5959+let pp ppf msg =
6060+ Format.fprintf ppf "@[<v 0>";
6161+ begin match msg.user, msg.device with
6262+ | Some user, Some device ->
6363+ Format.fprintf ppf "User: %s / Device: %s@," user device
6464+ | _ ->
6565+ Format.fprintf ppf "Topic: %s@," msg.topic
6666+ end;
6767+ Owntracks_message.pp ppf msg.message;
6868+ Format.fprintf ppf "@]"
+95
lib/owntracks_mqtt.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** MQTT integration for OwnTracks messages.
77+88+ @canonical Owntracks.Mqtt
99+1010+ This module provides helpers for parsing MQTT messages into OwnTracks
1111+ types and constructing MQTT topic patterns for subscriptions.
1212+1313+ {1 Topic Format}
1414+1515+ OwnTracks uses the topic pattern [owntracks/{user}/{device}] where:
1616+ - [{user}] is typically a username or identifier
1717+ - [{device}] identifies the specific device (phone, tablet, etc.)
1818+1919+ Use {!default_topic} to subscribe to all OwnTracks messages, or
2020+ {!user_topic} / {!device_topic} for filtered subscriptions. *)
2121+2222+(** {1 Types} *)
2323+2424+(** Raw MQTT message type. *)
2525+module Mqtt_message : sig
2626+ type t = {
2727+ topic : string;
2828+ payload : string;
2929+ qos : [ `At_most_once | `At_least_once | `Exactly_once ];
3030+ retain : bool;
3131+ }
3232+ (** Raw MQTT message with topic, payload, QoS level, and retain flag. *)
3333+end
3434+3535+type t
3636+(** Parsed OwnTracks message with extracted user/device information. *)
3737+3838+(** {1 Accessors} *)
3939+4040+val topic : t -> string
4141+(** [topic msg] returns the MQTT topic the message was published to. *)
4242+4343+val user : t -> string option
4444+(** [user msg] returns the user extracted from the topic, if present. *)
4545+4646+val device : t -> string option
4747+(** [device msg] returns the device extracted from the topic, if present. *)
4848+4949+val message : t -> Owntracks_message.t
5050+(** [message msg] returns the parsed OwnTracks message. *)
5151+5252+(** {1 Parsing} *)
5353+5454+val of_mqtt_message : Mqtt_message.t -> (t, string) result
5555+(** [of_mqtt_message msg] parses an MQTT message into an OwnTracks message.
5656+5757+ Extracts user and device from the topic if it follows the OwnTracks
5858+ convention ([owntracks/user/device]). The topic is also injected into
5959+ the message payload for location messages.
6060+6161+ Returns [Error] if the payload is not valid OwnTracks JSON. *)
6262+6363+val of_mqtt : topic:string -> payload:string -> (t, string) result
6464+(** [of_mqtt ~topic ~payload] is a convenience function for parsing
6565+ MQTT messages without constructing an {!Mqtt_message.t} record.
6666+6767+ Equivalent to calling {!of_mqtt_message} with default QoS and
6868+ retain settings. *)
6969+7070+(** {1 Topic Helpers} *)
7171+7272+val default_topic : string
7373+(** [default_topic] is ["owntracks/#"], a wildcard topic that matches
7474+ all OwnTracks messages from all users and devices. *)
7575+7676+val user_topic : string -> string
7777+(** [user_topic user] returns ["owntracks/{user}/#"], matching all
7878+ devices for a specific user. *)
7979+8080+val device_topic : user:string -> device:string -> string
8181+(** [device_topic ~user ~device] returns ["owntracks/{user}/{device}"],
8282+ matching a specific device. *)
8383+8484+val parse_topic : string -> (string * string) option
8585+(** [parse_topic topic] extracts the user and device from an OwnTracks topic.
8686+8787+ OwnTracks topics follow the pattern [owntracks/user/device].
8888+8989+ Returns [Some (user, device)] if the topic matches, [None] otherwise. *)
9090+9191+(** {1 Pretty Printing} *)
9292+9393+val pp : Format.formatter -> t -> unit
9494+(** [pp ppf msg] pretty-prints an OwnTracks MQTT message with user/device
9595+ information. *)
+32
lib/owntracks_recorder.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Auth = struct
77+ type t = { username : string; password : string }
88+99+ let v ~username ~password = { username; password }
1010+ let username t = t.username
1111+ let password t = t.password
1212+end
1313+1414+let locations_jsont : Owntracks_location.t list Jsont.t =
1515+ Jsont.list Owntracks_location.jsont_bare
1616+1717+let locations_data_jsont : Owntracks_location.t list Jsont.t =
1818+ let make data = data in
1919+ Jsont.Object.map ~kind:"data_response" make
2020+ |> Jsont.Object.mem "data" locations_jsont ~enc:Fun.id
2121+ |> Jsont.Object.skip_unknown
2222+ |> Jsont.Object.finish
2323+2424+let string_list_jsont : string list Jsont.t =
2525+ Jsont.list Jsont.string
2626+2727+let string_list_results_jsont : string list Jsont.t =
2828+ let make results = results in
2929+ Jsont.Object.map ~kind:"results_response" make
3030+ |> Jsont.Object.mem "results" string_list_jsont ~enc:Fun.id
3131+ |> Jsont.Object.skip_unknown
3232+ |> Jsont.Object.finish
+55
lib/owntracks_recorder.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** JSON codecs for OwnTracks Recorder HTTP API responses.
77+88+ @canonical Owntracks.Recorder
99+1010+ The {{:https://github.com/owntracks/recorder}OwnTracks Recorder} is a
1111+ server that stores location history and provides an HTTP API for
1212+ querying it.
1313+1414+ This module provides codecs for parsing JSON responses from the
1515+ Recorder API. Use these with jsont_bytesrw for decoding.
1616+1717+ {1 API Endpoints}
1818+1919+ The Recorder provides these endpoints:
2020+ - [GET /api/0/list] - List all users
2121+ - [GET /api/0/list?user=USER] - List devices for a user
2222+ - [GET /api/0/locations?user=USER&device=DEVICE&from=DATE&to=DATE] -
2323+ Fetch location history *)
2424+2525+(** {1 Types} *)
2626+2727+(** HTTP Basic Authentication credentials. *)
2828+module Auth : sig
2929+ type t
3030+ (** The type for authentication credentials. *)
3131+3232+ val v : username:string -> password:string -> t
3333+ (** [v ~username ~password] creates authentication credentials. *)
3434+3535+ val username : t -> string
3636+ (** [username auth] returns the username. *)
3737+3838+ val password : t -> string
3939+ (** [password auth] returns the password. *)
4040+end
4141+4242+(** {1 JSON Codecs} *)
4343+4444+val locations_jsont : Owntracks_location.t list Jsont.t
4545+(** Codec for a JSON array of location objects (without ["_type"] field).
4646+ Use with the [/api/0/locations] endpoint when it returns an array. *)
4747+4848+val locations_data_jsont : Owntracks_location.t list Jsont.t
4949+(** Codec for [{data: [...]}] response format from some recorder endpoints. *)
5050+5151+val string_list_jsont : string list Jsont.t
5252+(** Codec for a JSON array of strings (e.g., usernames or device names). *)
5353+5454+val string_list_results_jsont : string list Jsont.t
5555+(** Codec for [{results: [...]}] response format from [/api/0/list]. *)
+75
lib/owntracks_transition.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = {
77+ tid : string option;
88+ tst : int;
99+ lat : float;
1010+ lon : float;
1111+ acc : float option;
1212+ event : string;
1313+ desc : string option;
1414+ wtst : int option;
1515+}
1616+1717+let v ?tid ~tst ~lat ~lon ?acc ~event ?desc ?wtst () =
1818+ { tid; tst; lat; lon; acc; event; desc; wtst }
1919+2020+let tid t = t.tid
2121+let tst t = t.tst
2222+let lat t = t.lat
2323+let lon t = t.lon
2424+let acc t = t.acc
2525+let event t = t.event
2626+let desc t = t.desc
2727+let wtst t = t.wtst
2828+2929+let jsont_bare : t Jsont.t =
3030+ let make tid tst lat lon acc event desc wtst =
3131+ { tid; tst; lat; lon; acc; event; desc; wtst }
3232+ in
3333+ Jsont.Object.map ~kind:"transition" make
3434+ |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun t -> t.tid)
3535+ |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun t -> t.tst)
3636+ |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun t -> t.lat)
3737+ |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun t -> t.lon)
3838+ |> Jsont.Object.opt_mem "acc" Jsont.number ~enc:(fun t -> t.acc)
3939+ |> Jsont.Object.mem "event" Jsont.string ~enc:(fun t -> t.event)
4040+ |> Jsont.Object.opt_mem "desc" Jsont.string ~enc:(fun t -> t.desc)
4141+ |> Jsont.Object.opt_mem "wtst" Jsont.int ~enc:(fun t -> t.wtst)
4242+ |> Jsont.Object.skip_unknown
4343+ |> Jsont.Object.finish
4444+4545+let jsont : t Jsont.t =
4646+ let make _type tid tst lat lon acc event desc wtst =
4747+ ignore _type;
4848+ { tid; tst; lat; lon; acc; event; desc; wtst }
4949+ in
5050+ Jsont.Object.map ~kind:"transition" make
5151+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "transition")
5252+ |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun t -> t.tid)
5353+ |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun t -> t.tst)
5454+ |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun t -> t.lat)
5555+ |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun t -> t.lon)
5656+ |> Jsont.Object.opt_mem "acc" Jsont.number ~enc:(fun t -> t.acc)
5757+ |> Jsont.Object.mem "event" Jsont.string ~enc:(fun t -> t.event)
5858+ |> Jsont.Object.opt_mem "desc" Jsont.string ~enc:(fun t -> t.desc)
5959+ |> Jsont.Object.opt_mem "wtst" Jsont.int ~enc:(fun t -> t.wtst)
6060+ |> Jsont.Object.skip_unknown
6161+ |> Jsont.Object.finish
6262+6363+let pp ppf tr =
6464+ Format.fprintf ppf "@[<v 0>";
6565+ Format.fprintf ppf "-------------------------------------------@,";
6666+ Format.fprintf ppf " Event: %s@," (String.uppercase_ascii tr.event);
6767+ Option.iter (fun desc ->
6868+ Format.fprintf ppf " Region: %s@," desc
6969+ ) tr.desc;
7070+ Option.iter (fun tid ->
7171+ Format.fprintf ppf " Tracker: %s@," tid
7272+ ) tr.tid;
7373+ Format.fprintf ppf " Time: %s@," (Owntracks_location.format_timestamp tr.tst);
7474+ Format.fprintf ppf " Location: %.6f, %.6f@," tr.lat tr.lon;
7575+ Format.fprintf ppf "-------------------------------------------@]"
+72
lib/owntracks_transition.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Transition event type for OwnTracks.
77+88+ @canonical Owntracks.Transition
99+1010+ Published when entering or leaving a monitored region. Transitions
1111+ are triggered by geofences (circular regions) or beacons configured
1212+ in the OwnTracks app. *)
1313+1414+type t
1515+(** The type for transition events. *)
1616+1717+(** {1 Constructors} *)
1818+1919+val v :
2020+ ?tid:string ->
2121+ tst:int ->
2222+ lat:float ->
2323+ lon:float ->
2424+ ?acc:float ->
2525+ event:string ->
2626+ ?desc:string ->
2727+ ?wtst:int ->
2828+ unit ->
2929+ t
3030+(** [v ~tst ~lat ~lon ~event ()] creates a transition event. *)
3131+3232+(** {1 Accessors} *)
3333+3434+val tid : t -> string option
3535+(** [tid tr] returns the tracker ID of the device. *)
3636+3737+val tst : t -> int
3838+(** [tst tr] returns the timestamp when the transition occurred. *)
3939+4040+val lat : t -> float
4141+(** [lat tr] returns the latitude where the transition was detected. *)
4242+4343+val lon : t -> float
4444+(** [lon tr] returns the longitude where the transition was detected. *)
4545+4646+val acc : t -> float option
4747+(** [acc tr] returns the accuracy of the position in meters, if present. *)
4848+4949+val event : t -> string
5050+(** [event tr] returns the event type: ["enter"] when entering a region,
5151+ ["leave"] when leaving. *)
5252+5353+val desc : t -> string option
5454+(** [desc tr] returns the description/name of the region, if present. *)
5555+5656+val wtst : t -> int option
5757+(** [wtst tr] returns the timestamp of the waypoint definition that
5858+ triggered this transition, if present. *)
5959+6060+(** {1 JSON Codec} *)
6161+6262+val jsont : t Jsont.t
6363+(** [jsont] is a JSON codec for transition messages.
6464+ Expects the ["_type"] field to be ["transition"]. *)
6565+6666+val jsont_bare : t Jsont.t
6767+(** [jsont_bare] is a JSON codec that doesn't require the ["_type"] field. *)
6868+6969+(** {1 Pretty Printing} *)
7070+7171+val pp : Format.formatter -> t -> unit
7272+(** [pp ppf tr] pretty-prints a transition message. *)
+50
lib/owntracks_waypoint.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = {
77+ tst : int;
88+ lat : float;
99+ lon : float;
1010+ rad : int;
1111+ desc : string;
1212+}
1313+1414+let v ~tst ~lat ~lon ~rad ~desc = { tst; lat; lon; rad; desc }
1515+1616+let tst t = t.tst
1717+let lat t = t.lat
1818+let lon t = t.lon
1919+let rad t = t.rad
2020+let desc t = t.desc
2121+2222+let jsont_bare : t Jsont.t =
2323+ let make tst lat lon rad desc = { tst; lat; lon; rad; desc } in
2424+ Jsont.Object.map ~kind:"waypoint" make
2525+ |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun w -> w.tst)
2626+ |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun w -> w.lat)
2727+ |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun w -> w.lon)
2828+ |> Jsont.Object.mem "rad" Jsont.int ~enc:(fun w -> w.rad)
2929+ |> Jsont.Object.mem "desc" Jsont.string ~enc:(fun w -> w.desc)
3030+ |> Jsont.Object.skip_unknown
3131+ |> Jsont.Object.finish
3232+3333+let jsont : t Jsont.t =
3434+ let make _type tst lat lon rad desc =
3535+ ignore _type;
3636+ { tst; lat; lon; rad; desc }
3737+ in
3838+ Jsont.Object.map ~kind:"waypoint" make
3939+ |> Jsont.Object.mem "_type" Jsont.string ~enc:(fun _ -> "waypoint")
4040+ |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun w -> w.tst)
4141+ |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun w -> w.lat)
4242+ |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun w -> w.lon)
4343+ |> Jsont.Object.mem "rad" Jsont.int ~enc:(fun w -> w.rad)
4444+ |> Jsont.Object.mem "desc" Jsont.string ~enc:(fun w -> w.desc)
4545+ |> Jsont.Object.skip_unknown
4646+ |> Jsont.Object.finish
4747+4848+let pp ppf wp =
4949+ Format.fprintf ppf "Waypoint: %s at (%.6f, %.6f) radius %dm"
5050+ wp.desc wp.lat wp.lon wp.rad
+57
lib/owntracks_waypoint.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Waypoint definition type for OwnTracks.
77+88+ @canonical Owntracks.Waypoint
99+1010+ Describes a monitored circular region. Waypoints define geofences
1111+ that trigger transition events when the device enters or leaves them. *)
1212+1313+type t
1414+(** The type for waypoint definitions. *)
1515+1616+(** {1 Constructors} *)
1717+1818+val v :
1919+ tst:int ->
2020+ lat:float ->
2121+ lon:float ->
2222+ rad:int ->
2323+ desc:string ->
2424+ t
2525+(** [v ~tst ~lat ~lon ~rad ~desc] creates a waypoint definition. *)
2626+2727+(** {1 Accessors} *)
2828+2929+val tst : t -> int
3030+(** [tst wp] returns the timestamp when the waypoint was created or
3131+ last modified. *)
3232+3333+val lat : t -> float
3434+(** [lat wp] returns the latitude of the region center. *)
3535+3636+val lon : t -> float
3737+(** [lon wp] returns the longitude of the region center. *)
3838+3939+val rad : t -> int
4040+(** [rad wp] returns the radius of the circular region in meters. *)
4141+4242+val desc : t -> string
4343+(** [desc wp] returns the description/name of the waypoint. *)
4444+4545+(** {1 JSON Codec} *)
4646+4747+val jsont : t Jsont.t
4848+(** [jsont] is a JSON codec for waypoint messages.
4949+ Expects the ["_type"] field to be ["waypoint"]. *)
5050+5151+val jsont_bare : t Jsont.t
5252+(** [jsont_bare] is a JSON codec that doesn't require the ["_type"] field. *)
5353+5454+(** {1 Pretty Printing} *)
5555+5656+val pp : Format.formatter -> t -> unit
5757+(** [pp ppf wp] pretty-prints a waypoint. *)