gr-m17/python/m17/meta.py

165 lines
5.5 KiB
Python

#
# Copyright 2026 M17 Project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
'''
Builders for the 14-byte LSF META field used by the M17 Encoder block.
The position, ECD and raw builders return a Python str holding raw bytes (one
character per byte, latin-1), which is the form the C++ block expects for
byte-array META contents; meta_text() returns plain text, which the block
splits into Text Data blocks itself. Layouts follow the M17 specification 2.0.x. The encoders mirror libm17's set_LSF_meta_position() and
set_LSF_meta_ecd() byte for byte.
'''
import math
import numpy as np
META_LEN = 14
# Meta content selected by the encoder's Subtype field (encryption: None)
SUBTYPE_TEXT = 0
SUBTYPE_POSITION = 1
SUBTYPE_ECD = 2
SUBTYPE_RAW = 3
# GNSS data source and station type (spec, Appendix J)
SOURCE_M17_CLIENT = 0
SOURCE_OPENRTX = 1
SOURCE_OTHER = 15
STATION_FIXED = 0
STATION_MOBILE = 1
STATION_HANDHELD = 2
STATION_OTHER = 15
# GNSS validity flags
VALID_LAT_LON = 1 << 3
VALID_ALTITUDE = 1 << 2
VALID_VELOCITY = 1 << 1
VALID_RADIUS = 1 << 0
_CHAR_MAP = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/."
def _as_meta(data):
'''Pad/truncate to 14 bytes and return as a byte-per-character str.'''
return bytes(data).ljust(META_LEN, b'\0')[:META_LEN].decode('latin-1')
def _clampf(x, lo, hi):
x = np.float32(x)
if not x >= lo: # also catches NaN, like libm17
return np.float32(lo)
if x > hi:
return np.float32(hi)
return x
def _roundf(x):
# C roundf(): halves away from zero (Python's round() rounds to even)
return math.floor(float(x) + 0.5) if x >= 0 else math.ceil(float(x) - 0.5)
def encode_callsign(callsign):
'''Encode a callsign into 6 bytes (big-endian base-40), like libm17.'''
if len(callsign) > 9:
raise ValueError('callsign "%s" is longer than 9 characters' % callsign)
if callsign == '@ALL':
value = 0xFFFFFFFFFFFF
else:
start = 1 if callsign.startswith('#') else 0
value = 0
for c in reversed(callsign[start:].upper()):
idx = _CHAR_MAP.find(c)
value = value * 40 + (idx if idx > 0 else 0) # invalid -> space
if start:
value += 40 ** 9
return value.to_bytes(6, 'big')
def meta_position(lat, lon, altitude=0.0, speed=0.0, bearing=0,
station_type=STATION_MOBILE, data_source=SOURCE_M17_CLIENT,
validity=VALID_LAT_LON | VALID_ALTITUDE | VALID_VELOCITY,
radius=1.0):
'''GNSS position META (lat/lon in degrees, altitude in m, speed in km/h,
bearing in degrees). Same byte layout as libm17 set_LSF_meta_position().'''
tmp = [0] * META_LEN
bearing = int(bearing) & 0xFFFF
tmp[0] = ((int(data_source) << 4) | int(station_type)) & 0xFF
tmp[1] |= (int(validity) << 4) & 0xFF
radius = np.float32(radius)
log_r = 7
for i, r in enumerate((1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0)):
if radius <= r:
log_r = i
break
tmp[1] |= log_r << 1
tmp[1] |= (bearing >> 8) & 1
tmp[2] = bearing & 0xFF
# float32 arithmetic and truncation toward zero, as in the C code
lat_tmp = int(_clampf(lat, -90.0, 90.0) / np.float32(90.0) * np.float32(8388607.0))
lon_tmp = int(_clampf(lon, -180.0, 180.0) / np.float32(180.0) * np.float32(8388607.0))
tmp[3:6] = [(lat_tmp >> 16) & 0xFF, (lat_tmp >> 8) & 0xFF, lat_tmp & 0xFF]
tmp[6:9] = [(lon_tmp >> 16) & 0xFF, (lon_tmp >> 8) & 0xFF, lon_tmp & 0xFF]
alt = _roundf((np.float32(500.0) + _clampf(altitude, -500.0, 32267.5)) * np.float32(2.0)) & 0xFFFF
tmp[9] = alt >> 8
tmp[10] = alt & 0xFF
spd = _roundf(_clampf(speed, 0.0, 2047.5) * np.float32(2.0)) & 0xFFFF
tmp[11] = (spd >> 4) & 0xFF
tmp[12] = (spd << 4) & 0xF0 # low nibble reserved
tmp[13] = 0
return _as_meta(tmp)
def meta_ecd(cf1, cf2=''):
'''Extended Callsign Data META: callsign field 1 and optional field 2.'''
data = encode_callsign(cf1)
data += encode_callsign(cf2) if cf2 else bytes(6)
return _as_meta(data)
TEXT_MAX_LEN = 52 # 4 Text Data blocks of 13 bytes
def meta_text(text):
'''Text Data META (spec 2.0.x): plain UTF-8 text of up to 52 bytes. The encoder block
splits it into up to four 13-byte blocks, adds the Control Bytes and sends one block
per superframe. Returned as a normal str: the block copies its UTF-8 bytes.'''
n = len(text.encode('utf-8'))
if n > TEXT_MAX_LEN:
raise ValueError('META text is %d bytes long, the maximum is %d' % (n, TEXT_MAX_LEN))
return text
def meta_hex(hex_str):
'''Raw META from hex text (spaces allowed), zero-padded to 14 bytes.'''
return _as_meta(bytes.fromhex(hex_str))
def build_meta(encr_type, subtype, text='', lat=0.0, lon=0.0, altitude=0.0,
speed=0.0, bearing=0, station_type=STATION_MOBILE,
data_source=SOURCE_M17_CLIENT, cf1='', cf2='', raw_hex=''):
'''Build the META argument for m17_coder from the GRC dialog fields.
META contents are only defined without encryption; with AES the block
fills META with the IV itself, so '' (all zeros) is returned otherwise.'''
if int(encr_type) != 0:
return ''
subtype = int(subtype)
if subtype == SUBTYPE_TEXT:
return meta_text(text)
if subtype == SUBTYPE_POSITION:
return meta_position(lat, lon, altitude, speed, bearing,
station_type, data_source)
if subtype == SUBTYPE_ECD:
return meta_ecd(cf1, cf2) if cf1 else ''
return meta_hex(raw_hex)