Owntracks location tracking with MQTT and HTTPS (recorder) support
0
fork

Configure Feed

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

import

+507
+3
.gitignore
··· 1 + _build 2 + ocaml-mqtte 3 + vendor
+5
bin/dune
··· 1 + (executable 2 + (name owntracks_example) 3 + (public_name owntracks-subscriber) 4 + (package owntracks) 5 + (libraries mqtte mqtte.eio owntracks eio_main logs.fmt fmt.tty mirage-crypto-rng.unix))
+121
bin/owntracks_example.ml
··· 1 + (** OwnTracks MQTT Location Subscriber 2 + 3 + This example connects to an MQTT broker and subscribes to OwnTracks 4 + location messages, pretty-printing the received data. 5 + 6 + Usage: 7 + owntracks-subscriber [OPTIONS] 8 + 9 + Options can also be set via environment variables: 10 + MQTT_HOST - MQTT broker hostname (default: 127.0.0.1) 11 + MQTT_PORT - MQTT broker port (default: 1883, or 8883 with --tls) 12 + MQTT_TLS - Enable TLS (set to any value) 13 + MQTT_USER - MQTT username (optional) 14 + MQTT_PASSWORD - MQTT password (optional) 15 + MQTT_CLIENT_ID - MQTT client ID (optional) 16 + 17 + See vendor/git/recorder for the OwnTracks Recorder reference implementation. 18 + *) 19 + 20 + open Cmdliner 21 + 22 + let topic = 23 + let doc = "OwnTracks topic to subscribe to. Supports MQTT wildcards." in 24 + let env = Cmd.Env.info "OT_TOPIC" ~doc in 25 + Arg.(value & opt string "owntracks/#" & 26 + info ["t"; "topic"] ~docv:"TOPIC" ~doc ~env) 27 + 28 + let run mqtt topic = 29 + Fmt_tty.setup_std_outputs (); 30 + Logs.set_level (Some Logs.Info); 31 + Logs.set_reporter (Logs_fmt.reporter ()); 32 + 33 + let conn = mqtt.Mqtte_eio.Cmd.connection in 34 + let config = mqtt.Mqtte_eio.Cmd.config in 35 + let pool_config = mqtt.Mqtte_eio.Cmd.pool_config in 36 + 37 + Logs.info (fun m -> m "OwnTracks MQTT Subscriber"); 38 + Logs.info (fun m -> m "Connecting to %s:%d%s" conn.host conn.port 39 + (if conn.tls then " (TLS)" else "")); 40 + Logs.info (fun m -> m "Subscribing to: %s" topic); 41 + 42 + Eio_main.run @@ fun env -> 43 + Mirage_crypto_rng_unix.use_default (); 44 + Eio.Switch.run @@ fun sw -> 45 + 46 + let on_message msg = 47 + let open Mqtte_eio.Client in 48 + let payload_with_topic = 49 + let payload = msg.payload in 50 + if String.length payload > 0 && payload.[0] = '{' then 51 + let topic_json = Printf.sprintf "{\"topic\":%S," msg.topic in 52 + topic_json ^ String.sub payload 1 (String.length payload - 1) 53 + else 54 + payload 55 + in 56 + match Owntracks.decode_message payload_with_topic with 57 + | Ok message -> 58 + Format.printf "%a@." Owntracks.pp_message message 59 + | Error err -> 60 + Logs.warn (fun m -> m "Failed to parse message on [%s]: %s" 61 + msg.topic err); 62 + Logs.debug (fun m -> m "Raw payload: %s" msg.payload) 63 + in 64 + 65 + let on_disconnect () = 66 + Logs.warn (fun m -> m "Disconnected from broker") 67 + in 68 + 69 + let net = Eio.Stdenv.net env in 70 + let clock = Eio.Stdenv.clock env in 71 + 72 + let pool = Mqtte_eio.Cmd.create_pool ~sw ~net ~clock 73 + ~tls:conn.tls ~insecure:conn.insecure ~pool_config () in 74 + let endpoint = Mqtte_eio.Cmd.endpoint conn in 75 + 76 + let client = Mqtte_eio.Client.connect_with_pool 77 + ~sw 78 + ~clock 79 + ~on_message 80 + ~on_disconnect 81 + ~config 82 + ~pool 83 + ~endpoint 84 + () 85 + in 86 + 87 + Logs.info (fun m -> m "Connected to MQTT broker"); 88 + 89 + Mqtte_eio.Client.subscribe ~qos:`At_least_once [topic] client; 90 + Logs.info (fun m -> m "Subscribed to %s" topic); 91 + 92 + Logs.info (fun m -> m "Listening for OwnTracks location updates..."); 93 + Logs.info (fun m -> m "(Press Ctrl+C to exit)"); 94 + 95 + while Mqtte_eio.Client.is_connected client do 96 + Eio.Time.sleep clock 1.0 97 + done; 98 + 99 + Mqtte_eio.Client.disconnect client; 100 + Logs.info (fun m -> m "Disconnected") 101 + 102 + let term = 103 + Term.(const run $ Mqtte_eio.Cmd.term $ topic) 104 + 105 + let cmd = 106 + let doc = "Subscribe to OwnTracks location messages over MQTT" in 107 + let man = [ 108 + `S Manpage.s_description; 109 + `P "Connects to an MQTT broker and subscribes to OwnTracks location \ 110 + messages, pretty-printing the received data."; 111 + `S Manpage.s_examples; 112 + `Pre " owntracks-subscriber -h broker.example.com -p 1883 -t 'owntracks/#'"; 113 + `Pre " owntracks-subscriber -h secure.example.com --tls -u user"; 114 + `Pre " MQTT_HOST=broker.example.com owntracks-subscriber"; 115 + `S Manpage.s_bugs; 116 + `P "Report bugs at https://github.com/example/mqtt-eio/issues"; 117 + ] in 118 + let info = Cmd.info "owntracks-subscriber" ~version:"0.1.0" ~doc ~man in 119 + Cmd.v info term 120 + 121 + let () = exit (Cmd.eval cmd)
+1
dune
··· 1 + (data_only_dirs vendor)
+16
dune-project
··· 1 + (lang dune 3.20) 2 + (name owntracks) 3 + 4 + (generate_opam_files true) 5 + 6 + (license ISC) 7 + (authors "Anil Madhavapeddy") 8 + (maintainers "anil@recoil.org") 9 + 10 + (package 11 + (name owntracks) 12 + (synopsis "OwnTracks message types and JSON codecs") 13 + (description "Types and codecs for parsing OwnTracks MQTT location messages using jsont") 14 + (depends 15 + (ocaml (>= 5.1)) 16 + jsont))
+4
lib/dune
··· 1 + (library 2 + (name owntracks) 3 + (public_name owntracks) 4 + (libraries jsont jsont.bytesrw unix))
+328
lib/owntracks.ml
··· 1 + (** OwnTracks message types and JSON codecs using jsont. 2 + 3 + This module provides types and codecs for parsing OwnTracks MQTT messages. 4 + OwnTracks is an open-source location tracking app that publishes location 5 + data over MQTT. 6 + 7 + Message types include: 8 + - Location updates with coordinates, altitude, speed, etc. 9 + - Transition events for entering/leaving regions 10 + - Waypoint definitions 11 + - Cards with user information 12 + 13 + See {{:https://owntracks.org/booklet/tech/json/}OwnTracks JSON format} 14 + and the vendored recorder in vendor/git/recorder for reference. *) 15 + 16 + (** {1:types Message Types} *) 17 + 18 + (** Location message - the primary OwnTracks message type. 19 + 20 + Published when the device reports its location. Contains GPS coordinates, 21 + accuracy, altitude, speed, heading, and various device state information. *) 22 + type location = { 23 + tid : string option; (** Tracker ID (2 chars, configurable) *) 24 + tst : int; (** Timestamp (Unix epoch) *) 25 + lat : float; (** Latitude *) 26 + lon : float; (** Longitude *) 27 + alt : float option; (** Altitude in meters *) 28 + acc : float option; (** Horizontal accuracy in meters *) 29 + vel : float option; (** Velocity in km/h *) 30 + cog : float option; (** Course over ground (heading) in degrees *) 31 + batt : int option; (** Battery level percentage (0-100) *) 32 + bs : int option; (** Battery status: 0=unknown, 1=unplugged, 2=charging, 3=full *) 33 + conn : string option; (** Connection type: w=wifi, m=mobile, o=offline *) 34 + t : string option; (** Trigger: p=ping, c=circular region, b=beacon, r=response, u=manual, t=timer, v=monitoring *) 35 + m : int option; (** Monitoring mode: 0=quiet, 1=manual, 2=significant, 3=move *) 36 + poi : string option; (** Point of Interest name if at a defined waypoint *) 37 + inregions : string list; (** List of regions the device is currently in *) 38 + addr : string option; (** Reverse-geocoded address (added by recorder) *) 39 + topic : string option; (** MQTT topic (added by recorder) *) 40 + } 41 + 42 + (** Transition event - published when entering or leaving a region. *) 43 + type transition = { 44 + t_tid : string option; (** Tracker ID *) 45 + t_tst : int; (** Timestamp *) 46 + t_lat : float; (** Latitude *) 47 + t_lon : float; (** Longitude *) 48 + t_acc : float option; (** Accuracy *) 49 + t_event : string; (** "enter" or "leave" *) 50 + t_desc : string option; (** Region description *) 51 + t_wtst : int option; (** Waypoint timestamp *) 52 + } 53 + 54 + (** Waypoint definition - describes a monitored region. *) 55 + type waypoint = { 56 + w_tst : int; (** Timestamp *) 57 + w_lat : float; (** Latitude of center *) 58 + w_lon : float; (** Longitude of center *) 59 + w_rad : int; (** Radius in meters *) 60 + w_desc : string; (** Description/name *) 61 + } 62 + 63 + (** Card message - provides user information for display. *) 64 + type card = { 65 + c_name : string option; (** Full name *) 66 + c_face : string option; (** Base64-encoded image *) 67 + c_tid : string option; (** Tracker ID (must match location tid) *) 68 + } 69 + 70 + (** LWT (Last Will and Testament) message - published when client disconnects. *) 71 + type lwt = { 72 + lwt_tst : int; (** Timestamp *) 73 + } 74 + 75 + (** All OwnTracks message types. *) 76 + type message = 77 + | Location of location 78 + | Transition of transition 79 + | Waypoint of waypoint 80 + | Card of card 81 + | Lwt of lwt 82 + | Unknown of string * Jsont.json 83 + (** Unknown message type with the _type value and raw JSON *) 84 + 85 + (** {1:codecs JSON Codecs} *) 86 + 87 + (** Location message codec. *) 88 + let location_jsont : location Jsont.t = 89 + let make tid tst lat lon alt acc vel cog batt bs conn t m poi inregions addr topic = 90 + { tid; tst; lat; lon; alt; acc; vel; cog; batt; bs; conn; t; m; poi; 91 + inregions = Option.value ~default:[] inregions; addr; topic } 92 + in 93 + Jsont.Object.map ~kind:"location" make 94 + |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun l -> l.tid) 95 + |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.tst) 96 + |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun l -> l.lat) 97 + |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun l -> l.lon) 98 + |> Jsont.Object.opt_mem "alt" Jsont.number ~enc:(fun l -> l.alt) 99 + |> Jsont.Object.opt_mem "acc" Jsont.number ~enc:(fun l -> l.acc) 100 + |> Jsont.Object.opt_mem "vel" Jsont.number ~enc:(fun l -> l.vel) 101 + |> Jsont.Object.opt_mem "cog" Jsont.number ~enc:(fun l -> l.cog) 102 + |> Jsont.Object.opt_mem "batt" Jsont.int ~enc:(fun l -> l.batt) 103 + |> Jsont.Object.opt_mem "bs" Jsont.int ~enc:(fun l -> l.bs) 104 + |> Jsont.Object.opt_mem "conn" Jsont.string ~enc:(fun l -> l.conn) 105 + |> Jsont.Object.opt_mem "t" Jsont.string ~enc:(fun l -> l.t) 106 + |> Jsont.Object.opt_mem "m" Jsont.int ~enc:(fun l -> l.m) 107 + |> Jsont.Object.opt_mem "poi" Jsont.string ~enc:(fun l -> l.poi) 108 + |> Jsont.Object.opt_mem "inregions" (Jsont.list Jsont.string) 109 + ~enc:(fun l -> match l.inregions with [] -> None | xs -> Some xs) 110 + |> Jsont.Object.opt_mem "addr" Jsont.string ~enc:(fun l -> l.addr) 111 + |> Jsont.Object.opt_mem "topic" Jsont.string ~enc:(fun l -> l.topic) 112 + |> Jsont.Object.skip_unknown 113 + |> Jsont.Object.finish 114 + 115 + (** Transition message codec. *) 116 + let transition_jsont : transition Jsont.t = 117 + let make tid tst lat lon acc event desc wtst = 118 + { t_tid = tid; t_tst = tst; t_lat = lat; t_lon = lon; t_acc = acc; 119 + t_event = event; t_desc = desc; t_wtst = wtst } 120 + in 121 + Jsont.Object.map ~kind:"transition" make 122 + |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun t -> t.t_tid) 123 + |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun t -> t.t_tst) 124 + |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun t -> t.t_lat) 125 + |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun t -> t.t_lon) 126 + |> Jsont.Object.opt_mem "acc" Jsont.number ~enc:(fun t -> t.t_acc) 127 + |> Jsont.Object.mem "event" Jsont.string ~enc:(fun t -> t.t_event) 128 + |> Jsont.Object.opt_mem "desc" Jsont.string ~enc:(fun t -> t.t_desc) 129 + |> Jsont.Object.opt_mem "wtst" Jsont.int ~enc:(fun t -> t.t_wtst) 130 + |> Jsont.Object.skip_unknown 131 + |> Jsont.Object.finish 132 + 133 + (** Waypoint message codec. *) 134 + let waypoint_jsont : waypoint Jsont.t = 135 + let make tst lat lon rad desc = 136 + { w_tst = tst; w_lat = lat; w_lon = lon; w_rad = rad; w_desc = desc } 137 + in 138 + Jsont.Object.map ~kind:"waypoint" make 139 + |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun w -> w.w_tst) 140 + |> Jsont.Object.mem "lat" Jsont.number ~enc:(fun w -> w.w_lat) 141 + |> Jsont.Object.mem "lon" Jsont.number ~enc:(fun w -> w.w_lon) 142 + |> Jsont.Object.mem "rad" Jsont.int ~enc:(fun w -> w.w_rad) 143 + |> Jsont.Object.mem "desc" Jsont.string ~enc:(fun w -> w.w_desc) 144 + |> Jsont.Object.skip_unknown 145 + |> Jsont.Object.finish 146 + 147 + (** Card message codec. *) 148 + let card_jsont : card Jsont.t = 149 + let make name face tid = 150 + { c_name = name; c_face = face; c_tid = tid } 151 + in 152 + Jsont.Object.map ~kind:"card" make 153 + |> Jsont.Object.opt_mem "name" Jsont.string ~enc:(fun c -> c.c_name) 154 + |> Jsont.Object.opt_mem "face" Jsont.string ~enc:(fun c -> c.c_face) 155 + |> Jsont.Object.opt_mem "tid" Jsont.string ~enc:(fun c -> c.c_tid) 156 + |> Jsont.Object.skip_unknown 157 + |> Jsont.Object.finish 158 + 159 + (** LWT message codec. *) 160 + let lwt_jsont : lwt Jsont.t = 161 + let make tst = { lwt_tst = tst } in 162 + Jsont.Object.map ~kind:"lwt" make 163 + |> Jsont.Object.mem "tst" Jsont.int ~enc:(fun l -> l.lwt_tst) 164 + |> Jsont.Object.skip_unknown 165 + |> Jsont.Object.finish 166 + 167 + (** {1:decoding Decoding} *) 168 + 169 + (** Extract the _type field from a generic JSON object. *) 170 + let extract_type = function 171 + | Jsont.Object (members, _) -> 172 + List.find_map (fun ((name, _), value) -> 173 + if name = "_type" then 174 + match value with Jsont.String (s, _) -> Some s | _ -> None 175 + else None 176 + ) members 177 + | _ -> None 178 + 179 + (** Decode an OwnTracks JSON message from a string. 180 + 181 + Returns the appropriate message type based on the "_type" field. 182 + Unknown message types are returned as [Unknown (type_name, json)]. *) 183 + let decode_message (json_str : string) : (message, string) result = 184 + let ( let* ) = Result.bind in 185 + let decode_as jsont wrap = 186 + Result.map wrap (Jsont_bytesrw.decode_string jsont json_str) 187 + in 188 + try 189 + let* json = Jsont_bytesrw.decode_string Jsont.json json_str in 190 + match extract_type json with 191 + | Some "location" -> decode_as location_jsont (fun l -> Location l) 192 + | Some "transition" -> decode_as transition_jsont (fun t -> Transition t) 193 + | Some "waypoint" | Some "waypoints" -> decode_as waypoint_jsont (fun w -> Waypoint w) 194 + | Some "card" -> decode_as card_jsont (fun c -> Card c) 195 + | Some "lwt" -> decode_as lwt_jsont (fun l -> Lwt l) 196 + | Some other -> Ok (Unknown (other, json)) 197 + | None -> Ok (Unknown ("", json)) 198 + with exn -> 199 + Error (Printexc.to_string exn) 200 + 201 + (** {1:formatting Pretty Printing} *) 202 + 203 + (** Format a code string using a lookup table. *) 204 + let pp_code_map ~unknown codes ppf = function 205 + | Some s -> 206 + let display = List.assoc_opt s codes |> Option.value ~default:s in 207 + Format.pp_print_string ppf display 208 + | None -> Format.pp_print_string ppf unknown 209 + 210 + (** Format connection type as human-readable string. *) 211 + let pp_conn = 212 + pp_code_map ~unknown:"Unknown" 213 + ["w", "WiFi"; "m", "Mobile"; "o", "Offline"] 214 + 215 + (** Format trigger type as human-readable string. *) 216 + let pp_trigger = 217 + pp_code_map ~unknown:"Unknown" 218 + ["p", "Ping"; "c", "Circular region"; "b", "Beacon"; "r", "Response"; 219 + "u", "Manual"; "t", "Timer"; "v", "Monitoring"] 220 + 221 + (** Format timestamp as ISO 8601 string. *) 222 + let format_timestamp tst = 223 + let t = Unix.gmtime (float_of_int tst) in 224 + Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d UTC" 225 + (t.Unix.tm_year + 1900) (t.Unix.tm_mon + 1) t.Unix.tm_mday 226 + t.Unix.tm_hour t.Unix.tm_min t.Unix.tm_sec 227 + 228 + (** Parse user and device from OwnTracks topic. 229 + Topic format: owntracks/user/device *) 230 + let parse_topic topic = 231 + match String.split_on_char '/' topic with 232 + | _ :: user :: device :: _ -> Some (user, device) 233 + | _ -> None 234 + 235 + (** Pretty-print a location message. *) 236 + let pp_location ppf (loc : location) = 237 + Format.fprintf ppf "@[<v 0>"; 238 + Format.fprintf ppf "-------------------------------------------@,"; 239 + 240 + (* User/device from topic *) 241 + begin match loc.topic with 242 + | Some topic -> 243 + begin match parse_topic topic with 244 + | Some (user, device) -> 245 + Format.fprintf ppf " User: %s / %s" user device; 246 + Option.iter (fun tid -> Format.fprintf ppf " [%s]" tid) loc.tid; 247 + Format.fprintf ppf "@," 248 + | None -> 249 + Format.fprintf ppf " Topic: %s@," topic 250 + end 251 + | None -> 252 + Option.iter (fun tid -> 253 + Format.fprintf ppf " Tracker: %s@," tid 254 + ) loc.tid 255 + end; 256 + 257 + Format.fprintf ppf " Time: %s@," (format_timestamp loc.tst); 258 + Format.fprintf ppf " Location: %.6f, %.6f@," loc.lat loc.lon; 259 + 260 + Option.iter (fun alt -> 261 + Format.fprintf ppf " Altitude: %.1f m@," alt 262 + ) loc.alt; 263 + 264 + Option.iter (fun acc -> 265 + Format.fprintf ppf " Accuracy: +/- %.0f m@," acc 266 + ) loc.acc; 267 + 268 + Option.iter (fun vel -> 269 + Format.fprintf ppf " Speed: %.1f km/h@," vel 270 + ) loc.vel; 271 + 272 + Option.iter (fun cog -> 273 + Format.fprintf ppf " Heading: %.0f deg@," cog 274 + ) loc.cog; 275 + 276 + Option.iter (fun batt -> 277 + Format.fprintf ppf " Battery: %d%%@," batt 278 + ) loc.batt; 279 + 280 + Format.fprintf ppf " Conn: %a@," pp_conn loc.conn; 281 + 282 + Option.iter (fun _ -> 283 + Format.fprintf ppf " Trigger: %a@," pp_trigger loc.t 284 + ) loc.t; 285 + 286 + Option.iter (fun poi -> 287 + Format.fprintf ppf " POI: %s@," poi 288 + ) loc.poi; 289 + 290 + if loc.inregions <> [] then 291 + Format.fprintf ppf " Regions: %s@," (String.concat ", " loc.inregions); 292 + 293 + Option.iter (fun addr -> 294 + Format.fprintf ppf " Address: %s@," addr 295 + ) loc.addr; 296 + 297 + Format.fprintf ppf "-------------------------------------------@]" 298 + 299 + (** Pretty-print a transition message. *) 300 + let pp_transition ppf (tr : transition) = 301 + Format.fprintf ppf "@[<v 0>"; 302 + Format.fprintf ppf "-------------------------------------------@,"; 303 + Format.fprintf ppf " Event: %s@," (String.uppercase_ascii tr.t_event); 304 + Option.iter (fun desc -> 305 + Format.fprintf ppf " Region: %s@," desc 306 + ) tr.t_desc; 307 + Option.iter (fun tid -> 308 + Format.fprintf ppf " Tracker: %s@," tid 309 + ) tr.t_tid; 310 + Format.fprintf ppf " Time: %s@," (format_timestamp tr.t_tst); 311 + Format.fprintf ppf " Location: %.6f, %.6f@," tr.t_lat tr.t_lon; 312 + Format.fprintf ppf "-------------------------------------------@]" 313 + 314 + (** Pretty-print any OwnTracks message. *) 315 + let pp_message ppf = function 316 + | Location loc -> pp_location ppf loc 317 + | Transition tr -> pp_transition ppf tr 318 + | Waypoint wp -> 319 + Format.fprintf ppf "Waypoint: %s at (%.6f, %.6f) radius %dm" 320 + wp.w_desc wp.w_lat wp.w_lon wp.w_rad 321 + | Card c -> 322 + Format.fprintf ppf "Card: %s" 323 + (Option.value ~default:"(no name)" c.c_name) 324 + | Lwt l -> 325 + Format.fprintf ppf "LWT: client disconnected at %s" 326 + (format_timestamp l.lwt_tst) 327 + | Unknown (typ, _) -> 328 + Format.fprintf ppf "Unknown message type: %s" typ
+29
owntracks.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "OwnTracks message types and JSON codecs" 4 + description: 5 + "Types and codecs for parsing OwnTracks MQTT location messages using jsont" 6 + maintainer: ["anil@recoil.org"] 7 + authors: ["Anil Madhavapeddy"] 8 + license: "ISC" 9 + depends: [ 10 + "dune" {>= "3.20"} 11 + "ocaml" {>= "5.1"} 12 + "jsont" 13 + "odoc" {with-doc} 14 + ] 15 + build: [ 16 + ["dune" "subst"] {dev} 17 + [ 18 + "dune" 19 + "build" 20 + "-p" 21 + name 22 + "-j" 23 + jobs 24 + "@install" 25 + "@runtest" {with-test} 26 + "@doc" {with-doc} 27 + ] 28 + ] 29 + x-maintenance-intent: ["(latest)"]