From ff7b33d49a8282f7ad9ce1f72d7d0c31da65d885 Mon Sep 17 00:00:00 2001 From: Skylar Ittner Date: Sun, 22 Feb 2026 22:58:58 -0700 Subject: [PATCH] Full barcode scanner firmware: display driver, USB HID and CDC serial, vendor USB commands, etc --- build/barcodescanner.sh | 3 +- src/barcodescanner/config.py | 81 +++++++ src/barcodescanner/feedback.py | 81 +++++++ src/barcodescanner/main.py | 402 +++++++++++-------------------- src/barcodescanner/manifest.py | 9 +- src/barcodescanner/oledscreen.py | 194 +++++++++++++++ src/barcodescanner/scanmode.py | 146 +++++++++++ src/barcodescanner/scannerusb.py | 267 ++++++++++++++++++++ src/barcodescanner/watchdog.py | 48 ++++ 9 files changed, 967 insertions(+), 264 deletions(-) create mode 100644 src/barcodescanner/config.py create mode 100644 src/barcodescanner/feedback.py create mode 100644 src/barcodescanner/oledscreen.py create mode 100644 src/barcodescanner/scanmode.py create mode 100644 src/barcodescanner/scannerusb.py create mode 100644 src/barcodescanner/watchdog.py diff --git a/build/barcodescanner.sh b/build/barcodescanner.sh index 59cad54..92364c3 100755 --- a/build/barcodescanner.sh +++ b/build/barcodescanner.sh @@ -1,8 +1,7 @@ #!/bin/bash mkdir -p workspace/src/barcodescanner -cp ../src/barcodescanner/main.py workspace/src/barcodescanner/main.py -cp ../src/barcodescanner/manifest.py workspace/src/barcodescanner/manifest.py +cp ../src/barcodescanner/*.py workspace/src/barcodescanner/ cd workspace git clone https://github.com/micropython/micropython.git --branch=master --depth=1 cd micropython diff --git a/src/barcodescanner/config.py b/src/barcodescanner/config.py new file mode 100644 index 0000000..b6fa8ff --- /dev/null +++ b/src/barcodescanner/config.py @@ -0,0 +1,81 @@ +# Copyright 2025 PostalPortal LLC and Netsyms Technologies LLC +# +# Redistribution and use in source and binary forms, with or +# without modification, are permitted provided that +# the following conditions are met: +# 1. Redistributions of source code must retain +# the above copyright notice, this list of conditions +# and the following disclaimer. +# 2. Redistributions in binary form must reproduce +# the above copyright notice, this list of conditions +# and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder +# nor the names of its contributors may be used +# to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +# THE POSSIBILITY OF SUCH DAMAGE. + + +# +# Hardware configuration +# +UART_ID = 0 # TX/RX: UART 0: 0/1, 12/13, or 16/17; UART 1: 4/5 or 8/9 +UART_TX_PIN=0 +UART_RX_PIN=1 +TRIGGER_BUTTON_PIN = 12 # Pin to read for scan trigger button, connect trigger button between this and ground +TRIGGER_PIN = 13 # Pin that connects to the scan module's trigger line, pulls the line low while the user is pressing the trigger button +UP_BUTTON_PIN = 14 # Pin to read for navigation up button +DOWN_BUTTON_PIN = 15 # Pin to read for navigation down button +LED_PIN = "LED" # 3 in prod +FIRMWARE_VERSION = "0.0.1" + +# +# Scanner configuration +# +SCAN_GAP_MS = 50 # Amount of time to wait for more characters from the scan engine before sending a barcode +MAX_BARCODE_LENGTH = 8192 # Barcodes longer than this from the scan engine are assumed to be a glitch +TESTMODE = True # Sends a simulated barcode scan every 5 seconds + +# +# USB configuration +# +VID = 0x1209 # USB Vendor ID +PID = 0xA003 # USB Product ID +MANU = "PostalPortal LLC" # USB manufacturer string +PROD = "PostalPoint Barcode Scanner" # USB product string +INTERFACE = "PostalPoint" # Interface string +USBHID_ENABLED = True # Disable USB, use serial output only (good for debugging) + + +# +# Display configuration +# +ENABLE_DISPLAY = True # Set to False to ignore display commands +DISPLAY_WIDTH = 128 +DISPLAY_HEIGHT = 64 +CHAR_WIDTH = 8 +CHAR_HEIGHT = 8 +# Available pin combinations for I2C: +# I2C 0 – SDA: GP0/GP4/GP8/GP12/GP16/GP20 +# I2C 0 – SCL: GP1/GP5/GP9/GP13/GP17/GP21 +# I2C 1 – SDA: GP2/GP6/GP10/GP14/GP18/GP26 +# I2C 1 – SCL: GP3/GP7/GP11/GP15/GP19/GP27 +# Just don't conflict with the pin assignments for the sensor and buttons! +DISPLAY_SDA_PIN = 4 +DISPLAY_SCL_PIN = 5 +DISPLAY_I2C_CONTROLLER = 0 diff --git a/src/barcodescanner/feedback.py b/src/barcodescanner/feedback.py new file mode 100644 index 0000000..ec2111b --- /dev/null +++ b/src/barcodescanner/feedback.py @@ -0,0 +1,81 @@ +# Copyright 2026 PostalPortal LLC +# +# Redistribution and use in source and binary forms, with or +# without modification, are permitted provided that +# the following conditions are met: +# 1. Redistributions of source code must retain +# the above copyright notice, this list of conditions +# and the following disclaimer. +# 2. Redistributions in binary form must reproduce +# the above copyright notice, this list of conditions +# and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder +# nor the names of its contributors may be used +# to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +# THE POSSIBILITY OF SUCH DAMAGE. + +from machine import Pin +from time import sleep_us +import config +from oledscreen import iconDisplay, centerText + +onboardLED = Pin(config.LED_PIN, Pin.OUT) + +# Pulse the LED on and off with brightness fade +def pulseLED(speed): + r = 600 + if speed == "fast": + r = 300 + elif speed == "normal": + r = 600 + elif speed == "slow": + r = 1000 + + for i in range(0,r): + onboardLED.on() + sleep_us(i) + onboardLED.off() + sleep_us(r-i) + for i in range(0,r): + onboardLED.on() + sleep_us(r-i) + onboardLED.off() + sleep_us(i) + +def firmwareUpdateMessage(): + centerText("Firmware Update", False, True) + +def feedbackBuzzer(feedback): + if feedback == "OK": + iconDisplay("OK") + pulseLED("fast") + elif feedback == "ERR": + iconDisplay("ERR") + pulseLED("fast") + pulseLED("fast") + pulseLED("fast") + elif feedback == "POW": + iconDisplay("POW") + #pulseLED("normal") + elif feedback == "NAF": + iconDisplay("NAF") + pulseLED("normal") + pulseLED("fast") + pulseLED("fast") + pulseLED("normal") diff --git a/src/barcodescanner/main.py b/src/barcodescanner/main.py index 021b836..a9dc99d 100644 --- a/src/barcodescanner/main.py +++ b/src/barcodescanner/main.py @@ -32,278 +32,158 @@ from sys import stdin, exit -from _thread import start_new_thread import machine -from machine import Pin, USBDevice +from machine import Pin, USBDevice, UART from utime import sleep, sleep_ms, sleep_us -import usb.device from micropython import const -from usb.device.hid import HIDInterface +import time + +from config import * +from oledscreen import bootDisplay, clearDisplay, brightDisplay, mainDisplay, centerText +from scannerusb import initUSBHID, createAndSendBarcodeReports +from feedback import feedbackBuzzer +from scanmode import isScanInhibited, setModeID, getCurrentModeID, processNewListUSBReport +from watchdog import startwatchdog, feedwatchdog print("PostalPoint(r) Barcode Scanner") -print("Firmware version 0.0.1") +print("Firmware version " + FIRMWARE_VERSION) -onboardLED = Pin("LED", Pin.OUT) +startwatchdog() +uart = UART(UART_ID, baudrate=9600, bits=8, parity=None, stop=1, tx=UART_TX_PIN, rx=UART_RX_PIN, txbuf=100, rxbuf=MAX_BARCODE_LENGTH) -# VID and PID of the USB device. -VID = 0x1209 # USB Vendor ID -PID = 0xA003 # USB Product ID -MANU = "PostalPortal LLC" # USB manufacturer string -PROD = "PostalPoint Barcode Scanner" # USB product string -INTERFACE = "PostalPoint" # Interface string +triggerButton = Pin(TRIGGER_BUTTON_PIN, Pin.IN, Pin.PULL_UP) +triggerPin = Pin(TRIGGER_PIN, Pin.IN) +menuUpButton = Pin(UP_BUTTON_PIN, Pin.IN, Pin.PULL_UP) +menuDownButton = Pin(DOWN_BUTTON_PIN, Pin.IN, Pin.PULL_UP) -class USBHIDInterface(HIDInterface): - def __init__(self): - super().__init__( - bytes([ - 0x05, 0x8C, # Usage Page (barcode scanner) - 0x09, 0x02, # Usage (Barcode Scanner) - 0xA1, 0x01, # Collection (Application) - 0x09, 0x12, # Usage (Scanned Data Report) - 0xA1, 0x02, # Collection (Logical) - 0x85, 0x02, # Report ID 2 - 0x15, 0x00, # Logical Minimum 0 - 0x26, 0xFF, 0x00, # Logical Maximum 255 - 0x75, 0x08, # Report Size (8 bits) - 0x95, 0x01, # Report Count (1 byte long) - 0x05, 0x01, # Usage Page 0x01 (Generic Desktop Ctrls) - 0x09, 0x3B, # Usage (Byte count) - 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) - 0x95, 0x03, # Report Count (3 bytes long) - 0x05, 0x8C, # Usage Page (barcode scanner) - 0x09, 0xFB, # Usage (0xFB, Symbology Identifier 1) - 0x09, 0xFC, # Usage (0xFC, Symbology Identifier 2) - 0x09, 0xFD, # Usage (0xFD, Symbology Identifier 3) - 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) - 0x95, 0x38, # Report Count (56 bytes long) - 0x05, 0x8C, # Usage Page (barcode scanner) - 0x09, 0xFE, # Usage 0xFE (Decoded Data) - 0x82, 0x02, 0x01, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Buffered Bytes) - 0x95, 0x02, # Report Count (2 bytes) - 0x06, 0x69, 0xFF, # Usage Page (Vendor Defined 0xFF69, everyone else does FF66) - 0x09, 0x04, # Usage (0x04) - 0x09, 0x05, # Usage (0x05) - 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) - 0x05, 0x8C, # Usage Page (Bar Code Scanner Page) - 0x25, 0x01, # Logical Maximum (1) - 0x75, 0x01, # Report Size (1 bit) - 0x95, 0x08, # Report Count (8 * size = 1 byte total) - 0x09, 0xFF, # Usage (0xFF, Decode Data Continued) - 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) - 0xC0, # End Collection (Logical) - 0x09, 0x14, # Usage (Trigger report) - 0xA1, 0x02, # Collection (Logical) - 0x85, 0x04, # Report ID (4, trigger report from host) - 0x15, 0x00, # Logical Minimum (0) - 0x25, 0x01, # Logical Maximum (1) - 0x75, 0x01, # Report Size (1) - 0x95, 0x08, # Report Count (8) - 0x09, 0x00, # Usage (0x00, filler bit) - 0x09, 0x5F, # Usage (0x5F, prevent read of barcodes) - 0x09, 0x60, # Usage (0x60, initiate barcode read) - 0x09, 0x00, # Usage (0x00, filler) - 0x09, 0x84, # Usage (0x84, sound powerup beep) - 0x09, 0x85, # Usage (0x85, sound error beep) - 0x09, 0x86, # Usage (0x86, sound success beep) - 0x09, 0x87, # Usage (0x87, sound not-on-file beep) - 0x91, 0x82, # Output (Data,Var,Abs,Volatile) - 0xC0, # End Collection (Logical) - 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) - 0x09, 0x30, # Usage (Vendor-defined: Configuration report for setting up scan modes) - 0xA1, 0x02, # Collection (Logical) - 0x85, 0x05, # Report ID (5) - 0x15, 0x00, # Logical Minimum (0) - 0x26, 0xFF, 0x00, # Logical Maximum (255) - 0x75, 0x08, # Report Size (8 bits) - 0x95, 0x01, # Report Count (1 byte) - 0x05, 0x01, # Usage Page (Generic Desktop) - 0x09, 0x3B, # Usage (Byte Count) - 0x91, 0x02, # Output (Data,Var,Abs) - 0x95, 0x3E, # Report Count (62 bytes) - 0x06, 0x69, 0xFF, # Usage Page (Vendor Defined 0xFF69) - 0x09, 0x05, # Usage (0x05, Config ASCII Data) - 0x92, 0x02, 0x01, # Output (Data,Var,Abs,Buffered Bytes) - 0xC0, # End Collection (Logical) - 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) - 0x09, 0x31, # Usage (Vendor-defined: Scan mode switch) - 0xA1, 0x02, # Collection (Logical) - 0x85, 0x55, # Report ID (0x55) - 0x15, 0x00, # Logical Minimum (0) - 0x26, 0xFF, 0x00, # Logical Maximum (255) - 0x75, 0x08, # Report Size (8 bits) - 0x95, 0x01, # Report Count (1 byte) - 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) - 0x09, 0x55, # Usage (0x55) - 0x91, 0x02, # Output (Data,Var,Abs) - 0xC0, # End Collection (Logical) - 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) - 0x09, 0xF1, # Usage (Vendor-defined: Firmware update trigger) - 0xA1, 0x02, # Collection (Logical) - 0x85, 0xF1, # Report ID (0xF1) - 0x15, 0x00, # Logical Minimum (0) - 0x26, 0xFF, 0x00, # Logical Maximum (255) - 0x75, 0x08, # Report Size (8 bits) - 0x95, 0x01, # Report Count (1 byte) - 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) - 0x09, 0xF1, # Usage (0xF1) - 0x91, 0x02, # Output (Data,Var,Abs) - 0xC0, # End Collection (Logical) - 0xC0, # End Collection (Application) - # - # NOTES: - # - # Report ID 2 is 63-bytes to the host. - # Byte 0 is the number of characters of barcode data to send in this report. - # Bytes 1, 2, and 3 are the AIM barcode identifier string. Nobody really uses this, some cheap scanners fudge it to QR code. - # Bytes 4 through 60 are the barcode data. Padded with NUL (0x00) bytes if the barcode is shorter than the available space. - # Bytes 61 and 62 are vendor-specific, this isn't part of the HID spec but everyone does it (see report 5 below for what we do) - # because they're copying everyone else. Honeywell uses it for another symbology ID. - # Byte 63 is 0x01 (b00000001) if the barcode data couldn't fit in a single report; - # it means more reports are coming right away with the rest of the data. - # If byte 63 is 0x00 it means there's no more barcode to send and the host can process it now. - # - # Report ID 4 is a single byte from the host: 76543210 - # If pos. 7, 6, 5, or 4 is 1, the host wants us to make a particular beep sound/light indication. - # If pos. 1 is 1, host wants us to not send any barcode data or do any scans. - # If pos. 2 is 1, host wants us to start reading for barcodes without the user pressing a button. - # - # Report ID 5 is 63 bytes from the host to this device. - # This is a custom report for this specific device. - # The first byte is the data length. - # The rest of it is a "list" of ASCII strings, delimited with a NUL byte. - # The strings are displayed on our display and can be switched between by the user. - # The index of the string (as sent by the host) is sent as the second vendor byte in report 2. - # This allows the user to select a scan context/UI mode in the program, and the program - # can switch to it automatically when a barcode is scanned in that mode. - # - # Report ID 0x55 is a single byte from the host. - # It tells us to change our scan context to a string index from the most recent Report 5 list, - # overriding the user's last selection. This is for when the user changed modes in software, - # to keep the state in sync between the scanner and PC. - # - # Report ID 0xF1 triggers the Pi Pico to switch to firmware update mode when the byte sent is also 0xF1. - # The Pico will re-enumerate and appear as a USB flash drive so the firmware file can be dropped in. - ]), - set_report_buf=bytearray(64), - protocol=const(0x00), - interface_str=INTERFACE, - ) +scanButtonState = 1 - def on_set_report(self, report_data, report_id, report_type): - global inhibitScan, feedbackBuzzer - #createAndSendBarcodeReports(bytes([report_id, report_type, report_data[0], report_data[1], reverse_byte(report_data[1])])) - - if report_id == 0x04: - report_byte = report_data[1] - # HID POS trigger report - # Disable/enable scanning based on bit flag - inhibitScan = (report_byte >> 1 & 1 == 1) - - if (report_byte >> 7 & 1 == 1): - # not-on-file beep, 10000000 - feedbackBuzzer("NAF") - elif (report_byte >> 6 & 1 == 1): - # success beep, 01000000 - feedbackBuzzer("OK") - elif (report_byte >> 5 & 1 == 1): - # error beep, 00100000 - feedbackBuzzer("ERR") - elif (report_byte >> 4 & 1 == 1): - # powerup beep, 00010000 - feedbackBuzzer("POW") - - elif report_id == 0xF1 and report_data[1] == 0xF1: - # Enter firmware update mode - machine.bootloader() +def triggerButtonHandler(pin): + global scanButtonState + scanButtonState = pin.value() - def send_data(self, data=None): - while self.busy(): - machine.idle() - if data is None: +triggerButton.irq(handler=triggerButtonHandler, trigger=(Pin.IRQ_FALLING | Pin.IRQ_RISING)) + +menuUpButtonPressed = False +menuDownButtonPressed = False +menuUpButtonLastPressTime = time.ticks_ms() +menuDownButtonLastPressTime = time.ticks_ms() + +def menuUpButtonHandler(pin): + global menuUpButtonPressed, menuUpButtonLastPressTime + if pin.value() == 0: + # Don't allow button "wobble" to cause multiple events in quick succession, + # ignore button presses when they're less than 100ms apart + if time.ticks_diff(time.ticks_ms(), menuUpButtonLastPressTime) < 100: return - else: - self.send_report(data) - -usbinterface = USBHIDInterface() -usb.device.get().active(False) -usb.device.get().config(usbinterface, builtin_driver=True, manufacturer_str=MANU, product_str=PROD, id_vendor=VID, id_product=PID) -usb.device.get().active(True) - - -# Host can set this true and we won't send any scans. -inhibitScan = False - -# Pulse the LED on and off with brightness fade -def pulseLED(speed): - r = 600 - if speed == "fast": - r = 300 - elif speed == "normal": - r = 600 - elif speed == "slow": - r = 1000 - - for i in range(0,r): - onboardLED.on() - sleep_us(i) - onboardLED.off() - sleep_us(r-i) - for i in range(0,r): - onboardLED.on() - sleep_us(r-i) - onboardLED.off() - sleep_us(i) - -def feedbackBuzzer(feedback): - global onboardLED - if feedback == "OK": - pulseLED("fast") - elif feedback == "ERR": - pulseLED("fast") - pulseLED("fast") - pulseLED("fast") - elif feedback == "POW": - pulseLED("slow") - elif feedback == "NAF": - pulseLED("normal") - pulseLED("fast") - pulseLED("fast") - pulseLED("normal") - -def send_data(d): - print(d) - -def split(data, n): - return [data[i:i+n] for i in range(0, len(data), n)] - -def pad_data(chunk, size): - if len(chunk) >= size: - return chunk[:size] - return chunk + b'\x00' * (size - len(chunk)) - -def createAndSendBarcodeReports(barcodeData): - global usbinterface - chunks = split(barcodeData, 56) - for i, chunk in enumerate(chunks): - lastByte = b'\x01' - if len(chunks) - 1 == i: - lastByte = b'\x00' - report = b'\x02' + bytes([len(chunk)]) + b'\x00\x00\x00' + pad_data(chunk, 56) + b'\x00\x00' + lastByte - usbinterface.send_data(report) - return - - -try: - feedbackBuzzer("POW") - while True: - sleep(1) - if inhibitScan == False: - feedbackBuzzer("OK") - createAndSendBarcodeReports(b'test barcode') - sleep(2) + menuUpButtonPressed = True + menuUpButtonLastPressTime = time.ticks_ms() +def menuDownButtonHandler(pin): + global menuDownButtonPressed, menuDownButtonLastPressTime + if pin.value() == 0: + if time.ticks_diff(time.ticks_ms(), menuDownButtonLastPressTime) < 100: + return + menuDownButtonPressed = True + menuDownButtonLastPressTime = time.ticks_ms() -except KeyboardInterrupt: # trap Ctrl-C input - terminateThread = True # signal second 'background' thread to terminate - exit() +menuUpButton.irq(handler=menuUpButtonHandler, trigger=Pin.IRQ_FALLING) +menuDownButton.irq(handler=menuDownButtonHandler, trigger=Pin.IRQ_FALLING) + +# +# +# +# TODO: Send scan engine configuration commands over UART +# +# +# + +feedwatchdog() +feedbackBuzzer("POW") +feedwatchdog() +mainDisplay(scanButtonState == 0) + +if USBHID_ENABLED: + initUSBHID() + +feedwatchdog() + +lastTestSend = time.ticks_ms() + +uartBuffer = bytearray() +lastBufferActivity = None +bufferOverflow = False + +# +# Main loop +# +while True: + feedwatchdog() + try: + # Refresh OLED + mainDisplay(scanButtonState == 0) + feedwatchdog() + # Handle menu buttons + if menuUpButtonPressed: + setModeID(getCurrentModeID() - 1) + menuUpButtonPressed = False + if menuDownButtonPressed: + setModeID(getCurrentModeID() + 1) + menuDownButtonPressed = False + + feedwatchdog() + + # Set trigger line for scan engine + if isScanInhibited(): + triggerPin.init(Pin.IN) + else: + if scanButtonState == 0: + triggerPin.init(Pin.OUT) + triggerPin.value(0) + else: + triggerPin.init(Pin.IN) + + feedwatchdog() + + # Read scan data from engine + while uart.any(): + feedwatchdog() + data = uart.read(uart.any()) + if data: + if not bufferOverflow: + uartBuffer.extend(data) + lastBufferActivity = time.ticks_ms() + if len(uartBuffer) > MAX_BARCODE_LENGTH: + uartBuffer.clear() + bufferOverflow = True + lastBufferActivity = None + + # Send scan data to host + if lastBufferActivity is not None and time.ticks_diff(time.ticks_ms(), lastBufferActivity) > SCAN_GAP_MS: + if not isScanInhibited() and uartBuffer and not bufferOverflow: + createAndSendBarcodeReports(bytes(uartBuffer)) + feedbackBuzzer("OK") + uartBuffer.clear() + lastBufferActivity = None + bufferOverflow = False + + feedwatchdog() + + if TESTMODE and time.ticks_diff(time.ticks_ms(), lastTestSend) > 5000: + createAndSendBarcodeReports(b'test barcode 12345') + feedbackBuzzer("OK") + lastTestSend = time.ticks_ms() + + feedwatchdog() + + # Handle config from host if USB code queued it for processing + processNewListUSBReport() + + feedwatchdog() + # Take a tiny bit of time off + machine.idle() + except Exception as e: + # Dump exceptions over serial for debugging + print(e) + machine.reset() diff --git a/src/barcodescanner/manifest.py b/src/barcodescanner/manifest.py index 347f237..dde3294 100644 --- a/src/barcodescanner/manifest.py +++ b/src/barcodescanner/manifest.py @@ -1,4 +1,11 @@ -# Build manifest for PostalPoint Kiosk Controller (USB HID) +# Build manifest for PostalPoint Barcode Scanner include("$(MPY_DIR)/ports/rp2/boards/manifest.py") require("usb-device-hid") +require("ssd1306") +module("config.py") +module("watchdog.py") +module("scanmode.py") +module("feedback.py") +module("oledscreen.py") +module("scannerusb.py") module("main.py") diff --git a/src/barcodescanner/oledscreen.py b/src/barcodescanner/oledscreen.py new file mode 100644 index 0000000..4631ec1 --- /dev/null +++ b/src/barcodescanner/oledscreen.py @@ -0,0 +1,194 @@ +# Copyright 2026 PostalPortal LLC +# +# Redistribution and use in source and binary forms, with or +# without modification, are permitted provided that +# the following conditions are met: +# 1. Redistributions of source code must retain +# the above copyright notice, this list of conditions +# and the following disclaimer. +# 2. Redistributions in binary form must reproduce +# the above copyright notice, this list of conditions +# and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder +# nor the names of its contributors may be used +# to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +# THE POSSIBILITY OF SUCH DAMAGE. +# +# This code displays device info on an attached screen. +# Requires the ssd1306 module/package installed. +# Connect to GPIO 0 and 1 for i2c +# + +from machine import Pin, I2C +from ssd1306 import SSD1306_I2C +from time import sleep_ms +import framebuf +import math +from config import * +from scanmode import getCurrentModeStr, getModes, modeListIsSet, getPrevModeStr, getNextModeStr + +i2c = None +oled = None +if ENABLE_DISPLAY: + i2c = I2C(DISPLAY_I2C_CONTROLLER, sda=Pin(DISPLAY_SDA_PIN), scl=Pin(DISPLAY_SCL_PIN), freq=400000) + oled = SSD1306_I2C(DISPLAY_WIDTH, DISPLAY_HEIGHT, i2c) + + +def bootDisplay(): + if ENABLE_DISPLAY: + try: + # Bootup dead pixel self-test + oled.fill(0) + oled.show() + oled.fill(1) + oled.show() + sleep_ms(500) + oled.fill(0) + oled.show() + # 128x32 PostalPoint logo bitmap + logo = bytearray([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0xc0, 0x40, 0x40, 0xc0, 0x80, + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xfc, 0x0e, 0x0a, 0x1a, 0x13, 0xf1, 0x19, 0x08, 0x08, 0xfc, 0xfc, 0x0c, 0x08, + 0x19, 0xf1, 0xf1, 0x1b, 0x0a, 0x0e, 0xfc, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xf8, 0xf8, + 0x1c, 0x1c, 0x1c, 0xf8, 0xf8, 0xf0, 0x00, 0x00, 0x80, 0xc0, 0xe0, 0xe0, 0xe0, 0xc0, 0xc0, 0x00, + 0x00, 0x00, 0xc0, 0xe0, 0xe0, 0xe0, 0xe0, 0xc0, 0x00, 0x00, 0x80, 0xf0, 0xf8, 0xf8, 0xe0, 0xe0, + 0x00, 0x00, 0x80, 0xc0, 0xe0, 0xe0, 0xe0, 0xe0, 0xe0, 0x40, 0x00, 0x00, 0xf0, 0xfc, 0xfe, 0x0e, + 0x00, 0x00, 0xf0, 0xf8, 0xf8, 0x1c, 0x1c, 0x18, 0xf8, 0xf8, 0xf0, 0x00, 0x00, 0x80, 0xc0, 0xe0, + 0xe0, 0xe0, 0xc0, 0x80, 0x00, 0x00, 0x00, 0xc8, 0xfc, 0xfe, 0x0c, 0x00, 0xc0, 0xe0, 0xe0, 0xe0, + 0xe0, 0xe0, 0xc0, 0x00, 0x00, 0x00, 0xf0, 0xf8, 0xf8, 0xe0, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x7f, 0xc0, 0x80, 0x80, 0x80, 0x07, 0x0e, 0x0a, 0x1a, 0xf1, 0xf1, 0x1b, 0x0a, + 0x0e, 0x0f, 0x07, 0x80, 0x80, 0xc0, 0x7f, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x7e, 0x7f, 0x3f, 0x07, + 0x07, 0x07, 0x03, 0x03, 0x01, 0x00, 0x00, 0x1f, 0x3f, 0x7f, 0x70, 0x70, 0x38, 0x1f, 0x0f, 0x03, + 0x20, 0x71, 0x73, 0x77, 0x76, 0x3e, 0x3c, 0x08, 0x00, 0x18, 0x3f, 0x7f, 0x73, 0x70, 0x30, 0x00, + 0x0c, 0x3f, 0x7f, 0x73, 0x70, 0x30, 0x3f, 0x7f, 0x3f, 0x00, 0x00, 0x3f, 0x7f, 0x7f, 0x20, 0x00, + 0x70, 0x7f, 0x7f, 0x0f, 0x07, 0x07, 0x07, 0x03, 0x03, 0x01, 0x00, 0x0c, 0x3f, 0x3f, 0x73, 0x70, + 0x70, 0x3f, 0x1f, 0x0f, 0x00, 0x70, 0x7f, 0x3f, 0x0f, 0x00, 0x20, 0x7e, 0x7f, 0x1f, 0x01, 0x20, + 0x7f, 0x7f, 0x0f, 0x00, 0x00, 0x3f, 0x7f, 0x7f, 0x70, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x02, 0x06, 0x07, 0x07, 0x06, 0x02, + 0x02, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + fbuf = framebuf.FrameBuffer(logo, 128, 32, framebuf.MONO_VLSB) + oled.blit(fbuf, 0, 0, 0) + # Show info + oled.text("Barcode Scanner", 0, 36, 1) + firmVerStr = "FW Ver " + FIRMWARE_VERSION + oled.text(f"{firmVerStr:^16}", 0, 56, 1) + oled.show() + sleep_ms(1500) + mainDisplay() + except: + pass + +# Center a line of text on the screen (vertical and horizontal) +# If arrows, display up and down arrows above / below the text. +# If showNow, clears the display before drawing, and displays the text as well. Otherwise this function only renders the display. +# If arrows, the aboveLine and belowLine text will be rendered, centered horizontally, above/below the arrows. +def centerText(text, arrows=False, showNow = False, aboveLine = "", belowLine = ""): + if ENABLE_DISPLAY: + if showNow: + oled.fill(0) + textWidth = len(text) * CHAR_WIDTH + vCenter = DISPLAY_HEIGHT // 2 + textY = ((DISPLAY_HEIGHT - CHAR_HEIGHT) // 2) + oled.text(text, (DISPLAY_WIDTH - textWidth) // 2, textY, 1) + if arrows: + arrowLineXDistance = CHAR_WIDTH // 2 + arrowLineYDistance = CHAR_HEIGHT // 2 + topArrowPoint = vCenter - CHAR_HEIGHT - arrowLineYDistance + bottomArrowPoint = vCenter + CHAR_HEIGHT + arrowLineYDistance + xArrowCenterPoint = DISPLAY_WIDTH // 2 + topLineY = vCenter - CHAR_HEIGHT - (arrowLineYDistance // 2) + bottomLineY = vCenter + CHAR_HEIGHT + (arrowLineYDistance // 2) + oled.line(xArrowCenterPoint - arrowLineXDistance, topArrowPoint + arrowLineYDistance, xArrowCenterPoint, topArrowPoint, 1) # Left-top arrow part + oled.line(xArrowCenterPoint + arrowLineXDistance, topArrowPoint + arrowLineYDistance, xArrowCenterPoint, topArrowPoint, 1) # Right-top arrow part + oled.line(xArrowCenterPoint - arrowLineXDistance, bottomArrowPoint - arrowLineYDistance, xArrowCenterPoint, bottomArrowPoint, 1) # Left-bottom arrow part + oled.line(xArrowCenterPoint + arrowLineXDistance, bottomArrowPoint - arrowLineYDistance, xArrowCenterPoint, bottomArrowPoint, 1) # Right-bottom arrow part + # Horizontal lines above/below center text, with a gap in the middle for the arrows + oled.line(0, topLineY, xArrowCenterPoint - (arrowLineXDistance * 2), topLineY, 1) + oled.line(xArrowCenterPoint + (arrowLineXDistance * 2), topLineY, DISPLAY_WIDTH, topLineY, 1) + oled.line(0, bottomLineY, xArrowCenterPoint - (arrowLineXDistance * 2), bottomLineY, 1) + oled.line(xArrowCenterPoint + (arrowLineXDistance * 2), bottomLineY, DISPLAY_WIDTH, bottomLineY, 1) + # Top and bottom text lines, outside the arrows and lines + oled.text(aboveLine, (DISPLAY_WIDTH - 1 - (len(aboveLine) * CHAR_WIDTH)) // 2, 6, 1) + oled.text(belowLine, (DISPLAY_WIDTH - 1 - (len(belowLine) * CHAR_WIDTH)) // 2, DISPLAY_HEIGHT - CHAR_HEIGHT - 6, 1) + if showNow: + oled.show() + + +def mainDisplay(scanInProgress = False): + if ENABLE_DISPLAY: + try: + oled.fill(0) + if modeListIsSet(): + if modeListIsSet() and len(getModes()) > 1: + centerText(getCurrentModeStr(), True, False, getPrevModeStr(), getNextModeStr()) + else: + centerText(getCurrentModeStr(), False, False) + if scanInProgress: + # Outline around edge of display + oled.line(0, 0, DISPLAY_WIDTH - 1, 0, 1) + oled.line(0, 0, 0, DISPLAY_HEIGHT - 1, 1) + oled.line(DISPLAY_WIDTH - 1, 0, DISPLAY_WIDTH - 1, DISPLAY_HEIGHT - 1, 1) + oled.line(0, DISPLAY_HEIGHT - 1, DISPLAY_WIDTH - 1, DISPLAY_HEIGHT - 1, 1) + oled.show() + except: + pass + +def iconDisplay(icon): + # TODO: add code to render icons + if icon == "ERR": + pass + elif icon == "OK": + pass + elif icon == "NAF": + pass + elif icon == "POW": + bootDisplay() + +def clearDisplay(showMain = False): + if ENABLE_DISPLAY: + try: + oled.fill(0) + oled.show() + if showMain: + mainDisplay() + except: + pass + +def brightDisplay(): + if ENABLE_DISPLAY: + try: + oled.fill(1) + oled.show() + except: + pass + + diff --git a/src/barcodescanner/scanmode.py b/src/barcodescanner/scanmode.py new file mode 100644 index 0000000..53f10ac --- /dev/null +++ b/src/barcodescanner/scanmode.py @@ -0,0 +1,146 @@ +# Copyright 2026 PostalPortal LLC +# +# Redistribution and use in source and binary forms, with or +# without modification, are permitted provided that +# the following conditions are met: +# 1. Redistributions of source code must retain +# the above copyright notice, this list of conditions +# and the following disclaimer. +# 2. Redistributions in binary form must reproduce +# the above copyright notice, this list of conditions +# and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder +# nor the names of its contributors may be used +# to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +# THE POSSIBILITY OF SUCH DAMAGE. + + +currentScanModeIndex = 1 +gotScanModeList = True +scanModeList = [""] +inhibitScan = False + +newUsbListReport = None + +def queueNewListReportForProcessing(report_data): + global newUsbListReport + newUsbListReport = bytes(report_data) + +# Parse report 0x55 if there's one queued for processing +def processNewListUSBReport(): + global newUsbListReport + if newUsbListReport is None: + return + report = newUsbListReport + newUsbListReport = None + if len(report) < 2: + # Not valid at all + return + + listlen = report[1] + if listlen > len(report) - 2: + # Length is claiming to be longer than the actual data is + return + + payload = report[2 : 2 + listlen] + newScanModeList = [] + for part in payload.split(b'\x00'): + if part: + try: + newScanModeList.append(part.decode('ascii')) + except: + # Handle error on non-ASCII byte (value > 127) + continue + if len(newScanModeList) > 0: + setScanModeIndex(1) + setModeList(newScanModeList) + setModeListIsSet(True) + else: + setScanModeIndex(0) + setModeList([""]) + setModeListIsSet(False) + + +def setInhibitScan(b): + global inhibitScan + inhibitScan = b + +def isScanInhibited(): + global inhibitScan + return inhibitScan + +def getCurrentModeStr(): + global gotScanModeList, scanModeList, currentScanModeIndex + if gotScanModeList: + return scanModeList[currentScanModeIndex - 1] + else: + return scanModeList[0] + +def getPrevModeStr(): + global gotScanModeList, scanModeList, currentScanModeIndex + if gotScanModeList and currentScanModeIndex > 1: + return scanModeList[currentScanModeIndex - 2] + else: + return "" + +def getNextModeStr(): + global gotScanModeList, scanModeList, currentScanModeIndex + if gotScanModeList and len(scanModeList) > currentScanModeIndex: + return scanModeList[currentScanModeIndex] + else: + return "" + +def getCurrentModeID(): + global gotScanModeList, currentScanModeIndex + if gotScanModeList: + return currentScanModeIndex + else: + return 0 + +def setModeID(indx): + global gotScanModeList, scanModeList, currentScanModeIndex + if gotScanModeList: + if indx > len(scanModeList): + currentScanModeIndex = 1 + elif indx < 1: + currentScanModeIndex = len(scanModeList) + else: + currentScanModeIndex = indx + else: + currentScanModeIndex = 0 + +def setScanModeIndex(idx): + global currentScanModeIndex + currentScanModeIndex = idx + +def setModeListIsSet(b): + global gotScanModeList + gotScanModeList = b + +def getModes(): + global scanModeList + return scanModeList + +def setModeList(modelist): + global scanModeList + scanModeList = modelist + +def modeListIsSet(): + global gotScanModeList + return gotScanModeList diff --git a/src/barcodescanner/scannerusb.py b/src/barcodescanner/scannerusb.py new file mode 100644 index 0000000..a9c289f --- /dev/null +++ b/src/barcodescanner/scannerusb.py @@ -0,0 +1,267 @@ +# Copyright 2026 PostalPortal LLC +# +# Redistribution and use in source and binary forms, with or +# without modification, are permitted provided that +# the following conditions are met: +# 1. Redistributions of source code must retain +# the above copyright notice, this list of conditions +# and the following disclaimer. +# 2. Redistributions in binary form must reproduce +# the above copyright notice, this list of conditions +# and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder +# nor the names of its contributors may be used +# to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +# THE POSSIBILITY OF SUCH DAMAGE. + +import machine +import usb.device +from usb.device.hid import HIDInterface +from config import * +from scanmode import setInhibitScan, setScanModeIndex, setModeList, setModeListIsSet, setModeID, queueNewListReportForProcessing, getCurrentModeID +from feedback import feedbackBuzzer, firmwareUpdateMessage +from watchdog import feedwatchdog +import time +from sys import stdout + +usbinterface = None + +class USBHIDInterface(HIDInterface): + def __init__(self): + super().__init__( + bytes([ + 0x05, 0x8C, # Usage Page (barcode scanner) + 0x09, 0x02, # Usage (Barcode Scanner) + 0xA1, 0x01, # Collection (Application) + 0x09, 0x12, # Usage (Scanned Data Report) + 0xA1, 0x02, # Collection (Logical) + 0x85, 0x02, # Report ID 2 + 0x15, 0x00, # Logical Minimum 0 + 0x26, 0xFF, 0x00, # Logical Maximum 255 + 0x75, 0x08, # Report Size (8 bits) + 0x95, 0x01, # Report Count (1 byte long) + 0x05, 0x01, # Usage Page 0x01 (Generic Desktop Ctrls) + 0x09, 0x3B, # Usage (Byte count) + 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0x95, 0x03, # Report Count (3 bytes long) + 0x05, 0x8C, # Usage Page (barcode scanner) + 0x09, 0xFB, # Usage (0xFB, Symbology Identifier 1) + 0x09, 0xFC, # Usage (0xFC, Symbology Identifier 2) + 0x09, 0xFD, # Usage (0xFD, Symbology Identifier 3) + 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0x95, 0x38, # Report Count (56 bytes long) + 0x05, 0x8C, # Usage Page (barcode scanner) + 0x09, 0xFE, # Usage 0xFE (Decoded Data) + 0x82, 0x02, 0x01, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Buffered Bytes) + 0x95, 0x01, # Report Count (1 byte) + 0x06, 0x66, 0xFF, # Usage Page (Vendor Defined 0xFF66, everyone does this) + 0x09, 0x04, # Usage (0x04) + 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0x95, 0x01, # Report Count (1 byte) + 0x06, 0x69, 0xFF, # Usage Page (Vendor Defined 0xFF69 + 0x09, 0x55, # Usage (0x55) + 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0x05, 0x8C, # Usage Page (Bar Code Scanner Page) + 0x25, 0x01, # Logical Maximum (1) + 0x75, 0x01, # Report Size (1 bit) + 0x95, 0x08, # Report Count (8 * size = 1 byte total) + 0x09, 0xFF, # Usage (0xFF, Decode Data Continued) + 0x81, 0x02, # Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0xC0, # End Collection (Logical) + 0x09, 0x14, # Usage (Trigger report) + 0xA1, 0x02, # Collection (Logical) + 0x85, 0x04, # Report ID (4, trigger report from host) + 0x15, 0x00, # Logical Minimum (0) + 0x25, 0x01, # Logical Maximum (1) + 0x75, 0x01, # Report Size (1) + 0x95, 0x08, # Report Count (8) + 0x09, 0x00, # Usage (0x00, filler bit) + 0x09, 0x5F, # Usage (0x5F, prevent read of barcodes) + 0x09, 0x60, # Usage (0x60, initiate barcode read) + 0x09, 0x00, # Usage (0x00, filler) + 0x09, 0x84, # Usage (0x84, sound powerup beep) + 0x09, 0x85, # Usage (0x85, sound error beep) + 0x09, 0x86, # Usage (0x86, sound success beep) + 0x09, 0x87, # Usage (0x87, sound not-on-file beep) + 0x91, 0x82, # Output (Data,Var,Abs,Volatile) + 0xC0, # End Collection (Logical) + 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) + 0x09, 0x30, # Usage (Vendor-defined: Configuration report for setting up scan modes) + 0xA1, 0x02, # Collection (Logical) + 0x85, 0x05, # Report ID (5) + 0x15, 0x00, # Logical Minimum (0) + 0x26, 0xFF, 0x00, # Logical Maximum (255) + 0x75, 0x08, # Report Size (8 bits) + 0x95, 0x01, # Report Count (1 byte) + 0x05, 0x01, # Usage Page (Generic Desktop) + 0x09, 0x3B, # Usage (Byte Count) + 0x91, 0x02, # Output (Data,Var,Abs) + 0x95, 0x3E, # Report Count (62 bytes) + 0x06, 0x69, 0xFF, # Usage Page (Vendor Defined 0xFF69) + 0x09, 0x05, # Usage (0x05, Config ASCII Data) + 0x92, 0x02, 0x01, # Output (Data,Var,Abs,Buffered Bytes) + 0xC0, # End Collection (Logical) + 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) + 0x09, 0x31, # Usage (Vendor-defined: Scan mode switch) + 0xA1, 0x02, # Collection (Logical) + 0x85, 0x55, # Report ID (0x55) + 0x15, 0x00, # Logical Minimum (0) + 0x26, 0xFF, 0x00, # Logical Maximum (255) + 0x75, 0x08, # Report Size (8 bits) + 0x95, 0x01, # Report Count (1 byte) + 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) + 0x09, 0x55, # Usage (0x55) + 0x91, 0x02, # Output (Data,Var,Abs) + 0xC0, # End Collection (Logical) + 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) + 0x09, 0xF1, # Usage (Vendor-defined: Firmware update trigger) + 0xA1, 0x02, # Collection (Logical) + 0x85, 0xF1, # Report ID (0xF1) + 0x15, 0x00, # Logical Minimum (0) + 0x26, 0xFF, 0x00, # Logical Maximum (255) + 0x75, 0x08, # Report Size (8 bits) + 0x95, 0x01, # Report Count (1 byte) + 0x06, 0x69, 0xFF, # Usage Page (Vendor 0xFF69) + 0x09, 0xF1, # Usage (0xF1) + 0x91, 0x02, # Output (Data,Var,Abs) + 0xC0, # End Collection (Logical) + 0xC0, # End Collection (Application) + # + # NOTES: + # + # Report ID 2 is 63-bytes to the host. + # Byte 0 is the number of characters of barcode data to send in this report. + # Bytes 1, 2, and 3 are the AIM barcode identifier string. Nobody really uses this, some cheap scanners fudge it to QR code. + # Bytes 4 through 60 are the barcode data. Padded with NUL (0x00) bytes if the barcode is shorter than the available space. + # Bytes 61 and 62 are vendor-specific, this isn't part of the HID spec but everyone does it (see report 5 below for what we do) + # because they're copying everyone else. Honeywell uses it for another symbology ID. + # Byte 63 is 0x01 (b00000001) if the barcode data couldn't fit in a single report; + # it means more reports are coming right away with the rest of the data. + # If byte 63 is 0x00 it means there's no more barcode to send and the host can process it now. + # + # Report ID 4 is a single byte from the host: 76543210 + # If pos. 7, 6, 5, or 4 is 1, the host wants us to make a particular beep sound/light indication. + # If pos. 1 is 1, host wants us to not send any barcode data or do any scans. + # If pos. 2 is 1, host wants us to start reading for barcodes without the user pressing a button. + # + # Report ID 5 is 63 bytes from the host to this device. + # This is a custom report for this specific device. + # The first byte is the data length. + # The rest of it is a "list" of ASCII strings, delimited with a NUL byte. + # The strings are displayed on our display and can be switched between by the user. + # The index of the string (as sent by the host) is sent as the second vendor byte in report 2. + # This allows the user to select a scan context/UI mode in the program, and the program + # can switch to it automatically when a barcode is scanned in that mode. + # + # Report ID 0x55 is a single byte from the host. + # It tells us to change our scan context to a string index from the most recent Report 5 list, + # overriding the user's last selection. This is for when the user changed modes in software, + # to keep the state in sync between the scanner and PC. + # + # Report ID 0xF1 triggers the Pi Pico to switch to firmware update mode when the byte sent is also 0xF1. + # The Pico will re-enumerate and appear as a USB flash drive so the firmware file can be dropped in. + ]), + set_report_buf=bytearray(64), + protocol=0x00, + interface_str=INTERFACE, + ) + + def on_set_report(self, report_data, report_id, report_type): + #createAndSendBarcodeReports(bytes([report_id, report_type, report_data[0], report_data[1], reverse_byte(report_data[1])])) + + if report_id == 0x04: + report_byte = report_data[1] + # HID POS trigger report + # Disable/enable scanning based on bit flag + setInhibitScan(report_byte >> 1 & 1 == 1) + + if (report_byte >> 7 & 1 == 1): + # not-on-file beep, 10000000 + feedbackBuzzer("NAF") + elif (report_byte >> 6 & 1 == 1): + # success beep, 01000000 + feedbackBuzzer("OK") + elif (report_byte >> 5 & 1 == 1): + # error beep, 00100000 + feedbackBuzzer("ERR") + elif (report_byte >> 4 & 1 == 1): + # powerup beep, 00010000 + feedbackBuzzer("POW") + + elif report_id == 0xF1 and report_data[1] == 0xF1: + # Enter firmware update mode + firmwareUpdateMessage() + machine.bootloader() + + elif report_id == 0x05: + # Got mode list from host + # Don't process it here though since we're running in an interrupt + queueNewListReportForProcessing(report_data[:]) + + elif report_id == 0x55: + # Host says to switch modes + setModeID(report_data[1]) + + def send_data(self, data=None): + if data is None: + return True + start = time.ticks_ms() + while self.busy(): + # Wait for queue to open up, but drop this report on timeout + # because the host isn't paying attention to us and this will deadlock if we wait forever + if time.ticks_diff(time.ticks_ms(), start) > 5: + return False + machine.idle() + self.send_report(data) + return True + +def initUSBHID(): + global usbinterface, USBHIDInterface + usbinterface = USBHIDInterface() + usb.device.get().active(False) + usb.device.get().config(usbinterface, builtin_driver=True, manufacturer_str=MANU, product_str=PROD, id_vendor=VID, id_product=PID) + usb.device.get().active(True) + +def split(data, n): + return [data[i:i+n] for i in range(0, len(data), n)] + +def pad_data(chunk, size): + if len(chunk) >= size: + return chunk[:size] + return chunk + b'\x00' * (size - len(chunk)) + +def createAndSendBarcodeReports(barcodeData): + global usbinterface + chunks = split(barcodeData, 56) + for i, chunk in enumerate(chunks): + lastByte = b'\x01' + if len(chunks) - 1 == i: + lastByte = b'\x00' + report = b'\x02' + bytes([len(chunk)]) + b'\x00\x00\x00' + pad_data(chunk, 56) + b'\x00' + bytes([getCurrentModeID()]) + lastByte + if USBHID_ENABLED: + if not usbinterface.send_data(report): + break # Stop sending barcode over USB, the host didn't get this chunk but it might get the next and only have half a barcode + feedwatchdog() + # Write barcode data to serial out for non-HID system compatibility + try: + stdout.buffer.write(barcodeData.rstrip(b"\r\n")) # Strip any trailing newlines before sending our own + feedwatchdog() + stdout.buffer.write(b"\r\n") + except: + pass diff --git a/src/barcodescanner/watchdog.py b/src/barcodescanner/watchdog.py new file mode 100644 index 0000000..6628df8 --- /dev/null +++ b/src/barcodescanner/watchdog.py @@ -0,0 +1,48 @@ +# Copyright 2026 PostalPortal LLC +# +# Redistribution and use in source and binary forms, with or +# without modification, are permitted provided that +# the following conditions are met: +# 1. Redistributions of source code must retain +# the above copyright notice, this list of conditions +# and the following disclaimer. +# 2. Redistributions in binary form must reproduce +# the above copyright notice, this list of conditions +# and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder +# nor the names of its contributors may be used +# to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +# THE POSSIBILITY OF SUCH DAMAGE. + +from machine import WDT +from config import * + +wdt = None + +def startwatchdog(): + global wdt + wdt = WDT(timeout=5000) + + +def feedwatchdog(): + global wdt + if wdt is not None: + wdt.feed() + elif TESTMODE: + print("feeding watchdog")