the Worthless Writeup Library @ szy.lol

Sewing machine pedal over HID

work started on 2024-04

Adapting an old input method for modern usage.


When we moved house, there was an old sewing machine that the previous owner left. It got thrown out, but I kept its pedal, in order to adapt it for computer usage, for then unknown uses. I also kept its wire harness, which connects it to the rest of the machine and to power. It then sat around for a couple of years, until I got around to it.

The pedal has a connector with two pins, which go to an internal potentiometer, specified as 0÷min 800Ω (from 0 to at least 800 ohms). Within the cable, one of the pins got commoned with one of the mains wires, ending up with three pins on the sewing machine side. I cut away the mains wire and the sewing machine connector, leaving me with a cable with the pedal connector on one side, and bare wires on the other.

I used an ATtiny85, which I had laying around, to do the USB communication. Thanks to V-USB, I didn’t need a chip with a “real” PHY. The onboard ADC was also more than good enough for my needs. I adapted the V-USB example circuit, and added a simple resistance measurement circuit. It consists of a constant current source (made from a PNP-based current mirror and known resistance) powering the pedal and the microcontroller’s analog input measuring the voltage over it. It’s not a very precise circuit, but as it later turned out, still much more precise than necessary. This is a schematic I made now, for this page.

I built it on a piece of one-sided perfboard, salvaged from leftovers. I managed to drill out a few holes in it, letting me mount a USB A plug on it directly, and enlarge two more holes to directly connect a screw terminal. It also has a socket for the ATtiny85, and a bunch of supporting components. BC557 were used as the PNP transistors in the current source. The rest of the components are random - even the 100Ω and 1kΩ approximation of the suggested 68Ω/2.2kΩ was done out of laziness.

I am very proud of how it came out, thanks to the cheap perfboard, the DIP8 chip, and only through-hole parts, it looks like it could have been built twenty years ago just as much as now. I am also happy I managed to fit the circuit on a board this small, and I really like how the dense layout looks.


I do not have a lot of experience writing USB devices. I do know that all devices need an official VID/PID pair. The Vendor ID defines the producer of the USB device, and the Product ID, assignable by the vendor, allows them to differentiate between the products. This is a touchy subject, as it involves intellectual property of the USB Implementers Forum, expensive licensing fees, and basic interoperability between devices. As the USB-IF asserts, “Unauthorized use of assigned or unassigned USB Vendor ID Numbers is strictly prohibited.”. It is therefore vital to use only the magic numbers you have licensed, to ensu-

nano kernel: usb 1-1.3: New USB device found, idVendor=b00b, idProduct=2137, bcdDevice= 1.00
nano kernel: usb 1-1.3: New USB device strings: Mfr=1, Product=2, SerialNumber=0
nano kernel: usb 1-1.3: Product: MDS
nano kernel: usb 1-1.3: Manufacturer: szytronics
nano kernel: hid-generic 0003:B00B:2137.0094: hidraw3: USB HID v1.01 Device [szytronics MDS] on usb-0000:00:14.0-1.3/input0

Funny number picking aside, I knew that I wanted to use the Human Interface Device (HID) profile in order to not need drivers, so I adapted an example which simulated mouse input. I then tried to create a custom descriptor with a weird tool I found on the USB-IF website. The standard is weird, and I still really don’t get it, but I tried to create a Generic Slider, which sends values in range 0-1023, which vary and are absolute. I hoped that ideally some generic HID driver would understand it, and maybe even expose it in a neat way.

0x05, 0x01,        // Usage Page (Generic Desktop Ctrls)
0x09, 0x36,        // Usage (Slider)
0x15, 0x00,        // Logical Minimum (0)
0x26, 0xFF, 0x03,  // Logical Maximum (1023)
0x75, 0x10,        // Report Size (16)
0x95, 0x01,        // Report Count (1)
0x81, 0x02,        // Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)

This ended up not quite being the case. With some poking around sysfs, I did not find any path which would somehow expose parsed values. I did at least find a way to check if my descriptor was being sent correctly. Instead I found out the “proper” (i think?) way to handle a custom HID device was to use hidraw. It basically allows a program to directly read the device’s reports. As this is essentially a file read (unix !!!), I could even test with basic shell tools!

$ cat /dev/hidraw3 | xxd
00000000: ee03 ee03 ee03 ee03 ee03 ee03 ee03 ee03  ................
00000010: ee03 ee03 ee03 ee03 ee03 ee03 ee03 ee03  ................
00000020: ee03 ee03 ee03 ee03 ee03 ee03 ee03 ee03  ................

You can see that each two bytes contains the report, with the “slider” value encoded little-endian, currently at 0x3EE, 1006. The report contains a few averaged ADC readings. The entire microcontroller code will not be shared, both for brevity and to not piss off the (c) 2006 by OBJECTIVE DEVELOPMENT Software GmbH, but here are a few relevant (and written by me) snippets:

static uint16_t report;
#define MAXADC 64
static volatile uint16_t rawadcs[MAXADC];
static volatile uint8_t adccnt = 0;

void compute_rep() {
    uint16_t sum = 0;
    uint8_t cnt = adccnt;
    if (!cnt) {
        return;
    }
    for (uint8_t i = 0; i < cnt; i++) {
        sum += rawadcs[i];
    }
    adccnt = 0;
    report = sum/cnt;
}

ISR(ADC_vect) {
    uint16_t res = ADCL;
    res |= (ADCH<<8); // ordering is Very important here
    DDRB ^= 8;
    uint8_t next = adccnt+1;
    if (next<MAXADC) {
        rawadcs[next] = res;
        adccnt = next;
    }
}

// within main
    DIDR0 |= 0x10; // disable input on adc2
    ADMUX = 0b10000010; // 1v1REF, Radj, ADC2/PB4
    ADCSRA = 0b10101111; // enable, auto trigger, w/int, prescale 128
    ADCSRB = 0; // free running
    usbInit();
    sei();
    ADCSRA |= 0b01000000; // start

Mostly register sets to correctly set up the ADC, and a bit of code to average the readings. I am not sure the latter is necessary, but I did it.

Back to the USB host and hidraw, this method of access has some disadvantages. Unless special arrangements are made, the hidraw devices require root to access. They are also not stably numbered, which would require scanning them all to find the correct device. Let’s make some special arrangements then! This is done with udev rules, which I shall skip the fight with and just quote /etc/udev/rules.d/99-szytronics-usb.rules. These will make all devices produced by szytronics (VID 0xb00b) accessible by everyone (0666 perms), and additionally symlink the szytronics foot pedal (PID 0x2137) as /dev/footpdl, also world readable.

SUBSYSTEMS=="usb", ATTRS{idVendor}=="b00b", MODE="0666"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="b00b", ATTRS{idProduct}=="2137", MODE="0666", SYMLINK+="footpdl"

The device can now be accessed by any program, which can read the current position and do whatever it wants with it!


With the device physically built and digitally working, the hardest part was thinking what useful can actually be done with it. Well, first came fucking about, which is when I discovered just how crap and non-linear and noisy the reading is. There is a small linear region near the top, then a huge noisy and hysteretic jump, and then it floors out. It feels like a really worn out clutch.

with open('/dev/footpdl', 'rb') as fd:
    while 1:
        a = fd.read(2)
        a = a[1] * 256 + a[0]
        print('%04d'%a, '█' * int(a / 1023 * 230))

This is, as it turns out, good enough for some uses. I wouldn’t attach it to a driving sim, unless to simulate the aforementioned crap clutch, but it’s still usable as a simple binary input. The first “useful” thing I built was a hacky push-to-talk, implemented as an unpush-to-mute. It compares the read value to a simple threshold, then calls pactl to mute the microphone if necessary. It is written in C, enjoy. If you intend to use it, change the audio source name in MIC.

Listing of footptt
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdint.h>
#include <fcntl.h>
#include <errno.h>

const char *MIC = "alsa_input.pci-0000_00_1f.3.analog-stereo";

void
notify(const char *msg, int error)
{
    if (fork() == 0) {
        execlp(
            "notify-send", "notify-send",
            "-u", error?"normal":"low",
            "-a", "footptt",
            "-i", "mic-ready",
            "-t", error?"2000":"250",
            "footptt", msg, (char*)0
        );
        _exit(1); // exec failed
    }
}

void
set_mute(int mute)
{
    if (fork() == 0) {
        execlp("pactl", "pactl", "set-source-mute", MIC, mute?"1":"0", (char*)0);
        _exit(1);
    }
}

void
nerror(const char *label) {
    int e = errno;
    char buf[256];
    snprintf(buf, 256, "%s: %s[%d]", label, strerror(e), e);
    puts(buf);
    notify(buf, 1);
}

int
main(int argc, char **argv)
{
    int fd = open((argc>1)?argv[1]:"/dev/footpdl", O_RDONLY|O_CLOEXEC);
    if (fd < 0) {
        nerror("open device");
        return 1;
    }
    int mute = 1;
    set_mute(1);
    for (;;) {
        uint8_t buf[2];
        int r = read(fd, buf, 2);
        if (r < 0) {
            nerror("read");
            break;
        }
        if (r != 2) {
            printf("unexpected length %d, [%2x %2x]\n", r, buf[0], buf[1]);
            continue;
        }
        uint16_t val = buf[0] | (buf[1] << 8);
        if (val > 1023 || val <= 0) {
            printf("invalid? value %d\n", val);
            continue;
        }
        int should_mute = val > 400;
        if (mute != should_mute) {
            //notify(mute?"mute":"not", 0);//dbg
            set_mute((mute = should_mute));
        }
    }
    close(fd);
}

This was used in a few calls, but not that much, since the age of remote classes was long gone by then, and also because I enjoy sitting cross legged and this requires keeping my feet down on the floor.

Some time later, I found a new use - automating doomscrolling. I use Twitter a fair bit, and have a habit of checking the entire chronological timeline. I don’t want algorithms, and I have curated my follows, so I want to see the posts and reposts by the people I follow. It is however unpleasant (and probably harmful) to abuse the scroll wheel that much, so instead I wrote a small program to emulate scrolling based on how much I am pressing on the pedal. The code is mostly the same as footptt above, but instead of calling pactl on a threshold, it tries to compute a keypress rate based on the input and uses XTest to send scroll events.

Listing of sewing
#include <stdio.h>
#include <unistd.h>
#include <time.h>
#include <string.h>
#include <stdint.h>
#include <fcntl.h>
#include <errno.h>
#include <math.h>

#include <X11/Xlib.h>
#include <X11/extensions/XTest.h>

int btn = 5;

double period(uint16_t val) {
    if (val > 950) return INFINITY;
    if (val > 600) val -= 400; // try to remove the jump
    if (val < 10) val = 10;
    return ((double)sqrt(val))/500; // to taste
}

void
notify(const char *msg, int error)
{
    if (fork() == 0) {
        execlp(
            "notify-send", "notify-send",
            "-u", error?"normal":"low",
            "-a", "sewing",
            // "-i", "mic-ready",
            "-t", error?"2000":"250",
            "sewing scroll", msg, (char*)0
        );
        _exit(1); // exec failed
    }
}

void
nerror(const char *label) {
    int e = errno;
    char buf[256];
    snprintf(buf, 256, "%s: %s[%d]", label, strerror(e), e);
    puts(buf);
    notify(buf, 1);
}

double
timestamp()
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    double t = ts.tv_sec;
    t += ((double)ts.tv_nsec) / 1000000000.0;
    return t;
}


int
main(int argc, char **argv)
{
    if (argc > 1)
        btn = atoi(argv[1]);
    int fd = open("/dev/footpdl", O_RDONLY|O_CLOEXEC|O_NONBLOCK);
    if (fd < 0) {
        nerror("open device");
        return 1;
    }
    Display *dpy;
    if (!(dpy = XOpenDisplay(0)))
        return 1;
    fcntl(ConnectionNumber(dpy), F_SETFD, FD_CLOEXEC);
    double lastts = timestamp();
    uint16_t val = 1000;
    for (;;) {
        while (XPending(dpy) > 0) {
            XEvent ev;
            XNextEvent(dpy, &ev);
            (void)ev;
        }
        uint8_t buf[2];
        int r = read(fd, buf, 2);
        if (r >= 0 || errno != EAGAIN) {
            if (r < 0) {
                nerror("read");
                break;
            }
            if (r != 2) {
                printf("unexpected length %d, [%2x %2x]\n", r, buf[0], buf[1]);
                continue;
            }
            uint16_t rval = buf[0] | (buf[1] << 8);
            if (rval > 1023 || rval <= 0) {
                printf("invalid? value %d\n", rval);
                continue;
            }
            val = rval;
        }
        //printf("%d\n", val);
        double ts = timestamp();
        if (lastts + period(val) < ts) {
            XTestFakeButtonEvent(dpy, btn, 1, 0);
            usleep(10000);
            XTestFakeButtonEvent(dpy, btn, 0, 0);
            printf("click! %i %f\n", val, ts);
            lastts = ts;
        }
        usleep(1000);
    }
    XCloseDisplay(dpy);
    close(fd);
}

It even works! It gets regular use, and lives under my desk. For now. Would it be much easier and better to use an actual pedal set, like from a driving sim controller? Yes, but it would be much less fun to make.