Skip to main content

Open-source CLI and GUI tool for Unication G4/G5 pager codeplug management

Project description

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 for the full text.

Install & use

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

Command line:

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:

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

Run with Docker

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.

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 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/. They are superseded by the maintained pagerplug package (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 read / unpack / repack the .unipps container (regenerates header + CRCs); list-wavs / replace-wav for voice prompts
legacy/sqlite2.py read SQLite 2.1 databases; patch string values in place (same length)
legacy/unipps_export.py dump every table in every database to JSON + CSV + a readable data map
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 TCP protocol client for pager communication (opcodes, read/write file, handshake, directory listing)
legacy/fileformat.py bridge between pager protocol and .unipps format; read_to_unipps(), write_from_unipps()
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 for the hardware testing procedure and 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 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 for how the toolchain pairs with OP25, Trunk Recorder, SDRTrunk, DSD-FME, and dsd-neo.

Inspect a file

python3 unipps.py info "../GxPPS_..._260601.unipps"

Read the config databases

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

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/ — start with 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 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

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:

    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.

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:

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):

# 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:

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

extractpatch_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 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.
# 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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pagerplug-0.1.0a2.tar.gz (130.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pagerplug-0.1.0a2-py3-none-any.whl (83.9 kB view details)

Uploaded Python 3

File details

Details for the file pagerplug-0.1.0a2.tar.gz.

File metadata

  • Download URL: pagerplug-0.1.0a2.tar.gz
  • Upload date:
  • Size: 130.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for pagerplug-0.1.0a2.tar.gz
Algorithm Hash digest
SHA256 41a98401fad08eea2a7484f75dd04f1dc8b21ee3c4127bc5f77a66c1a7a281f1
MD5 8e3ad69c671fcaeb1407d34978d73723
BLAKE2b-256 56c01c1b0614adef07d8682843372b6df96a9ac7e31034790e6483dc4c9a64c5

See more details on using hashes here.

File details

Details for the file pagerplug-0.1.0a2-py3-none-any.whl.

File metadata

  • Download URL: pagerplug-0.1.0a2-py3-none-any.whl
  • Upload date:
  • Size: 83.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for pagerplug-0.1.0a2-py3-none-any.whl
Algorithm Hash digest
SHA256 05ba8884ec8e471363e26216f009dc67c50bf57bfd428e2a7e7c3d1abbf434f7
MD5 1a2948d1e685cc3ffa59f3c819ea854d
BLAKE2b-256 fd06396642eaf2bccf743610f2f7dd91f7cbdad2cd737a94714520392b8f6575

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page