akp02
Linux library for the Ajazz AKP02 9.2" (1920x462) USB secondary display. The AKP02 officially ships with Windows-only software; this project is the result of reverse engineering its USB HID protocol so the panel can be driven natively from Linux.
Looking for something that just puts system stats on the display out of the box? That's akp02d, a daemon built on this library. This repo is the underlying library -- use it if you want to write your own thing for the panel.
Confirmed on real hardware: full-screen frames, partial (region) updates including correct color rendering, brightness, screen on/off, clear, firmware version query, splash-screen orientation, and sustained keepalive operation.
Install
Arch Linux
Available in the AUR as
python-akp02:
paru -S python-akp02 # or yay, or makepkg -si
The package installs the udev rule for you. Replug the panel afterwards and you're done -- skip the rest of this section.
Other distributions
pip install akp02
Then install the udev rule so the device is usable without root. The rule ships inside the package, so there's no need to clone the repo:
sudo cp "$(python -c 'from importlib.resources import files; print(files("akp02") / "udev" / "99-akp02.rules")')" /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
Replug the device after installing the rule -- udev only applies it on the next connect.
From a checkout
git clone https://github.com/deonspengler/akp02
cd akp02
pip install .
sudo cp udev/99-akp02.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
Verify
With the panel connected:
from akp02 import AKP02
with AKP02() as panel:
panel.screen_on()
panel.set_brightness(30)
The backlight should visibly dim. A permissions error here almost always
means the udev rule isn't active yet -- confirm the file is in
/etc/udev/rules.d/ and that you replugged the device.
Note on hidapi
Most Linux users get a prebuilt hidapi wheel and need nothing extra.
If pip falls back to building it from source -- unusual architecture, or
a Python version newer than the available wheels -- install the headers
first:
sudo apt install libhidapi-dev libudev-dev # Debian/Ubuntu
sudo dnf install hidapi-devel systemd-devel # Fedora
sudo pacman -S hidapi # Arch
Library
from akp02 import AKP02
with AKP02() as panel:
panel.screen_on()
panel.set_brightness(80)
panel.show(pil_image) # full screen, letterboxed if needed
panel.show(widget, at=(1600, 16)) # partial update, rest preserved
jpeg = panel.encode_region(widget) # encode once...
panel.show(jpeg, at=(1600, 16)) # ...push it as often as you like
encode_region() returns the bytes show() would have sent, so a region
whose pixels haven't changed costs no re-encode; the size is read from the
JPEG itself, making a cache entry just (jpeg, at). Re-encode after an
orientation() or inverted change -- stale bytes are reinterpreted, not
rejected.
AKP02() raises DeviceNotFoundError if the panel isn't connected; the
message lists what was on the bus instead.
The panel sleeps unless it receives periodic heartbeats, so entering the
context manager starts a keepalive thread for you (every 5s by default).
AKP02(keepalive_interval=None) starts nothing, for one-shot use:
panel.start_keepalive(interval_sec=10) # or resume after a disconnect
panel.stop_keepalive() # let the panel sleep
panel.heartbeat() # one beat, e.g. from your own timer
The thread starts in __enter__ rather than __init__, so constructing
an AKP02 never has a background side effect. Constructing one without
the with statement means no automatic keepalive -- call
start_keepalive() yourself in that case, and close() when done.
All public methods are thread-safe: a single lock is held across whole multi-report image transfers so the keepalive thread can never interleave with or corrupt a frame. Image composition and JPEG encoding happen outside the lock, so the keepalive thread isn't blocked by them.
Orientation and mounting
The panel is sold as a 1920x462 landscape strip, and that is the default:
show() takes landscape images and at=(x, y) is in landscape
coordinates. Hang it on its side and switch:
from akp02 import AKP02, Orientation
panel.orientation(Orientation.PORTRAIT) # now a 462x1920 surface
panel.orientation() # read it back, sends nothing
panel.size # (462, 1920) -- follows the mode
show() expects images in whichever mode is active, so build them at
panel.size and they stay right across a switch. The JPEG on the wire
is 462x1920 either way; only your view of it changes.
If the panel is mounted the other way up, set panel.inverted = True.
That turns the image a further 180 degrees, so what you draw at the top
appears at the top of the panel as you are looking at it. It is a
rotation, not a mirror -- text stays readable. It is software only, has
no hardware command behind it, and does not change panel.size.
orientation() also sends the device's SET command, which orients the
splash screen the panel draws for itself at power-on. That setting
persists across power cycles and is the command's only effect --
nothing on the device rotates frames the host sends, which is why the
rotation above happens here. Entering the context manager deliberately
does not send SET, so a with block can't overwrite a splash setting
you never mentioned.
Region updates need a brief settling delay before them, or the device drops them: one sent immediately after a full-frame draw can stop that frame rendering at all, and one sent immediately after another region replaces it before the device has drawn it -- send a burst back to back and only the last may survive. The delay is tracked and applied automatically before every region, measured from the end of the previous update, so a caller already spacing its own draws pays nothing and one that isn't is corrected. Callers don't need to do anything either way.
Region placement is also corrected automatically: the device renders a
region with rotated color channels, or slides its contents by a pixel
or two, unless its position satisfies a specific alignment rule (see
Protocol notes below). show() nudges the position by up to 4 pixels
when needed and warns when it does, so at=(x, y) always renders
correctly without the caller having to know about this.
To place a region exactly where you asked and skip the nudge, keep it
on the 8-pixel lattice the rule describes: in landscape that is
y % 8 == (462 - height) % 8, which moves with the height, so compute
it rather than hardcoding a value; in upright portrait it is simply
x % 8 == 0.
Examples
Runnable scripts in examples/ -- repo only, not shipped in the
sdist -- one file each, with the panel connected:
python examples/clock.py # portrait background, one-second region updates
python examples/animation.py # landscape, a looping webp drawn into a region
clock.py composites a scrim over the strip of background the time sits on,
then redraws only that band once a second; the rest of the image is never
resent. animation.py does the same thing at frame rate, decoding every webp
frame up front and cycling them into a fixed region. Between them they cover
both orientations, full-frame draws, region updates, and the keepalive the
context manager runs for you.
clock.py renders its text with Liberation Mono (ttf-liberation on Arch,
fonts-liberation on Debian/Ubuntu) and falls back to Pillow's built-in font
if that isn't installed. Both image assets were generated with ComfyUI for
this repository and are covered by its license -- reuse them freely.
For a full application built on the library rather than a demo of it, see akp02d: a stats panel daemon that renders live system information to the display -- CPU, memory, network and temperatures -- updated in place with region draws.
Tests
pip install -e ".[test]"
pytest
The suite runs entirely against a fake HID device -- no physical
hardware or hidapi installation required -- and holds the library at
100% line and branch coverage. It covers protocol byte-exactness pinned
against real captures, the region color-alignment correction with real
hardware-confirmed data points, the settling delay including that an
already-waited caller is not made to wait twice, and concurrency
behavior: dead-thread recovery after a disconnect, bounded shutdown
against a wedged device, and lock-interleaving prevention verified
under real contention.
The orientation geometry is checked against Pillow and against the full frame rather than against the library's own arithmetic: for each of the four mode/inverted combinations, the net transform is identified by name from the whole dihedral group (and asserted to be a rotation, never a reflection), and a region is required to reproduce exactly the bytes the full frame put at that rect.
For linting and type checking as well:
pip install -e ".[dev]"
ruff check .
mypy
Protocol notes (reverse engineered)
Plain USB HID, no encryption. VID:PID 0300:3017. Output reports are
1024 bytes (EP1 OUT), input reports 512 bytes (EP2 IN).
Commands are one zero-padded report:
"CRT" + 00 00 + <mnemonic> + 00 00 + <params>
| Mnemonic | Action |
|---|---|
HAN |
screen off |
DIS |
screen on |
LIG |
brightness (1 param byte, 0-100) |
CONNECT |
heartbeat (device sleeps without periodic heartbeats) |
STP |
commit / render buffered image data |
SET |
splash-screen orientation (see below) |
Layout exceptions: CLE (clear) uses a 3-byte gap plus a literal 0xFF
trailer instead of the usual 2-byte gap; VER (firmware version) has a
leading 0x00 device-context byte before CRT, no gap after the
mnemonic, and returns its answer via a synchronous GET_REPORT on the
IN endpoint. Gap sizes are not universal across commands -- verify
per command when adding new ones.
Brightness reset on screen-on (observed on real hardware): the
device reverts its backlight to its factory default (80) when the
screen comes back on after HAN. The library tracks the last
host-requested brightness and re-applies it in screen_on() -- right
after DIS, in the same lock hold -- so an off/on cycle preserves the
brightness the caller set.
Image transfers: a 32-byte CRT..DRA header (big-endian length =
payload + 0x20, then width/height/x/y as big-endian uint16, all zero
for a full-panel draw) followed immediately by JPEG bytes, chunked into
1024-byte reports, then STP. The JPEG is always 462x1920
portrait; the library's orientation mode only decides which rotation
it applies to get there -- landscape is 90 degrees clockwise
(confirmed on hardware; the other way renders the panel upside down),
portrait passes through unrotated, and inverted turns either a further
180. Non-zero header coordinates draw a partial region in that buffer's
space; content outside it is preserved.
Region color alignment (measured on real hardware across all eight
residues): the value that lands in the header's x field decides how the
device renders the region. With e = (3 * header_x) % 8, it rotates the
color channels by e % 3 and slides the region's contents -- not its
rect -- by e // 3 pixels. Residues 0, 1 and 2 all give correct color;
only 0 also has no slide, so 0 is the target. Which of your coordinates
reaches that field depends on the mode: in landscape it is
462 - y - height, in portrait it is x directly, and inverted
reverses each. The 1920 axis shows no equivalent sensitivity. The
library corrects this automatically rather than requiring callers to
pick special coordinates.
Settling delay: a region update needs a gap before it, whatever the previous update was; full-after-full needs nothing. The library applies one settle time -- 10ms, measured from the end of the previous update -- before every region.
After a full-frame draw, a region can stop the full frame rendering at all; after another region, the second update replaces the first before the device acts on it, so a burst of N regions can leave only the last one drawn -- and, where the rect latches separately from the data, one region's pixels at another region's coordinates. Measured with a burst probe: 2.3ms fails, 2.4ms passes, and the threshold does not move with region size (24px and 128px blocks behave alike), which points at a periodic service tick rather than at render time; below it, which updates survive varies run to run with the phase. The same 10ms also holds after a full frame, which fits one tick governing both rather than two separate delays. Note that the 2.4ms figure comes from one host; if part of it is USB scheduling rather than the device, another machine could differ.
Measured from the end of the previous update rather than slept blindly, so a caller drawing on its own clock -- a frame loop, say -- pays nothing for a gap it has already left.
Splash-screen orientation (confirmed on real hardware):
"CRT" + 00,00 + "SET" + 00,00 + 0x00 + <orientation byte>, where the
orientation byte is 0x00 for horizontal or 0x01 for vertical.
Found by comparing two real captures of the same action with different
outcomes -- the first capture only ever showed the default value
(indistinguishable from "no parameter"), a second capture with actual
mode switches revealed the real byte. The setting persists across a
power cycle, verified by physically unplugging and replugging after
setting each value.
It affects only the splash screen the device draws for itself at power-on -- it does not rotate frames sent by the host, and nothing visible happens when the command is sent. (An earlier reading of the same evidence had it applying to the live display too; the persisted splash coming back rotated after a replug is consistent with both, and the narrower one is correct.) The device therefore offers no way to rotate host frames, which is why the library rotates them itself.
hidapi note: write() needs a leading 0x00 report-ID placeholder per
report; the kernel strips it before the wire (captures show no
report-ID byte on the wire itself).
License
MPL-2.0
Metadata
Release files for akp02 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| akp02-2.0.0.tar.gz | 47.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| akp02-2.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 74.6 kB
Release files / akp02-2.0.0.tar.gz
| Download URL | akp02-2.0.0.tar.gz |
|---|---|
| Size | 47.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6b9d603b1374d6109ae39427b02830e7466da8c285a4bd5ee465c0f8cda071e0
|
|
BLAKE2b-256 checksum How to use checksums |
ba80a892cdac6454bd55e20ce076e27a07eca7b00959dc91563aabb3a6ad4d94
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.
Transparency logRelease files / akp02-2.0.0-py3-none-any.whl
| Download URL | akp02-2.0.0-py3-none-any.whl |
|---|---|
| Size | 27.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2190aa46b90bf7ff81d3bfafdf7aa8004630e4bcbbaf0c9c0dd1aa9a2076d937
|
|
BLAKE2b-256 checksum How to use checksums |
6c9d8e2c47e5df78e180ee713912d15ac75e9671ab7b4b9ebfe879dc944c41a2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.
Transparency log