···11+open Cmdliner
22+open Owntracks_to_clickhouse
33+44+let process_file input_file output_file =
55+ try
66+ let records = Owntracks_parser.parse_file input_file in
77+ match output_file with
88+ | Some out_path ->
99+ Clickhouse_formatter.write_jsonl_file out_path records;
1010+ Printf.printf "Converted %d records from %s to %s\n"
1111+ (List.length records) input_file out_path
1212+ | None ->
1313+ Clickhouse_formatter.print_jsonl records
1414+ with
1515+ | Sys_error msg ->
1616+ Printf.eprintf "Error: %s\n" msg;
1717+ exit 1
1818+ | e ->
1919+ Printf.eprintf "Unexpected error: %s\n" (Printexc.to_string e);
2020+ exit 1
2121+2222+let process_directory dir_path output_file recursive =
2323+ let rec find_rec_files dir =
2424+ let entries = Sys.readdir dir in
2525+ Array.fold_left (fun acc entry ->
2626+ let full_path = Filename.concat dir entry in
2727+ if Sys.is_directory full_path then
2828+ if recursive then
2929+ acc @ find_rec_files full_path
3030+ else
3131+ acc
3232+ else if Filename.check_suffix entry ".rec" then
3333+ full_path :: acc
3434+ else
3535+ acc
3636+ ) [] entries
3737+ in
3838+3939+ let rec_files = find_rec_files dir_path in
4040+ let all_records = List.fold_left (fun acc file ->
4141+ try
4242+ let records = Owntracks_parser.parse_file file in
4343+ Printf.printf "Processed %s: %d records\n" file (List.length records);
4444+ acc @ records
4545+ with e ->
4646+ Printf.eprintf "Warning: Failed to process %s: %s\n"
4747+ file (Printexc.to_string e);
4848+ acc
4949+ ) [] rec_files in
5050+5151+ match output_file with
5252+ | Some out_path ->
5353+ Clickhouse_formatter.write_jsonl_file out_path all_records;
5454+ Printf.printf "Total: Converted %d records from %d files to %s\n"
5555+ (List.length all_records) (List.length rec_files) out_path
5656+ | None ->
5757+ Clickhouse_formatter.print_jsonl all_records
5858+5959+let main input output recursive =
6060+ if Sys.is_directory input then
6161+ process_directory input output recursive
6262+ else
6363+ process_file input output
6464+6565+let input_arg =
6666+ let doc = "Input OwnTracks .rec file or directory containing .rec files" in
6767+ Arg.(required & pos 0 (some string) None & info [] ~docv:"INPUT" ~doc)
6868+6969+let output_arg =
7070+ let doc = "Output JSON lines file (if not specified, outputs to stdout)" in
7171+ Arg.(value & opt (some string) None & info ["o"; "output"] ~docv:"OUTPUT" ~doc)
7272+7373+let recursive_arg =
7474+ let doc = "Recursively process directories for .rec files" in
7575+ Arg.(value & flag & info ["r"; "recursive"] ~doc)
7676+7777+let main_term =
7878+ Term.(const main $ input_arg $ output_arg $ recursive_arg)
7979+8080+let info =
8181+ let doc = "Convert OwnTracks .rec files to ClickHouse JSON lines" in
8282+ let man = [
8383+ `S Manpage.s_description;
8484+ `P "$(tname) converts OwnTracks recorder files (.rec) to JSON lines format suitable for importing into ClickHouse with geo data types.";
8585+ `P "Each location record is converted to a JSON object with the following fields:";
8686+ `P "- timestamp: ISO 8601 formatted timestamp";
8787+ `P "- timestamp_epoch: Unix timestamp";
8888+ `P "- point: [longitude, latitude] array for ClickHouse Point type";
8989+ `P "- latitude, longitude: Individual coordinate fields";
9090+ `P "- altitude, accuracy, battery: Optional fields from OwnTracks";
9191+ `P "- tracker_id: Device identifier from OwnTracks";
9292+ `S Manpage.s_examples;
9393+ `P "Convert a single file to stdout:";
9494+ `Pre " $(tname) path/to/file.rec";
9595+ `P "Convert a single file to output file:";
9696+ `Pre " $(tname) path/to/file.rec -o output.jsonl";
9797+ `P "Process all .rec files in a directory:";
9898+ `Pre " $(tname) path/to/directory -o output.jsonl";
9999+ `P "Process directory recursively:";
100100+ `Pre " $(tname) path/to/directory -r -o output.jsonl";
101101+ ] in
102102+ Cmd.info "owntracks2clickhouse" ~version:"1.0.0" ~doc ~man
103103+104104+let cmd = Cmd.v info main_term
105105+106106+let () = exit (Cmd.eval cmd)
+14
rec-converter/dune-project
···11+(lang dune 3.0)
22+(name owntracks_to_clickhouse)
33+44+(generate_opam_files true)
55+66+(package
77+ (name owntracks_to_clickhouse)
88+ (synopsis "Convert OwnTracks .rec files to ClickHouse JSON lines")
99+ (description "A library and CLI tool to convert OwnTracks recorder files to JSON lines suitable for ClickHouse import with geo data types")
1010+ (depends
1111+ ocaml
1212+ dune
1313+ ezjsonm
1414+ cmdliner))
+54
rec-converter/example.sh
···11+#!/bin/bash
22+33+# Build the project
44+dune build
55+66+# Example 1: Convert a single .rec file to stdout
77+echo "Converting single file to stdout:"
88+dune exec owntracks2clickhouse avsm/avsm-ip15/2025-08.rec | head -5
99+1010+# Example 2: Convert a single .rec file to output file
1111+echo -e "\nConverting single file to output.jsonl:"
1212+dune exec owntracks2clickhouse avsm/avsm-ip15/2025-08.rec -o single_output.jsonl
1313+echo "Created single_output.jsonl"
1414+1515+# Example 3: Process all .rec files in a directory recursively
1616+echo -e "\nProcessing all .rec files recursively:"
1717+dune exec owntracks2clickhouse avsm -r -o all_records.jsonl
1818+1919+# Example 4: Create ClickHouse table and import data
2020+cat << 'EOF'
2121+2222+To import into ClickHouse, create a table like this:
2323+2424+CREATE TABLE owntracks_locations (
2525+ timestamp DateTime64(3),
2626+ timestamp_epoch UInt32,
2727+ point Point,
2828+ latitude Float64,
2929+ longitude Float64,
3030+ altitude Nullable(Float64),
3131+ accuracy Nullable(Float64),
3232+ battery Nullable(UInt8),
3333+ tracker_id Nullable(String)
3434+) ENGINE = MergeTree()
3535+ORDER BY (tracker_id, timestamp);
3636+3737+Then import the JSON lines file:
3838+3939+clickhouse-client --query="INSERT INTO owntracks_locations FORMAT JSONEachRow" < all_records.jsonl
4040+4141+Or using clickhouse-local for testing:
4242+4343+clickhouse-local --query="
4444+ SELECT
4545+ tracker_id,
4646+ toDate(timestamp) as date,
4747+ count() as points,
4848+ round(avg(battery), 2) as avg_battery
4949+ FROM file('all_records.jsonl', 'JSONEachRow')
5050+ GROUP BY tracker_id, date
5151+ ORDER BY date DESC
5252+ LIMIT 10
5353+"
5454+EOF
+43
rec-converter/lib/clickhouse_formatter.ml
···11+open Owntracks_parser
22+33+let option_to_json = function
44+ | Some v -> v
55+ | None -> `Null
66+77+let location_to_clickhouse_json record =
88+ let point = `A [`Float record.lon; `Float record.lat] in
99+ let timestamp_epoch = record.tst in
1010+ let timestamp_iso = record.timestamp in
1111+1212+ `O [
1313+ ("timestamp", `String timestamp_iso);
1414+ ("timestamp_epoch", `Float (float_of_int timestamp_epoch));
1515+ ("point", point);
1616+ ("latitude", `Float record.lat);
1717+ ("longitude", `Float record.lon);
1818+ ("altitude", option_to_json (Option.map (fun x -> `Float x) record.alt));
1919+ ("accuracy", option_to_json (Option.map (fun x -> `Float x) record.acc));
2020+ ("battery", option_to_json (Option.map (fun x -> `Float (float_of_int x)) record.batt));
2121+ ("tracker_id", option_to_json (Option.map (fun x -> `String x) record.tid));
2222+ ]
2323+2424+let to_jsonl records =
2525+ List.map (fun record ->
2626+ let json = location_to_clickhouse_json record in
2727+ Ezjsonm.to_string json
2828+ ) records
2929+3030+let write_jsonl_file path records =
3131+ let oc = open_out path in
3232+ List.iter (fun record ->
3333+ let json = location_to_clickhouse_json record in
3434+ output_string oc (Ezjsonm.to_string json);
3535+ output_char oc '\n'
3636+ ) records;
3737+ close_out oc
3838+3939+let print_jsonl records =
4040+ List.iter (fun record ->
4141+ let json = location_to_clickhouse_json record in
4242+ print_endline (Ezjsonm.to_string json)
4343+ ) records