QZ qz thoughts
a blog from Eli the Bearded

udev for the win


So I have an Arduino Uno now and some parts to begin working on my alarm clock project but I'm still new to Arduino and need to get comfortable with programming it. So first thing I do is fire up the standard Arduino GUI programming tool ("IDE" in the argot of those that like such tools).

You need to be added to the "dialout" group...

And I'm ready to Ackchyually it.

Being added to the dialout group is a pretty good fix. But it's not the only way to do that, so "need" is a strong word. The advantage of being in the dialout group is it works pretty generally and without any extra tooling. The disadvantage is group membership is inherited from login, and I don't want to log out and log in again. A logout is as good as a reboot, and usually it is the reboot that forces the logout. These days I try to limit it those to once a month or less, depending on important kernel updates. It's been less than two weeks now, way too soon for that much disruption.

So here follows an alternative route with udev rules.

First I need to identify the device to apply those rules. I'll start with watching what's going on in /var/log/syslog when I plug the device in:

Nov  7 19:49:59 slate-asus kernel: [947954.307562] usb 1-3: new full-speed USB device number 8 using xhci_hcd
Nov  7 19:49:59 slate-asus kernel: [947954.458285] usb 1-3: New USB device found, idVendor=2341, idProduct=0043
Nov  7 19:49:59 slate-asus kernel: [947954.458291] usb 1-3: New USB device strings: Mfr=1, Product=2, SerialNumber=220
Nov  7 19:49:59 slate-asus kernel: [947954.458294] usb 1-3: Manufacturer: Arduino (www.arduino.cc)
Nov  7 19:49:59 slate-asus kernel: [947954.458296] usb 1-3: SerialNumber: 8593731353731234A012
Nov  7 19:49:59 slate-asus mtp-probe: checking bus 1, device 8: "/sys/devices/pci0000:00/0000:00:15.0/usb1/1-3"
Nov  7 19:49:59 slate-asus mtp-probe: bus: 1, device: 8 was not an MTP device
Nov  7 19:49:59 slate-asus kernel: [947954.532024] cdc_acm 1-3:1.0: ttyACM0: USB ACM device

That's all I really need there, but there's other ways to get that information. If you are not used to reading that log, here's the general format of what it is showing:

  • Nov 7 19:49:59 date and time in system default timezone (US Pacific for me).
  • slate-asus computer name. I uniquely name all my computers, and this one doesn't have an obvious model name on it, so color - maker.
  • kernel: / mtp-probe: the name of the component creating the message. Messages from the kernel include the uptime in seconds, eg [947954.532024], which is one week, three days, twenty-three hours, nineteen minutes and a bit over fourteen seconds. Way too soon to reboot.
  • checking bus 1, device 8: "/sys/devices/pci0000:00/0000:00:15.0/usb1/1-3 This message from the media transport protocol probe has one way to identify the device.
  • cdc_acm 1-3:1.0: ttyACM0: USB ACM device This contains the other way. cdc: communications device class; acm: abstract control model. Essentially this is a modem-like serial device on USB (the "universial serial bus"). And modem-like is why the group is dialout.
  • Another way to find the device name is look in the /dev/ directory and find the new file that was created when plugging the device in (here, /dev/ttyACM0) and have the udev system tell us what it is with the udevadm info -a -n /dev/ttyACM0 command (verbose output partially removed):

    # udevadm info -a -n /dev/ttyACM0
    Udevadm info starts with the device specified by the devpath and then
    walks up the chain of parent devices. It prints for every device
    found, all possible attributes in the udev rules key format.
    A rule to match, can be composed by the attributes of the device
    and the attributes from one single parent device.
    
      looking at device '/devices/pci0000:00/0000:00:15.0/usb1/1-3/1-3:1.0/tty/ttyACM0':
        KERNEL=="ttyACM0"
        SUBSYSTEM=="tty"
        DRIVER==""
    
      looking at parent device '/devices/pci0000:00/0000:00:15.0/usb1/1-3/1-3:1.0':
    [...]
      looking at parent device '/devices/pci0000:00/0000:00:15.0/usb1/1-3':
    [...]
      looking at parent device '/devices/pci0000:00/0000:00:15.0/usb1':
    [...]
      looking at parent device '/devices/pci0000:00/0000:00:15.0':
        KERNELS=="0000:00:15.0"
        SUBSYSTEMS=="pci"
        DRIVERS=="xhci_hcd"
        ATTRS{broken_parity_status}=="0"
        ATTRS{class}=="0x0c0330"
        ATTRS{consistent_dma_mask_bits}=="64"
        ATTRS{d3cold_allowed}=="1"
        ATTRS{dbc}=="disabled"
        ATTRS{device}=="0x5aa8"
        ATTRS{dma_mask_bits}=="64"
        ATTRS{driver_override}=="(null)"
        ATTRS{enable}=="1"
        ATTRS{irq}=="123"
        ATTRS{local_cpulist}=="0-1"
        ATTRS{local_cpus}=="3"
        ATTRS{msi_bus}=="1"
        ATTRS{numa_node}=="-1"
        ATTRS{revision}=="0x0b"
        ATTRS{subsystem_device}=="0x201f"
        ATTRS{subsystem_vendor}=="0x1043"
        ATTRS{vendor}=="0x8086"
    
      looking at parent device '/devices/pci0000:00':
    [...]
    

    We'd get the same relevant information from the other identifier using the udevadm info -a /sys/devices/pci0000:00/0000:00:15.0 command. Every single thing in that list of device info can be used as a key for trigging a udev script. The ATTRS{subsystem_device} or ATTRS{subsystem_vendor} is a good choice for being relatively specific to plugging in an Uno and getting a device.

    So now we know what to use as a trigger, let's use that to run a script. In /etc/udev/rules.d/ live *.rules files for what to do for devices. On my system the highest numbered existing rule is 70-snap.snapd.rules, so I'll pick a two digit number higher than that and create my new rules file.

    $ cat /etc/udev/rules.d/80-arduino.rules
    ATTRS{subsystem_device}=="0x201f", ACTION=="add", RUN+="/usr/local/bin/plugin uno"
    $
    

    Each line is a rule. This one says when the subsystem_device attribute is 0x201f and the action happening is an add, then add to the list of things to be run /usr/local/bin/plugin uno. The argument isn't really needed for udev, because there are enough environment variables to use for context, but could be handy for hand running it. I (or you) could match on more than one attribute by just adding them to the list: ATTRS{subsystem_vendor}=="0x1043", ATTRS{subsystem_device}=="0x201f", ...

    With the rule in place, we need to reload the udev configuration in order to have the rule take effect. This is much easier than a reboot.

    # udevadm control --reload
    #
    

    A note on prompts: in my copies of terminal output, a prompt of $ means that it is a command to be run as a regular user, and a prompt of # means it requires superuser privileges. I usually use a superuser shell, but sudo in front of the command is the common way these days.

    Now we have a rule to run something when the device is plugged in, we should figure out what to run. A stub script is very helpful here:

    $ cat /usr/local/bin/plugin
    #!/bin/sh
    
    log=/var/log/plugin-udev.log
    exec >> "$log" 2>&1     # "exec" changes stdout, etc, while running
    
    env
    $
    

    Plug the Uno in, and then look at the plugin-udev.log file.

    $ cat /var/log/plugin-udev.log
    ID_BUS=usb
    DEVNAME=/dev/ttyACM0
    ACTION=add
    ID_VENDOR_FROM_DATABASE=Arduino SA
    ID_PCI_INTERFACE_FROM_DATABASE=XHCI
    ID_SERIAL_SHORT=8593731353731234A012
    ID_USB_DRIVER=cdc_acm
    ID_USB_CLASS_FROM_DATABASE=Communications
    DEVPATH=/devices/pci0000:00/0000:00:15.0/usb1/1-2/1-2.1/1-2.1:1.0/tty/ttyACM0
    ID_MODEL_ENC=0043
    ID_USB_INTERFACES=:020201:0a0000:
    ID_MODEL=0043
    SUBSYSTEM=tty
    ID_MODEL_ID=0043
    MINOR=0
    ID_MODEL_FROM_DATABASE=Uno R3 (CDC ACM)
    ID_PATH=pci-0000:00:15.0-usb-0:2.1:1.0
    ID_MM_CANDIDATE=1
    ID_VENDOR_ENC=Arduino\x20\x28www.arduino.cc\x29
    [...]
    

    There's a lot in there, so I could use ID_MODEL_FROM_DATABASE or ID_MODEL_FROM_DATABASE instead of uno as an argument, but I'm going to stick with that method to flag what to do. But I will have DEVNAME tell me what to act on. Next version of the plugin script:

    #!/bin/sh
    
    what="$1"
    
    # device to try for "uno" if $DEVNAME is not set
    uno_dev=/dev/ttyACM0
    
    log=/var/log/plugin-udev.log 
    if [ ! -w "$log" ] ; then
      echo "$0: Intended to be run as root from udev scripts."
      exit
    fi
    
    exec 3>&1               # create 3 as copy of stdout
    exec >> "$log" 2>&1     # make orig stdout/stderr logfile
    
    now=$(date +%Y/%m/%dT%H:%M:%S)
    
    case "$1" in
      uno) device=${DEVNAME:-$uno_dev} 
           if [ -c "$device" ] ; then
             echo "$now - chowning $device now"
             chown username "$device"
           else
             echo "$now - can't find $device"
           fi
           ;;
      ?*) echo "$now - unrecognized plugin event: $1"
          echo        "unrecognized plugin event: $1" >&3
           ;;
      *)  echo "$now - need a udev event, eg 'uno'"
          echo        "need a udev event, eg 'uno'" >&3
           ;;
    esac

    I juggle stdout/stderr a bit here. First I open file descriptor 3 as a copy of stdout, then I change stdout and stderr to go to the logfile. This means anything normally sent to stdout or stderr will go in the log, and I can capture output and errors there, but because if I want to print to the original stdout (probably a terminal), I need to do it explicitly through file descriptor 3. Any program I run, will use the logfile for output but I can also print to the terminal. I use that for the unexpected usage cases. If someone with privileges runs /usr/local/bin/plugin that gets logged in the logfile and gets a message on the terminal. (Someone without privileges probably gets stopped when I check if the logfile is writable with [ -w "$log" ].)

    So, let's try it out. Plug in the USB cable from the Arduino and:

    $ ls -l /dev/ttyA*     
    crw-rw---- 1 username dialout 166, 0 Nov  8 13:39 /dev/ttyACM0
    $ groups
    username adm cdrom sudo dip plugdev lpadmin sambashare docker rvm
    $ tail -1 /var/log/plugin-udev.log
    2020/11/07T21:51:46 - chowning /dev/ttyACM0 now
    $
    

    Presto, the device "belongs" to me, I can write to it without being in group dialout.

    People often use udev rules for dealing with permissions related to thumbdrives, and there are a bunch of tutorials on doing that sort of thing, but udev is more useful and I wanted to show that.