AX.25 Amateur Radio Link-Layer Protocol
0
fork

Configure Feed

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

Squashed 'ocaml-ax25/' content from commit 4089ab86 git-subtree-split: 4089ab86f35f8e13956dce92c5bb6a77547ca34c

+1531
+1
.ocamlformat
··· 1 + version = 0.28.1
+17
ax25.install
··· 1 + lib: [ 2 + "_build/install/default/lib/ax25/META" 3 + "_build/install/default/lib/ax25/ax25.a" 4 + "_build/install/default/lib/ax25/ax25.cma" 5 + "_build/install/default/lib/ax25/ax25.cmi" 6 + "_build/install/default/lib/ax25/ax25.cmt" 7 + "_build/install/default/lib/ax25/ax25.cmti" 8 + "_build/install/default/lib/ax25/ax25.cmx" 9 + "_build/install/default/lib/ax25/ax25.cmxa" 10 + "_build/install/default/lib/ax25/ax25.ml" 11 + "_build/install/default/lib/ax25/ax25.mli" 12 + "_build/install/default/lib/ax25/dune-package" 13 + "_build/install/default/lib/ax25/opam" 14 + ] 15 + libexec: [ 16 + "_build/install/default/lib/ax25/ax25.cmxs" 17 + ]
+26
ax25.opam
··· 1 + opam-version: "2.0" 2 + synopsis: "AX.25 Amateur Radio Link-Layer Protocol" 3 + description: """ 4 + AX.25 is the data link layer protocol used by amateur (ham) radio operators 5 + for packet radio communications. It's also used by many amateur satellites 6 + and CubeSats. 7 + 8 + This library provides encoding/decoding for AX.25 frames including UI frames, 9 + control frames, KISS TNC framing, and HDLC bit stuffing. 10 + """ 11 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 12 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 13 + license: "ISC" 14 + homepage: "https://git.recoil.org/gazagnaire.org/ocaml-ax25" 15 + bug-reports: "https://git.recoil.org/gazagnaire.org/ocaml-ax25/issues" 16 + dev-repo: "git+https://git.recoil.org/gazagnaire.org/ocaml-ax25.git" 17 + depends: [ 18 + "ocaml" {>= "4.14"} 19 + "dune" {>= "3.0"} 20 + "alcotest" {with-test} 21 + "crowbar" {with-test} 22 + ] 23 + build: [ 24 + ["dune" "subst"] {dev} 25 + ["dune" "build" "-p" name "-j" jobs] 26 + ]
+3
dune-project
··· 1 + (lang dune 3.0) 2 + (name ax25) 3 + (formatting (enabled_for ocaml))
+1
fuzz/corpus/seed_000
··· 1 + ��@@@@a�`����a���
+1
fuzz/corpus/seed_001
··· 1 + ��@@@@a�`����a�Hiʤ
+1
fuzz/corpus/seed_002
··· 1 + ��@@@@a�`����a�Hello AX.25!v�
+1
fuzz/corpus/seed_003
··· 1 + ������`�b��@@k�Test��
+1
fuzz/corpus/seed_004
··· 1 + ��@@@@`�`����`�����@a�Via digi��
+1
fuzz/corpus/seed_005
··· 1 + ��@@@@`�`����`�����@`����b@c�Via digis��
fuzz/corpus/seed_006

This is a binary file and will not be displayed.

fuzz/corpus/seed_007

This is a binary file and will not be displayed.

+1
fuzz/corpus/seed_008
··· 1 + ~��@@@@a�`����a�HDLC��~
+1
fuzz/corpus/seed_009
··· 1 + ��@@@@a�`����a�IP packet��
+1
fuzz/corpus/seed_010
··· 1 + ��@@@@a�`����a�ARPf�
+1
fuzz/corpus/seed_011
··· 1 + ��@@@@a�`����a/d�
+1
fuzz/corpus/seed_012
··· 1 + ��@@@@a�`����aCW
+1
fuzz/corpus/seed_013
··· 1 + ��@@@@a�`����ac v
+1
fuzz/corpus/seed_014
··· 1 + ��@@@@a�`����a��I frame data^n
+1
fuzz/corpus/seed_015
··· 1 + ��@@@@a�`����a��
+1
fuzz/corpus/seed_016
··· 1 + ��@@@@a�`����aU�"
+1
fuzz/corpus/seed_017
··· 1 + ��@@@@a�`����a�X>
+1
fuzz/corpus/seed_018
··· 1 + �@@@@@a�@@@@@a�6�
+1
fuzz/corpus/seed_019
··· 1 + ���npr~���bdf��i
+1
fuzz/corpus/seed_020
··· 1 + ��@@@@`�`����`����`@`����b@b����d@d����f@f����h@h����j@j����l@l����n@o�8 digis-�
fuzz/corpus/seed_021

This is a binary file and will not be displayed.

fuzz/corpus/seed_022

This is a binary file and will not be displayed.

fuzz/corpus/seed_023

This is a binary file and will not be displayed.

fuzz/corpus/seed_024

This is a binary file and will not be displayed.

+1
fuzz/corpus/seed_025
··· 1 + ����������������������������������������������������������������������������������������������������
+30
fuzz/dune
··· 1 + ; Crowbar fuzz testing for AX.25 roundtripping 2 + ; 3 + ; To run fuzz tests (requires crowbar): 4 + ; opam install crowbar 5 + ; dune exec fuzz/fuzz_ax25.exe 6 + ; 7 + ; For AFL-based fuzzing: 8 + ; dune exec fuzz/fuzz_ax25.exe -- --fuzz 9 + ; afl-fuzz -i fuzz/corpus -o fuzz/findings -- ./_build/default/fuzz/fuzz_ax25.exe @@ 10 + ; 11 + ; To generate seed corpus: 12 + ; dune exec fuzz/gen_corpus.exe 13 + 14 + (executable 15 + (name fuzz_ax25) 16 + (modules fuzz_ax25) 17 + (libraries ax25 crowbar)) 18 + 19 + (executable 20 + (name gen_corpus) 21 + (modules gen_corpus) 22 + (libraries ax25 unix)) 23 + 24 + (rule 25 + (alias fuzz) 26 + (deps 27 + fuzz_ax25.exe 28 + (source_tree corpus)) 29 + (action 30 + (run %{exe:fuzz_ax25.exe})))
+154
fuzz/fuzz_ax25.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (* Crowbar-based fuzz testing for AX.25 roundtripping *) 7 + 8 + open Crowbar 9 + 10 + (* Generator for valid SSID (0-15) *) 11 + let ssid_gen = map [ range 16 ] Fun.id 12 + 13 + (* Generator for valid callsign string (1-6 chars) *) 14 + let call_gen = 15 + map 16 + [ range 6 ] 17 + (fun len -> 18 + let len = len + 1 in 19 + (* 1-6 chars *) 20 + String.init len (fun _ -> 21 + let n = Random.int 36 in 22 + if n < 26 then Char.chr (Char.code 'A' + n) 23 + else Char.chr (Char.code '0' + n - 26))) 24 + 25 + (* Generator for callsign *) 26 + let callsign_gen = 27 + map [ call_gen; ssid_gen ] (fun call ssid -> 28 + match Ax25.callsign ~call ~ssid with 29 + | Some cs -> cs 30 + | None -> Ax25.callsign_exn ~call:"TEST" ~ssid:0) 31 + 32 + (* Generator for control field *) 33 + let control_gen = 34 + choose 35 + [ 36 + const Ax25.UI; 37 + const Ax25.SABM; 38 + const Ax25.DISC; 39 + const Ax25.UA; 40 + const Ax25.DM; 41 + const Ax25.FRMR; 42 + map [ range 8; range 8; bool ] (fun ns nr poll -> Ax25.I { ns; nr; poll }); 43 + map [ range 8; bool ] (fun nr poll -> Ax25.RR { nr; poll }); 44 + map [ range 8; bool ] (fun nr poll -> Ax25.RNR { nr; poll }); 45 + map [ range 8; bool ] (fun nr poll -> Ax25.REJ { nr; poll }); 46 + ] 47 + 48 + (* Reserved PID values that decode to specific variants *) 49 + let reserved_pids = [ 0xF0; 0xCC; 0xCD; 0xCE; 0xCF; 0xFF ] 50 + 51 + (* Generator for PID - avoid reserved values for Other *) 52 + let pid_gen = 53 + choose 54 + [ 55 + const Ax25.No_layer3; 56 + const Ax25.IP; 57 + const Ax25.ARP; 58 + const Ax25.FlexNet; 59 + const Ax25.NetROM; 60 + const Ax25.Escape; 61 + map [ uint8 ] (fun b -> 62 + (* Avoid reserved PIDs that decode to specific variants *) 63 + if List.mem b reserved_pids then Ax25.Other 0x00 else Ax25.Other b); 64 + ] 65 + 66 + (* Generator for info field (0-256 bytes) *) 67 + let info_gen = map [ bytes ] (fun s -> Bytes.of_string s) 68 + 69 + (* Generator for digipeater list (0-8) *) 70 + let digipeaters_gen = 71 + map [ list callsign_gen ] (fun cs -> List.filteri (fun i _ -> i < 8) cs) 72 + 73 + (* Generator for UI frame (most common type) *) 74 + let ui_frame_gen = 75 + map [ callsign_gen; callsign_gen; digipeaters_gen; info_gen ] 76 + (fun src dst digis info -> Ax25.ui_frame ~src ~dst ~digis info) 77 + 78 + (* Compare frames for equality *) 79 + let frame_equal (a : Ax25.frame) (b : Ax25.frame) = 80 + Ax25.string_of_callsign a.address.source 81 + = Ax25.string_of_callsign b.address.source 82 + && Ax25.string_of_callsign a.address.destination 83 + = Ax25.string_of_callsign b.address.destination 84 + && List.length a.address.digipeaters = List.length b.address.digipeaters 85 + && List.for_all2 86 + (fun x y -> Ax25.string_of_callsign x = Ax25.string_of_callsign y) 87 + a.address.digipeaters b.address.digipeaters 88 + && Ax25.byte_of_control a.control = Ax25.byte_of_control b.control 89 + && Option.map Ax25.byte_of_pid a.pid = Option.map Ax25.byte_of_pid b.pid 90 + && a.info = b.info 91 + 92 + let pp_frame ppf frame = Ax25.pp ppf frame 93 + 94 + (* Test encode-decode roundtrip for UI frames *) 95 + let test_ui_encode_decode_roundtrip frame = 96 + let encoded = Ax25.encode frame in 97 + match Ax25.decode encoded with 98 + | Ok decoded -> check_eq ~eq:frame_equal ~pp:pp_frame frame decoded 99 + | Error e -> failf "decode failed: %a" Ax25.pp_error e 100 + 101 + (* Test KISS encode-decode roundtrip *) 102 + let test_kiss_roundtrip frame = 103 + let kiss_data = Ax25.kiss_encode frame in 104 + match Ax25.kiss_decode kiss_data with 105 + | Ok decoded -> check_eq ~eq:frame_equal ~pp:pp_frame frame decoded 106 + | Error e -> failf "KISS decode failed: %a" Ax25.pp_error e 107 + 108 + (* Test decode from arbitrary bytes - should not crash *) 109 + let test_decode_arbitrary_bytes input = 110 + let data = Bytes.of_string input in 111 + match Ax25.decode data with 112 + | Ok _ -> () 113 + | Error _ -> () (* Invalid input is fine, just shouldn't crash *) 114 + 115 + (* Test KISS decode from arbitrary bytes - should not crash *) 116 + let test_kiss_decode_arbitrary_bytes input = 117 + let data = Bytes.of_string input in 118 + match Ax25.kiss_decode data with Ok _ -> () | Error _ -> () 119 + 120 + (* Test HDLC decode from arbitrary bytes - should not crash *) 121 + let test_hdlc_decode_arbitrary_bytes input = 122 + let data = Bytes.of_string input in 123 + match Ax25.hdlc_decode data with Ok _ -> () | Error _ -> () 124 + 125 + (* Test CRC computation doesn't crash *) 126 + let test_crc_arbitrary_bytes input = 127 + let data = Bytes.of_string input in 128 + let _ = Ax25.crc_ccitt data in 129 + () 130 + 131 + (* Test control byte roundtrip *) 132 + let test_control_roundtrip control = 133 + let byte = Ax25.byte_of_control control in 134 + let decoded = Ax25.control_of_byte byte in 135 + check_eq ~eq:( = ) ~pp:(fun ppf c -> Ax25.pp_control ppf c) control decoded 136 + 137 + (* Test PID byte roundtrip *) 138 + let test_pid_roundtrip pid = 139 + let byte = Ax25.byte_of_pid pid in 140 + let decoded = Ax25.pid_of_byte byte in 141 + check_eq ~eq:( = ) ~pp:(fun ppf p -> Ax25.pp_pid ppf p) pid decoded 142 + 143 + let () = 144 + add_test ~name:"UI frame encode-decode roundtrip" [ ui_frame_gen ] 145 + test_ui_encode_decode_roundtrip; 146 + add_test ~name:"KISS roundtrip" [ ui_frame_gen ] test_kiss_roundtrip; 147 + add_test ~name:"decode arbitrary bytes" [ bytes ] test_decode_arbitrary_bytes; 148 + add_test ~name:"KISS decode arbitrary bytes" [ bytes ] 149 + test_kiss_decode_arbitrary_bytes; 150 + add_test ~name:"HDLC decode arbitrary bytes" [ bytes ] 151 + test_hdlc_decode_arbitrary_bytes; 152 + add_test ~name:"CRC arbitrary bytes" [ bytes ] test_crc_arbitrary_bytes; 153 + add_test ~name:"control byte roundtrip" [ control_gen ] test_control_roundtrip; 154 + add_test ~name:"PID byte roundtrip" [ pid_gen ] test_pid_roundtrip
+158
fuzz/gen_corpus.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (* Generate seed corpus for AX.25 fuzz testing *) 7 + 8 + let write_seed dir n data = 9 + let filename = Printf.sprintf "%s/seed_%03d" dir n in 10 + let oc = open_out_bin filename in 11 + output_bytes oc data; 12 + close_out oc 13 + 14 + let () = 15 + let dir = "fuzz/corpus" in 16 + (try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); 17 + let n = ref 0 in 18 + let add data = 19 + write_seed dir !n data; 20 + incr n 21 + in 22 + (* Basic UI frames *) 23 + let src = Ax25.callsign_exn ~call:"N0CALL" ~ssid:0 in 24 + let dst = Ax25.callsign_exn ~call:"CQ" ~ssid:0 in 25 + (* Empty info *) 26 + add (Ax25.encode (Ax25.ui_frame ~src ~dst Bytes.empty)); 27 + (* Short info *) 28 + add (Ax25.encode (Ax25.ui_frame ~src ~dst (Bytes.of_string "Hi"))); 29 + (* Longer info *) 30 + add (Ax25.encode (Ax25.ui_frame ~src ~dst (Bytes.of_string "Hello AX.25!"))); 31 + (* With SSID *) 32 + let src1 = Ax25.callsign_exn ~call:"W1AW" ~ssid:5 in 33 + let dst1 = Ax25.callsign_exn ~call:"BEACON" ~ssid:0 in 34 + add (Ax25.encode (Ax25.ui_frame ~src:src1 ~dst:dst1 (Bytes.of_string "Test"))); 35 + (* With digipeaters *) 36 + let digi1 = Ax25.callsign_exn ~call:"RELAY" ~ssid:0 in 37 + let digi2 = Ax25.callsign_exn ~call:"WIDE1" ~ssid:1 in 38 + add 39 + (Ax25.encode 40 + (Ax25.ui_frame ~src ~dst ~digis:[ digi1 ] (Bytes.of_string "Via digi"))); 41 + add 42 + (Ax25.encode 43 + (Ax25.ui_frame ~src ~dst ~digis:[ digi1; digi2 ] 44 + (Bytes.of_string "Via digis"))); 45 + (* KISS framed *) 46 + add (Ax25.kiss_encode (Ax25.ui_frame ~src ~dst (Bytes.of_string "KISS"))); 47 + (* KISS with special characters *) 48 + add 49 + (Ax25.kiss_encode (Ax25.ui_frame ~src ~dst (Bytes.of_string "A\xC0B\xDBC"))); 50 + (* HDLC framed *) 51 + add (Ax25.hdlc_encode (Ax25.ui_frame ~src ~dst (Bytes.of_string "HDLC"))); 52 + (* Different PIDs *) 53 + let frame_ip = 54 + { 55 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 56 + control = Ax25.UI; 57 + pid = Some Ax25.IP; 58 + info = Bytes.of_string "IP packet"; 59 + } 60 + in 61 + add (Ax25.encode frame_ip); 62 + let frame_arp = 63 + { 64 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 65 + control = Ax25.UI; 66 + pid = Some Ax25.ARP; 67 + info = Bytes.of_string "ARP"; 68 + } 69 + in 70 + add (Ax25.encode frame_arp); 71 + (* Control frames *) 72 + let sabm_frame = 73 + { 74 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 75 + control = Ax25.SABM; 76 + pid = None; 77 + info = Bytes.empty; 78 + } 79 + in 80 + add (Ax25.encode sabm_frame); 81 + let disc_frame = 82 + { 83 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 84 + control = Ax25.DISC; 85 + pid = None; 86 + info = Bytes.empty; 87 + } 88 + in 89 + add (Ax25.encode disc_frame); 90 + let ua_frame = 91 + { 92 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 93 + control = Ax25.UA; 94 + pid = None; 95 + info = Bytes.empty; 96 + } 97 + in 98 + add (Ax25.encode ua_frame); 99 + (* I frame *) 100 + let i_frame = 101 + { 102 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 103 + control = Ax25.I { ns = 3; nr = 5; poll = true }; 104 + pid = Some Ax25.No_layer3; 105 + info = Bytes.of_string "I frame data"; 106 + } 107 + in 108 + add (Ax25.encode i_frame); 109 + (* S frames *) 110 + let rr_frame = 111 + { 112 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 113 + control = Ax25.RR { nr = 7; poll = false }; 114 + pid = None; 115 + info = Bytes.empty; 116 + } 117 + in 118 + add (Ax25.encode rr_frame); 119 + let rnr_frame = 120 + { 121 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 122 + control = Ax25.RNR { nr = 2; poll = true }; 123 + pid = None; 124 + info = Bytes.empty; 125 + } 126 + in 127 + add (Ax25.encode rnr_frame); 128 + let rej_frame = 129 + { 130 + Ax25.address = { destination = dst; source = src; digipeaters = [] }; 131 + control = Ax25.REJ { nr = 4; poll = false }; 132 + pid = None; 133 + info = Bytes.empty; 134 + } 135 + in 136 + add (Ax25.encode rej_frame); 137 + (* Edge cases *) 138 + (* Minimum callsign *) 139 + let min_src = Ax25.callsign_exn ~call:"A" ~ssid:0 in 140 + let min_dst = Ax25.callsign_exn ~call:"B" ~ssid:0 in 141 + add (Ax25.encode (Ax25.ui_frame ~src:min_src ~dst:min_dst Bytes.empty)); 142 + (* Maximum callsign *) 143 + let max_src = Ax25.callsign_exn ~call:"ABC123" ~ssid:15 in 144 + let max_dst = Ax25.callsign_exn ~call:"XYZ789" ~ssid:15 in 145 + add (Ax25.encode (Ax25.ui_frame ~src:max_src ~dst:max_dst Bytes.empty)); 146 + (* Many digipeaters *) 147 + let digis = 148 + List.init 8 (fun i -> 149 + Ax25.callsign_exn ~call:(Printf.sprintf "DIGI%d" i) ~ssid:i) 150 + in 151 + add (Ax25.encode (Ax25.ui_frame ~src ~dst ~digis (Bytes.of_string "8 digis"))); 152 + (* Raw bytes for negative testing *) 153 + add (Bytes.make 0 '\x00'); 154 + add (Bytes.make 1 '\x00'); 155 + add (Bytes.make 16 '\x00'); 156 + add (Bytes.make 17 '\x00'); 157 + add (Bytes.make 100 '\xFF'); 158 + Printf.printf "Generated %d seed files in %s/\n" !n dir
+611
lib/ax25.ml
··· 1 + (** AX.25 Amateur Radio Link-Layer Protocol. 2 + 3 + @see <https://www.ax25.net/AX25.2.2-Jul%2098-2.pdf> 4 + AX.25 Link Access Protocol *) 5 + 6 + (* {1 Internal Binary Utilities} 7 + 8 + Minimal reader/writer for in-memory byte operations. 9 + These are self-contained and don't depend on bytesrw. *) 10 + 11 + type truncated = { need : int; have : int } 12 + 13 + module Reader = struct 14 + type t = { buf : bytes; len : int; mutable pos : int } 15 + 16 + let of_bytes buf = { buf; len = Bytes.length buf; pos = 0 } 17 + let remaining t = t.len - t.pos 18 + 19 + let ensure t n = 20 + if remaining t >= n then Ok () 21 + else Error (`Truncated { need = n; have = remaining t }) 22 + 23 + let uint8 t = 24 + if t.pos >= t.len then invalid_arg "Reader.uint8: need 1 byte, have 0"; 25 + let b = Bytes.get_uint8 t.buf t.pos in 26 + t.pos <- t.pos + 1; 27 + b 28 + 29 + let bytes t n = 30 + if t.pos + n > t.len then 31 + invalid_arg 32 + (Printf.sprintf "Reader.bytes: need %d bytes, have %d" n (remaining t)); 33 + let result = Bytes.create n in 34 + Bytes.blit t.buf t.pos result 0 n; 35 + t.pos <- t.pos + n; 36 + result 37 + end 38 + 39 + module Writer = struct 40 + type t = { buf : bytes; len : int; mutable pos : int; mutable max_pos : int } 41 + 42 + let create n = { buf = Bytes.create n; len = n; pos = 0; max_pos = 0 } 43 + let update_max_pos t = if t.pos > t.max_pos then t.max_pos <- t.pos 44 + 45 + let uint8 t v = 46 + if t.pos >= t.len then invalid_arg "Writer.uint8: buffer full"; 47 + Bytes.set_uint8 t.buf t.pos (v land 0xFF); 48 + t.pos <- t.pos + 1; 49 + update_max_pos t 50 + 51 + let bytes t data = 52 + let n = Bytes.length data in 53 + if t.pos + n > t.len then 54 + invalid_arg 55 + (Printf.sprintf "Writer.bytes: need %d bytes, have %d" n (t.len - t.pos)); 56 + Bytes.blit data 0 t.buf t.pos n; 57 + t.pos <- t.pos + n; 58 + update_max_pos t 59 + 60 + let contents t = Bytes.sub t.buf 0 t.max_pos 61 + end 62 + 63 + (* {1 Callsigns} *) 64 + 65 + type ssid = int 66 + 67 + let ssid_of_int i = if i >= 0 && i <= 15 then Some i else None 68 + 69 + let ssid_of_int_exn i = 70 + match ssid_of_int i with 71 + | Some s -> s 72 + | None -> invalid_arg (Printf.sprintf "SSID must be 0-15, got %d" i) 73 + 74 + let int_of_ssid s = s 75 + 76 + type callsign = { call : string; ssid : ssid } 77 + 78 + let is_valid_call_char c = (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') 79 + 80 + let callsign ~call ~ssid = 81 + let call = String.uppercase_ascii call in 82 + let len = String.length call in 83 + if len < 1 || len > 6 then None 84 + else if not (String.for_all is_valid_call_char call) then None 85 + else 86 + match ssid_of_int ssid with 87 + | None -> None 88 + | Some ssid -> Some { call; ssid } 89 + 90 + let callsign_exn ~call ~ssid = 91 + match callsign ~call ~ssid with 92 + | Some c -> c 93 + | None -> invalid_arg (Printf.sprintf "Invalid callsign: %s-%d" call ssid) 94 + 95 + let callsign_of_string s = 96 + match String.split_on_char '-' s with 97 + | [ call ] -> callsign ~call ~ssid:0 98 + | [ call; ssid_str ] -> ( 99 + match int_of_string_opt ssid_str with 100 + | Some ssid -> callsign ~call ~ssid 101 + | None -> None) 102 + | _ -> None 103 + 104 + let string_of_callsign c = 105 + if c.ssid = 0 then c.call else Printf.sprintf "%s-%d" c.call c.ssid 106 + 107 + let pp_callsign ppf c = Format.pp_print_string ppf (string_of_callsign c) 108 + 109 + (* {1 Addresses} *) 110 + 111 + type address = { 112 + destination : callsign; 113 + source : callsign; 114 + digipeaters : callsign list; 115 + } 116 + 117 + let pp_address ppf addr = 118 + Format.fprintf ppf "%a>%a" pp_callsign addr.source pp_callsign 119 + addr.destination; 120 + List.iter (fun d -> Format.fprintf ppf ",%a" pp_callsign d) addr.digipeaters 121 + 122 + (* {1 Control Field} *) 123 + 124 + type control = 125 + | UI 126 + | SABM 127 + | DISC 128 + | UA 129 + | DM 130 + | I of { ns : int; nr : int; poll : bool } 131 + | RR of { nr : int; poll : bool } 132 + | RNR of { nr : int; poll : bool } 133 + | REJ of { nr : int; poll : bool } 134 + | FRMR 135 + | Unknown of int 136 + 137 + let pp_control ppf = function 138 + | UI -> Format.pp_print_string ppf "UI" 139 + | SABM -> Format.pp_print_string ppf "SABM" 140 + | DISC -> Format.pp_print_string ppf "DISC" 141 + | UA -> Format.pp_print_string ppf "UA" 142 + | DM -> Format.pp_print_string ppf "DM" 143 + | I { ns; nr; poll } -> 144 + Format.fprintf ppf "I(N(S)=%d,N(R)=%d%s)" ns nr 145 + (if poll then ",P" else "") 146 + | RR { nr; poll } -> 147 + Format.fprintf ppf "RR(N(R)=%d%s)" nr (if poll then ",P/F" else "") 148 + | RNR { nr; poll } -> 149 + Format.fprintf ppf "RNR(N(R)=%d%s)" nr (if poll then ",P/F" else "") 150 + | REJ { nr; poll } -> 151 + Format.fprintf ppf "REJ(N(R)=%d%s)" nr (if poll then ",P/F" else "") 152 + | FRMR -> Format.pp_print_string ppf "FRMR" 153 + | Unknown b -> Format.fprintf ppf "Unknown(0x%02X)" b 154 + 155 + let byte_of_control = function 156 + | UI -> 0x03 157 + | SABM -> 0x2F (* with P bit set: 0x3F *) 158 + | DISC -> 0x43 (* with P bit set: 0x53 *) 159 + | UA -> 0x63 (* with F bit set: 0x73 *) 160 + | DM -> 0x0F (* with F bit set: 0x1F *) 161 + | I { ns; nr; poll } -> 162 + (nr lsl 5) lor (if poll then 0x10 else 0) lor (ns lsl 1) 163 + | RR { nr; poll } -> (nr lsl 5) lor (if poll then 0x10 else 0) lor 0x01 164 + | RNR { nr; poll } -> (nr lsl 5) lor (if poll then 0x10 else 0) lor 0x05 165 + | REJ { nr; poll } -> (nr lsl 5) lor (if poll then 0x10 else 0) lor 0x09 166 + | FRMR -> 0x87 167 + | Unknown b -> b 168 + 169 + let control_of_byte b = 170 + if b land 0x01 = 0 then 171 + (* I frame: LSB = 0 *) 172 + let ns = (b lsr 1) land 0x07 in 173 + let poll = b land 0x10 <> 0 in 174 + let nr = (b lsr 5) land 0x07 in 175 + I { ns; nr; poll } 176 + else if b land 0x03 = 0x01 then 177 + (* S frame: bits 0-1 = 01 *) 178 + let poll = b land 0x10 <> 0 in 179 + let nr = (b lsr 5) land 0x07 in 180 + match b land 0x0F with 181 + | 0x01 -> RR { nr; poll } 182 + | 0x05 -> RNR { nr; poll } 183 + | 0x09 -> REJ { nr; poll } 184 + | _ -> Unknown b 185 + else 186 + (* U frame: bits 0-1 = 11 *) 187 + match b land 0xEF with 188 + (* mask out P/F bit *) 189 + | 0x03 -> UI 190 + | 0x2F -> SABM 191 + | 0x43 -> DISC 192 + | 0x63 -> UA 193 + | 0x0F -> DM 194 + | 0x87 -> FRMR 195 + | _ -> Unknown b 196 + 197 + (* {1 Protocol Identifier} *) 198 + 199 + type pid = No_layer3 | IP | ARP | FlexNet | NetROM | Escape | Other of int 200 + 201 + let pp_pid ppf = function 202 + | No_layer3 -> Format.pp_print_string ppf "No_layer3" 203 + | IP -> Format.pp_print_string ppf "IP" 204 + | ARP -> Format.pp_print_string ppf "ARP" 205 + | FlexNet -> Format.pp_print_string ppf "FlexNet" 206 + | NetROM -> Format.pp_print_string ppf "NET/ROM" 207 + | Escape -> Format.pp_print_string ppf "Escape" 208 + | Other b -> Format.fprintf ppf "PID(0x%02X)" b 209 + 210 + let byte_of_pid = function 211 + | No_layer3 -> 0xF0 212 + | IP -> 0xCC 213 + | ARP -> 0xCD 214 + | FlexNet -> 0xCE 215 + | NetROM -> 0xCF 216 + | Escape -> 0xFF 217 + | Other b -> b 218 + 219 + let pid_of_byte = function 220 + | 0xF0 -> No_layer3 221 + | 0xCC -> IP 222 + | 0xCD -> ARP 223 + | 0xCE -> FlexNet 224 + | 0xCF -> NetROM 225 + | 0xFF -> Escape 226 + | b -> Other b 227 + 228 + (* {1 Frames} *) 229 + 230 + type frame = { 231 + address : address; 232 + control : control; 233 + pid : pid option; 234 + info : bytes; 235 + } 236 + 237 + let pp ppf frame = 238 + Format.fprintf ppf "@[<v>AX.25 Frame:@,"; 239 + Format.fprintf ppf " Address: %a@," pp_address frame.address; 240 + Format.fprintf ppf " Control: %a@," pp_control frame.control; 241 + (match frame.pid with 242 + | Some p -> Format.fprintf ppf " PID: %a@," pp_pid p 243 + | None -> ()); 244 + Format.fprintf ppf " Info: %d bytes@]" (Bytes.length frame.info) 245 + 246 + (* {1 Errors} *) 247 + 248 + type error = 249 + | Truncated of { need : int; have : int } 250 + | Invalid_callsign of string 251 + | Invalid_fcs of { expected : int; actual : int } 252 + | Invalid_control of int 253 + 254 + let pp_error ppf = function 255 + | Truncated { need; have } -> 256 + Format.fprintf ppf "truncated: need %d bytes, have %d" need have 257 + | Invalid_callsign s -> Format.fprintf ppf "invalid callsign: %s" s 258 + | Invalid_fcs { expected; actual } -> 259 + Format.fprintf ppf "FCS mismatch: expected 0x%04X, got 0x%04X" expected 260 + actual 261 + | Invalid_control b -> Format.fprintf ppf "invalid control byte: 0x%02X" b 262 + 263 + (* {1 CRC-16-CCITT} 264 + 265 + AX.25 uses CRC-16-CCITT with polynomial 0x8408 (bit-reversed 0x1021), 266 + initial value 0xFFFF, and the result is bit-inverted. *) 267 + 268 + let crc_ccitt_table = 269 + Array.init 256 (fun i -> 270 + let crc = ref i in 271 + for _ = 0 to 7 do 272 + if !crc land 1 <> 0 then crc := (!crc lsr 1) lxor 0x8408 273 + else crc := !crc lsr 1 274 + done; 275 + !crc) 276 + 277 + let crc_ccitt data = 278 + let crc = ref 0xFFFF in 279 + for i = 0 to Bytes.length data - 1 do 280 + let byte = Char.code (Bytes.get data i) in 281 + crc := crc_ccitt_table.(!crc lxor byte land 0xFF) lxor (!crc lsr 8) 282 + done; 283 + !crc lxor 0xFFFF 284 + 285 + (* {1 Address Encoding} 286 + 287 + Each callsign is 7 bytes: 6 bytes for call (ASCII left-shifted by 1, space padded) 288 + plus 1 byte for SSID and flags. *) 289 + 290 + let encode_callsign w (cs : callsign) ~is_last ~has_been_repeated = 291 + (* Pad call to 6 chars with spaces, then left-shift each by 1 *) 292 + let padded = Printf.sprintf "%-6s" cs.call in 293 + for i = 0 to 5 do 294 + Writer.uint8 w (Char.code padded.[i] lsl 1) 295 + done; 296 + (* SSID byte: bits 0 = extension, 1-4 = SSID, 5-6 = reserved (set to 1), 7 = C/H *) 297 + let ssid_byte = 298 + (if is_last then 0x01 else 0x00) 299 + lor ((cs.ssid land 0x0F) lsl 1) 300 + lor 0x60 301 + lor 302 + (* reserved bits = 11 *) 303 + if has_been_repeated then 0x80 else 0x00 304 + in 305 + Writer.uint8 w ssid_byte 306 + 307 + let decode_callsign r ~is_digipeater:_ = 308 + let call_bytes = Reader.bytes r 6 in 309 + let call_buf = Buffer.create 6 in 310 + for i = 0 to 5 do 311 + let c = Char.chr (Char.code (Bytes.get call_bytes i) lsr 1) in 312 + if c <> ' ' then Buffer.add_char call_buf c 313 + done; 314 + let call = Buffer.contents call_buf in 315 + let ssid_byte = Reader.uint8 r in 316 + let ssid = (ssid_byte lsr 1) land 0x0F in 317 + let is_last = ssid_byte land 0x01 <> 0 in 318 + let _has_been_repeated = ssid_byte land 0x80 <> 0 in 319 + match callsign ~call ~ssid with 320 + | Some cs -> Ok (cs, is_last) 321 + | None -> Error (Invalid_callsign call) 322 + 323 + (* {1 Encoding/Decoding} *) 324 + 325 + let write w frame = 326 + (* Destination *) 327 + encode_callsign w frame.address.destination 328 + ~is_last:(frame.address.source.ssid = 0 && frame.address.digipeaters = []) 329 + ~has_been_repeated:false; 330 + (* Source *) 331 + let is_source_last = frame.address.digipeaters = [] in 332 + encode_callsign w frame.address.source ~is_last:is_source_last 333 + ~has_been_repeated:false; 334 + (* Digipeaters *) 335 + let rec write_digis = function 336 + | [] -> () 337 + | [ d ] -> encode_callsign w d ~is_last:true ~has_been_repeated:false 338 + | d :: rest -> 339 + encode_callsign w d ~is_last:false ~has_been_repeated:false; 340 + write_digis rest 341 + in 342 + write_digis frame.address.digipeaters; 343 + (* Control *) 344 + Writer.uint8 w (byte_of_control frame.control); 345 + (* PID (for I and UI frames) *) 346 + (match frame.pid with 347 + | Some p -> Writer.uint8 w (byte_of_pid p) 348 + | None -> ()); 349 + (* Info *) 350 + Writer.bytes w frame.info 351 + 352 + let read r = 353 + (* Need at least 14 bytes for addresses (dest + src) + 1 for control *) 354 + match Reader.ensure r 15 with 355 + | Error (`Truncated { have; need }) -> Error (Truncated { have; need }) 356 + | Ok () -> ( 357 + (* Destination *) 358 + match decode_callsign r ~is_digipeater:false with 359 + | Error _ as e -> e 360 + | Ok (destination, _) -> ( 361 + (* Source *) 362 + match decode_callsign r ~is_digipeater:false with 363 + | Error _ as e -> e 364 + | Ok (source, is_last) -> ( 365 + (* Digipeaters (if any) *) 366 + let rec read_digis acc = 367 + if is_last then Ok (List.rev acc) 368 + else 369 + match Reader.ensure r 7 with 370 + | Error (`Truncated { have; need }) -> 371 + Error (Truncated { have; need }) 372 + | Ok () -> ( 373 + match decode_callsign r ~is_digipeater:true with 374 + | Error _ as e -> e 375 + | Ok (digi, is_last_digi) -> 376 + if is_last_digi then Ok (List.rev (digi :: acc)) 377 + else read_digis (digi :: acc)) 378 + in 379 + let digipeaters_result = 380 + if is_last then Ok [] else read_digis [] 381 + in 382 + match digipeaters_result with 383 + | Error _ as e -> e 384 + | Ok digipeaters -> ( 385 + let address = { destination; source; digipeaters } in 386 + (* Control *) 387 + match Reader.ensure r 1 with 388 + | Error (`Truncated { have; need }) -> 389 + Error (Truncated { have; need }) 390 + | Ok () -> 391 + let control = control_of_byte (Reader.uint8 r) in 392 + (* PID (only for I and UI frames) *) 393 + let pid = 394 + match control with 395 + | UI | I _ -> ( 396 + match Reader.ensure r 1 with 397 + | Error _ -> None 398 + | Ok () -> Some (pid_of_byte (Reader.uint8 r))) 399 + | _ -> None 400 + in 401 + (* Remaining is info field *) 402 + let info_len = Reader.remaining r in 403 + let info = 404 + if info_len > 0 then Reader.bytes r info_len 405 + else Bytes.empty 406 + in 407 + Ok { address; control; pid; info })))) 408 + 409 + let encode frame = 410 + let w = Writer.create 512 in 411 + write w frame; 412 + let data = Writer.contents w in 413 + (* Append FCS *) 414 + let fcs = crc_ccitt data in 415 + let result = Bytes.create (Bytes.length data + 2) in 416 + Bytes.blit data 0 result 0 (Bytes.length data); 417 + Bytes.set result (Bytes.length data) (Char.chr (fcs land 0xFF)); 418 + Bytes.set result (Bytes.length data + 1) (Char.chr ((fcs lsr 8) land 0xFF)); 419 + result 420 + 421 + let decode data = 422 + let len = Bytes.length data in 423 + if len < 17 then (* 14 addr + 1 ctrl + 2 FCS minimum *) 424 + Error (Truncated { need = 17; have = len }) 425 + else 426 + (* Verify FCS *) 427 + let frame_data = Bytes.sub data 0 (len - 2) in 428 + let fcs_expected = crc_ccitt frame_data in 429 + let fcs_lo = Char.code (Bytes.get data (len - 2)) in 430 + let fcs_hi = Char.code (Bytes.get data (len - 1)) in 431 + let fcs_actual = fcs_lo lor (fcs_hi lsl 8) in 432 + if fcs_expected <> fcs_actual then 433 + Error (Invalid_fcs { expected = fcs_expected; actual = fcs_actual }) 434 + else 435 + let r = Reader.of_bytes frame_data in 436 + read r 437 + 438 + (* {1 HDLC Framing} 439 + 440 + AX.25 uses HDLC-like framing: 441 + - Flag bytes (0x7E) at start and end 442 + - Bit stuffing: after 5 consecutive 1s, insert a 0 443 + - Zero insertion/deletion for transparency *) 444 + 445 + let hdlc_flag = '\x7E' 446 + 447 + (* Bit stuffing for transmission *) 448 + let bit_stuff data = 449 + let bits = Buffer.create (Bytes.length data * 9) in 450 + let ones = ref 0 in 451 + for i = 0 to Bytes.length data - 1 do 452 + let byte = Char.code (Bytes.get data i) in 453 + for b = 0 to 7 do 454 + if byte land (1 lsl b) <> 0 then begin 455 + Buffer.add_char bits '1'; 456 + incr ones; 457 + if !ones = 5 then begin 458 + Buffer.add_char bits '0'; 459 + (* stuff a zero *) 460 + ones := 0 461 + end 462 + end 463 + else begin 464 + Buffer.add_char bits '0'; 465 + ones := 0 466 + end 467 + done 468 + done; 469 + (* Convert bit string back to bytes *) 470 + let bit_str = Buffer.contents bits in 471 + let out_len = (String.length bit_str + 7) / 8 in 472 + let out = Bytes.create out_len in 473 + for i = 0 to out_len - 1 do 474 + let byte = ref 0 in 475 + for b = 0 to 7 do 476 + let pos = (i * 8) + b in 477 + if pos < String.length bit_str && bit_str.[pos] = '1' then 478 + byte := !byte lor (1 lsl b) 479 + done; 480 + Bytes.set out i (Char.chr !byte) 481 + done; 482 + out 483 + 484 + (* Remove bit stuffing *) 485 + let bit_unstuff data = 486 + let bits = Buffer.create (Bytes.length data * 8) in 487 + let ones = ref 0 in 488 + for i = 0 to Bytes.length data - 1 do 489 + let byte = Char.code (Bytes.get data i) in 490 + for b = 0 to 7 do 491 + if byte land (1 lsl b) <> 0 then begin 492 + Buffer.add_char bits '1'; 493 + incr ones 494 + end 495 + else begin 496 + if !ones = 5 then 497 + (* Skip stuffed zero *) 498 + ones := 0 499 + else begin 500 + Buffer.add_char bits '0'; 501 + ones := 0 502 + end 503 + end 504 + done 505 + done; 506 + (* Convert bit string back to bytes *) 507 + let bit_str = Buffer.contents bits in 508 + let out_len = String.length bit_str / 8 in 509 + let out = Bytes.create out_len in 510 + for i = 0 to out_len - 1 do 511 + let byte = ref 0 in 512 + for b = 0 to 7 do 513 + let pos = (i * 8) + b in 514 + if pos < String.length bit_str && bit_str.[pos] = '1' then 515 + byte := !byte lor (1 lsl b) 516 + done; 517 + Bytes.set out i (Char.chr !byte) 518 + done; 519 + out 520 + 521 + let hdlc_encode frame = 522 + let data = encode frame in 523 + let stuffed = bit_stuff data in 524 + let result = Bytes.create (Bytes.length stuffed + 2) in 525 + Bytes.set result 0 hdlc_flag; 526 + Bytes.blit stuffed 0 result 1 (Bytes.length stuffed); 527 + Bytes.set result (Bytes.length result - 1) hdlc_flag; 528 + result 529 + 530 + let hdlc_decode data = 531 + let len = Bytes.length data in 532 + if len < 4 then Error (Truncated { need = 4; have = len }) 533 + else if Bytes.get data 0 <> hdlc_flag || Bytes.get data (len - 1) <> hdlc_flag 534 + then Error (Truncated { need = len; have = len }) (* Invalid framing *) 535 + else 536 + let stuffed = Bytes.sub data 1 (len - 2) in 537 + let unstuffed = bit_unstuff stuffed in 538 + decode unstuffed 539 + 540 + (* {1 KISS Framing} 541 + 542 + KISS TNC protocol: 543 + - FEND (0xC0) - frame delimiter 544 + - FESC (0xDB) - escape character 545 + - TFEND (0xDC) - escaped FEND 546 + - TFESC (0xDD) - escaped FESC *) 547 + 548 + let kiss_fend = '\xC0' 549 + let kiss_fesc = '\xDB' 550 + let kiss_tfend = '\xDC' 551 + let kiss_tfesc = '\xDD' 552 + 553 + let kiss_encode frame = 554 + let w = Writer.create 512 in 555 + write w frame; 556 + let data = Writer.contents w in 557 + (* Escape special characters *) 558 + let buf = Buffer.create (Bytes.length data + 10) in 559 + Buffer.add_char buf kiss_fend; 560 + Buffer.add_char buf '\x00'; 561 + (* Command byte: data frame on port 0 *) 562 + for i = 0 to Bytes.length data - 1 do 563 + match Bytes.get data i with 564 + | c when c = kiss_fend -> 565 + Buffer.add_char buf kiss_fesc; 566 + Buffer.add_char buf kiss_tfend 567 + | c when c = kiss_fesc -> 568 + Buffer.add_char buf kiss_fesc; 569 + Buffer.add_char buf kiss_tfesc 570 + | c -> Buffer.add_char buf c 571 + done; 572 + Buffer.add_char buf kiss_fend; 573 + Bytes.of_string (Buffer.contents buf) 574 + 575 + let kiss_decode data = 576 + let len = Bytes.length data in 577 + if len < 4 then Error (Truncated { need = 4; have = len }) 578 + else if Bytes.get data 0 <> kiss_fend || Bytes.get data (len - 1) <> kiss_fend 579 + then Error (Truncated { need = len; have = len }) 580 + else 581 + (* Skip FEND and command byte *) 582 + let buf = Buffer.create (len - 3) in 583 + let i = ref 2 in 584 + while !i < len - 1 do 585 + match Bytes.get data !i with 586 + | c when c = kiss_fesc -> 587 + incr i; 588 + if !i < len - 1 then begin 589 + (match Bytes.get data !i with 590 + | c when c = kiss_tfend -> Buffer.add_char buf kiss_fend 591 + | c when c = kiss_tfesc -> Buffer.add_char buf kiss_fesc 592 + | c -> Buffer.add_char buf c); 593 + incr i 594 + end 595 + | c -> 596 + Buffer.add_char buf c; 597 + incr i 598 + done; 599 + let frame_data = Bytes.of_string (Buffer.contents buf) in 600 + let r = Reader.of_bytes frame_data in 601 + read r 602 + 603 + (* {1 Helpers} *) 604 + 605 + let ui_frame ~src ~dst ?(digis = []) info = 606 + { 607 + address = { destination = dst; source = src; digipeaters = digis }; 608 + control = UI; 609 + pid = Some No_layer3; 610 + info; 611 + }
+145
lib/ax25.mli
··· 1 + (** AX.25 Amateur Radio Link-Layer Protocol. 2 + 3 + AX.25 is the data link layer protocol used by amateur (ham) radio operators 4 + for packet radio communications. It's also used by many amateur satellites 5 + and CubeSats. 6 + 7 + This implementation focuses on UI (Unnumbered Information) frames, which are 8 + used for connectionless datagram service - the most common mode for amateur 9 + satellite communications. 10 + 11 + @see <https://www.ax25.net/AX25.2.2-Jul%2098-2.pdf> 12 + AX.25 Link Access Protocol *) 13 + 14 + (** {1 Callsigns} *) 15 + 16 + type ssid = private int 17 + (** Secondary Station Identifier (0-15). *) 18 + 19 + val ssid_of_int : int -> ssid option 20 + val ssid_of_int_exn : int -> ssid 21 + val int_of_ssid : ssid -> int 22 + 23 + type callsign = private { call : string; ssid : ssid } 24 + (** Amateur radio callsign with SSID. Call is 1-6 uppercase alphanumeric 25 + characters. Example: "N0CALL-1" is call="N0CALL", ssid=1 *) 26 + 27 + val callsign : call:string -> ssid:int -> callsign option 28 + (** Create a callsign. Call must be 1-6 alphanumeric chars (uppercase). *) 29 + 30 + val callsign_exn : call:string -> ssid:int -> callsign 31 + (** Create a callsign, raising Invalid_argument on invalid input. *) 32 + 33 + val callsign_of_string : string -> callsign option 34 + (** Parse "CALL-SSID" format. SSID defaults to 0 if omitted. *) 35 + 36 + val string_of_callsign : callsign -> string 37 + (** Format as "CALL-SSID" or just "CALL" if SSID is 0. *) 38 + 39 + val pp_callsign : Format.formatter -> callsign -> unit 40 + 41 + (** {1 Addresses} *) 42 + 43 + type address = { 44 + destination : callsign; 45 + source : callsign; 46 + digipeaters : callsign list; (** Up to 8 digipeaters *) 47 + } 48 + (** AX.25 address field. *) 49 + 50 + val pp_address : Format.formatter -> address -> unit 51 + 52 + (** {1 Control Field} *) 53 + 54 + type control = 55 + | UI (** Unnumbered Information - connectionless datagram *) 56 + | SABM (** Set Async Balanced Mode - connection request *) 57 + | DISC (** Disconnect *) 58 + | UA (** Unnumbered Acknowledge *) 59 + | DM (** Disconnected Mode *) 60 + | I of { ns : int; nr : int; poll : bool } (** Information frame *) 61 + | RR of { nr : int; poll : bool } (** Receive Ready *) 62 + | RNR of { nr : int; poll : bool } (** Receive Not Ready *) 63 + | REJ of { nr : int; poll : bool } (** Reject *) 64 + | FRMR (** Frame Reject *) 65 + | Unknown of int (** Unknown control byte *) 66 + 67 + val pp_control : Format.formatter -> control -> unit 68 + val byte_of_control : control -> int 69 + val control_of_byte : int -> control 70 + 71 + (** {1 Protocol Identifier} *) 72 + 73 + type pid = 74 + | No_layer3 (** 0xF0: No layer 3 protocol *) 75 + | IP (** 0xCC: Internet Protocol *) 76 + | ARP (** 0xCD: Address Resolution Protocol *) 77 + | FlexNet (** 0xCE: FlexNet *) 78 + | NetROM (** 0xCF: NET/ROM *) 79 + | Escape (** 0xFF: Escape - next byte is PID *) 80 + | Other of int (** Other protocol identifier *) 81 + 82 + val pp_pid : Format.formatter -> pid -> unit 83 + val byte_of_pid : pid -> int 84 + val pid_of_byte : int -> pid 85 + 86 + (** {1 Frames} *) 87 + 88 + type frame = { 89 + address : address; 90 + control : control; 91 + pid : pid option; (** Only present for I and UI frames *) 92 + info : bytes; (** Information field (payload) *) 93 + } 94 + (** AX.25 frame (without flags and FCS). *) 95 + 96 + val pp : Format.formatter -> frame -> unit 97 + 98 + (** {1 Errors} *) 99 + 100 + type error = 101 + | Truncated of { need : int; have : int } 102 + | Invalid_callsign of string 103 + | Invalid_fcs of { expected : int; actual : int } 104 + | Invalid_control of int 105 + 106 + val pp_error : Format.formatter -> error -> unit 107 + 108 + (** {1 Encoding/Decoding} *) 109 + 110 + val encode : frame -> bytes 111 + (** Encode frame to bytes with FCS (without HDLC flags). *) 112 + 113 + val decode : bytes -> (frame, error) result 114 + (** Decode frame from bytes, verifying FCS. *) 115 + 116 + (** {1 HDLC Framing} 117 + 118 + AX.25 uses HDLC-like framing with 0x7E flags and bit stuffing. These 119 + functions handle the raw on-air format. *) 120 + 121 + val hdlc_encode : frame -> bytes 122 + (** Encode frame with HDLC flags and bit stuffing. *) 123 + 124 + val hdlc_decode : bytes -> (frame, error) result 125 + (** Decode HDLC-framed data, removing bit stuffing and verifying FCS. *) 126 + 127 + (** {1 KISS Framing} 128 + 129 + KISS (Keep It Simple, Stupid) is the standard TNC interface protocol. Used 130 + to communicate between host software and the radio TNC. *) 131 + 132 + val kiss_encode : frame -> bytes 133 + (** Encode frame in KISS format (for TNC communication). *) 134 + 135 + val kiss_decode : bytes -> (frame, error) result 136 + (** Decode KISS-framed data. *) 137 + 138 + (** {1 Helpers} *) 139 + 140 + val ui_frame : 141 + src:callsign -> dst:callsign -> ?digis:callsign list -> bytes -> frame 142 + (** Create a UI (Unnumbered Information) frame - the most common type. *) 143 + 144 + val crc_ccitt : bytes -> int 145 + (** Compute CRC-16-CCITT (polynomial 0x8408, init 0xFFFF). *)
+3
lib/dune
··· 1 + (library 2 + (name ax25) 3 + (public_name ax25))
+3
test/dune
··· 1 + (test 2 + (name test_ax25) 3 + (libraries ax25 alcotest))
+360
test/test_ax25.ml
··· 1 + (** Tests for AX.25 module. 2 + 3 + Test vectors derived from AX.25 Version 2.2 specification: 4 + @see <https://www.ax25.net/AX25.2.2-Jul%2098-2.pdf> *) 5 + 6 + open Ax25 7 + 8 + (* {1 Test Helpers} *) 9 + 10 + let callsign_testable = 11 + Alcotest.testable pp_callsign (fun a b -> 12 + string_of_callsign a = string_of_callsign b) 13 + 14 + (* {1 Callsign Tests} *) 15 + 16 + let test_callsign_valid () = 17 + match callsign ~call:"N0CALL" ~ssid:0 with 18 + | None -> Alcotest.fail "should accept valid callsign" 19 + | Some cs -> 20 + Alcotest.(check string) "call" "N0CALL" cs.call; 21 + Alcotest.(check int) "ssid" 0 (int_of_ssid cs.ssid) 22 + 23 + let test_callsign_with_ssid () = 24 + match callsign ~call:"W1AW" ~ssid:15 with 25 + | None -> Alcotest.fail "should accept valid callsign with SSID" 26 + | Some cs -> 27 + Alcotest.(check string) "call" "W1AW" cs.call; 28 + Alcotest.(check int) "ssid" 15 (int_of_ssid cs.ssid) 29 + 30 + let test_callsign_lowercase () = 31 + (* Should uppercase automatically *) 32 + match callsign ~call:"n0call" ~ssid:0 with 33 + | None -> Alcotest.fail "should accept lowercase" 34 + | Some cs -> Alcotest.(check string) "call" "N0CALL" cs.call 35 + 36 + let test_callsign_too_long () = 37 + match callsign ~call:"TOOLONG1" ~ssid:0 with 38 + | None -> () 39 + | Some _ -> Alcotest.fail "should reject call > 6 chars" 40 + 41 + let test_callsign_empty () = 42 + match callsign ~call:"" ~ssid:0 with 43 + | None -> () 44 + | Some _ -> Alcotest.fail "should reject empty call" 45 + 46 + let test_callsign_invalid_ssid () = 47 + match callsign ~call:"N0CALL" ~ssid:16 with 48 + | None -> () 49 + | Some _ -> Alcotest.fail "should reject SSID > 15" 50 + 51 + let test_callsign_of_string () = 52 + match callsign_of_string "W1AW-5" with 53 + | None -> Alcotest.fail "should parse W1AW-5" 54 + | Some cs -> 55 + Alcotest.(check string) "call" "W1AW" cs.call; 56 + Alcotest.(check int) "ssid" 5 (int_of_ssid cs.ssid) 57 + 58 + let test_callsign_of_string_no_ssid () = 59 + match callsign_of_string "N0CALL" with 60 + | None -> Alcotest.fail "should parse without SSID" 61 + | Some cs -> 62 + Alcotest.(check string) "call" "N0CALL" cs.call; 63 + Alcotest.(check int) "ssid" 0 (int_of_ssid cs.ssid) 64 + 65 + let test_string_of_callsign () = 66 + let cs = callsign_exn ~call:"W1AW" ~ssid:5 in 67 + Alcotest.(check string) "with ssid" "W1AW-5" (string_of_callsign cs); 68 + let cs0 = callsign_exn ~call:"N0CALL" ~ssid:0 in 69 + Alcotest.(check string) "no ssid" "N0CALL" (string_of_callsign cs0) 70 + 71 + (* {1 Control Field Tests} *) 72 + 73 + let test_control_ui () = 74 + let b = byte_of_control UI in 75 + Alcotest.(check int) "UI byte" 0x03 b; 76 + match control_of_byte b with 77 + | UI -> () 78 + | _ -> Alcotest.fail "should decode as UI" 79 + 80 + let test_control_sabm () = 81 + let b = byte_of_control SABM in 82 + match control_of_byte b with 83 + | SABM -> () 84 + | _ -> Alcotest.fail "should decode as SABM" 85 + 86 + let test_control_i_frame () = 87 + let ctrl = I { ns = 3; nr = 5; poll = true } in 88 + let b = byte_of_control ctrl in 89 + match control_of_byte b with 90 + | I { ns; nr; poll } -> 91 + Alcotest.(check int) "ns" 3 ns; 92 + Alcotest.(check int) "nr" 5 nr; 93 + Alcotest.(check bool) "poll" true poll 94 + | _ -> Alcotest.fail "should decode as I frame" 95 + 96 + let test_control_rr () = 97 + let ctrl = RR { nr = 7; poll = false } in 98 + let b = byte_of_control ctrl in 99 + match control_of_byte b with 100 + | RR { nr; poll } -> 101 + Alcotest.(check int) "nr" 7 nr; 102 + Alcotest.(check bool) "poll" false poll 103 + | _ -> Alcotest.fail "should decode as RR" 104 + 105 + (* {1 PID Tests} *) 106 + 107 + let test_pid_roundtrip () = 108 + let pids = [ No_layer3; IP; ARP; FlexNet; NetROM; Escape; Other 0x42 ] in 109 + List.iter 110 + (fun pid -> 111 + let b = byte_of_pid pid in 112 + let pid' = pid_of_byte b in 113 + let b' = byte_of_pid pid' in 114 + Alcotest.(check int) "PID roundtrip" b b') 115 + pids 116 + 117 + (* {1 Frame Encoding/Decoding Tests} *) 118 + 119 + let test_ui_frame_roundtrip () = 120 + let src = callsign_exn ~call:"N0CALL" ~ssid:1 in 121 + let dst = callsign_exn ~call:"CQ" ~ssid:0 in 122 + let info = Bytes.of_string "Hello AX.25!" in 123 + let frame = ui_frame ~src ~dst info in 124 + let encoded = encode frame in 125 + match decode encoded with 126 + | Error e -> Alcotest.failf "decode failed: %a" pp_error e 127 + | Ok frame' -> 128 + Alcotest.(check callsign_testable) "src" src frame'.address.source; 129 + Alcotest.(check callsign_testable) "dst" dst frame'.address.destination; 130 + Alcotest.(check string) 131 + "info" "Hello AX.25!" 132 + (Bytes.to_string frame'.info) 133 + 134 + let test_frame_with_digipeaters () = 135 + let src = callsign_exn ~call:"N0CALL" ~ssid:0 in 136 + let dst = callsign_exn ~call:"W1AW" ~ssid:0 in 137 + let digi1 = callsign_exn ~call:"RELAY" ~ssid:0 in 138 + let digi2 = callsign_exn ~call:"WIDE1" ~ssid:1 in 139 + let info = Bytes.of_string "Via digis" in 140 + let frame = ui_frame ~src ~dst ~digis:[ digi1; digi2 ] info in 141 + let encoded = encode frame in 142 + match decode encoded with 143 + | Error e -> Alcotest.failf "decode failed: %a" pp_error e 144 + | Ok frame' -> 145 + Alcotest.(check int) 146 + "digi count" 2 147 + (List.length frame'.address.digipeaters) 148 + 149 + let test_crc_ccitt () = 150 + (* Test vector: "123456789" should give CRC 0x906E *) 151 + let data = Bytes.of_string "123456789" in 152 + let crc = crc_ccitt data in 153 + Alcotest.(check int) "CRC-CCITT(123456789)" 0x906E crc 154 + 155 + let test_empty_info () = 156 + let src = callsign_exn ~call:"N0CALL" ~ssid:0 in 157 + let dst = callsign_exn ~call:"BEACON" ~ssid:0 in 158 + let frame = ui_frame ~src ~dst Bytes.empty in 159 + let encoded = encode frame in 160 + match decode encoded with 161 + | Error e -> Alcotest.failf "decode failed: %a" pp_error e 162 + | Ok frame' -> Alcotest.(check int) "empty info" 0 (Bytes.length frame'.info) 163 + 164 + (* {1 KISS Framing Tests} *) 165 + 166 + let test_kiss_roundtrip () = 167 + let src = callsign_exn ~call:"N0CALL" ~ssid:0 in 168 + let dst = callsign_exn ~call:"CQ" ~ssid:0 in 169 + let info = Bytes.of_string "KISS test" in 170 + let frame = ui_frame ~src ~dst info in 171 + let kiss_data = kiss_encode frame in 172 + (* Check FEND delimiters *) 173 + Alcotest.(check char) "start FEND" '\xC0' (Bytes.get kiss_data 0); 174 + Alcotest.(check char) 175 + "end FEND" '\xC0' 176 + (Bytes.get kiss_data (Bytes.length kiss_data - 1)); 177 + match kiss_decode kiss_data with 178 + | Error e -> Alcotest.failf "KISS decode failed: %a" pp_error e 179 + | Ok frame' -> 180 + Alcotest.(check string) "info" "KISS test" (Bytes.to_string frame'.info) 181 + 182 + let test_kiss_escape () = 183 + let src = callsign_exn ~call:"N0CALL" ~ssid:0 in 184 + let dst = callsign_exn ~call:"CQ" ~ssid:0 in 185 + (* Info contains FEND and FESC characters that need escaping *) 186 + let info = Bytes.of_string "A\xC0B\xDBC" in 187 + let frame = ui_frame ~src ~dst info in 188 + let kiss_data = kiss_encode frame in 189 + match kiss_decode kiss_data with 190 + | Error e -> Alcotest.failf "KISS decode failed: %a" pp_error e 191 + | Ok frame' -> 192 + Alcotest.(check string) 193 + "escaped info" "A\xC0B\xDBC" 194 + (Bytes.to_string frame'.info) 195 + 196 + (* {1 Error Cases} *) 197 + 198 + let test_truncated_frame () = 199 + let short = Bytes.make 10 '\x00' in 200 + match decode short with 201 + | Error (Truncated _) -> () 202 + | Error e -> Alcotest.failf "wrong error: %a" pp_error e 203 + | Ok _ -> Alcotest.fail "should reject truncated frame" 204 + 205 + let test_bad_fcs () = 206 + let src = callsign_exn ~call:"N0CALL" ~ssid:0 in 207 + let dst = callsign_exn ~call:"CQ" ~ssid:0 in 208 + let frame = ui_frame ~src ~dst (Bytes.of_string "test") in 209 + let encoded = encode frame in 210 + (* Corrupt the last byte (FCS) *) 211 + let len = Bytes.length encoded in 212 + Bytes.set encoded (len - 1) 213 + (Char.chr ((Char.code (Bytes.get encoded (len - 1)) + 1) mod 256)); 214 + match decode encoded with 215 + | Error (Invalid_fcs _) -> () 216 + | Error e -> Alcotest.failf "wrong error: %a" pp_error e 217 + | Ok _ -> Alcotest.fail "should reject bad FCS" 218 + 219 + (* {1 AX.25 Spec compliance tests} *) 220 + 221 + let test_spec_callsign_encoding () = 222 + (* Verify individual character encoding (ASCII << 1) *) 223 + let check_char c expected = 224 + let actual = Char.code c lsl 1 in 225 + Alcotest.(check int) (Printf.sprintf "char '%c'" c) expected actual 226 + in 227 + check_char 'N' 0x9C; 228 + check_char 'J' 0x94; 229 + check_char '7' 0x6E; 230 + check_char 'P' 0xA0; 231 + check_char 'L' 0x98; 232 + check_char 'E' 0x8A; 233 + check_char 'M' 0x9A; 234 + check_char 'O' 0x9E; 235 + check_char ' ' 0x40 236 + 237 + let test_spec_control_u_frames () = 238 + Alcotest.(check int) "UI" 0x03 (byte_of_control UI); 239 + Alcotest.(check int) "SABM" 0x2F (byte_of_control SABM); 240 + Alcotest.(check int) "DISC" 0x43 (byte_of_control DISC); 241 + Alcotest.(check int) "UA" 0x63 (byte_of_control UA); 242 + Alcotest.(check int) "DM" 0x0F (byte_of_control DM); 243 + Alcotest.(check int) "FRMR" 0x87 (byte_of_control FRMR); 244 + (* Verify decoding *) 245 + (match control_of_byte 0x03 with 246 + | UI -> () 247 + | _ -> Alcotest.fail "0x03 should be UI"); 248 + (match control_of_byte 0x2F with 249 + | SABM -> () 250 + | _ -> Alcotest.fail "0x2F should be SABM"); 251 + (match control_of_byte 0x43 with 252 + | DISC -> () 253 + | _ -> Alcotest.fail "0x43 should be DISC"); 254 + (match control_of_byte 0x63 with 255 + | UA -> () 256 + | _ -> Alcotest.fail "0x63 should be UA"); 257 + (match control_of_byte 0x0F with 258 + | DM -> () 259 + | _ -> Alcotest.fail "0x0F should be DM"); 260 + match control_of_byte 0x87 with 261 + | FRMR -> () 262 + | _ -> Alcotest.fail "0x87 should be FRMR" 263 + 264 + let test_spec_pid_values () = 265 + Alcotest.(check int) "No_layer3" 0xF0 (byte_of_pid No_layer3); 266 + Alcotest.(check int) "IP" 0xCC (byte_of_pid IP); 267 + Alcotest.(check int) "ARP" 0xCD (byte_of_pid ARP); 268 + Alcotest.(check int) "FlexNet" 0xCE (byte_of_pid FlexNet); 269 + Alcotest.(check int) "NetROM" 0xCF (byte_of_pid NetROM); 270 + Alcotest.(check int) "Escape" 0xFF (byte_of_pid Escape) 271 + 272 + let test_spec_minimum_frame () = 273 + let src = callsign_exn ~call:"A" ~ssid:0 in 274 + let dst = callsign_exn ~call:"B" ~ssid:0 in 275 + let frame = ui_frame ~src ~dst Bytes.empty in 276 + let encoded = encode frame in 277 + (* Should be: 14 (address) + 1 (control) + 1 (PID) + 2 (FCS) = 18 bytes *) 278 + Alcotest.(check int) "minimum frame size" 18 (Bytes.length encoded); 279 + match decode encoded with 280 + | Ok f -> 281 + Alcotest.(check string) "src" "A" f.address.source.call; 282 + Alcotest.(check string) "dst" "B" f.address.destination.call 283 + | Error e -> Alcotest.failf "decode failed: %a" pp_error e 284 + 285 + let test_spec_nj7p_n7lem_frame () = 286 + let src = callsign_exn ~call:"N7LEM" ~ssid:0 in 287 + let dst = callsign_exn ~call:"NJ7P" ~ssid:0 in 288 + let frame = ui_frame ~src ~dst Bytes.empty in 289 + let encoded = encode frame in 290 + (* Verify addresses are correctly encoded *) 291 + (* Destination NJ7P: N=0x9C J=0x94 7=0x6E P=0xA0 sp=0x40 sp=0x40 *) 292 + Alcotest.(check int) "dst[0] N" 0x9C (Char.code (Bytes.get encoded 0)); 293 + Alcotest.(check int) "dst[1] J" 0x94 (Char.code (Bytes.get encoded 1)); 294 + Alcotest.(check int) "dst[2] 7" 0x6E (Char.code (Bytes.get encoded 2)); 295 + Alcotest.(check int) "dst[3] P" 0xA0 (Char.code (Bytes.get encoded 3)); 296 + Alcotest.(check int) "dst[4] sp" 0x40 (Char.code (Bytes.get encoded 4)); 297 + Alcotest.(check int) "dst[5] sp" 0x40 (Char.code (Bytes.get encoded 5)); 298 + (* Source N7LEM *) 299 + Alcotest.(check int) "src[0] N" 0x9C (Char.code (Bytes.get encoded 7)); 300 + Alcotest.(check int) "src[1] 7" 0x6E (Char.code (Bytes.get encoded 8)); 301 + Alcotest.(check int) "src[2] L" 0x98 (Char.code (Bytes.get encoded 9)); 302 + Alcotest.(check int) "src[3] E" 0x8A (Char.code (Bytes.get encoded 10)); 303 + Alcotest.(check int) "src[4] M" 0x9A (Char.code (Bytes.get encoded 11)); 304 + Alcotest.(check int) "src[5] sp" 0x40 (Char.code (Bytes.get encoded 12)); 305 + (* Control field for UI = 0x03 *) 306 + Alcotest.(check int) "control UI" 0x03 (Char.code (Bytes.get encoded 14)); 307 + (* PID for No_layer3 = 0xF0 *) 308 + Alcotest.(check int) "PID" 0xF0 (Char.code (Bytes.get encoded 15)) 309 + 310 + (* {1 Test Suite} *) 311 + 312 + let () = 313 + Alcotest.run "ax25" 314 + [ 315 + ( "callsign", 316 + [ 317 + ("valid", `Quick, test_callsign_valid); 318 + ("with SSID", `Quick, test_callsign_with_ssid); 319 + ("lowercase", `Quick, test_callsign_lowercase); 320 + ("too long", `Quick, test_callsign_too_long); 321 + ("empty", `Quick, test_callsign_empty); 322 + ("invalid SSID", `Quick, test_callsign_invalid_ssid); 323 + ("of_string", `Quick, test_callsign_of_string); 324 + ("of_string no SSID", `Quick, test_callsign_of_string_no_ssid); 325 + ("to_string", `Quick, test_string_of_callsign); 326 + ] ); 327 + ( "control", 328 + [ 329 + ("UI", `Quick, test_control_ui); 330 + ("SABM", `Quick, test_control_sabm); 331 + ("I frame", `Quick, test_control_i_frame); 332 + ("RR", `Quick, test_control_rr); 333 + ] ); 334 + ("pid", [ ("roundtrip", `Quick, test_pid_roundtrip) ]); 335 + ( "frame", 336 + [ 337 + ("UI roundtrip", `Quick, test_ui_frame_roundtrip); 338 + ("with digipeaters", `Quick, test_frame_with_digipeaters); 339 + ("CRC-CCITT", `Quick, test_crc_ccitt); 340 + ("empty info", `Quick, test_empty_info); 341 + ] ); 342 + ( "kiss", 343 + [ 344 + ("roundtrip", `Quick, test_kiss_roundtrip); 345 + ("escape", `Quick, test_kiss_escape); 346 + ] ); 347 + ( "errors", 348 + [ 349 + ("truncated", `Quick, test_truncated_frame); 350 + ("bad FCS", `Quick, test_bad_fcs); 351 + ] ); 352 + ( "spec", 353 + [ 354 + ("callsign encoding", `Quick, test_spec_callsign_encoding); 355 + ("control U frames", `Quick, test_spec_control_u_frames); 356 + ("PID values", `Quick, test_spec_pid_values); 357 + ("minimum frame", `Quick, test_spec_minimum_frame); 358 + ("NJ7P-N7LEM frame", `Quick, test_spec_nj7p_n7lem_frame); 359 + ] ); 360 + ]