Full barcode scanner firmware: display driver, USB HID and CDC serial, vendor USB commands, etc

This commit is contained in:
Skylar Ittner 2026-02-22 22:58:58 -07:00
parent 82566d09bf
commit ff7b33d49a
9 changed files with 967 additions and 264 deletions

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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()

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")