not logged in | [Login]

Author: fpp

Contact: forum topic

Context:

I ordered a pyboard because I couldn't resist trying out Python on a microcontroller, but also because I had a specific use case in mind, involving its USB HID capabilities.

However I was suprised to find that there was nothing in the documentation about the HID Keyboard mode, only an example for the mouse mode.

I didn't know where to start, and didn't really understand the only code snippet I found in the forums. So I opened my own thread in the forum.

Fortunately I got a lot of useful hints from helpful members there, and after a while was able to achieve my original goal (many thanks to dhylands, pythoncoder & deshipu !).

So I'm attempting to help others in turn, by documenting the steps I followed to get USB HID keyboard mode working, and adapt it to my use case.

For now I'm putting this in the Wiki, as I don't really know how Git works ; if anyone feels like moving this inside the official documentation (alongside the HID mouse example maybe), feel free :-)

Use case: an USB password dongle

I wanted to turn the pyboard into an USB dongle to hold and emit some of the lenghty logins and passwords I have to use daily at work.

I had already done this with an Arduino, similar to this.

I didn't know anything about Arduinos, and very little of C, but it was actually very easy thanks to the tutorial, the Arduino IDE and all the ready-made libraries inside.

It worked well, although I didn't have to understand any of it, but I wanted something easier to manage (like changing passwords), and that also felt like I'd done it myself...

Python notwithstanding, this was actually harder because I had to start from scratch without any directions :-)

The code examples below will show the various blocks that are needed, and how they work together.

1. Enabling the USB HIB keyboard mode

This is well documented, but worth mentioning:

The boot.py file needs to be modified like this:

#boot.py
import machine
import pyb
#pyb.main('main.py') # main script to run after this one
#pyb.usb_mode('CDC+MSC') # act as a serial and a storage device
pyb.usb_mode('CDC+HID',hid=pyb.hid_keyboard)

Note: In this mode there is no USB storage.

To access the onboard files for editing, you need to press the Reset button while pressing down the User button.

The LEDs start cycling, when the orange LED is lit alone, release the User button.

2. Sending a character to the USB host (hex codes)

This is the trickiest, low-level part, which needs digging into the USB reference docs.

The device communicates with the host using 8-byte arrays of hex codes, called "reports".

All bytes are initialized to 0x00.

Byte 0 is for a modifier key, or combination thereof. It is used as a bitmap, each bit mapped to a modifier:

  • bit 0: left control
  • bit 1: left shift
  • bit 2: left alt
  • bit 3: left GUI (Win/Apple/Meta key)
  • bit 4: right control
  • bit 5: right shift
  • bit 6: right alt
  • bit 7: right GUI

Examples: 0x02 for Shift, 0x05 for Control+Alt

Byte 1 is "reserved" (unused, actually)

Bytes 2-7 are for the actual key scancode(s) - up to 6 at a time ("chording").

In most usual cases, "press key" reports will be sent using just bytes 0 and 2.

To "release" the key(s), send another report with all bytes at 0x00.

Here is the minimal code in main.py that will print 'T' to your host screen when you reset the pyboard :

(the focused window on your host needs to be writable :-)

import pyb
kb = pyb.USB_HID()
buf = bytearray(8)
# Sending T
# Do the key down
buf[0] = 0x02 # LEFT_SHIFT
buf[2] = 0x17 # keycode for 't/T'
kb.send(buf)
pyb.delay(5)
# Do the key up
buf[0] = 0x00
buf[2] = 0x00
kb.send(buf)
(of course this will become a function later)

3. Sending a character to the USB host (literal)

Seeing the above working for the first time is pure joy, but it quickly brings questions:

  • how do I know which hex codes to send ?, and
  • why can't I just send the literal character ?

The first question is answered by another USB reference document (chapter 10, page 53).

This gives the hex scan code for each key on a standard US QWERTY keyboard.

What we need then is a reverse mapping of literal characters to their scan codes and modifier keys (only the ones we'll actually use, as it's quite tedious).

I chose to do this as a dict with chars as keys and scancode/modifier tuples as values:

PLAIN = 0x00
SHIFT = 0x02
kbmap = dict()
# part of second row example
kbmap['t'] = (0x17,PLAIN)
kbmap['T'] = (0x17,SHIFT)
kbmap['y'] = (0x1c,PLAIN)
kbmap['Y'] = (0x1c,SHIFT)
kbmap['u'] = (0x18,PLAIN)
kbmap['U'] = (0x18,SHIFT)
kbmap['i'] = (0x0c,PLAIN)
kbmap['I'] = (0x0c,SHIFT)
kbmap['o'] = (0x12,PLAIN)
kbmap['O'] = (0x12,SHIFT)
kbmap['p'] = (0x13,PLAIN)
kbmap['P'] = (0x13,SHIFT)
# ...and so on

I put this in a separate kb_map.py module as it can grow large, and import it from main.py.

From there on we can have a function that sends literal chars:

import pyb
from kb_map import kbmap
kb = pyb.USB_HID()
buf = bytearray(8)

def sendchr(char) :
    if not char in kbmap.keys() :
        print("Unknow char") ; return
    # key down
    buf[2], buf[0] = kbmap[char]
    kb.send(buf)
    pyb.delay(5)
    # key up
    buf[2], buf[0] = 0x00, 0x00
    kb.send(buf)
    pyb.delay(5)

send('t')
send('T')

Note: the USB reference scancodes are for physical positions of keys on a QWERTY-US keyboard.

The actual character(s) sent for a given key will vary wildly between local non-QWERTY-US layouts.

Thus each variant will need its own kb_map.py mapping, which is why I don't include a full version.

4. Sending a string to the USB host

Once we have all of the above it becomes trivial to send a string:

def sendstr(str) :
    for c in str : sendchr(c)

5. User interface : one button

For easier testing we can use the pyboard as a one-trick pony with the single User button by adding this code:

led = pyb.LED(1)
sw = pyb.Switch()

mypw = "My P4ssw0rd"

while 1 :
    if sw() :
        led.toggle()
        kb.sendstr(mypw)
        # Wait for switch to be released
        while sw():
            pyb.delay(2)
            pass
        led.toggle()
    pyb.delay(1)
It's easy to adapt this to use a list of strings, and cycle through it with each button press.

6. User interface : a keypad with multiple strings per button

This is all well and good, but what we need is a device that will store and output several logins et passwords, which means several buttons.

Also, a button should be able to output several strings in sequence, such as "login, Tab, password, Enter".

Again I put these strings in a separate store.py module, as a list of tuples :

# store.py
pwds = [ ("C.A.D", "login1", "\t", "pwd1", "\n"),
("pwd1", "\n"),
("my.email@ddress.com", "\t"),
("pwd2", "\n"),
("pwd3", "\n"),
("pwd4", "\n"),
("abbreviation1", "\n"),
("abbreviation2",)
]

The first line is a special case, as it sends the "C.A.D" string, which is a moniker for Control-Alt-Del, to automate a Windows login sequence.

In the code below you will see that there is a separate sendCAD() function to send that code, which is used instead of sendstr() when the string matches the moniker (I could have used a special char in the kbmap table, but I find it clearer this way).

Of course you can send any string you want, not just logins and passwords.

Note the trailing comma in the last tuple, which holds only one string.

I used an 8-button (2x4) keypad that fits nicely on the back of the pyboard (actually, everything fits nicely inside the black plastic case it was shipped in :-).

It needs 8 pins plus Ground, and a simple-minded debouncing class I found at McHobby's (there are much more elaborate ones, such as pythoncoder's, but it works OK).

Keypad

7. String store management

With the pyboard, editing the strings in store.py, as logins/passwords change, becomes a breeze.

You just put it back in USB storage mode (as described in section 1), then access store.py from any host, and change it with any available editor (yes, even Notepad if you have no choice :-).

Then eject the device and press the Reset button to return to HID Keyboard mode.

8. Full code for main.py

Below is a working version of the above bits, put together.

Obviously the buttons and strings lists should be adapted to the actual number of buttons and pins used.

You must supply your own kb_map.py and store.py.

Questions/comments/suggestions should go to the thread in the forum. Enjoy!

# main.py
import pyb
from kb_map import kbmap
from store import pwds
kb = pyb.USB_HID()
buf = bytearray(8)

def sendchr(char) :
    if not char in kbmap.keys() : # print warning to REPL if active
        print("\nUnknow char\n") ; return
    # key down with modifiers
    buf[2], buf[0] = kbfr[char]
    kb.send(buf)
    pyb.delay(10)
    # key up
    buf[2], buf[0] = 0x00, 0x00
    kb.send(buf)
    pyb.delay(10)

def sendCAD() : # send Control-Alt-DEL
    sendchr((0x4c,0x05)) # DEL with Alt+Ctl
    pyb.delay(500)

def sendstr(str) :
    for c in str : sendchr(c)

class PullUpButton:
    pin = None # Pin object
    state = None # Last known state

    def __init__( self, button_pin ):
        self.pin = pyb.Pin( button_pin, pyb.Pin.IN, pull=pyb.Pin.PULL_UP )
        self.state = self.pin.value()

    def is_pressed(self):
        val = self.pin.value()
        result = False
        if val != self.state:
            pyb.delay( 10 ) # retry in 10 ms (debouncing)
            val2 = self.pin.value()
            if val == val2: # value is stable :)
                self.state = val
                result = (val == 0) # Is pressed
        return result

btns = [ PullUpButton( pyb.Pin.board.Y12 ),
         PullUpButton( pyb.Pin.board.Y11 ),
         PullUpButton( pyb.Pin.board.Y10 ),
         PullUpButton( pyb.Pin.board.Y9 ),
         PullUpButton( pyb.Pin.board.X8 ),
         PullUpButton( pyb.Pin.board.X7 ),
         PullUpButton( pyb.Pin.board.X6 ),
         PullUpButton( pyb.Pin.board.X5 ) ]

while True:
    pyb.delay( 2 )
    for i in range(8) :
        if btns[i].is_pressed():
            print('button pressed : ', i+1)
            for s in pwds[i] :
                print( s )
                if s == "C.A.D" : sendCAD()
                else : sendstr(s) ; pyb.delay( 20 )

Oh, and don't worry about the pyb.delay(n)calls all over the place: that's just me being paranoid :-)

(The only one that's definitely needed is in PullUpButton)