Metadata-Version: 2.4
Name: pagerplug
Version: 0.1.0a2
Summary: Open-source CLI and GUI tool for Unication G4/G5 pager codeplug management
License: MIT
License-File: LICENSE
Requires-Python: >=3.9
Provides-Extra: all
Requires-Dist: fastapi>=0.109.0; extra == 'all'
Requires-Dist: jinja2>=3.1.0; extra == 'all'
Requires-Dist: python-multipart>=0.0.9; extra == 'all'
Requires-Dist: uvicorn[standard]>=0.27.0; extra == 'all'
Provides-Extra: dev
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Provides-Extra: tts
Provides-Extra: web
Requires-Dist: fastapi>=0.109.0; extra == 'web'
Requires-Dist: jinja2>=3.1.0; extra == 'web'
Requires-Dist: python-multipart>=0.0.9; extra == 'web'
Requires-Dist: uvicorn[standard]>=0.27.0; extra == 'web'
Description-Content-Type: text/markdown

# pagerplug

**pagerplug** is an open-source, MIT-licensed tool for reading, editing, and
writing Unication G4/G5 pager codeplugs (`.unipps` files) — a cross-platform
alternative to the proprietary, Windows-only GxPPS software.

This repository also documents the `.unipps` profile format and provides the
underlying scripts to read, edit, and repack it. The format is **fully decoded
and verified** — repacked files reproduce valid CRCs and import back into the
original software.

## ⚠️ Disclaimer & trademarks

pagerplug is an independent, open-source project and is **not affiliated with,
authorized by, or endorsed by Unication Co., Ltd.** "Unication", "G4", "G5",
and "GxPPS" are trademarks of their respective owners, used here only for
identification and interoperability (nominative fair use).

This software writes to radio hardware; an invalid configuration can disable a
device. It is provided with **no warranty and no liability** for damage, data
loss, or failed communications — always keep a verified backup before writing.
See [DISCLAIMER.md](DISCLAIMER.md) for the full text.

## Install & use

```bash
pip install -e .            # core CLI — Python standard library only
pip install -e ".[web]"     # + browser GUI (FastAPI / uvicorn / Jinja2)
```

Command line:

```bash
pagerplug file info CONFIG.unipps          # header, CRCs, ZIP contents
pagerplug edit talkgroups CONFIG.unipps    # list talkgroups
pagerplug edit set CONFIG.unipps tabZone_New --match ZoneNo=1 --set "Name=Station 1"
pagerplug export op25 CONFIG.unipps out/   # export to the P25 ecosystem
```

Browser GUI — view and edit zones, talkgroups, members, P25 systems, and voice
prompts, then save back to a `.unipps`:

```bash
pagerplug web CONFIG.unipps                # then open http://127.0.0.1:8765
```

### Run with Docker

```bash
docker compose up --build                  # serves the UI on http://localhost:8765
```

Put your `.unipps` files in `./data` (mounted at `/data` in the container), then
open `/data/<your-file>.unipps` in the UI and save back to `/data` to persist
edits to the host. Programming a physical pager over USB needs host networking —
see the note in [docker-compose.yml](docker-compose.yml).

## TL;DR

A `.unipps` file = a small 24-byte wrapper (`FE FE FE FE`, version bytes, an
export timestamp, two CRC-16/KERMIT checksums, a length) + a GUID + a normal
ZIP. The ZIP holds the config as **SQLite 2.1** databases (an old format modern
`sqlite3` can't open) plus `.wav` voice prompts and `.png` images.

See [FORMAT.md](FORMAT.md) for the byte-level spec.

## What the "opaque" header field turned out to be

Comparing three exports (original; a no-change re-export; one with "Test" added
to the profile name) showed the header bytes changing every time. The variable
bytes are:

* **offset 10–13** — the export time as a Unix timestamp (UTC). This is why even
  a "no changes" re-export differs. The three samples decoded to
  `19:00:34`, `20:35:47`, `20:36:11` on 2026-06-01, matching the file
  timestamps exactly.
* **offset 16–17** — CRC-16/KERMIT of the version+timestamp+count header bytes.
* **offset 22–23** — CRC-16/KERMIT of the GUID+ZIP body (offset 18–21 is its
  length).

Nothing is encrypted or obfuscated. All of it is recomputable, which is what
makes safe repacking possible.

## Scripts

> **These are the original analysis scripts**, now living in
> [legacy/](legacy/). They are superseded by the maintained **`pagerplug`**
> package ([pagerplug/](pagerplug/), installed as the `pagerplug` command) and
> are kept as the historical record the package was built from. Run a legacy
> script from inside `legacy/` (they import each other as siblings), e.g.
> `cd legacy && python3 unipps.py info FILE.unipps`. The shell examples further
> down this document assume that working directory.

| Script | Purpose |
|---|---|
| [legacy/unipps.py](legacy/unipps.py) | read / unpack / repack the `.unipps` container (regenerates header + CRCs); `list-wavs` / `replace-wav` for voice prompts |
| [legacy/sqlite2.py](legacy/sqlite2.py) | read SQLite 2.1 databases; patch string values in place (same length) |
| [legacy/unipps_export.py](legacy/unipps_export.py) | dump every table in every database to JSON + CSV + a readable data map |
| [legacy/unipps_to_op25.py](legacy/unipps_to_op25.py) | export P25 trunking data from a `.unipps` profile to an OP25 (boatbod fork) `multi_rx.py` JSON config |
| [legacy/protocol.py](legacy/protocol.py) | TCP protocol client for pager communication (opcodes, read/write file, handshake, directory listing) |
| [legacy/fileformat.py](legacy/fileformat.py) | bridge between pager protocol and `.unipps` format; `read_to_unipps()`, `write_from_unipps()` |
| [legacy/unipps_provision.py](legacy/unipps_provision.py) | CLI tool for live pager provisioning: `info`, `read`, `write`, `read-raw`, `list` |

Requires only Python 3 (standard library) for offline tools. Live provisioning
requires RNDIS kernel support (`modprobe rndis_host`), a connection to the
pager at `192.168.128.1:49165`, and TCP access — see [TESTING.md](TESTING.md)
for the hardware testing procedure and [ECOSYSTEM.md](ECOSYSTEM.md) for
integration with the broader P25 ecosystem.

> **Protocol specification**: the opcode values, TCP packet format, and
> programming workflow are documented inline in the source code — enough to
> write a Linux programming tool or CHIRP backend.
>
> **Hardware testing procedure**: see [TESTING.md](TESTING.md) for the
> step-by-step guide to testing the provisioning toolchain against a real pager
> (connection, filesystem probe, read/write round-trip).
>
> **P25 ecosystem integration**: see [ECOSYSTEM.md](ECOSYSTEM.md) for how the
> toolchain pairs with OP25, Trunk Recorder, SDRTrunk, DSD-FME, and dsd-neo.

### Inspect a file
```bash
python3 unipps.py info "../GxPPS_..._260601.unipps"
```

### Read the config databases
```bash
python3 unipps.py extract "../GxPPS_..._260601.unipps" /tmp/work
# list tables in the main codeplug:
python3 sqlite2.py /tmp/work/files/<GUID>.db
# dump a table:
python3 sqlite2.py /tmp/work/files/<GUID>.db tabConvFreqList
```

### Export everything to readable formats
```bash
python3 unipps_export.py "../GxPPS_..._260601.unipps" out            # JSON + CSV
python3 unipps_export.py "../GxPPS_..._260601.unipps" out --format csv
```
This reads all 7 databases (main codeplug + the P25-trunking / voice-prompt /
alert-tone sub-databases) and writes:

* `out/DATA_MAP.md` — a human-readable index: every database, table, row count,
  and column list.
* `out/summary.json` — machine-readable table/row index.
* `out/json/<db>.json` — full dump of each database, rows keyed by column name.
* `out/csv/<db>/<table>.csv` — one CSV per non-empty table, with a header row.

A ready-made dump of the current config is in [sample-export/](sample-export/)
— start with [sample-export/DATA_MAP.md](sample-export/DATA_MAP.md).

The exporter self-checks every row (the decoded field offsets must end exactly
at the stored record length) and flags any table that decodes oddly with a ⚠ in
DATA_MAP.md. The current profile exports **0** suspect rows across all 213
tables (5774 rows).

#### What's in there
The main codeplug holds ~163 tables. The data-bearing ones include:
`tabChannel` / `tabConvFreqList` (RX/TX frequencies in Hz, bandwidth, power),
`tabZone_New`, `tabGroup_New` / `tabGroupAddress` / `tabGroupMembers_New`,
`tabMember_New` / `tabMemberAddress` (1600+ address rows), `tabChannelGroup`,
`tabHomeScreenSettings`, `tabBacklightSettings`, alert/audio tone tables, GPS
and emergency settings, and many feature toggles. The four GUID-named
sub-databases are P25 trunking systems (`tabP25TrunkingSystem`, sites, control
channels, talkgroups); `VoicePromptTotal.db` maps knob-position announcements to
the `.wav` files; `AlertToneTotal.db` lists custom alert tones.

---

## Export to OP25 (boatbod fork)

[unipps_to_op25.py](unipps_to_op25.py) reads any `.unipps` profile, finds all P25
trunking system databases inside it, and generates a ready-to-use
`multi_rx.py` JSON configuration with per-system talkgroup (TGID) and
radio-ID (RID) tag files.

### What it produces

| File | Purpose |
|---|---|
| `op25_config.json` | `multi_rx.py` JSON — channels, devices, trunking, audio, terminal |
| `*_tgid_tags.tsv` | TGID-to-GroupName mapping for talkgroup tagging |
| `*_rid_tags.tsv` | RID-to-MemberName mapping (optional) |
| `all_frequencies.tsv` | Every site frequency in MHz — reference |

### Quick start

```bash
python3 unipps_to_op25.py "config.unipps" /tmp/op25
# Then:
./multi_rx.py -c /tmp/op25/op25_config.json -U -l http:0.0.0.0:8080
```

### How control channel detection works

The pager stores all site frequencies (control + voice) in its trunking
databases but doesn't mark which are the active control channels.
`unipps_to_op25.py` handles this in two ways:

1. **Default (no external data)** — all frequencies for each trunking system
   are placed in `control_channel_list`. OP25's `tk_p25.py` scans the list
   to find and track the active control channel automatically. This works
   without any external data source.

2. **With `--rr-dsd`** — a RadioReference DSD-format site file
   (semicolon-separated, cols `Sysname;SysID;WACN;RFSS;SiteID;Name;Freq;Chan`)
   cross-references pager sites by `(RFSS, SiteID)` and limits the
   `control_channel_list` to only known control-channel frequencies, reducing
   scan time. Requires a RadioReference Premium subscription to download.
   Use multiple times for multiple systems:
   ```bash
   python3 unipps_to_op25.py config.unipps /tmp/op25 \
       --rr-dsd tacn_sites.dsd --rr-dsd tvrs_sites.dsd
   ```

### Per-system output

When the profile contains multiple trunking systems (the sample profile has
four: TVRS Valley, TVRS Site 10, TVRS NWGA, Statewide), each gets its own
trunking entry, channel, and tag files:

```
/tmp/op25/
  op25_config.json
  TVRS_Valley_tgid_tags.tsv      TVRS_Valley_rid_tags.tsv
  TVRS_Site_10_tgid_tags.tsv     TVRS_Site_10_rid_tags.tsv
  TVRS_NWGA_tgid_tags.tsv        TVRS_NWGA_rid_tags.tsv
  Statewide_tgid_tags.tsv        Statewide_rid_tags.tsv
  all_frequencies.tsv
```

### Options reference

| Flag | Default | Description |
|---|---|---|
| `--sysname NAME` | from DB | Override trunking system name |
| `--mod cqpsk\|c4fm` | `cqpsk` | Modulation type |
| `--rr-dsd FILE` | — | RR DSD-format site file (repeatable) |
| `--no-rid-tags` | off | Skip generating `rid_tags.tsv` |
| `--tgid-tags-file F` | `tgid_tags.tsv` | TGID tags filename |
| `--rid-tags-file F` | `<sysname>_rid_tags.tsv` | RID tags filename |
| `--crypt-behavior N` | `2` | 0=play, 1=silence, 2=skip |
| `--device-name NAME` | `sdr0` | SDR device name |
| `--device-args ARGS` | `rtl=0` | SDR device args |
| `--device-gains G` | `LNA:39` | SDR gains |
| `--device-ppm PPM` | `0` | SDR PPM correction |
| `--device-rate RATE` | `1000000` | SDR sample rate |
| `--device-freq HZ` | `0` (auto) | SDR center frequency Hz |
| `--audio-port PORT` | `23456` | UDP audio port |
| `--audio-device DEV` | `pulse` | Audio output device |
| `--terminal TYPE` | `http:127.0.0.1:8080` | Terminal type |

---

## Editing — three workflows

### A. Same-length value edit (no extra tools) — safest
Good for changing a frequency digit, a flag, or a name to one of equal length.
```python
import sqlite2
db = sqlite2.SQLite2DB("/tmp/work/files/<GUID>.db")
db.patch_value("tabConvFreqList", 3, "763000000", "764000000",
               save_to="/tmp/work/files/<GUID>.db")   # column 3 = RxFreq (Hz)
```
Then repack:
```bash
python3 unipps.py wrap /tmp/work /tmp/new.unipps      # fresh timestamp + CRCs
```

### B. Arbitrary edits via the legacy SQLite 2 engine
For inserts, deletes, or length-changing updates, use the original SQLite **2**
command-line tool (file format 3 tools will not work):
```bash
# build it locally (last 2.x release):
curl -O https://www.sqlite.org/sqlite-2.8.17.tar.gz
tar xzf sqlite-2.8.17.tar.gz && cd sqlite-2.8.17
CFLAGS="-std=gnu89 -w -O1 -DNDEBUG=1" ./configure --disable-tcl && make
# then:
./sqlite /tmp/work/files/<GUID>.db "UPDATE tabConvFreqList SET Name='Frequency 9' WHERE Name='Frequency 1';"
```
Repack with `unipps.py wrap` as above.

### C. Highest-fidelity ZIP edit
To change one DB but leave every other ZIP entry byte-identical, edit the
extracted DB, update the preserved `payload.zip` in place, then wrap the blob:
```bash
cd /tmp/work/files && zip ../payload.zip "<GUID>.db"   # updates that entry only
cd - && python3 unipps.py wrapzip /tmp/work/payload.zip /tmp/new.unipps <GUID>
```

## Verified round-trip

`extract` → `patch_value` (RxFreq 763000000 → 764000000) → `wrap` produced a
file whose head and body CRCs both validate and whose inner database reads back
the edited value. See [samples/header_compare.txt](samples/header_compare.txt)
for the three-file evidence the decode was built on.

## Editing voice prompts (WAV)

Voice prompts are stored as `<wav-GUID>.wav` inside the ZIP and referenced by
`VoicePromptTotal.db` (`tabKnobChAnnouncement.ID` = the wav GUID;
`VoicePromptAlias` is the display name). They are **PCM, mono, 8000 Hz,
16-bit** — use that format. Replacing a prompt's audio needs no database change
(keep the same GUID filename); the size may change freely because the container
is repacked with recomputed length + CRCs.

**Limits enforced by PPS** (from the import workflow):

* **Duration ≤ 29.5 s** (`num = 29500.0` ms). A longer `.wav` is rejected
  ("Unable to analyze file"); a longer `.mp3` is silently truncated at 29.5 s.
  `replace-wav` warns if your clip is over this.
* Input must be **PCM** (`Encoding == 1`), ≥8-bit; PPS resamples it to
  8000 Hz / 16-bit / mono.
* **Alias ≤ 25 characters**; **≤ 100 voice-prompt entries** total.

```bash
# see the prompts (alias, GUID, size, format check):
python3 unipps.py list-wavs config.unipps

# swap one prompt's audio, converting any input via ffmpeg (target by alias or GUID):
python3 unipps.py replace-wav config.unipps new.unipps \
        --target "LIFEFORCE" --audio my_clip.mp3

# or use a WAV you already made in the right format, skipping conversion:
python3 unipps.py replace-wav config.unipps new.unipps \
        --target 5c77848f-5b9a-43bd-9022-e512fa35c0ce --audio ready.wav --no-convert
```

`replace-wav` rewrites only that one ZIP entry — every other file (including all
databases) stays byte-identical — then regenerates the header. `ffmpeg` is
required for conversion (the `--no-convert` path needs none). The converted
audio is normalised to the **exact factory WAV layout** (see below), so a strict
embedded parser sees the same structure as a stock prompt.

### WAV header: factory layout vs ffmpeg default
The factory prompts use a **46-byte** header; ffmpeg's default differs for two
independent reasons:

| | factory (NAudio) | ffmpeg default | ffmpeg `-bitexact` |
|---|---|---|---|
| `fmt ` chunk size | **18** (`WAVEFORMATEX`, trailing `cbSize=0`) | 16 (`WAVEFORMAT`) | 16 |
| extra chunks | none | `LIST/INFO/ISFT "Lavf…"` tag before `data` | none |
| data starts at | 46 | 70 | 44 |

The 2-byte `cbSize=0` extension (18- vs 16-byte fmt) is what makes the factory
header 46 rather than the canonical 44; it's the signature of the Windows/.NET
audio stack (NAudio, which PPS bundles). `unipps.py`'s `canonical_pager_wav()`
rebuilds converted audio as `RIFF + 18-byte WAVEFORMATEX fmt + data` (no LIST),
which reproduces a factory prompt **byte-for-byte** when fed the same audio. To change the
*name* a prompt shows/announces, edit `VoicePromptAlias` / `VoicePromptFileName`
in `VoicePromptTotal.db` (a string edit). Adding a brand-new prompt also needs a
`tabKnobChAnnouncement` row.

## Caveats

* The inner ZIP is rebuilt with Python's deflate on `wrap`, so byte size differs
  slightly from PPS's output — it is still a valid ZIP and the databases are
  untouched. Use workflow **C** if you need the other entries preserved exactly.
* `sqlite2.py` writing is limited to same-length, locally-stored string values.
  It does not rebalance the b-tree; use the real engine (workflow B) for
  anything structural.
* `replace-wav` is verified at the container level (valid CRCs, single entry
  swapped, databases untouched) and reproduces the factory WAV layout exactly
  (18-byte `WAVEFORMATEX` fmt, no LIST tag — byte-identical to a stock prompt
  for the same audio). It has **not** been tested on actual hardware — there may
  be a per-prompt length/duration limit. Confirm in PPS and on a pager before
  relying on it. (`--no-convert` passes your WAV through untouched, so it is
  your responsibility to match the factory format then.)
* Always keep a backup and confirm an edited file imports cleanly in PPS before
  flashing a pager.
