jumperless-mcp 0.1.0

MCP server for the Jumperless V5 — persistent USB-serial bridge exposing the firmware API to LLMs
# pyright: reportUndefinedVariable=false, reportAttributeAccessIssue=false, reportMissingImports=false
#
# Pyright type-checks against CPython; on this Jumperless V5 firmware
# 5.6.6.2 MicroPython runtime context the `jumperless` module is
# firmware-injected (not in any CPython search path), `jumperless_mcp.*`
# submodules resolve via the device's sys.path at /python_scripts/lib/,
# `overlay_clear_all` is a firmware builtin (not Python stdlib), and
# `time.sleep_ms` is a MicroPython extension. Suppressing the noise here
# so the LSP isn't loud on what's actually correct device-side code.
"""jumperless_mcp — Jumperless MCP bridge package

Installed at /python_scripts/lib/jumperless_mcp/ by the host-side
jumperless-mcp CLI; importable as `import jumperless_mcp`.

Source of truth for version is the VERSION sibling file (so the host
can read it back via fs without parsing Python). __version__ here
should match.

TODO (future phases):
- set_net_color_hsv(net, h, s, v) install-ack: paint a net green on
  connect as a one-liner "the board knows MCP arrived" signal.
- FakeGpioPin-backed effects: digital effects without consuming real
  GPIO pins. Requires machine.Pin-compatible wrapper from firmware.
- probe_read_nonblocking() / clickwheel_get_button() tap-to-continue:
  elegant ceremony pause waiting for user acknowledgement via two
  input methods.
"""

# IDE autocomplete trick (per Kevin's script_template.py convention):
# never executes on device, but tells the IDE what jumperless exposes.
if False:
    from jumperless import *  # noqa

__version__ = "0.2.12+20260512"

# Native jumperless module is globally injected by the firmware;
# import explicitly so our submodules can reference it cleanly.
import jumperless as _jl  # noqa: E402 (firmware-injected, not in stdlib)

# Pull firmware-injected builtins explicitly into module scope. When this
# package is loaded via `import jumperless_mcp` (as opposed to old-style
# exec(jfs.read(...)) in the global script scope), firmware globals don't
# auto-resolve at module level — they need to be explicitly imported.
from jumperless import overlay_clear_all  # noqa: E402

# Re-export the effects we want callable directly from jumperless_mcp:
from jumperless_mcp.effects import (  # noqa: E402
    wipe_edges,
    marquee_scroll,
    corner_frame,
    overlay_print_at,
    NASA_ORANGE,
)
from jumperless_mcp.font import FONT  # noqa: F401,E402  (loaded for side-effect/availability)


def _ceremony_connect():
    """Run the MCP connect ceremony. Called by host after import.

    Phases:
    1. Clear OLED
    2. Print "MCP Connected" to OLED
    3. Marquee scroll (the marquee IS the banner — no separate wipe preamble)
    4. Corner frame settle + OLED clear

    Removed in v0.2.3: the wipe_edges preamble + 800ms hold. Reading the two
    visuals (edge wipe + then marquee) as "two banners" was disjointed; the
    marquee itself contains the orange fill so it reads as a unified
    arrival of an MCP banner with text.

    Wall-clock: ~3-4s on device.
    """
    _jl.oled_clear()
    _jl.oled_print("MCP Connected", 2)
    marquee_scroll("MCP CONNECTED", NASA_ORANGE, "L2R")
    overlay_clear_all()
    corner_frame(NASA_ORANGE)
    _jl.oled_clear()


def _ceremony_disconnect():
    """Run the MCP disconnect ceremony. Called by host before disconnect.

    Phases (symmetric with connect):
    1. Clear OLED
    2. Print "MCP Disconnected" to OLED
    3. Marquee scroll (R2L, mirror direction of connect's L2R)
    4. Corner "unbracket" + OLED clear

    Wall-clock: ~3s on device.

    **Bracket-unbracket strategy (2026-05-10):** instead
    of fighting Kevin's autosave system with overlay_clear_all + nodes_save
    (v0.2.1-0.2.3 attempts — never worked reliably because the autosave is
    timer-driven and races us, AND it's working-as-designed to preserve user
    work), we PAINT the same corner pixels we painted on connect, but with
    color=0x000000. The autosave then saves an overlay buffer whose color
    array is all-zeros for the bracket positions → on reboot, the overlay
    re-renders as invisible. Kevin's autosave still does its job (preserves
    overlay state); we just make the saved state visually empty.
    """
    _jl.oled_clear()
    _jl.oled_print("MCP Disconnected", 2)
    marquee_scroll("MCP DISCONNECTED", NASA_ORANGE, "R2L")
    overlay_clear_all()
    corner_frame(0x000000)  # unbracket: same pixels, black color
    _jl.oled_clear()