Supplier photo of an assembled radio kit with a purple circuit board, telescopic antenna and clear case.
Supplier photo
Temu affiliate offer · Project idea

Build a radio

Soldering required. Check the supplied parts and board revision.

View kit on Temu

Tricky Circuit may earn a commission through these links. Product ideas have not been bench-tested by Tricky Circuit and are not verified lesson parts lists.

Calibrate a KY-023 Joystick on Pico: Center, Endpoints and Dead Zone

Your thumb leaves a joystick, yet a game cursor keeps crawling. The stick may be mechanically centered while its electrical reading sits a little away from the number your program calls zero. Build an input tester that measures the real center, learns both ends of each axis and gives small unwanted movements a quiet zone.

This experiment uses a documented Joy-IT COM-KY023JM, also called KY-023, with an original Raspberry Pi Pico. It prints two normalized position values and a clean push-button count in the computer’s text console. It drives no motor.

Verification: this is a source-reviewed experiment with desktop-tested Python logic. The circuit has not been assembled or measured by the author, and the MicroPython program has not been run on a Pico. Numerical examples are invented teaching values. The supplier photograph shows the product, not our bench.

One stick contains three different inputs

A joystick is a hand-operated input that reports movement along two directions, called the X and Y axes. This unit uses a potentiometer for each axis: a resistive track with a movable contact, or wiper. Moving the stick changes where that contact sits between the supply and ground. The two wiper voltages appear at the board’s VRx and VRy output labels. Those names identify these axis outputs; they are not a guarantee of the same connector order on another module.

An analog signal can vary across a range of voltages. An analog-to-digital converter, ADC, measures such a voltage and gives the program a number. The Pico already has suitable ADC inputs. Its GPIO means general-purpose input/output; these are configurable connections to the processor. GP26 and GP27 are GPIO numbers that also provide ADC0 and ADC1. They are different from physical header numbers 31 and 32.

The third input is a switch under the stick. SW is the board’s switch label. In this experiment a pull-up resistor, enabled inside the Pico, weakly holds the released input at a high voltage. Pressing the verified switch connects that input to ground, GND, the shared voltage reference, producing a low reading. Active-low means that low represents the active, pressed condition.

Joy-IT supplier photograph of a KY-023 joystick module with a black thumb cap, two side potentiometers and a five-pin header.
Supplier photograph from Joy-IT SensorKit, saved unchanged from its product image. This is not a photograph of a Tricky Circuit assembly. Use the exact module’s documentation and printed labels to identify its connector; this angled image is not a pinout.

The existing KY-023 overview introduces the module. Here the new task is measurement and calibration on a 3.3 V Pico: a generic midpoint such as 512 or 32768 is deliberately replaced by readings from your own unit. A rotary encoder, which reports discrete movement events, asks a different question from this joystick’s two position voltages.

Choose the documented parts and voltage

ItemExact choice or requirementRole
JoystickJoy-IT COM-KY023JM / KY-023, matching the reviewed manual and SensorKit pageTwo wiper signals and one switch. A similarly named marketplace board needs its own review.
ControllerOriginal Raspberry Pi Pico with RP2040 processor and correctly fitted headers; a Pico H provides fitted headersTwo analog inputs and one pulled-up digital input. This wiring does not cover Pico 2 or a full-size Raspberry Pi computer.
ConnectionsFive suitable short signal jumper leads, with connector types matching your actual headersDirect connections are sufficient; a breadboard is optional. No servo or other load is attached.
Power and consoleA data-capable Universal Serial Bus (USB) cable and computer; MicroPython documentation baseline 1.24.0 for the original PicoUSB powers the Pico and carries the program’s text output.
Check toolsA multimeter for unpowered continuity and low-voltage measurementConfirm the switch and power/signal connections before relying on software.

The Joy-IT product page specifies 3.3 V operation, and its Pico wiring example uses the board’s low-voltage supply. The 7 May 2024 manual also connects the module’s printed +5V terminal to a 3V3 source in its Raspberry Pi example. Therefore the terminal label does not instruct us to feed it 5 V here. Use the Pico’s 3V3(OUT) regulated 3.3 V output for this exact module.

There is another documentation inconsistency worth catching before wiring: the manual overview describes the switch changing low to high, while its example code and the SensorKit Pico code use a pull-up and a pressed-low switch. With power disconnected, check that pressing your module connects SW to GND and releasing it opens that connection. If it behaves differently, stop and resolve the actual unit’s wiring instead of reversing a software label to hide the difference.

Five connections, with both kinds of pin number

GND, ground, is the common reference used when comparing voltages. Disconnect USB before making connections. With the Pico’s USB connector at the top, compare its headers with the official Pico pinout; do not count pins from an unrelated board photograph.

Joy-IT terminalPico signalPico physical pinProgram configuration
GNDGND38Shared voltage reference
+5V printed supply terminal3V3(OUT)363.3 V supply only
VRxGP26 / ADC031ADC(Pin(26))
VRyGP27 / ADC132ADC(Pin(27))
SWGP2834Pin(28, Pin.IN, Pin.PULL_UP)
Joy-IT KY-023 GND to Pico physical38, printed plus5V terminal supplied by3V3OUT physical36, VRx to GP26 ADC0 physical31, VRy to GP27 ADC1 physical32 and SW to pulled-up GP28 physical34.
Original electrical connection diagram, not a breadboard placement map. The module supply is 3.3 V despite its printed +5V label. Open the full-size PNG · Editable SVG (joystick-module-calibration.svg inside ZIP).
  1. Check the module identity, five terminal labels and unpowered SW-to-GND operation. Do not identify pins from wire color alone.
  2. Connect only module supply and ground first. Power the Pico by USB and measure the module supply, then its two wiper voltages through gentle travel. They must remain between ground and the actual 3.3 V rail. Do not connect a signal that exceeds that rail to a Pico input.
  3. Disconnect USB again, add VRx, VRy and SW to the listed Pico pins, and inspect for adjacent-header shorts. The Pico’s VBUS, the USB supply connection, and VSYS, its system-power input, are not used for the module.
  4. Keep the joystick on the desk with no motor or external circuit attached. Reconnect USB and open the Python editor’s output console.

The MicroPython reference for its Raspberry Pi microcontroller port, called RP2, describes the usual 0–3.3 V ADC range and explicitly configuring an analog input with ADC(Pin(...)). We use that form, not a bare channel number that might leave previous pin settings in place. The 0–65535 value returned by read_u16() is a scaled result; the RP2040 converter has 12-bit conversion hardware. A bit is a binary digit, with two possible values; more bits can represent more codes. Sixteen output bits do not establish sixteen bits of physical accuracy.

Teach the program your center and travel

Calibration establishes a relationship between readings and the meaning we want to assign to them. Here it is deliberately local to this joystick and session. An endpoint is a reading near one end of its permitted mechanical travel. A dead zone is a region around the released center that the program deliberately reports as zero.

The complete program has two guided phases. Release the stick for the one-second preparation message, then leave it alone during approximately two seconds of center sampling. The program takes 200 pairs of readings. Each reading averages four ADC conversions; this can soften short variation, but it neither measures the supply reference nor repairs poor contacts.

A median is a middle ordered value after sorting readings. We use the lower middle value for an even sample count. The center is rejected if its largest and smallest readings differ by more than 2000 codes. That limit is a chosen teaching guard, not a manufacturer accuracy specification. Releasing the stick matters: holding it slightly off-center teaches the software a false zero.

After a second preparation message, gently sweep both axes for about eight seconds. Reach each end without forcing the mechanism, pause briefly at each end and repeat; do not merely draw a tiny circle around center. The program collects 800 pairs. Its 1st and 99th percentiles are ordered readings near the ends, leaving about one percent of samples beyond each selected boundary. This reduces the influence of isolated extreme codes. It cannot compensate for never reaching an end.

Each selected endpoint must lie at least 8000 codes from the captured center. A flat or badly incomplete sweep is rejected rather than accepted with a tiny denominator. These thresholds and the 8% dead zone are software design choices for this experiment. Record them with your results. Calibration stays in memory and is repeated whenever you run the program; no calibration file is written to flash storage.

Give the two halves their own ruler

Normalization converts a reading into a convenient common scale. Here center becomes 0, the selected low endpoint becomes −1 and the high endpoint becomes +1. Real center need not be halfway between the endpoints, so one scale factor for the entire travel can give unequal behavior on opposite sides.

positive side: u = (raw - center) / (high - center)
negative side: u = (raw - center) / (center - low)
clamp u to [-1, +1]
if abs(u) <= 0.08: output = 0
otherwise: output = sign(u) * (abs(u) - 0.08) / 0.92

Clamping limits a number to an allowed range. A reading beyond a selected endpoint therefore stays at −1 or +1. After the dead zone, the remaining range is stretched to reach those same endpoints continuously. Without this second stretch, the output would jump from zero directly to roughly 0.08 at the edge of the quiet region.

Calculated joystick normalization with low8000, center30000 and high58000 uses unequal half-spans, zero from28240through32240 and saturation at minusone and plusone.
Original calculated illustration from the supplied normalization code. These calibration numbers are invented, not measured. Each axis has its own axial dead zone. Open the full-size PNG · Editable SVG (joystick-module-calibration-deadzone.svg inside ZIP).

For the illustrated center 30000, low 8000 and high 58000, raw 44000 lies halfway along the positive half: u = 14000 / 28000 = 0.5. After the dead zone it becomes approximately 0.457. Raw 19000 lies halfway along the negative half and becomes about −0.457. A similar distance on either side gets a similar command magnitude despite the unequal electrical spans.

The 0.08 setting means 8% of the corresponding calibrated half-span, rather than 8% of all possible ADC codes. X and Y are processed independently. Their zero region is rectangular in the two-axis plane, rather than a circular radial dead zone based on combined distance. The output pair (1, 1) is allowed; its combined distance from (0, 0) can exceed 1. This matters if you later build a cursor-speed rule. Nothing in these normalized numbers measures degrees, force or physical distance.

Count a press once, not every raw transition

Switch contacts can briefly alternate between open and closed during a press. Debouncing means deciding when those changing readings represent one stable action. Our switch class accepts a candidate only after it has remained unchanged for 30 milliseconds, where a millisecond, ms, is one thousandth of a second. A new count occurs only when an accepted state changes to pressed. Holding the stick down produces no repeated counts.

If the button is already held when the reporting loop starts, that state becomes the initial state and is not counted as a new press. Release it, allow the released state to settle, then press again. The code uses MicroPython’s ticks_diff() for elapsed intervals because its millisecond counter eventually wraps. Directly subtracting two counter values is not reliable across that wrap.

The loop aims to inspect inputs about every 10 ms and print about every 100 ms. Those are requested intervals, not measured guarantees: interpreter work and console output can add delay. Hold a test press for roughly a tenth of a second or longer. This is a manual-input experiment, not a detector for very short pulses.

Run the complete input tester

Use the official original-Pico MicroPython download page and its installation instructions. This article’s software reference baseline is version 1.24.0; that is a reproducible documentation target, not a claim that it is the latest release or that we tested its firmware on a board. Firmware is the software installed on the controller; here MicroPython runs your Python program. Save the supplied file as joystick_calibration.py, open it in an editor configured for the Pico’s MicroPython interpreter, and run it. Stop with the editor’s stop control or Control-C. Running it again starts a fresh calibration.

Download the complete program (joystick_calibration.py inside ZIP) · Blank observation worksheet (joystick-module-calibration-worksheet.csv inside ZIP). The console prints raw readings beside normalized readings so you can distinguish electrical variation from the software’s quiet-zone behavior. CSV, comma-separated values, is the plain-text row format used for the output. Copy a short section into a text file if you want to plot it; the program creates no log file on the Pico.

Complete MicroPython program, including calibration and debounce logic
"""Joy-IT COM-KY023JM on original RP2040 Pico, powered only from 3V3(OUT).

MicroPython RP2 documentation baseline: 1.24.0. No actuator outputs.
Pure calibration/debounce/application functions are also importable on CPython.
Hardware execution has not been performed by the author.
"""

DEAD_ZONE = 0.08
MIN_HALF_SPAN = 8000
MAX_CENTER_SPREAD = 2000
INVERT_X = False
INVERT_Y = False


def checked_raw(value):
    if type(value) is not int or not 0 <= value <= 65535:
        raise ValueError("Expected an integer ADC code from 0 to 65535")
    return value


def percentile(values, percent):
    if not values or not 0 <= percent <= 100:
        raise ValueError("Percentile needs samples and a percent from 0 to 100")
    ordered = sorted(checked_raw(v) for v in values)
    return ordered[((len(ordered) - 1) * percent) // 100]


def center_value(values):
    if len(values) < 20:
        raise ValueError("Collect at least 20 released-center samples")
    for value in values:
        checked_raw(value)
    if max(values) - min(values) > MAX_CENTER_SPREAD:
        raise ValueError("Center moved too much: release the stick and inspect wiring")
    return percentile(values, 50)


class AxisCalibration:
    def __init__(self, low, center, high, dead_zone=DEAD_ZONE):
        self.low = checked_raw(low)
        self.center = checked_raw(center)
        self.high = checked_raw(high)
        if not 0 <= dead_zone < 1:
            raise ValueError("Dead zone must be at least 0 and less than 1")
        if not low < center < high:
            raise ValueError("Center must lie between the travel endpoints")
        if min(center - low, high - center) < MIN_HALF_SPAN:
            raise ValueError("Travel too small on one side: inspect wiring and repeat")
        self.dead_zone = dead_zone

    def normalize(self, raw, invert=False):
        raw = checked_raw(raw)
        span = self.high - self.center if raw >= self.center else self.center - self.low
        unit = (raw - self.center) / span
        unit = max(-1.0, min(1.0, unit))
        magnitude = abs(unit)
        if magnitude <= self.dead_zone:
            return 0.0
        output = (magnitude - self.dead_zone) / (1.0 - self.dead_zone)
        if unit < 0:
            output = -output
        return -output if invert else output


def calibration_from_samples(center_samples, travel_samples, dead_zone=DEAD_ZONE):
    if len(travel_samples) < 100:
        raise ValueError("Collect at least 100 travel samples")
    return AxisCalibration(percentile(travel_samples, 1), center_value(center_samples),
                           percentile(travel_samples, 99), dead_zone)


class DebouncedSwitch:
    def __init__(self, initial_raw, now, interval_ms=30):
        if type(initial_raw) is not int or initial_raw not in (0, 1):
            raise ValueError("Switch input must be 0 or 1")
        if type(interval_ms) is not int or interval_ms <= 0:
            raise ValueError("Debounce interval must be positive milliseconds")
        self.stable = initial_raw
        self.candidate = initial_raw
        self.changed_at = now
        self.interval_ms = interval_ms

    def update(self, raw, now, ticks_diff):
        if type(raw) is not int or raw not in (0, 1):
            raise ValueError("Switch input must be 0 or 1")
        if raw != self.candidate:
            self.candidate = raw
            self.changed_at = now
        if self.candidate != self.stable and ticks_diff(now, self.changed_at) >= self.interval_ms:
            self.stable = self.candidate
            return self.stable == 0  # Exactly one event on each accepted press.
        return False


def capture(read_pair, delay_ms, count):
    x_values, y_values = [], []
    for _ in range(count):
        x, y = read_pair()
        x_values.append(checked_raw(x))
        y_values.append(checked_raw(y))
        delay_ms(10)
    return x_values, y_values


def calibrate(read_pair, delay_ms, log=print):
    log("# Release the stick. Center sampling starts in 1 second; keep it still.")
    delay_ms(1000)
    cx, cy = capture(read_pair, delay_ms, 200)
    center_value(cx)
    center_value(cy)  # Reject unstable center before requesting the travel phase.
    log("# In 1 second, sweep BOTH axes gently to each end for about 8 seconds.")
    delay_ms(1000)
    tx, ty = capture(read_pair, delay_ms, 800)
    x_axis = calibration_from_samples(cx, tx)
    y_axis = calibration_from_samples(cy, ty)
    log("# X low,center,high = %d,%d,%d" % (x_axis.low, x_axis.center, x_axis.high))
    log("# Y low,center,high = %d,%d,%d" % (y_axis.low, y_axis.center, y_axis.high))
    return x_axis, y_axis


def run(read_pair, read_button, ticks_ms, delay_ms, ticks_diff, log=print, iterations=None):
    x_axis, y_axis = calibrate(read_pair, delay_ms, log)
    button = DebouncedSwitch(read_button(), ticks_ms())
    last_report = ticks_ms()
    presses = 0
    loops = 0
    log("ticks_ms,x_raw,y_raw,x_norm,y_norm,pressed,presses")
    while iterations is None or loops < iterations:
        x, y = read_pair()
        now = ticks_ms()
        if button.update(read_button(), now, ticks_diff):
            presses += 1
        nx = x_axis.normalize(x, INVERT_X)
        ny = y_axis.normalize(y, INVERT_Y)
        if ticks_diff(now, last_report) >= 100:
            log("%d,%d,%d,%.3f,%.3f,%d,%d" %
                (now, x, y, nx, ny, int(button.stable == 0), presses))
            last_report = now
        delay_ms(10)
        loops += 1


def average_adc(adc):
    return sum(adc.read_u16() for _ in range(4)) // 4


def main():
    from machine import ADC, Pin
    import time
    x_adc = ADC(Pin(26))  # ADC0, physical pin31, explicit analog pin setup.
    y_adc = ADC(Pin(27))  # ADC1, physical pin32.
    switch = Pin(28, Pin.IN, Pin.PULL_UP)  # Physical pin34; pressed LOW.

    def read_pair():
        return average_adc(x_adc), average_adc(y_adc)

    run(read_pair, switch.value, time.ticks_ms, time.sleep_ms, time.ticks_diff)


if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        print("Stopped. The program drives no actuator outputs.")
    except ValueError as error:
        print("Calibration/input rejected:", error)
        print("Stop, inspect the unpowered wiring, then run the program again.")

A useful ten-minute bench session

  1. Record the setup. Write down the exact module, Pico version, MicroPython version and measured supply. Complete the unpowered and voltage checks before recording software results.
  2. Calibrate without helping the center. Let the springs return the stick. During the sweep, hold each end briefly and visit both directions of both axes. Record the four endpoint values and two centers printed by the program.
  3. Observe release. Leave the stick alone for ten seconds. Record the raw variation and whether both normalized outputs remain zero. A stable zero output does not mean the electrical signal stopped varying.
  4. Check both halves. Move just beyond the quiet region in each direction. The output should leave zero smoothly. Reach each end gently and check for values near −1 or +1. Rotate the module if necessary to discover which physical direction makes each number increase; the manufacturer model name does not define your screen orientation.
  5. Test the switch separately. Make ten deliberate presses with full releases. Compare your manual count with presses. Watch whether pressing down also changes an axis: that can be real thumb movement, not a software counting defect.
  6. Repeat rather than trust one sweep. Restart, recalibrate and compare centers and endpoints. Large differences suggest inconsistent handling, wiring or mechanics. Keep the original record instead of replacing it with whichever run looks nicest.

Troubleshoot the observation you actually made

ObservationCheck nextWhat not to conclude
An axis never moves or calibration says travel too smallWith power off, check its wiper lead and the physical-pin/GPIO distinction. Then repeat a full sweep.A rejected range is not proof that the joystick itself failed.
Readings wander with a disconnected leadRestore a verified signal and common ground. An unconnected analog input can produce plausible-looking numbers.A larger dead zone does not repair a floating input.
Center rejection occurs repeatedlyRelease the stick, shorten loose leads and inspect the supply and contacts before changing the software threshold.The chosen 2000-code gate is not a calibrated noise specification.
One direction reaches its endpoint unusually earlyRepeat the travel capture and compare the stored low/high readings.Endpoint saturation does not prove full mechanical travel was measured.
SW stays pressed or never registersCheck unpowered SW continuity, GP28 physical34, the common ground and Pin.PULL_UP.The conflicting overview sentence does not override the verified electrical behavior.
Ten presses give too many or too few countsCompare deliberate press/release timing with raw behavior; inspect the switch and use a longer stable interval only after checking wiring.One successful count test is not a contact-lifetime qualification.
Optional challenge: make the quiet region visible

Copy a short CSV recording and mark every sample for which both normalized values are zero. Then change DEAD_ZONE from 0.08 to 0.12, recalibrate and compare how far you must move before a value appears. Label each run with its settings and captured endpoints. The wider zone should deliberately discard more small movement; it is a trade between quiet release and fine control, not a universal improvement. Keep the same module and wiring while comparing the settings.

Sources and what was checked

Desktop tests exercise the actual calibration, normalization, debouncing and reporting functions with supplied inputs, including rejected calibration and timer wrap. They do not test real ADC noise, switch bounce, connector quality, power limits or a compiled firmware image. Those observations belong in your own bench record.

Download the experiment sources (ZIP): complete programs, full-size PNG and editable SVG diagrams, and blank worksheets where supplied. The complete article program remains inline above.

📡Broadcast the signal — amplify the connection.

Leave a Reply

Temu affiliate offers

Find your next electronics project

Explore these do-it-yourself project kits. Check the selected option, included parts and instructions on each offer.

Tricky Circuit may earn a commission if you buy through these Temu affiliate links. These are product ideas; they have not been bench-tested by Tricky Circuit and are not verified lesson parts lists.

Supplier photo of an assembled infinity mirror light cube illuminated on a dark background.

Supplier product photo

Build an infinity light cube

Listing specifies 5-volt direct current. Soldering required; battery and glue not included.

View kit on Temu

Supplier photo of a breadboard, jumper wires and assorted electronic components in a plastic storage case.

Supplier product photo

Try breadboard experiments

Breadboard, wires and assorted parts. Arduino controller board not included.

View kit on Temu

Supplier photo of an assembled blue surface-mount soldering practice board with a circular ring of small lights.

Supplier product photo

Solder a chasing-light board

Surface-mount soldering practice. Battery and battery holder not included.

View kit on Temu