Alternative firmware for the BTT Knomi V2 and similar round displays, which replaces the network-reliant Moonraker connection with a direct serial link to the Klipper host.
No WiFi, no IP address, no Moonraker. The display is a USB device on the Klipper
machine, and a klippy_extras module talks to it directly.
| Waiting | Heating | Tool, at temperature | Printing | Shutdown |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
Real captures off the glass — python scripts/screenshot.py COM5 --all.
Taken with the stock accent, color_machine: FFA7C4, against a 9572BF
filament, so that the two are visibly not the same thing.
Colour carries three separate things and never mixes them.
Filament is whatever the slicer loaded — the purple above — and it is the
fill that rises with progress, its surface moving with the extruder so the
screen says the machine is working rather than merely part-way through. The
fill is the only progress indicator: a numeral saying the same thing spent the
largest element on the screen restating it, and on a toolchanger it restated a
property of the job as the biggest thing on a tool's screen.
Heat is a gradient behind everything, anchored to the target rather than to
a temperature: 60 °C on the way to 65 is nearly there, 60 °C on the way to 250
has barely started. The machine's own accent — the pink dots either side of
T0, and yours to change in printer.cfg — is reserved for chrome that is
about the printer rather than about the print.
Nothing in the protocol says goodbye, and a Klipper that is killed rather than closed simply stops sending. Three seconds of silence and the display says so, rather than holding its last frame indefinitely — a print that finished an hour ago should not look like one at 55%. It keeps showing what it last heard, because that is still the most useful thing on the glass; it just stops claiming to be current, and lets the screen sleep on its normal timers again.
- Using it
- Scope, and what is finished
- Installation
- Updating firmware
- Klipper configuration
- The corner keys
- Telling the screens about the job
- Status reference
- Testing the display without printing
- Versioning
- Acknowledgements
Three documents sit outside this one: docs/protocol.md for the wire format, docs/mcu-updater.md for what a flashing tool needs from this repo, and docs/hardware.md for the pin map. service/README.md covers the watcher.
The idle screen is a row of pages you swipe between horizontally. Which
pages, and in what order, is pages: in printer.cfg.
The emergency stop hangs off that row vertically, so it is one swipe from
every page rather than several along. estop_at: bottom (the default) means
drag up to reach it; top is the notification-shade gesture. It is never in
pages: and cannot be configured away.
The four corners are controls; see the corner keys.
This fork is developed toolchanger-first: several displays on one printer, one per tool, coordinated by a single Klipper module so that the state they share is computed once rather than once per screen. That is the case the design is worked out against.
Single-toolhead machines are supported by the same firmware, but they are the
secondary target and it shows: the home and move pages have not been brought
into the same design language as the rest. They work; they look like the
firmware this was forked from.
| Area | State |
|---|---|
| Tool page | current design |
| G-code page | |
| Printing screen | |
| Emergency stop page | |
| Home and move pages | inherited, not yet reworked |
| Corner keys as touch targets | working |
| Corner keys from the display's own GPIO | not implemented, see below |
Plug the display into the Klipper host over USB — it enumerates as a CH340 serial port — then build and flash with PlatformIO:
pio run -e knomi -t uploadOne firmware, whatever the machine. There used to be a separate toolchanger build that compiled two pages out; it saved 1572 bytes of a 4.7 MB flash and cost a second image to pick between, where flashing the wrong one silently removed pages with nothing on screen to explain it.
The Klipper module is installed by running install.sh on the Klipper host. It
symlinks rather than copies, so git pull updates the module too. Add a config
section as below and restart Klipper.
The host side updates itself — install.sh symlinks, so git pull moves the
Klipper module and the watcher with it. Only the firmware needs writing to a
device, and the awkward part of writing it is not the flash, it is naming which
display to flash.
mcu-updater is the recommended way,
because it addresses displays the same way everything else here does: by the
six-hex-character id burned into the chip, resolved to a port at flash time.
Flashing by remembered path is the failure this repo exists to avoid —
/dev/ttyUSB0 moves between reboots, and on a toolchanger the display that
answers to it today is not reliably the one that did yesterday. Reflashing T3
because the ports renumbered is a quiet failure: both displays come back up,
both work, and the wrong one is running the wrong build.
It reads the three sources described in docs/mcu-updater.md — Klipper if it is up, the watcher's map if it is not, a live scan immediately before writing — and refuses rather than guesses when they disagree.
Failing that, from a checkout, with the id resolved by hand:
sudo systemctl stop klipper knomi_serial
python scripts/discover.py # which id is on which port
pio run -e knomi -t upload --upload-port /dev/ttyUSB0
sudo systemctl start knomi_serial klipperStopping Klipper is not optional: it holds its ports under an advisory flock
that esptool also takes, so the upload fails outright otherwise.
Every v* tag builds a single merged image — bootloader, partition table,
boot_app0 and the app in one file — attached to its
release. It flashes at one
offset, so there are no offsets to get wrong:
esptool --chip esp32s3 -p /dev/ttyUSB0 write_flash 0x0 knomi-0.6.0.binThis is for hardware that has never had a toolchain pointed at it — a display
out of the box, or a recovery from a bad flash. For a working printer, prefer
either route above: they check which display they are writing to, and this does
not. Each release also carries a manifest.json, which is what a browser-based
ESP Web Tools flasher reads to write
the same image over WebSerial with nothing installed at all.
A tag whose VERSION file disagrees with it fails the release build rather than
publishing, so a downloaded image always reports the version on the tin — see
Versioning.
[knomi_serial T0_knomi] # a named device like this, or a bare [knomi_serial]
device_id: 19aa44 # Which display this is. Six hex characters, case
# insensitive, shown on the display's own waiting screen
# and listed by `python scripts/discover.py`. See "Which
# display is which" below. Use this *or* `serial:`, not
# both.
tool: # Which tool this screen belongs to, e.g. T0. Optional. It sets the
# tag the screen shows, and lets KNOMI_TOOL address this screen by
# tool number as well as by name. `T0`, `t0` and `0` are equivalent.
pages: # Which pages the idle screen carries, in order, from
# tool, gcode, home, move. The screen lands on the first, so this
# chooses where you start as well as the sequence. A page that would
# be empty is skipped - listing `gcode` with no `gcodes:` below gets
# you no G-code page. The emergency stop is always last and is never
# listed. Default: tool, gcode, home, move
# A multi-screen setup may only want: pages: tool, gcode
# The screen shows a pair of accent dots either side of its tool tag when this
# is the extruder the toolhead currently has mounted - which is how you tell,
# at a glance across a row of displays, which one the machine is using. That is
# what heater_hotend does beyond temperature: it is matched against the active
# extruder's name, so it has to be the real extruder name (extruder, extruder1,
# ...) rather than any alias.
heater_hotend: extruder # Name of the hotend heater.
heater_bed: heater_bed # Name of the bed heater.
# Only one of heater_chamber and sensor_chamber should be configured.
heater_chamber: # Name of the chamber heater.
sensor_chamber: # Name of the chamber thermistor.
sensor_mcu: # Name of an MCU temperature sensor. Reported in get_status.
move_x: 10 # Millimetres to move the toolhead in the positive X direction.
move_y: 10
move_z: 10
speed_x: 100 # Speed to move the toolhead in the X direction.
speed_y: 100
speed_z: 100
gcodes: # Comma separated G-Codes to show on the G-code page.Every display carries a permanent id burned into its chip, and says it in the status line it already sends every two seconds. Two ways to read it:
python scripts/discover.py # a table of everything plugged in
python scripts/discover.py --config # the same, as sections to pasteor just read it off the glass — it is on each display's waiting screen, under
the printer name. Case does not matter when you type it into printer.cfg.
Stop Klipper before running the script. Ports in use are locked with flock,
which Klipper, esptool and these scripts all take, so a port already being
driven is reported as busy rather than probed. The lock is advisory — it keeps
out the tools that ask for it, not a cat /dev/ttyUSB0 — but it covers the case
that matters, which is two of these tools reaching for one display at once.
At startup the cluster listens on every port once, before any section opens
one, and refuses a config that describes one display twice — whether by two
device_id: sections, two serial: paths, or one of each:
That single pass is the only moment the two schemes can be checked against each
other, because once a serial: section holds its port nothing else can ask what
is on the end of it. Left to run, both sections would open the same port and
split its byte stream between them, so the symptom would be two screens blinking
out at random with nothing naming the cause.
Addressing by device_id: means the display keeps its identity whichever socket
it is in. That matters most on a toolchanger, where the failure it prevents is a
quiet one: swap two leads and two screens describe the wrong tools, with nothing
on either to say so.
serial: still works and is unchanged. If you use it, be aware of what a
path can and cannot promise here. The CH340K on these displays reports no USB
serial number — the descriptor is empty — so nothing on the USB side tells one
display from another, and every path names a socket:
| two identical displays | move a cable | move a hub | replace a dead display | |
|---|---|---|---|---|
/dev/ttyUSB0 |
swaps between reboots | swaps | swaps | keeps working |
/dev/serial/by-id/ |
the _1 suffix is assigned by the kernel in enumeration order, not by the device — so it can move too |
swaps | swaps | keeps working |
udev rule on KERNELS== |
distinct | swaps silently | all of them stop at once | keeps working |
device_id: |
distinct | follows the display | follows the display | needs a one-line edit |
The two path failures are worth telling apart, because they feel nothing alike.
Swapping two leads on the same hub is the quiet one — two screens confidently
describe the wrong tools and nothing on either says so. Moving the hub itself is
loud but total: a rule keyed to 3-1.6.5 matches nothing once that hub enumerates
as 3-2, so every display drops out together. The more USB tree you have above
the displays, the more ways there are for a position map to stop being true, and
the fragility scales with the size of the tree rather than the number of screens.
device_id: has one cost in exchange, and it is the last column: a hardware id
names that display, so replacing a dead one means editing the section that
referred to it. A position map does not care which unit is in the socket.
That is the actual choice — identity or position — rather than one being simply better. A udev rule keyed to the physical USB port is the right tool when you genuinely mean the socket, and is still fully supported.
Worth separating from the other udev in this repo, because they are unrelated: the watcher service listens to udev to learn when a port appears, which is about noticing a device. A rules file names one. Nothing here writes rules, and that is a deliberate line rather than an omission — see below.
# /etc/udev/rules.d/99-knomi.rules - one line per USB port on the hub
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", KERNELS=="3-1.6.5", SYMLINK+="knomi_t0"
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", KERNELS=="3-1.7.1", SYMLINK+="knomi_t1"
install.sh does not copy udev rules into place for you. Writing a rules file
is granting a device the right to appear under a name your config trusts, and
that is a decision to make deliberately rather than one a setup script should
make on your behalf.
One section per display. They find each other and share a single set of timers and one computation of the printer state, so a fourth screen costs a serial write rather than a fourth pass over the machine:
[knomi_serial T0_knomi]
device_id: 19aa44
tool: T0
heater_hotend: extruder
heater_bed: heater_bed
pages: tool, gcode
[knomi_serial T1_knomi]
device_id: 19aa45
tool: T1
heater_hotend: extruder1
heater_bed: heater_bed
pages: tool, gcodeBed and chamber only need declaring once — whichever section names them, every screen shows them, because there is only one bed.
Every one of these is optional. Anything left out keeps the value the firmware
was compiled with in src/user_conf.h — setting one here overrides that
default rather than replacing the whole set.
color_machine: # The printer's own accent, RRGGBB. Default FFA7C4.
color_filament_unknown: # Shown when the host has not said what is loaded.
# Deliberately not black - unknown filament and black
# filament are different facts. Default 5A5A5A.
brightness: # Backlight level, 0-16. Default 8.
dim_brightness: # Level once dimmed. Default 3.
dim_time: # Seconds idle before dimming. Default 30.
sleep_time: # Seconds idle before the backlight goes out. Default 60.
estop_at: # Which side of the page row the emergency stop hangs off,
# bottom or top. Bottom means swipe up to reach it, top means
# pull down. Default bottom.
readouts: # Which secondary temperatures show above the hotend, in
# order, from bed, chamber, mcu. Each gets its own pill. One
# the machine does not have is skipped, so listing it costs
# nothing.
# Default: bed, chamber
# A toolchanger usually wants: readouts: mcu
# - the bed is the same on every screen, while the tool's own
# MCU sits in the chamber and only that screen can report it.These reach the device over the config channel, which it asks for whenever what
it holds stops matching what the host has. Editing them and restarting Klipper
is enough — there is nothing to reflash and nothing to power-cycle, and they
survive a reflash where the firmware's own defaults do not.
printer["knomi_serial T0_knomi"].config_applied says whether the screen is
actually running what was sent.
Four controls sit on the diagonals, where a round layout has room to spare and where physical keys can go. The lower pair is context-aware — load and unload on the tool page, pause and cancel while printing — and the upper pair is reserved for feed and retract, which on this machine are wired to the filament buffer and work with the host down.
By default all four are soft keys: the glass is the button, and a bare display needs no wiring to be fully usable.
hardware_keys: NW, NE # any of NW NE SW SE, comma or space separatedListing a corner keeps its symbol exactly where it is, as a legend, and removes its touch target. Nothing on screen moves in that transition, which is the point of putting the soft keys on the diagonals first — the positions are learned before the hardware arrives.
This option does not define pins. It only tells the display to stop offering an action that something else already reports. Where the pin is declared depends on which board the switch is wired to:
- To the Klipper host. Declare it as a standard Klipper
[gcode_button]with its ownpin:andpress_gcode:. Klipper reads the switch; the display just stops duplicating it. - To the display itself. Not implemented yet. Every free GPIO on the Knomi V2 sits behind the unpopulated U10 camera FPC, which needs a breakout before anything can be soldered — see docs/hardware.md for the pin map, the four pins worth using, and the two that will bite you.
KNOMI_TOOL [SCREEN=T0_knomi | TOOL=0] [USED=1] [COLOR=FF8800] [TYPE=PLA]
| Parameter | Value | If omitted |
|---|---|---|
SCREEN |
Which screen, by section name — [knomi_serial T0_knomi] is T0_knomi, the way [fan_generic my_fan] is my_fan. |
see below |
TOOL |
Which screen, by its tool: value. T0, t0 and 0 are equivalent. May match several screens. |
see below |
USED |
0 or 1 — whether the running job uses this tool. Decides whether the screen sleeps. |
Unchanged |
COLOR |
Filament colour as RRGGBB, leading # allowed. Empty clears it back to unknown. |
Unchanged |
TYPE |
Material name, up to 15 characters. | Unchanged |
Multi-screen — give either SCREEN= or TOOL=, not both.
Single screen — both are optional; there is nothing to disambiguate.
Both forms exist because two callers need different things. SCREEN= names the
object, the way Klipper does everywhere else, and is the only form that reaches
a section with no tool:. TOOL= is the only form a slicer can emit
generically — TOOL={i} sits in the same loop as filament_colour[i], so the
macro writes itself — and it matches every screen declaring that tool, so a
spare display of one tool follows one spool.
Every parameter is optional, so one fact can be changed without restating the others, and the command is safe to repeat. That makes mid-job reassignment ordinary rather than a special case:
# print start, one per tool - Orca knows all three from is_extruder_used[],
# filament_colour[] and filament_type[]
KNOMI_TOOL TOOL=0 USED=1 COLOR={filament_colour[0]} TYPE={filament_type[0]}
# T0 jammed, hand the rest of the job to T4
KNOMI_TOOL TOOL=0 USED=0
KNOMI_TOOL TOOL=4 USED=1 COLOR=FF0000 TYPE=ABS
# one display, no tool: declared - nothing to name
KNOMI_TOOL COLOR=9572BF TYPE=ABSUSED returns to true for every tool when a print ends, so the next job starts
clean, and it defaults to true on startup — a module restart mid-print must not
black out every screen. Filament colour and type deliberately survive a print
ending, because the spool is still in the tool.
A screen refuses to sleep while any of these hold:
USEDis set and a job is running. The host is told which tools a job uses rather than inferring it from nozzle temperature, because temperature cannot separate a docked tool still in the job from one merely warm from the chamber — with ooze prevention dropping a docked tool by 100 °C and a chamber at 60 °C, no threshold splits those two.- Klipper has shut down, because a dark screen cannot report a fault.
- The nozzle is hotter than
SLEEP_HOT_THRESHOLD. A compile-time safety net insrc/user_conf.h— not aprinter.cfgoption and not aKNOMI_TOOLparameter — so a hot nozzle keeps its screen lit whatever the host believes. Defaults to 80 °C, and must sit above chamber temperature or a tool idling at chamber heat reads as busy and the screen never sleeps at all.
All three are claims about the machine, so a link that has gone quiet stops making them and the normal timers resume.
The device reports its own state back over the same serial link every two
seconds (REPORT_PERIOD_MS in src/user_conf.h), so it is available to macros
and to the Moonraker API as printer["knomi_serial T0_knomi"] (or
printer.knomi_serial for an unnamed section):
| Field | Description |
|---|---|
connected |
Host has the serial port open. |
port |
Configured serial path. |
module_version |
Version of this Klipper module. |
protocol_version |
Wire format version this module speaks. |
config_crc |
CRC32 of the config this module is holding. |
screen_name |
What KNOMI_TOOL SCREEN= addresses this section as. |
tool |
Normalised tool: value, e.g. 0. None if unset. |
used |
Whether the running job uses this tool. |
filament_color |
Loaded filament colour as RRGGBB, or None. |
filament_type |
Loaded material name, or None. |
device_online |
Device has reported within the last 10 seconds. |
report_age |
Seconds since the last report, or None. |
firmware_version |
Version actually flashed on the device. |
device_protocol_version |
Wire format version the device speaks. |
protocol_match |
Whether the two protocol versions agree. |
device_config_crc |
CRC32 of the config the device is actually running. |
config_applied |
Whether the device is running what was sent. |
build_variant |
Which PlatformIO env was flashed. |
sleep_state |
awake, dim, or off. |
screen |
init, idle, printing, or shutdown. |
page |
Index of the idle screen page in view. |
page_count |
How many pages the screen built, after empty ones were skipped. |
free_heap / min_free_heap |
Current and lowest-ever free heap, bytes. |
device_uptime |
Seconds since the device booted. |
Every device-side field is None until the first report arrives, so a device
running firmware older than this feature reads as device_online: False with a
None version.
scripts/simulate.py drives a display with made-up state over USB, so UI work
does not need a printer — or even Klipper. It builds packets with
knomi_serial.encode_state, the same encoder the module uses, so the screen
sees byte-for-byte what it sees in service, and it answers the commands the
display sends back so the buttons actually do things.
pip install pyserial
python scripts/simulate.py --list # find the port
python scripts/simulate.py COM7 # loop a whole fake print
python scripts/simulate.py COM7 --progress 54 # park the fill mid-screen
python scripts/simulate.py COM7 --cycle-colours # step through filaments
python scripts/simulate.py COM7 --no-used # should dim, then sleepWith nothing pinned it loops cold → heating → printing 0–100% → finished,
ramping temperatures rather than jumping them so the heat colour visibly crosses
steel to amber. Pin any value and it stops moving: --progress 54 parks the
fill right at the ink crossover, which is where black-versus-white text is worth
checking against a real panel.
--cycle-colours walks the presets, which deliberately include the awkward
cases — white and yellow, where the ink must flip to black; true black, where it
must not; and a pink close enough to the machine accent to check identity
survives it.
Stop Klipper first if the display is wired to a running host. Both would be writing to the same port and the screen would see interleaved packets.
Every image in this README is a real capture pulled off the glass over the same serial link — the display has no network, no filesystem and no second port.
python scripts/screenshot.py COM5 --drive printing -o docs/img/printing.png
python scripts/screenshot.py COM5 -o now.png # whatever is on screen nowAbout fourteen seconds a frame. --drive feeds the display a state first, so a
documentation shot does not depend on catching the printer in the right mood.
See docs/protocol.md for how it works.
pip install -r requirements-dev.txt
python tests/test_protocol.py # firmware and module agree about the wire
python tests/test_addressing.py # which screen a KNOMI_TOOL lands on
ruff check . # Python lint
pio run -e knomiAll of these run in CI on every push and pull request, as three jobs. The protocol test is the one
worth having: the firmware's static_asserts pin the packet layout, but only
against other C++, and nothing else compares it against the Python that has to
produce those bytes. A field added to struct State without a matching change
to _STATE_FMT compiles clean, installs clean, and produces a display reading
every field from the wrong offset. The test reads the constants out of
printer.h and fails if the two sides have drifted.
The addressing test covers rules that read as obvious and are not: a command
with no target is correct on one machine and ambiguous on the next, and TOOL=
is a one-to-many lookup.
The canonical version is the VERSION file at the repo root. scripts/version.py
runs before each build and compiles it into the firmware, appending semver build
metadata when the tree is not a clean release build:
0.4.0 clean tree, tagged v0.4.0
0.4.0+3.gd34db33 3 commits past the tag
0.4.0+3.gd34db33.dirty ...with uncommitted changes
0.4.0+gd34db33 no matching tag
The Klipper module reads the same VERSION file, which works because
install.sh symlinks it into klippy/extras rather than copying it.
To cut a release: bump VERSION, commit, then git tag -a v0.6.0 -m 0.6.0 and
push the tag. .github/workflows/release.yml builds the merged image and
publishes it as a GitHub release.
The tag is the only thing that decides this, and it has to be — a build on
main is not a release build unless main is sitting on the tag, and gating
on the branch would publish an asset whose own version string reads
0.6.0+85.g2892739. Anything comparing versions, including
mcu-updater, treats build metadata as
"not a release", so that asset would contradict the release carrying it. The
workflow refuses to publish when VERSION and the tag disagree.
Semver already says which kind of release a tag is, so nothing is passed by
hand: a hyphen makes it a prerelease. v0.7.0 is published as latest,
v0.7.0-rc1 as a prerelease — with VERSION set to 0.7.0-rc1 to match, since
the check compares them literally.
Moonraker's update manager infers the repo version from that tag on its own — it
needs at least one tag in vX.Y.Z form, and no manifest file in this repo.
Comparing the tag Moonraker reports against firmware_version above is what
tells you the device is due a reflash.
In moonraker.conf, to get this into the update panel:
[update_manager knomi_serial]
type: git_repo
path: ~/knomi_serial
origin: https://github.com/Vylyne/knomi_serial.git
primary_branch: main
managed_services: klipper knomi_serial
system_dependencies: scripts/moonraker-system-dependencies.jsonprimary_branch must name the branch your checkout is actually on. Moonraker
builds its upstream ref from it, so a mismatch compares you against a branch you
are not tracking — and worse, recover() does checkout(primary_branch)
followed by a hard reset, so the "recover" button offered when a repo looks
wrong would move you onto that branch and discard local state.
You do not have to keep that right by hand. install.sh sets it from the branch
the checkout is on, so switching branches is:
git checkout dev && ./install.shOn a detached HEAD it leaves the line alone and says so, because there is no
branch to name and primary_branch: HEAD is exactly the value that would make
that recover button destructive.
system_dependencies is how the watcher gets python3-serial and
python3-pyudev, which it needs because the unit runs the system python3
rather than Klipper's virtualenv. It is the only place this repo declares
packages. install_script: is the option it replaced and Moonraker still reads
it — one or the other, never both — but it will find nothing here, so a config
still using it installs no dependencies. Note that neither option runs
install.sh; Moonraker only ever reads it.
managed_services is what Moonraker restarts after pulling. klipper because
the module is symlinked into klippy/extras and only reloads on restart, and
the watcher because ExecStart points into the repo, so a pull updates it in
place and a restart is what picks that up.
install.sh keeps this section correct rather than only writing it once. On
every run it repairs the keys that decide whether Moonraker can manage the repo
at all — path, primary_branch, system_dependencies, managed_services,
and a missing type — and leaves everything else exactly as it found it. That
includes origin, because running a fork is a legitimate thing to be doing and
nothing about your checkout contradicts it, and it includes your comments, your
key order and any key it does not recognise. Nothing is rewritten when nothing
is wrong; when something is, it says what it changed and leaves the previous
file at moonraker.conf.knomi.bak.
This is how a config written by an older version of this repo gets fixed. When
scripts/moonraker-system-dependencies.json moved into scripts/, every
existing section kept pointing at where it used to be — and Moonraker's
_verify_path only warns, so those printers silently stopped getting packages.
The section name is not free. Moonraker accepts a managed_services value
only if it equals the [update_manager <name>] section itself, klipper, or
moonraker — a case-sensitive comparison, in _configure_managed_services. So
the section has to be called exactly what the systemd unit is called.
[update_manager Knomi_Serial] with managed_services: knomi_serial is
rejected, and it is rejected as a warning in moonraker.log rather than as
anything you would notice.
Moonraker also refuses to control a service that is not in its allowlist, so the
watcher needs a line in ~/printer_data/moonraker.asvc:
knomi_serial
install.sh appends that line if it is missing, since it is one word and
trivially undone. It will add the [update_manager] section too if there is
none — but if one already exists it reports what it found and leaves it alone,
because renaming a section changes what the update panel shows and that is not
a change to make silently in a live printer config.
Nothing here is a Moonraker agent. The watcher opens no sockets and exchanges no events — it writes a file, and Klipper reads it. Making it an agent would mean it could only work while Moonraker was up, which is the opposite of the point: see service/README.md.
printer::kProtoVersion (src/printer/printer.h) and _PROTO_VERSION
(klippy_extras/knomi_serial.py) are separate from the release version and are
bumped only when a wire format changes. A mismatch is logged once to
klippy.log and surfaced as protocol_match: False.
docs/protocol.md describes the wire format;
tests/test_protocol.py fails the build if the two sides disagree about it.
- Klipper, by Kevin O'Connor and its
contributors. This firmware is a client of it and would have nothing to
display without it. The Klipper module here follows its
klippy_extrasconventions throughout. - ruiqimao/zerod, which this is a fork of, and which established the serial-instead-of-Moonraker approach the whole project rests on.
- BIGTREETECH for the Knomi V2 hardware and for publishing its schematic, without which docs/hardware.md would be guesswork.
- LVGL and TFT_eSPI, which do the drawing.






