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:11on 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
pagerplugpackage (pagerplug/, installed as thepagerplugcommand) and are kept as the historical record the package was built from. Run a legacy script from insidelegacy/(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:
-
Default (no external data) — all frequencies for each trunking system are placed in
control_channel_list. OP25'stk_p25.pyscans the list to find and track the active control channel automatically. This works without any external data source. -
With
--rr-dsd— a RadioReference DSD-format site file (semicolon-separated, colsSysname;SysID;WACN;RFSS;SiteID;Name;Freq;Chan) cross-references pager sites by(RFSS, SiteID)and limits thecontrol_channel_listto 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
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
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.0ms). A longer.wavis rejected ("Unable to analyze file"); a longer.mp3is silently truncated at 29.5 s.replace-wavwarns 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.pywriting 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-wavis verified at the container level (valid CRCs, single entry swapped, databases untouched) and reproduces the factory WAV layout exactly (18-byteWAVEFORMATEXfmt, 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-convertpasses 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
41a98401fad08eea2a7484f75dd04f1dc8b21ee3c4127bc5f77a66c1a7a281f1
|
|
| MD5 |
8e3ad69c671fcaeb1407d34978d73723
|
|
| BLAKE2b-256 |
56c01c1b0614adef07d8682843372b6df96a9ac7e31034790e6483dc4c9a64c5
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
05ba8884ec8e471363e26216f009dc67c50bf57bfd428e2a7e7c3d1abbf434f7
|
|
| MD5 |
1a2948d1e685cc3ffa59f3c819ea854d
|
|
| BLAKE2b-256 |
fd06396642eaf2bccf743610f2f7dd91f7cbdad2cd737a94714520392b8f6575
|