#!/usr/bin/env python3
"""Fill the pager watch list of a CHIRP image from tones.cfg files.

  cfg2pager.py RADIO.img tones.cfg [tones.cfg ...] [--match REGEX ...] [--list]

Workflow: download the radio in CHIRP (F4HWN Pager driver), save the image,
run this, open the image in CHIRP, check the Pager tab, upload.

Reads only the tone fields of each section (atone, btone, atonelength,
btonelength, longtone, longtonelength, tone_tolerance, description); every
other field (mail, API keys...) is ignored. Pages whose description matches one
of the --match regexes are kept (all when none is given), the same department
from several files once. Names lose a trailing " - <24-digit hex id>" and are
cut to 15 characters. The radio holds 150 pages. --list prints them and
writes nothing. Channels and other settings in the image are left alone.
"""
import argparse, configparser, re, struct, sys

PAGER_START = 0xD000
PAGER_END = 0xDE20
TONES = 0xD010
NAMES = 0xD4C0
MAX = 150
MAGIC = 0x31475150
CHIRP_META = b'\x00\xffchirp\xeeimg\x00\x01'


def num(x):
    try:
        return float(str(x).strip())
    except (TypeError, ValueError):
        return None


def read_cfg(path):
    cp = configparser.RawConfigParser(strict=False)
    with open(path, encoding='utf-8', errors='replace') as f:
        cp.read_file(f)
    for sec in cp.sections():
        g = lambda k: cp.get(sec, k, fallback=None)
        desc = re.sub(r'\s+-\s+[0-9a-f]{24}$', '', (g('description') or sec).strip())
        tol = num(g('tone_tolerance')) or 0.02
        if num(g('longtone')):
            yield {'name': desc, 'a': num(g('longtone')), 'b': 0.0,
                   'alen': num(g('longtonelength')) or 6.0, 'blen': 0.0, 'tol': tol}
        elif num(g('atone')) and num(g('btone')):
            yield {'name': desc, 'a': num(g('atone')), 'b': num(g('btone')),
                   'alen': num(g('atonelength')) or 0.6, 'blen': num(g('btonelength')) or 0.6, 'tol': tol}


def pager_region(pages, monitor_s, beep):
    blob = bytearray(b'\xFF' * (PAGER_END - PAGER_START))
    struct.pack_into('<IBBB', blob, 0, MAGIC, len(pages), monitor_s, 1 if beep else 0)
    blob[7:16] = b'\xFF' * 9
    for i in range(MAX):
        if i < len(pages):
            p = pages[i]
            rec = struct.pack('<HHBBBB', round(p['a'] * 10), round(p['b'] * 10),
                              min(255, round(p['alen'] * 10)), min(255, round(p['blen'] * 10)),
                              min(255, round(p['tol'] * 1000)), 0)
            name = p['name'].encode('ascii', 'replace')[:15].ljust(16, b'\0')
        else:
            rec, name = b'\0' * 7 + b'\x01', b'\0' * 16
        t, n = TONES - PAGER_START + 8 * i, NAMES - PAGER_START + 16 * i
        blob[t:t + 8] = rec
        blob[n:n + 16] = name
    return bytes(blob)


def main():
    ap = argparse.ArgumentParser(description=__doc__.split('\n')[1])
    ap.add_argument('image')
    ap.add_argument('cfg', nargs='+')
    ap.add_argument('--match', action='append', default=[], help='keep descriptions matching (repeatable)')
    ap.add_argument('--monitor', type=int, default=60, help='seconds a silent channel stays heard (1-254)')
    ap.add_argument('--no-beep', action='store_true')
    ap.add_argument('--list', action='store_true', help='print the pages, write nothing')
    a = ap.parse_args()

    pages, seen = [], set()
    for path in a.cfg:
        for p in read_cfg(path):
            if a.match and not any(re.search(m, p['name'], re.I) for m in a.match):
                continue
            key = (p['name'][:15], p['a'], p['b'])
            if key not in seen:
                seen.add(key)
                pages.append(p)
    for i, p in enumerate(pages):
        tones = f"A {p['a']:7.1f}  B {p['b']:7.1f}" if p['b'] else f"long {p['a']:7.1f} {p['alen']:.1f}s"
        print(f"{i + 1:3d}  {p['name'][:15]:<15}  {tones}")
    if len(pages) > MAX:
        sys.exit(f'{len(pages)} pages: the radio holds {MAX}; narrow it with --match')
    if a.list:
        return

    data = open(a.image, 'rb').read()
    cut = data.find(CHIRP_META)
    mm, meta = (data, b'') if cut < 0 else (data[:cut], data[cut:])
    if len(mm) < PAGER_END:
        mm += b'\xFF' * (PAGER_END - len(mm))
    mm = mm[:PAGER_START] + pager_region(pages, max(1, min(254, a.monitor)), not a.no_beep) + mm[PAGER_END:]
    open(a.image, 'wb').write(mm + meta)
    print(f'{len(pages)} pages written to {a.image}')


if __name__ == '__main__':
    main()
