All notable changes to ScrollKit are recorded here. This project loosely follows Keep a Changelog.
Brightness that actually dims, a panel renderer that no longer needs pygame, and the pixel-art chapter the docs never had.
Numbered 0.11.1 because v0.11.0 was tagged and then blocked at the gate: a test asserted which of two rapidly-cycling items a headless run happened to stop on, which passed on macOS and failed on Linux CI, and a red CI stops the upload. Nothing was ever published under 0.11.0 — it is a tag with no release behind it. The test now asserts that content expires rather than which item is up at the cutoff.
- A pygame-free rendering backend, and a 3.7x faster panel composite. pygame is a C
extension over SDL with no wasm build — not in Pyodide's package set, no emscripten
wheels for pygame-ce on PyPI — so six calls (
Surface,SRCALPHA,draw.circle,transform.smoothscale,BLEND_RGB_ADD,image.save) stranded the entire LED cosmetic layer on the desktop, and a browser preview showed flat squares instead of a panel.simulator/core/_surface.pypicks a backend: pygame where importable, numpy where not. pygame stays primary wherever it exists, so desktop output is untouched — verified by frame hash, unchanged before and after. The composite also replaces up to 4,096 small per-LED array ops with a handful of whole-array writes: dots tile exactly on the pitch, and 20 px glow sprites overlap on an 11 px pitch but not on a two-cell sub-lattice, where the pitch is 22 ≥ 20 — so four passes cover the panel with no overlap inside any pass. Additive saturation is order-independent, so the result is bit-identical rather than merely close, and the test asserts that. 13.6 → 3.67 ms/frame, and measured end to end in Pyodide, 190.7 → 64.8 ms/frame (5.2 → 15.4 fps), with the paint handoff going 127 → ~1 ms once the panel composes into a persistent RGBA buffer the host wraps zero-copy. scrollkit.utils.pixel_art—normalize_art(),normalize_all(),art_problems(). Hand-authored ASCII art has exactly two typos, and both crash from deep inside the conversion loop with nothing on the panel: a ragged row throwsIndexError, an unmapped character throwsKeyError. Across six documented model-written signs, every first-attempt failure was one of those two and nothing else — one of them a single row of 25 characters where its two neighbours were 26, in a 598-line program with 34 sprites, which cost two full regeneration rounds. Short rows now pad with transparent, unmapped whitespace becomes transparent, and any other unmapped character becomes the first lit slot, because the author drew something there and substituting transparent would silently delete the sprite — a worse outcome than the crash. Every repair reports one line naming the sprite, and clean art passes through untouched.art_problems()returns the same findings as a list for tests that would rather assert than repair.- A pixel-art chapter.
docs/guide/pixel-art.mdcovers art as ASCII rows over palette slots, authoring small and doubling, the width arithmetic for 64x32, converting to aBitmaponce, and then animating with palette writes, tile moves and hidden flags rather than redrawing.demos/medium/pixel_wordmark.pyis the worked example: hand-authored letterforms, a sprite on its own material slots, and four acts through anActScheduler, at ~9 palette writes a frame. Given the library's own docs, three models each built a competent multi-scene sign — correct areas, real tool lists, inside the frame budget — and not one drew a picture.AGENTS.mdhad no pixel-art chapter and led its content section withScrollingText/StaticText, so a text-oriented prompt produced text signs. UnifiedDisplay.set_color_scale(brightness)and.color_scale— the software dimmer behind the brightness change below. Synchronous;set_brightness()remains as the async wrapper for existing callers.run_headless/run_headless_asynctakethrottle=None|True|False. The harness forced the performance manager's throttle off on every headless run, which is right for a test suite and left a preview with no supported path to hardware-speed playback at all.Truesets the flag rather than leaving it intact: the manager defaults to off, and a preview handed an arbitrary app cannot arrange otherwise. Ambient console nags stay off regardless — the callers who want pacing are UIs, not terminals.run_headless/run_headless_asynctakevirtual_clock=True, andscrollkit.dev.clockis the injection point behind it. Duration-driven content asks the clock how long it has been on screen, and in a headless run wall time is the wrong answer: frames step with no inter-frame sleep, 60 of them pass in ~0.2 s, aduration=2.0item never expires, and a correct animated app reports that it never animates. With the flag, content time comes fromPerformanceManager.modeled_elapsed_s— what the frames would have cost the panel — so a four-second act lasts four seconds of device time no matter how fast the host renders. It installs after the display exists, uninstalls when the run ends, and raisesVirtualClockUnavailablerather than returning 0.0 if there is no timing model to read.PerformanceManager.modeled_elapsed_us/.modeled_elapsed_s/.frames_ended— a monotonic run total, kept separate from the boundedframeshistory a clock built on it would run backwards on.
- Brightness is a software colour scale now, not a hardware property.
display.brightnesson the MatrixPortal S3 is not a dimmer — it is effectively on/off: 0.0 blanks the panel and 0.15 looks identical to 1.0 (confirmed on hardware, six live changes, no visible difference). The re-platform had replaced a working software dimmer with it, so the brightness setting did nothing across its whole range for the entire 3.x line, and a stored "0" left a customer's sign dark for months with a perfectly healthy app behind it. The panel is now pinned to FULL and colours are scaled on their way out, from a RAW cached base — pre-dimming the base looks cheaper but double-dimsCoverAnimator, which builds its overlay colours frombase_colorsand passes them through the same_make_overlaythat dims. Covered:draw_text/draw_text_scaled, theset_pixel/fillpaint path, the gradient ramp (withcolor_scalein the layer cache key, or a scrolling name would keep its old brightness until the content cycled), all fiveBitmapTextpalette effects, the write-once effect palettes, every icon overlay animator, the pulse, and the three cloned palettesSpriteLift/FrameCycle/GravityDripbuild. Note for upgraders: a stored brightness that has been silently ignored will now take effect, so a sign configured low will visibly dim on this release. pixel_write_usis measured now, not guessed. The feasibility gate false-rejected a field-proven act at 17.7 fps against a 20 fps target, and the whole error was one constant:pixel_writesdecided the verdict (92% of that act's frame) and the term isset_pixel_calls * 3 * profile.pixel_write_us, wherepixel_write_uswas a hardcoded 5.0 that never came from a device — inside a profile reportingconfidence: CALIBRATED_FROM_DEVICE.calibrate_device.pynow measures it (and takes--out, so a run can be inspected without overwriting the shipped baseline). On a MatrixPortal S3 it is 4.333 µs, of which the write is 3.36 µs and 74% of per-pixel cost is Python loop overhead — which is why a per-pixel effect is expensive on this board regardless of what it writes. The board measured runs CircuitPython 10.2.1 while the rest of the baseline is 9.1.0; frame-time terms are stable across the two (bitmap_rebuild +0.4%, full_refresh +0.7%), which is why this one is mixed in, while memory is not (usable_ram −24.6% on 10.2.1) and is deliberately left at 9.1.0. The baseline records that in_pixel_write_us_source.
- A browser preview locked the page and painted nothing.
unified.py's no-pygame branch skipped the await, so the whole run blocked until it finished; it now refreshes, records and yields like the pygame path.save_surface_pngopened withimport pygameand returnedNoneonImportError, which left the publicdisplay.screenshot()broken in exactly the environment the numpy backend exists for, whilematrix.save_screenshot()worked.capture_frameandsave_surface_pnghandle both backends now. swarm_revealwas not reproducible across Python versions. Running 35 acts in CPython 3.12 and again in Pyodide's 3.13, 34 matched frame for frame andswarmdiverged from frame 0 — not flakiness, since two runs with the same seed are identical. The queue came fromlist(self._remaining)over a set, and_shuffle()permutes whatever order it is handed; set iteration order is stable within one Python build but is not guaranteed across versions, so seeding the RNG was not sufficient.sorted()makes the seeded shuffle the only source of order. Worth stating as a general rule for anything compared across runtimes: never derive an ORDER from iterating a set or dict.- Throttled pacing overshot every frame. It slept the whole modeled frame cost after
the frame had already rendered, so a frame took
real_work + modeledrather thanmax(real_work, modeled)and a host faster than the device still ran slower than it, by the render time, every frame. Measured against a browser preview doing its real work in 14–25% of modeled, that was a 12–25% overshoot: a window claiming to crawl at hardware speed while crawling under it. It now sleeps only the unspent remainder, and a frame that overruns its budget does not sleep — it cannot un-spend the time, and pacing must never run backwards to make it up. url_decodemangled every non-ASCII character. Percent-escapes are bytes, and browsers encode form fields as UTF-8, soéarrives as%C3%A9. Turning each escape straight into a character withchr()producedé— two wrong characters, silently, in whatever the user typed. For a WiFi password that means the board stores something the user never entered and can never join their network, while the setup portal reports success, because the corruption happens before anything checks. The bytes are now collected and decoded once, with the old byte-wise reading kept as a fallback for input that is not valid UTF-8 (a mangled password beats no password). Note the fix applies to new saves only: a board that already stored a mangled credential keeps it until the user re-enters it.SettingsManager.save_settingstruncated the live file before writing. That file holds the WiFi credentials and is rewritten by every ordinary settings save, so a power cut anywhere in the window left a 0-byte or half-written file that loaded as{}on the next boot: factory defaults, credentials included, with nothing in the log to say why. It now writes a temp file and swaps it in, andload_settingsfalls back to that temp file when the live one is missing. It also returnsTrue/Falserather than swallowing the failure, and rejects valid JSON with a non-object root (null,[]) which previously raised during construction.- A failed credential save reported success.
WiFiManager.save_credentialsnow returns a result andWiFiSetupPortalrefuses to declare the network saved when the write did not land. Saving is what makesrun_setup_portalreboot the board, so a silently failed write sent the user away happy and brought the box back with no credentials at all. - A swallowed display-init failure looked healthy.
_initialize_displaycaughtImportError/OSErrorand carried on, leaving every draw a no-op: a panel black forever while the watchdog was fed and the web UI served normally. Nothing reset, nothing retried, and every health signal read green. It is still caught, but recorded toerror_logand exposed asdisplay_init_error.
- The reveals table had no signatures. It showed
DripReveal's call withcolor=BRANDand then listedSwarmReveal,show_reveal_splashand the transitions with none at all. A model reading that generalised the keyword it had seen and wroteSwarmReveal(pixels, color=...), which died at frame 1 withTypeError: unexpected keyword argument 'color'—SwarmRevealtakestext_color=andbird_color=and has nocolor=. Reasonable inference from what the page showed; the page was the problem. The table now carries each constructor's actual colour arguments and says plainly that they are not shared. - The
random()availability list was written from recollection. The warning box asserted seven functions includinggetrandbitsandseed, neither of which is called anywhere in this library or in the reference sign — the same failure mode as a profile reportingCALIBRATED_FROM_DEVICEfor a term nobody measured. It now states the five the shipped library and the field-proven sign actually call (random,uniform,randint,randrange,choice) as the working set, with the provenance attached, and says whatActSchedulerdoes instead of shuffling, since "use the scheduler" without the reason invites a hand-rolled deck anyway.
A sensor layer, and the network work from three field failures on a fielded MatrixPortal S3 (CircuitPython 9.2.x).
scrollkit.sensors— the first sensor package.sensors.tilt.TiltSensorreads the MatrixPortal S3's onboard LIS3DH through a minimal built-in register driver, so there is noadafruit_lis3dhto copy into/lib. Everything it reports is in PANEL space, not chip space:gravity_angle(continuous degrees clockwise from "bottom edge down") andorientation(which edge faces the floor, hysteretic so a sign resting near a diagonal does not chatter between two names every frame). Reads throttle to 10 Hz, so calling it every frame in a 20 fps loop costs nothing, and it never raises — no accelerometer, a busy bus, or no display all giveavailable == Falseandorientation == "flat". Note the address gotcha: on the S3 the part answers at I2C 0x19, not the 0x18 Adafruit's own libraries default to. Likescrollkit.effects, the package imports nothing at package level — importscrollkit.sensors.tiltdirectly so a board that never tilts pays no RAM.BoardSpec.has_accelerometer/accel_i2c_address, so a board that differs is corrected in the registry rather than in the driver (the Interstate 75 W has no accelerometer and is never probed).capabilities()gains a livesensorssection queried from that registry.GravityDripAnimator(effects/image_animators.py): whatever is on screen lets go and pours toward whichever edge is now the floor;set_gravity()re-aims the pile mid-fall. Pixels lift onto a full-panel overlay, so a 7-row text strip falls to the panel floor instead of piling up inside its own bitmap. It is an image animator, not aTransition— it decorates a layer already on screen rather than covering, swapping and revealing.- Virtual tilt in the simulator:
display.virtual_tilt/set_virtual_tilt(angle=, flat=)carry a gravity vector thatTiltSensorreads when there is no real chip (you cannot tilt a laptop). Arrow keys steer it in the pygame window; the setter gives tests and headless runs exact, reproducible angles. Same class both places. Plusdemos/medium/tilt_drip.pyanddocs/guide/sensors.md. HttpClient.get(stream=True)returns a socket-owningStreamingResponse:iter_contentchunks on the device, a whole-body fallback for desktop and mocks,close()in__exit__. A stream that dies mid-iteration raises from the caller's loop and is the caller's retry to make.StreamingResponse.readinto(buf)— the drain primitive for a drain-then-parse fetch: drain the whole body into your own reusable buffer, close, then parse. The response stays open for network time only instead of across a multi-second parse, and the loop allocates nothing per iteration. It uses adafruit_requests 4.1.17's nativeResponse._readintowhen present (chunked encoding and content-length handled), otherwise drivesiter_contentcarrying an oversized chunk's tail into the next call. EOF returns 0 repeatably, including afterclose(). It is not a cure for the-12288TLS-SRAM exhaustion — that leak scales with bytes read, sits below the CircuitPython heap, and is cured only by a reset.note_fetch_result(rearm=)separates "stamp the success time" from "re-arm recovery state", so a PARTIAL refresh can never read as full health.
BaseResponse.contentis lazy. The eagertext.encode()kept every payload resident TWICE (~180 KB for a 90 KB body) although the hot paths only read.text; on a non-compacting heap that shattered the largest free block until 54 KB requests failed with 1.37 MB free.- The watchdog now arms BEFORE
setup()at a boot-sized timeout — boot was previously unprotected. CircuitPython 9.2.8 rejects retightening a running watchdog, so one window covers boot and runtime. - A data-progress deadman on the display loop cold-resets on a dead task, an attempt that never returns, or a loop that stops iterating. Progress means attempts COMPLETING, success or failure alike, so a box that is offline and actively retrying reads as healthy. The data task is always created; a transient low-memory reading used to omit it permanently.
hard_reset()ladders cold reset → raw reset →supervisor.reload, so a decided reset never silently no-ops. A failure-reboot epoch flag in NVM rate-limits failure-driven reboots, cleared only by a real fetch success.
- OTA on CircuitPython 9.x. 9.2.x ships a
hashlibwith no sha256, so every OTA download failed verification and rolled back._new_digest()now picks the strongest checksum the runtime can actually compute (sha256, else nativebinascii.crc32) at all four verification sites, including the delta comparison. Verification is never skipped — no usable digest raises a named error. Payloads are unsigned either way, so authenticity still rests on TLS; the checksum's job is catching corrupt downloads. Staged manifests arejson.dump-streamed rather than built as one string, and the allowlist accepts/safemode.py. - A desktop
rtcmock constructedadafruit_datetime.datetime()eagerly and raisedTypeError, silently failing every desktop RTC write.
scrollkit.utils.system_utilsimports cleanly on a bare desktop install: its module-level fallback importedadafruit_datetime, which pip does not carry — so thecold_reset()import 0.9.2's changelog advertises crashed any run-unchanged-on-both app at desktop import time. Caught by the clean-room wheel check minutes after 0.9.2 shipped.
Field-resilience APIs from two days of on-hardware incident work (ESP32-S3, CircuitPython 10.2.1, a two-node mesh network; the full falsification trail lives in the ThemeParkWaits repo's docs/ota-check-failure-ledger.md).
- The hardware watchdog now arms even when a USB serial console is
attached. The old guard silently skipped arming whenever a host held the
CDC port open, so a board living next to a computer ran with NO watchdog at
all. Boards that were silently unprotected become protected on upgrade:
size
watchdog_timeoutABOVE your longest legitimate event-loop block (e.g. a synchronous HTTP call inside a web handler) or the board will reset-loop. Opt out for interactive debugging by creating a/no_watchdogfile on the device (do it BEFORE rebooting into the debug session).ScrollKitApp.watchdog_statereports the arming outcome. - The display loop stops feeding the watchdog after
MAX_CONSECUTIVE_RENDER_ERRORS(10) consecutive render errors, so a permanently-broken render path hardware-resets instead of sitting frozen behind a fed watchdog; one successful frame resumes feeding. - Every deliberate reboot in the library — OTA apply, the auto-reboot
watchdog,
WiFiManager.reset()— is now a COLD reset (radio disabled first): a reset issued while the station is associated degrades the next session until new outbound connects failOSError: 16while pooled keep-alive flows still work.
OTAClient(check_url=...)(also onfor_github): point the frequent update CHECK at a ~6-byteversion.txton a host you control. With it set a check never handshakes withserver_url— useful when the download host serves an RSA-2048 chain whose mbedTLS verification needs more internal SRAM than a running app has free (-0x3F80 PK_ALLOC_FAILED); the manifest fetch defers to download time, which can run at early boot with maximal headroom.WiFiManager.bounce()/bounce_sync(): forced radio restart + fresh association that acts even while the link LOOKS up. Complete every bounce withHttpClient.rebuild_session()(below) — reassociation alone leaves the session's stale socket plumbing failing.HttpClient.rebuild_session(): public full session rebuild (fresh SocketPool + ssl context + Session).scrollkit.utils.system_utils.cold_reset(): radio-off-then-reset, for app code that reboots deliberately.ScrollKitApp.watchdog_stateandScrollKitApp.frames_rendereddiagnostics attributes (surface them in your status page: a frozen frame counter means the display loop died; an advancing counter with a dark panel means the output path died below Python).
- OTA: installing
X.mpynow removes a staleX.pysibling (a device USB-deployed as source then OTA-updated to compiled accumulated both generations interleaved). bounce_sync()keepsWiFiManager.is_connectedtruthful.
First-run developer experience, from a clean-room audit of what
pip install "scrollkit[simulator]" actually delivers to a new user.
- Simulator
displayio.FourWire(reset=...)no longer overwrites its callablereset()method with the reset pin. StaticText/ScrollingTextnow accept(r, g, b)tuple colors — previously a tuple silently rendered the wrong color (the docs' owncolor=(0, 255, 128)example drew blue instead of green).- The
[web]extra now installsadafruit-circuitpython-httpserver— the dependencySettingsWebServeractually imports — instead ofaiohttp, which nothing in the library uses.pip install "scrollkit[web]"gives a working browser settings UI on desktop. - The README / docs Quick Start now opens the simulator window
(
create_display()→SimulatorDisplay); the previous snippet ran headless and invisible on desktop. Dropped the leftoversys.path.insert(0, "src")repo-ism from the getting-started example.
- Quieter, friendlier desktop startup: a missing
adafruit_httpserverprints one actionable line instead of a stack of failures; the meaningless desktop "Free memory: 100000 bytes" placeholder is no longer printed (real device and hardware-sim numbers still are); "Starting SLDK application" is now "Starting ScrollKit application"; and importing the library on desktop no longer creates an emptyerror_login the working directory (the file appears on first actual write).
- Focused simulator primitive, URL utility, and MP4 recording tests, including a real ffmpeg/ffprobe H.264 smoke check.
- A non-writing MatrixPortal S3 raw-REPL smoke probe (
make test-device-s3 PORT=...) for deployed-library, panel, painter, text, refresh, and memory validation. - CI changed-line coverage on pull requests plus clean-wheel and media-encode smoke jobs, so package data and MP4 support are verified before release.
The DarkOwl promotion: the effect mechanisms invented for the DarkOwl LED logo sign — a 24/7 show on a MatrixPortal S3 — generalized into the library. The headline is palette-partition animation: bake a mark's pixels into an indexed layer once, then animate purely with palette writes.
effects/palette_partition.py:PalettePartition(indexed layer with reserved identity slots) plus ten partition builders (diagonal, anchor distance, radial, angular, rain phase, checker, exposure, Voronoi regions, stroke topology, BFS route) andbfs_paths.effects/palette_treatments.py: thirteen frame-driven dwell treatments (VelvetSweep, AnchorWake, HaloPulse, SonarSweep, CipherRain, InkShimmer, RimLight, HeatmapDrift, EclipseCross, GradientDwell, StrokeAnatomy, RouteCircuit, PacketTrace) with a 5-stop theme contract, caller-owned blink beats (blink_now),TREATMENT_CLASSES+treatments_for().effects/swirl_in.py:SwirlIn— sprites spiral in around a center onto exact target positions (deliberately NOT a named Transition: it needs a per-sprite target list).SwarmRevealtrue-color and reverse modes:index_map=/pixel_colors=paint an arbitrary source image's exact colors;reverse=Truepre-lights the image and the flock carries it away pixel by pixel.utils/scheduler.py:ActScheduler— weighted-age, family-aware deck picking for 24/7 variety (least-recently-seen leads, no family repeats,force=for openers).- Visual Reference: a
treatmentsgallery category with a sample for every treatment class (coverage-gated);capabilities()gains apalette_treatmentssection.
A hardening release forged by a fielded MatrixPortal S3: three of these fixes
were found because a real device failed in the field, not because a test went
red. Detailed post-mortem in the ThemeParkWaits app repo
(docs/ota-check-failure-ledger.md).
RegionRotateAnimatornow works on real hardware:math.hypotdoes not exist on CircuitPython (start() raised, hosts silently fell back to a still image), and the erase-everything-then-redraw restamp flickered against the panel's continuous refresh — restamps are now pose diffs, byte-identical to the old poses.- OTA client streams manifest and file bodies to flash in small chunks instead
of
response.json()/response.content— a ~31 KB body needed one contiguous allocation that a hot heap often cannot provide (intermittentMemoryErroron update checks). HttpClient._rebuild_sessioncloses the old pool's sockets before building the replacement (new publicclose_pooled_sockets()). Dropping the pool to the GC orphaned its native mbedtls TLS contexts (~40 KB of ESP32-S3 internal SRAM each); with a rebuild threshold of 2, multi-day uptime starved every TLS handshake (PK_ALLOC_FAILED/MemoryError/Out of sockets).- Update checks use a dedicated 8 s
check_timeout(downloads keep 30 s): the check runs inside a synchronous handler that freezes the display for its duration, so one stalled read must not cost 30 frozen seconds. - OTA takeover messages ("Updating — DO NOT UNPLUG") blank the screen properly:
new
GraphicsMixin.clear_layers()strips persistent bitmap layers thatclear()deliberately leaves alone (the message used to paint on top of the interrupted content), resetting the bounded painter so it self-heals.
- ~6-byte update checks:
check_for_updatesreads the channel'sversion.txtfirst and answers "up to date" without fetching the manifest (strict MAJOR.MINOR[.PATCH] validation so an error page can never fake the answer; 404 falls back to the manifest for older channels). Publishers shipversion.txtbesidemanifest.json. WiFiManager(ap_name=...): apps brand the onboarding portal's access point (e.g.ThemeParkWaits-XXXX); the library owns only the MAC-derived uniqueness tail and never hardwires a product name.- Interstate 75 W bring-up: named-matrix-pin fallback coverage, a host-side
smoke probe, and
--port-aware device calibration/benchmark tooling. - CircuitPython math-surface guard test: device-path code is statically checked
against the REAL board's
mathmodule (nohypot,tau,inf,nan,isclose,log2,log10, ...), the same trap class asrandom.shuffle. - Cel-walk demo: nodding head.
- New OTA guide section: shipping the library as
.mpy— pinned CircuitPython-matched mpy-cross (the PyPImpy-crosspackage is MicroPython's compiler and boards reject its bytecode),-sfor deterministic builds, the free-space rule, and the updater's no-deletion-by-omission semantics. - Corrected the
pip install mpy-crossguidance in getting-started and the makefile.
scrollkit.effects.image_animators— twelve per-frame animators that decorate a static image layer already on screen (twinkle, tile motion, particle emitter, palette pulse, region shift with sine/ramp/ripple/hinge waves, orbiter, blink, sprite lift with automatic scene inpainting, cover, vanish, pre-baked frame cycling, and combos). Extracted up from the ThemeParkWaits app's ride-intro engine; start/step/detach contract, FEASIBILITY dicts on every class, and an orderedANIMATOR_CLASSEScatalog.RegionRotateAnimator— the thirteenth image animator: tilts the lit pixels inside a box about a pivot point, oscillating, for a real rotation (a nodding head, a waving arm, a see-sawing plank) rather thanRegionShift's uprighthingeshear. Hole-free by inverse-mapping every destination pixel; anexcludebox freezes the attached body it rotates on (no seam tears); cost-guarded (refuses >320 lit px or a >1600-cell scan box and falls back), and settles the region upright ondetach().CelWalkAnimator— a multi-pose cel walk-cycle primitive: plays an authored walk-cycle spritesheet (a sibling<image>_walk.bmpof N panel-sized tiles) via a tile-indexedTileGridwhile translating the sprite across the panel, so the legs are genuinely different authored drawings frame to frame and the gait reads as real stepping. Pose change is a single tile write and travel is atile.xwrite: no per-frame allocation, no layer churn.- OTA delta apply — a device can consume a large combined manifest (app plus a
bundled library under
/lib/scrollkit) on thin free space: it hashes its live tree and downloads/backs up/installs only the files whose sha256 differs, sizing the free-space guard to the delta (2*delta+50KB) rather than the whole manifest. The full manifest is still verified after apply; acreated_pathsmarker deletes newly-created files on rollback so an interrupted apply leaves no orphans; plus a device-side path-safety allowlist. Verified on hardware. image_animators.read_indexed_bmp()— decode an 8-bit indexed BMP straight into a writableBitmap. On-deviceOnDiskBitmapis not subscriptable, so animators that read/rewrite image pixels need this; the demo and reference generator use it as the device-correct loader (OnDiskBitmapfor the palette +read_indexed_bmpfor pixels).- Docs: an animated GIF for every image animator, in the Effects guide and the
Visual Reference gallery, generated by
demos/render_reference.py(a newanimatorsroute driven from the liveANIMATOR_CLASSES, guarded bytest_reference_coverage.pyso a new animator can't ship without a sample). demos/medium/image_intro.py— a runnable demo showing image animators in context: an animated image intro (twinkle / traverse / rocket-liftoff combo) handing off to a data screen, illustrating the self-driving display loop vs. the content queue. Added to the Demo Gallery.capabilities()gains a distinctimage_animatorscategory (its own key, not folded intoeffects) enumerated fromANIMATOR_CLASSES, with each class's FEASIBILITY budget; surfaced inas_text()and documented inAGENTS.md.
- The extracted twinkle animator now shuffles candidate pixels with a hand-rolled
Fisher-Yates: the app original used
random.shuffle, which does not exist on CircuitPython — on hardware those animations silently fell back to a still image. - Composed animators clean up already-started parts when a later part fails to start (previously the survivors' overlay layers leaked on the display).
- OTA now surfaces real failure reasons instead of "up to date." A failed check,
download, or apply was reported to the app as "device is current," so an invalid
published manifest could hide a fleet-wide outage behind a lie.
OTAProgressDisplayrecords every outcome inlast_error(cleared only after a successful stage); only the genuineUP_TO_DATEsentinel reads as "current," and an apply failure now paints an "Update / failed" frame on the panel instead of rebooting. - OTA apply is now a crash-safe transaction, with real-CircuitPython fixes found
live on a MatrixPortal S3: route checksums through
hashlib.new()(nosha256()on device), dropjson.dumps(indent=…)and theIOErrorname (neither exists on device), and replaceos.walk/os.makedirswithlistdir/mkdirhelpers. Apply writesAPPLY_STARTED/BACKUP_COMPLETEmarkers, backs up once per transaction, re-verifies each installed file, writes the.versioncommit marker last, and rolls back staging on failure so a bad payload can't reboot-loop. Manifest validation now rejects an unparseable version loudly and drops the unused mandatoryrequiredkey that had rejected every published manifest. - The WiFi setup portal now boots on-device and re-scrolls its instructions:
import socketmoved into the desktop-only branch (CircuitPython has no stdlibsocket, so the eager import crashed the portal), and the one-line status panel restarts its scroll so the AP name, password, and URL can all be read.
The post-0.8.2 review fixes (a 4-agent + 3-model-panel code review, then five
fix tranches) plus the restored WiFi onboarding feature. Note that some fixes
change behavior code may have relied on (ContentQueue priority/loop are now
real contracts, the OTA manifest script hooks are gone). First release
published to PyPI: a tag push now builds and uploads via GitHub Actions
Trusted Publishing (.github/workflows/publish.yml), and the wheel/sdist now
declare the simulator's BDF fonts and hardware-calibration JSONs as package
data (previously only reachable via editable/source installs).
- WiFi onboarding portal restored — configure Wi-Fi from a phone, no file
editing (
scrollkit.web.wifi_setup.WiFiSetupPortal, entry pointWiFiManager.run_setup_portal(display=...)). The device starts its own access point, scrolls join instructions on the panel, serves a setup page (scanned networks with signal bars + manual SSID + password) athttp://192.168.4.1, saves through theSettingsManagerintosettings.json, and reboots to connect. The original feature had been silently unwired since the settings-server rewrite and was then deleted as dead code; this is a redesign, not a revert.start_access_point/stop_access_point/ap_ip_addressare back onWiFiManager. Needs hardware verification (AP mode on a real board). - WiFi credentials now resolve settings-first: portal-saved
wifi_ssid/wifi_passwordinsettings.jsonbeat a stalesecrets.py. - Recording (
start_recording/save_gif/save_video),screenshot(), and thehardware_timing/throttle/strictfeasibility flags are now onUnifiedDisplay(no-ops returningNoneon hardware) — no need to bypass the auto-detecting display to record or gate. ContentQueuehonors the documented contracts it previously ignored:priority(higher plays first, stable within equal priority) andloop=False(queue exhausts after the last item;add()re-arms it).
- Interstate 75 W was unusable by construction:
UnifiedDisplayreached the displayio display viaself.hardware.display, which only exists on the S3's Matrix wrapper — every frame raised a swallowedAttributeErrorand the panel never refreshed. All paths now useself.display. - Corrupt
settings.jsonno longer bricks boot: CircuitPython raisesValueErrorfor bad JSON;load_settingscaught onlyOSError. set_pixel/fillnow work on hardware and on desktopUnifiedDisplay(previously the particle system rendered only onSimulatorDisplay): both render through the paint-canvas displayio layer, survive refresh, and are feasibility-accounted.run_headless(strict=True)now exercises transitions: the harness drives the app's ownstep_frame()(new, shared with_display_process) instead of a hand-copied loop that skipped the transition path.RainDrop/Snowno longer hardcode a 32-px panel height; wrongpip install sldk[simulator]hints corrected toscrollkit[simulator]; urllib POST records success forlast_errorbookkeeping;run_headlessrestoresSDL_VIDEODRIVERso later live-window runs aren't silently headless.
- OTA manifest scripts:
OTAClient.apply_updateexec()'dpre/post_update_scriptsfrom the downloaded manifest — unsigned remote code execution that no publisher ever used. The whole surface is gone; legacy manifests carrying the (empty) keys still parse. SimulatorDisplayis now a thin subclass ofUnifiedDisplay— one per-frame pipeline for hardware and simulator (its private_overlay_pixelsmechanism and duplicated render loop are gone).- Zero-reference orphans:
simulator.adafruit_display_text.scrolling_label,simulator.core.display_manager(+BaseDevice.run/run_once),WiFiManager.disconnect/is_available/get_ip_address, allColorUtilsstatic helpers, andUpdateManifest's unused builder half.
A pre-1.0 legacy-cleanup release: remove dead code and trap APIs, fix real bugs, and lock down the public surface before a 1.0 freeze. Contains breaking removals (pre-1.0 semver permits them); the one downstream app (ThemeParkWaits) is migrated in lockstep.
- The entire dead pre-consolidation display pipeline:
scrollkit.content_classes, the top-levelscrollkit.contentshim,scrollkit.display.strategy(itsDisplayStrategy/StrategyRegistry/DisplayItem/*Strategyclasses),scrollkit.display.queue(DisplayQueue), andscrollkit.display.manager(DisplayManager). These had zero production consumers;content_classes'create_*/example_usage()were a trap (they builtDisplayItems the liveContentQueuenever consumed).Prioritysurvives, relocated toscrollkit.display.content. scrollkit.app.minimal(MinimalLEDApp) — disjoint fromScrollKitApp, nothing built on it, and its desktop fallback was broken.scrollkit.ota.updater(OTAUpdater) andscrollkit.ota.server(OTAServer) — unused duplicates ofota.client/ota.publish.- Zero-importer orphans:
scrollkit.utils.timer,scrollkit.utils.image_processor,scrollkit.simulator.devices.generic_matrix,scrollkit.simulator.adafruit_display_text.bitmap_label,scrollkit.simulator.terminalio.font_scaler. wifi_manager's unused captive-portal web server,_save_to_secrets_file, the no-opupdate_http_clients, and the unreachable (shadowed)is_connected()method.DisplayInterface.scroll_text/SimulatorDisplay.scroll_text— no callers; silently no-op'd on hardware.- Nine caught-but-never-raised / dead exception classes (see below).
scrollkit.display.gradient_text._GradientTextLayer→ publicGradientTextLayer(old name kept as an alias through 0.9.x).- Exception base
SLDKError→ScrollKitError(old name kept as an alias). The hierarchy is collapsed to only what the library raises:ScrollKitError,NetworkError,OTAError,FeasibilityError.DisplayError,ContentError,ConfigurationError,WebServerError,DeploymentError,SimulatorError,ResourceNotFoundError,UpdateError,ValidationErrorare removed. HttpClient.get/get_sync/postnow raiseNetworkErrorwhen every retry fails, instead of returning a synthesized500response.HttpClient.last_errorretains the raw underlying cause.OTAClientraisesNetworkError/OTAErrorinternally but preserves its public(ok, reason)tuple contract.scrollkit.dev.performance.as_text→performance_text(removes a name collision withdev.capabilities.as_text).MinimalLEDApp.COLORS→scrollkit.utils.color_utils.NAMED_COLORS.scrollkit.effectsis now import-free: import each effect from its submodule (effects.transitions,effects.reveal_splash,effects.particles, …) — a no-splash app no longer loads the particle/splash modules just to use a transition. No plugin/registry was added.
- The settings web server no longer mutates display/queue state from the request
handler; it sets a flag the display loop applies via the new
ScrollKitApp.notify_settings_changed(). HttpClientplatform detection imported a retired module (display.display_factory.is_dev_mode), silently always falling back to production mode; it now usesnetwork.wifi_manager.is_dev_mode.- Import-time side effects removed:
config.settings_manager,network.http_client, andnetwork.wifi_managerno longer construct anErrorHandler(which write-tests the filesystem) merely on import. - Two banned
json.JSONDecodeErroruses inota.client/ota.manifest(would raiseAttributeErroron CircuitPython) →ValueError.
__all__added to every public module; a newtest/unit/docs/gate executes everyimportshown in the README/docs so advertised APIs can't drift.- Simulator device setup shared between
UnifiedDisplayandSimulatorDisplayviadisplay/_sim_backend.py. - Device deploy (
make copy-to-circuitpy/make mpy) now excludes the desktop-onlydev/andsimulator/trees and the host-onlyota/publish.py.
| Old | New |
|---|---|
from scrollkit.app.minimal import MinimalLEDApp |
from scrollkit.app.base import ScrollKitApp |
from scrollkit.content import ... |
from scrollkit.display.content import ... |
from scrollkit.display.strategy import Priority |
from scrollkit.display.content import Priority |
from scrollkit.display.queue import DisplayQueue |
from scrollkit.display.content import ContentQueue |
from scrollkit.display.gradient_text import _GradientTextLayer |
... import GradientTextLayer |
from scrollkit.effects import SwarmReveal |
from scrollkit.effects.swarm_reveal import SwarmReveal |
from scrollkit.exceptions import SLDKError |
... import ScrollKitError (alias still works) |
resp = await client.get(url) then check resp.status_code == 500 |
try: resp = await client.get(url) / except NetworkError: |
These were app-local; they are generic enough that every ScrollKit app should get them for free. All are additive — defaults preserve prior behaviour.
scrollkit.utils.diagnostics— NVM-backed boot/crash diagnostics with a reboot-loop safe-mode breaker.diagnostics.open()binds tomicrocontroller.nvmon device and returns a no-op store on desktop (no platform check needed); the store takes an injectable backend so the boot-loop logic is unit-tested with a plainbytearray.scrollkit.network.mdns.advertise(hostname, *, port=80, service_type, protocol)— non-blocking<hostname>.localadvertising. Returns themdns.Server(the caller MUST retain it — GC stops resolution) orNoneon desktop / no radio; never raises.scrollkit.ota.display_progress.OTAProgressDisplay— a display-progress + staged-install adapter around an existingOTAClient(renders the "Installing… DO NOT UNPLUG!" frame, applies, reboots). The client stays headless; the update source/channel remains the app's concern.ScrollKitApp.suspend_render()/resume_render()/suspended_render()context manager +render_suspendedproperty — pause queue rendering (queue preserved) while painting an off-queue status frame and blocking on a fetch, without overridingprepare_display_content(). Default: not suspended.BitmapText(complete_after_passes=N)— frame-based one-pass completion so a scrolling banner can advance aContentQueuewithout subclassing. Keyed on scroll POSITION, not wall-clock, so a low frame rate never cuts the text off mid-scroll;start()now rebuilds the layer so a banner is queue-safe when it cycles back. DefaultNonekeeps the persistent-banner behaviour.
First public release: an LED-matrix display framework that runs unchanged on the Adafruit MatrixPortal S3 (CircuitPython 8.x/9.x/10.x) and a desktop pygame simulator.
- Opt-in hardware watchdog on
ScrollKitApp(enable_watchdog,watchdog_timeout, default 8s) that resets the board if the display loop freezes — e.g. a hung synchronous fetch — and self-recovers instead of sitting frozen until a power cycle. Hardware-validated on CP 9.2.7 and 10.2.1 (test/claude/RELIABILITY_TESTING.md). - Data-refresh memory floor
MIN_FREE_FOR_UPDATE(default 25000) with a force-after-N-skips guard, so a low-memory device can't serve stale data forever. scrollkit.dev.capabilities()now catalogs the built-in transitions and their per-frame feasibility budgets (and renders them inas_text()), so AI agents and contributors can discover what's available and its modeled cost.
- Transition names now have a single source of truth
(
scrollkit.config.transition_names.TRANSITION_NAMES), kept in lockstep with the dispatch factory inscrollkit.effects.transitionsby a unit test. Selecting a transition can no longer silently fall back to no transition, and an unknown savedtransition_styleis now logged instead of silently ignored. - Field reliability:
HttpClientdefault per-requesttimeout10s → 6s (kept below the watchdog window);ErrorHandlerno longer deletes or truncates the log on boot (crash evidence is preserved) and rotates with a tail-preserving trim instead of blanking it; PRODUCTION persists only errors to flash;ErrorHandleris now a real per-file singleton so a read-only-filesystem detection is shared across callers.
- Effect-attachment API.
DisplayItem.add_effect()/with_effect(),BaseContent.with_effect()/with_effects(),DisplayManager.add_item(..., effects=...), and theDisplayQueue._apply_effectsrender path have been removed. They drove the oldEffect.apply()contract, which no longer exists — the surface was a no-op (and internally buggy), and a trap for AI-authored code. Visual variety now comes from theTransitionsystem (thetransition_stylesetting) and the standalone splash/particle helpers. - The dead
Effect/EffectRegistry/CompositeEffectbase classes (scrollkit.effects.base), theSimpleEffect/EffectsEnginesystem and its concrete effects (scrollkit.effects.effects), and the orphanedEnhancedDisplayContentfamily (scrollkit.display.enhanced_content) — none were wired into the display loop, and the latter violated the library's own per-frame-allocation / no-per-pixel-loop feasibility rules.