Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Paraphraser/650eeab26794dc7363b7123b33f671c4 to your computer and use it in GitHub Desktop.
Save Paraphraser/650eeab26794dc7363b7123b33f671c4 to your computer and use it in GitHub Desktop.
IOTstack+OctoPrint: When your 3D printer turns on and off

octoprint-docker: when your 3D printer turns on and off

Task goals

  • Keep the OctoPrint Docker container service running even when your printer is switched off:

    • GCODE files can still be uploaded
    • Plugins can still be updated
  • React gracefully and appropriately to the 3D printer being switched on and off

  • Support camera streaming "following the printer" (this is optional).

Contents

Theory

A Dynamic Device Management UDEV rule set matches on a 3D printer as it connects or disconnects over USB. Each event triggers a reaction script within the running OctoPrint container.

The container's service definition is augmented to give it read-only visibility of the Raspberry Pi's /dev directory. Because the reaction script running inside the container can see what is going on in /dev outside the container, it is in a position to compare "what the Raspberry Pi knows" with "what the container knows" and play follow the leader. If the printer device:

  • is present in the Raspberry Pi's /dev directory but is not present in the container's /dev directory, the script adds the named device to the container's /dev directory using the major and minor device numbers discovered from the Raspberry Pi's /dev directory. The container gains access to the dynamically-mapped printer via a device_cgroup_rule in its service definition.
  • is not present in the Raspberry Pi's /dev directory but is present in the container's /dev directory, the script removes the named device from the container's /dev directory.

The script restarts the OctoPrint process (not the container) whenever a printer device change occurs. The dynamic addition and removal of the 3D printer device, coupled with the process restart, stops the container from crashing when a 3D printer is switched off or disconnected.

Camera support is optional and is handled similarly, save that while the external and internal printer device names are assumed to be the same, the camera argument is assumed to be the external device name, with the internal device name obtained from the CAMERA_DEV environment variable.

Camera support is optional to give the user the choice of:

  • No camera support (ENABLE_MJPG_STREAMER=false)
  • Standard camera support (passing the camera device via a static devices: mapping)
  • Follow the printer camera support (a design goal of this gist).

Follow the printer camera support:

  1. Activates or disables the streamer service as and when the printer device comes and goes. The camera is not constantly imaging a scene where nothing is happening.

  2. Requires changes to the internal s6 mjpg-streamer run file:

    /etc/services.d/mjpg-streamer/run
    

    to avoid it looping whenever the 3D printer is disconnected and the dynamic camera device mapping goes away.

  3. Needs a device_cgroup_rule to give the container access to the camera.

About IOTstack

The examples in here assume SensorsIot/IOTstack conventions.

It is not essential for you to clone IOTstack onto your Raspberry Pi to make use of these instructions. However, some understanding of IOTstack's conventions will help you make sense of what is going on so you can adapt the techniques to your own environment.

IOTstack is not a system or application that you "run". IOTstack is a framework plus a set of conventions for making it as easy as possible to run arbitrary collections of Docker containers.

This is the basic IOTstack folder structure:

~/IOTstack
├── .templates ❶
│   └── octoprint ❷
│       └── service.yml ❸
├── menu.sh ❹
├── docker-compose.yml ❺
└── volumes
    └── octoprint ❻
        ├── octoprint
        └── plugins

A clone of the IOTstack repository includes a collection of service definitions which are stored in named folders within the .templates directory ❶.

IOTstack comes with a basic menu system ❹. If you run the menu and choose OctoPrint, the resulting docker-compose.yml ❺ will contain:

version: '3.6'

services:
  octoprint:
    container_name: octoprint
    image: octoprint/octoprint
    restart: unless-stopped
    environment:
    - TZ=Etc/UTC
  # - ENABLE_MJPG_STREAMER=true
  # - MJPG_STREAMER_INPUT=-r 640x480 -f 10 -y
  # - CAMERA_DEV=/dev/video0
    ports:
    - "9980:80"
    devices:
    - /dev/ttyAMA0:/dev/ttyACM0
  # - /dev/video0:/dev/video0
    volumes:
    - ./volumes/octoprint:/octoprint

networks:

  default:
    driver: bridge
    ipam:
      driver: default

  nextcloud:
    driver: bridge
    internal: true
    ipam:
      driver: default

That is the assumed starting point for the rest of this gist.

If you are not familiar with IOTstack's conventions:

  1. In the 9980:80 port mapping directive, the external port was chosen because it does not conflict with any other service definition in the .templates folder. There is nothing magical about 9980. You can use any external port number that meets your needs.

  2. The OctoPrint container's persistent storage is at the path ./volumes/octoprint. The leading dot implies "the folder containing docker-compose.yml" so, in practice, this means the absolute path:

    ~/IOTstack/volumes/octoprint ❻
    

    You do not have to follow this convention. You can place persistent storage anywhere on your Raspberry Pi.

  3. The networks: line and all lines after that are for NextCloud support. You can omit all those lines if you never intend to use IOTstack to run NextCloud on your Raspberry Pi.

Special Notes

If you are a Windows user, please do not copy material from this gist, paste it into a Windows text editor, and then move the file to your Raspberry Pi. Unless you take precautions, Windows will add its CR+LF line endings and those extra CRs will stop things from working properly on your Raspberry Pi.

Implementation

step 1: device discovery

  1. Disconnect your 3D printer from your Raspberry Pi, either by turning the printer off or by detaching its USB cable.

  2. Run the command:

    $ tail -f /var/log/messages
  3. While observing the log output, connect your 3D printer to your Raspberry Pi, either by turning the printer on or by attaching its USB cable. You are interested in messages that look like this:

    mmm dd hh:mm:ss mypi kernel: [423839.626522] cp210x x-x.x.x:x.x: device disconnected
    mmm dd hh:mm:ss mypi kernel: [431265.973308] usb x-x.x.x: new full-speed USB device number 10 using dwc_otg
    mmm dd hh:mm:ss mypi kernel: [431266.109418] usb x-x.x.x: New USB device found, idVendor=dead, idProduct=beef, bcdDevice= 1.00
    mmm dd hh:mm:ss mypi kernel: [431266.109439] usb x-x.x.x: New USB device strings: Mfr=1, Product=2, SerialNumber=3
    mmm dd hh:mm:ss mypi kernel: [431266.109456] usb x-x.x.x: Product: CP2102N USB to UART Bridge Controller
    mmm dd hh:mm:ss mypi kernel: [431266.109471] usb x-x.x.x: Manufacturer: Silicon Labs
    mmm dd hh:mm:ss mypi kernel: [431266.109486] usb x-x.x.x: SerialNumber: cafe80facefeed
    mmm dd hh:mm:ss mypi kernel: [431266.110657] cp210x x-x.x.x:x.x: cp210x converter detected
    mmm dd hh:mm:ss mypi kernel: [431266.119225] usb x-x.x.x: cp210x converter now attached to ttyUSB0
    

    Record the critical information:

    • from:

       … New USB device found, idVendor=dead, idProduct=beef, bcdDevice= 1.00
      
      • «vendorID» = dead
      • «productID» = beef
    • from:

       … SerialNumber: cafe80facefeed
      
      • «serialNumber» = cafe80facefeed
    • from:

       … converter now attached to ttyUSB0
      
      • «device» = ttyUSB0
  4. Terminate the tail command by pressing control+c.

  5. Substitute «device» in the following command and then execute it:

    $ ls -l /dev/«device»

    Example:

    $ ls -l /dev/ttyUSB0
    crw-rw---- 1 root dialout 188,  0 mmm dd hh:mm /dev/ttyUSB0

    From the output, record the printer's major device number:

    • «printerMajorDeviceNumber» = 188
  6. If you have a camera attached, find it and list its device details. For example:

    $ ls -l /dev/video0
    crw-rw---- 1 root video 81, 7 mmm dd hh:mm /dev/video0

    Record the camera's major device number:

    • «cameraMajorDeviceNumber» = 81

step 2: edit service definition

Use a text editor to open docker-compose.yml. Replace the OctoPrint service definition (from the menu run) with these lines:

  octoprint:
    container_name: octoprint
    build:
      context: ./.templates/octoprint/.
      args:
      - OCTOPRINT_BASE=octoprint/octoprint:latest
    restart: unless-stopped
    environment:
    - TZ=${TZ:-Etc/UTC}
    - ENABLE_MJPG_STREAMER=true
    - MJPG_STREAMER_INPUT=-r 640x480 -f 10 -y
    - CAMERA_DEV=/dev/video0
    ports:
    - "9980:80"
    device_cgroup_rules:
    - "c «printerMajorDeviceNumber»:* rw"
    - "c «cameraMajorDeviceNumber»:* rw"
    volumes:
    - ./volumes/octoprint:/octoprint
    - /dev:/host/dev:ro

Next, replace all the place-holders with the correct values:

  1. Set your time-zone correctly. You can either"

    • replacing Etc/UTC with your country and city; or

    • create the file ~/IOTstack/.env with the relevant content. For example:

       $ cat ~/IOTstack/.env
       TZ=Australia/Sydney
      

      The advantage of using .env is you can leave all service definitions at TZ=${TZ:-Etc/UTC} with the single entry in the .env file covering all containers.

      Tip:

      • Do not surround timezone strings with quote marks.
  2. Replace «printerMajorDeviceNumber» with the value determined during device discovery.

  3. Replace «cameraMajorDeviceNumber» with the value determined during device discovery.

If you do not have a camera at all:

  • set ENABLE_MJPG_STREAMER=false

  • remove the line:

     - "c «cameraMajorDeviceNumber»:* rw"

If you have a camera but want it to be always on:

  • remove the line:

     - "c «cameraMajorDeviceNumber»:* rw"
  • add the lines:

     devices:
     - /dev/video0:/dev/video0

    If necessary, adjust the left hand side of device map to be your actual camera device.

You can also tweak the camera settings. For example, I'm using this "widescreen" value for my Raspberry Pi ribbon camera:

- MJPG_STREAMER_INPUT=-r 1152x648 -f 10

Here's a completed example:

  octoprint:
    container_name: octoprint
    build:
      context: ./.templates/octoprint/.
      args:
      - OCTOPRINT_BASE=octoprint/octoprint:latest
    restart: unless-stopped
    environment:
    - TZ=Australia/Sydney
    - ENABLE_MJPG_STREAMER=true
    - MJPG_STREAMER_INPUT=-r 1152x648 -f 10
    - CAMERA_DEV=/dev/video0
    ports:
    - "9980:80"
    device_cgroup_rules:
    - "c 188:* rw"
    - "c 81:* rw"
    volumes:
    - ./volumes/octoprint:/octoprint
    - /dev:/host/dev:ro

step 3: add s6 mjpg-streamer run file

Add the script file below to the octoprint template folder ❷:

  • Path: ~/IOTstack/.templates/octoprint/etc_services.d_mjpg-streamer_run

  • Mode: 755

  • Ownership: pi:pi

  • Content:

     #!/usr/bin/with-contenv sh
    
     if [ -n "$MJPEG_STREAMER_INPUT" ]; then
       echo "Deprecation warning: the environment variable '\$MJPEG_STREAMER_INPUT' was renamed to '\$MJPG_STREAMER_INPUT'"
    
       MJPG_STREAMER_INPUT=$MJPEG_STREAMER_INPUT
     fi
    
     if ! expr "$MJPG_STREAMER_INPUT" : ".*\.so.*" > /dev/null; then
       MJPG_STREAMER_INPUT="input_uvc.so $MJPG_STREAMER_INPUT"
     fi
    
     # only exec the streamer if the (internal) camera device exists
     if [ -e "$CAMERA_DEV" ] ; then
       exec mjpg_streamer \
         -i "/usr/local/lib/mjpg-streamer/$MJPG_STREAMER_INPUT -d $CAMERA_DEV" \
         -o "/usr/local/lib/mjpg-streamer/output_http.so -w /usr/local/share/mjpg-streamer/www -p 8080"
     fi
    
     # arriving here means camera device not linked yet - take the service down
     s6-svc -d /var/run/s6/services/mjpg-streamer

The script above is a replacement for the default version that is supplied with the OctoPrint-docker image. It makes the exec mjpg_streamer conditional on whether the $CAMERA_DEV device exists inside the container. If the exec occurs, the script ends at that point. If the $CAMERA_DEV device does not exist, the service is taken down. This avoids it looping continually.

This script is only executed by the running container if ENABLE_MJPG_STREAMER=true. If that variable is false, the running container deletes this script entirely so that it never runs.

step 4: add printerDidChange script

Add the reaction script below to the OctoPrint template folder ❷:

  • Path: ~/IOTstack/.templates/octoprint/printerDidChange

  • Mode: 755

  • Ownership: pi:pi

  • Content:

     #!/usr/bin/with-contenv bash
    
     # support script re-naming
     SCRIPT=$(basename "$0")
    
     log() {
    
        # being run interactively?
        if [ "$(tty)" = "not a tty" ] ; then
           # no! use s6 logging
           echo "$1" | s6-log T n20 s50000 /octoprint/octoprint/logs/printerDidChange
        else
           # yes! fake it
           echo "$(date +"%Y-%m-%d %H:%M:%S"),$$ - $SCRIPT - $1"
        fi
    
     }
    
     # decode arguments
     case "$#" in
    
       2 )
         EXT_CAMERA_DEV=/host/dev/$(basename "$2")
         INT_CAMERA_DEV="$CAMERA_DEV"
         ;&
    
       1 )
         EXT_PRINTER_DEV=/host/dev/$(basename "$1")
         INT_PRINTER_DEV=/dev/$(basename "$1")
         ;;
    
       *)
         log "Usage: $SCRIPT printer {camera}"
         exit -1
         ;;
    
     esac
    
     # assumptions
     RESTART_OCTOPRINT=false
     SHOULD_ENABLE_CAMERA=false
    
     # does the external printer device exist?
     if [ -e "$EXT_PRINTER_DEV" ] ; then
    
        # yes! that means the camera should be on
        SHOULD_ENABLE_CAMERA=true
    
        # discover the external device attributes
        EXT_MAJOR=$(( 16#$(stat -L -c "%t" "$EXT_PRINTER_DEV") ))
        EXT_MINOR=$(( 16#$(stat -L -c "%T" "$EXT_PRINTER_DEV") ))
    
        # does the internal printer device exist?
        if [ -e "$INT_PRINTER_DEV" ] ; then
    
           # yes! discover its device attributes
           INT_MAJOR=$(( 16#$(stat -L -c "%t" "$EXT_PRINTER_DEV") ))
           INT_MINOR=$(( 16#$(stat -L -c "%T" "$EXT_PRINTER_DEV") ))
    
           # both exist - do they match?
           if [ "$EXT_MAJOR" = "$INT_MAJOR" -a "$EXT_MINOR" = "$INT_MINOR" ] ; then
    
              # yes! no need to do anything
              log "$INT_PRINTER_DEV already linked"
    
           else
    
              # no! mismatch - must re-link
              log "re-linking $INT_PRINTER_DEV"
    
              # unlink old
              unlink "$INT_PRINTER_DEV"
              
              # link new
              mknod "$INT_PRINTER_DEV" c "$EXT_MAJOR" "$EXT_MINOR"
              
              # remember to restart OctoPrint
              RESTART_OCTOPRINT=true
    
           fi
    
        else
    
           # no! simply link internal to external
           log "linking $INT_PRINTER_DEV"
    
           # link it
           mknod "$INT_PRINTER_DEV" c "$EXT_MAJOR" "$EXT_MINOR"
    
           # remember to restart OctoPrint
           RESTART_OCTOPRINT=true
    
        fi
        
     else
    
        # no! passed a non-existent external printer device
        log "$EXT_PRINTER_DEV does not exist"
    
        # does an internal printer device exist?
        if [ -e "$INT_PRINTER_DEV" ] ; then
    
           # yes! internal is now redundant
           log "unlinking $INT_PRINTER_DEV"
    
           # unlink it
           unlink "$INT_PRINTER_DEV"
           
           # remember to restart OctoPrint
           RESTART_OCTOPRINT=true
    
        fi
        
     fi
    
     # is the container set up to run the streamer?
     if $ENABLE_MJPG_STREAMER ; then
    
        # yes! should the streamer be active?
        if $SHOULD_ENABLE_CAMERA ; then
    
           # does the external camera device exist?
           if [ -e "$EXT_CAMERA_DEV" ] ; then
    
              # yes! discover its device attributes
              EXT_MAJOR=$(( 16#$(stat -L -c "%t" "$EXT_CAMERA_DEV") ))
              EXT_MINOR=$(( 16#$(stat -L -c "%T" "$EXT_CAMERA_DEV") ))
    
              # does the internal camera device exist?
              if [ -e "$INT_CAMERA_DEV" ] ; then
    
                 # yes! discover its device attributes
                 INT_MAJOR=$(( 16#$(stat -L -c "%t" "$INT_CAMERA_DEV") ))
                 INT_MINOR=$(( 16#$(stat -L -c "%T" "$INT_CAMERA_DEV") ))
    
                 # both exist - do they match?
                 if [ "$EXT_MAJOR" = "$INT_MAJOR" -a "$EXT_MINOR" = "$INT_MINOR" ] ; then
    
                    # yes! no need to do anything
                    log "$INT_CAMERA_DEV already linked"
    
                 else
    
                    # no! mismatch - must re-link
                    log "re-linking $INT_CAMERA_DEV"
    
                    # unlink old
                    unlink "$INT_CAMERA_DEV"
    
                    # link new
                    mknod "$INT_CAMERA_DEV" c "$EXT_MAJOR" "$EXT_MINOR"
    
                    # restart the streaming service
                    s6-svc -r /var/run/s6/services/mjpg-streamer
    
                 fi
    
              else
    
                 # no! simply link internal to external
                 log "linking $INT_CAMERA_DEV"
    
                 # link it
                 mknod "$INT_CAMERA_DEV" c "$EXT_MAJOR" "$EXT_MINOR"
    
                 # bring up the streaming service
                 s6-svc -u /var/run/s6/services/mjpg-streamer
    
              fi
    
           else
    
              # no! passed a null or non-existent external camera device
              log "$EXT_CAMERA_DEV does not exist"
    
              # does the internal camera device exist?
              if [ -e "$INT_CAMERA_DEV" ] ; then
    
                 # yes! internal is now redundant
                 log "unlinking $INT_CAMERA_DEV"
    
                 # unlink it
                 unlink "$INT_CAMERA_DEV"
    
                 # stop the streaming service
                 s6-svc -d /var/run/s6/services/mjpg-streamer
    
              fi
    
           fi
    
        else
    
           # no! camera not required
           log "no printer, no shoes, no camera service"
    
           # is the internal camera device defined?
           if [ -e "$INT_CAMERA_DEV" ] ; then
    
              # yes! internal is now redundant
              log "disabling $INT_CAMERA_DEV"
    
              # unlink it
              unlink "$INT_CAMERA_DEV"
    
              # stop the streaming service
              s6-svc -d /var/run/s6/services/mjpg-streamer
    
           fi
    
        fi
    
     else
    
        log "ENABLE_MJPG_STREAMER disabled in docker-compose.yml"
    
     fi
    
     # should OctoPrint be restarted ?
     if $RESTART_OCTOPRINT ; then
    
        # yes!
        log "restarting OctoPrint service"
    
        # go do it
        s6-svc -r /var/run/s6/services/octoprint
    
     fi

This script can be called with at least one, and at most two, arguments:

  1. the first argument is required and is the device name of the 3D printer, as known to the Raspberry Pi. This name is also assumed to be the device name inside the container.
  2. the second argument is optional and is the device name of the camera, as known to the Raspberry Pi. If present, the $CAMERA_DEV environment variable is assumed to hold the camera device name inside the container.

Note:

  • The ;& at the end of the 2 ) argument case label is not a mistake. It is a "fall through" instruction, like a case statement in C without a break.

step 5: add Dockerfile

Add the Dockerfile below to the OctoPrint template folder ❷:

  • Path: ~/IOTstack/.templates/octoprint/Dockerfile

  • Mode: 644

  • Ownership: pi:pi

  • Content:

     # reference supported arguments
     ARG OCTOPRINT_BASE=octoprint/octoprint:latest
    
     # Download base image
     FROM $OCTOPRINT_BASE
    
     # re-reference supported arguments and copy to environment vars
     ARG OCTOPRINT_BASE
     ENV OCTOPRINT_BASE=${OCTOPRINT_BASE}
    
     # copy extra files to image
     COPY etc_services.d_mjpg-streamer_run /etc/services.d/mjpg-streamer/run
     COPY printerDidChange /usr/local/bin/printerDidChange
    
     # set container metadata
     LABEL com.github.SensorsIot.IOTstack.Dockerfile.based-on.version="${OCTOPRINT_BASE}"
     LABEL com.github.SensorsIot.IOTstack.Dockerfile.based-on.repo="https://github.com/OctoPrint/octoprint-docker"
     LABEL com.github.SensorsIot.IOTstack.Dockerfile.based-on.maintainer="chief@hackerhappyhour.com"
    
     # don't need these variables in the container
     ENV OCTOPRINT_BASE=
    
     # EOF

step 6: build OctoPrint image

Execute the following commands to build the local image and instantiate the OctoPrint container:

$ cd ~/IOTstack
$ docker-compose up --build -d octoprint

This is what happens:

  1. docker-compose reads your docker-compose.yml file and looks for the octoprint service definition.

  2. the build: clause in the service definition tells docker-compose to process the Dockerfile at:

    ~/IOTstack/.templates/octoprint/Dockerfile
    

    You can also control the version of Octoprint by adjusting the OCTOPRINT_BASE directive. For example, to pin to version 1.8.7:

    - OCTOPRINT_BASE=octoprint/octoprint:1.8.7
    
  3. the FROM directive in the Dockerfile downloads the octoprint/octoprint base image, and then the two COPY directives copy the following files from the templates folder into the base image to form a local image:

  4. The newly-built local image is instantiated to become the running container.

step 7: disconnect your 3D printer

Disconnect your 3D printer, either by turning the printer off or by detaching its USB cable.

step 8: create UDEV rule set

Three example rule set templates are provided below. You need to select one template, tailor it to your needs, and install it in the Raspberry Pi directory:

/etc/udev/rules.d/

Some guides recommend rebooting or taking other action to "activate" UDEV rules. I have never found that to be necessary on the Raspberry Pi. In my experience, any newly-added (or edited) file in the rules.d directory is always active immediately.

option 1: generic rule set

This rule set will match on anything that has the potential to be a 3D printer. That's any device which naturally mounts as either /dev/ttyUSBn or /dev/ttyACMn.

This rule set has the advantage of simplicity and one-size-fits-all but the potential disadvantage of confusing OctoPrint if a matching device is not actually a 3D printer.

  • Path: /etc/udev/rules.d/88-generic3DPrinter.rules

  • Mode: 644

  • Ownership: root:root

  • Content (must be exactly one line):

     ACTION=="add|change|remove", SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-9]|ttyACM[0-9]", RUN+="/usr/bin/docker exec octoprint printerDidChange %k video0"
    
  • Optional edit:

    1. If you do not want the camera to follow the printer, remove video0 as the second argument to printerDidChange (the %k is the first argument).

If you choose this rule set and a real 3D printer does not mount inside the OctoPrint container, check the device's major number because a new device_cgroup_rules line may need to be added to the service definition.

Acknowledgements:

option 2: specific rule set

This rule set matches on actual printer characteristics. The only time you are likely to encounter trouble is if you have two printers with identical vendor IDs, product IDs and serial numbers.

  • Path: /etc/udev/rules.d/88-specific3DPrinter.rules

  • Mode: 644

  • Ownership: root:root

  • Template (must be exactly two lines):

     ACTION=="add|change" SUBSYSTEM=="tty", ATTRS{idVendor}=="«vendorID»", ATTRS{idProduct}=="«productID»", ATTRS{serial}=="«serialNumber»", RUN+="/usr/bin/docker exec octoprint printerDidChange %k video0"
     ACTION=="remove" SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="«vendorID»", ENV{ID_MODEL_ID}=="«productID»", ENV{ID_SERIAL_SHORT}=="«serialNumber»", RUN+="/usr/bin/docker exec octoprint printerDidChange %k video0"
    
  • Required edits:

    1. Replace the following template fields with the values determined during device discovery:

  • Optional edit:

    1. If you do not want the camera to follow the printer, remove video0 as the second argument to printerDidChange (the %k is the first argument).

option 3: named-printer rule set

This rule set is essentially the same as 88-specificPrinter.rules, save that you can also associate a human-readable name with your printer.

The advantage of this approach is that your printer will always get the same device name. The disadvantage of this approach is that you also have to tell OctoPrint about the name (but you only need to do it once for each printer that you set up this way).

Assume your printer model is a "MasterDisasterPro" and that you want to use that as the human-readable device name (rather than ttyUSB0). The name you choose for your printer can be a mixture of upper- and lower-case letters, digits and hyphens but you should probably avoid any other characters. You should also avoid re-using any name which is likely to be found in /dev/.

  • Path: /etc/udev/rules.d/88-namedPrinter.rules

  • Mode: 644

  • Ownership: root:root

  • Template (must be exactly two lines):

     ACTION=="add|change" SUBSYSTEM=="tty", ATTRS{idVendor}=="«vendorID»", ATTRS{idProduct}=="«productID»", ATTRS{serial}=="«serialNumber»", SYMLINK+="«humanName»", RUN+="/usr/bin/docker exec octoprint printerDidChange «humanName» video0"
     ACTION=="remove" SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="«vendorID»", ENV{ID_MODEL_ID}=="«productID»", ENV{ID_SERIAL_SHORT}=="«serialNumber»", RUN+="/usr/bin/docker exec octoprint printerDidChange «humanName» video0"
    
  • Required edits:

    1. Replace the following template fields with the values determined during device discovery:

    2. Replace «humanName» with your chosen human-readable device name. In this example:

      • «humanName» = MasterDisasterPro
  • Optional edit:

    1. If you do not want the camera to follow the printer, remove video0 as the second argument to printerDidChange. The «humanName» field is the first argument.

step 9: connect your 3D printer

Connect your 3D printer, either by turning the printer on or by attaching its USB cable.

The s6-log facility used by printerDidChange does not make its logging directory available to group and world. You should change the directory permissions to something a little more appropriate:

$ sudo chmod 755 ~/IOTstack/volumes/octoprint/octoprint/logs/printerDidChange

Check the printerDidChange log:

$ tail ~/IOTstack/volumes/octoprint/octoprint/logs/printerDidChange/current

Depending on the UDEV rule set you implemented and the options you chose, you should expect to see messages like the following:

yyyy-mm-dd hh:mm:ss.sss  linking /dev/«printerDeviceHere»
yyyy-mm-dd hh:mm:ss.sss  linking /dev/«optionalVideoDeviceHere»
yyyy-mm-dd hh:mm:ss.sss  restarting OctoPrint service

Both the existence of the log file and these messages are confirmation that the UDEV rule triggered printerDidChange within the container. You will need to go back and check your work if the log file does not exist or does not contain the expected messages.

step 10: use your browser to connect to OctoPrint

Use a browser to open a connection with the OctoPrint web UI on port 9980 (or whatever port you are using if you changed it).

step 11: tell OctoPrint your printer's name (optional)

If you chose 88-namedPrinter.rules and associated a human-readable name with your printer, you need to tell OctoPrint about the device name you gave to your printer.

You only need to do this once per printer. Thereafter, OctoPrint will sense the printer when it is online in the same way as it would for any /dev/ttyUSBn or /dev/ttyACMn device.

The steps are:

  • Click the 🔧 ("Settings") icon on the toolbar.

  • Choose "Serial Connection" in the left hand panel (the default).

  • Add your printer to the "Additional serial ports" field. For example:

     /dev/MasterDisasterPro
    
  • Click Save .

  • Restart OctoPrint, either from the "System" menu on the toolbar, or from the command line by:

     $ docker exec octoprint s6-svc -r /var/run/s6/services/octoprint

step 12: connect to your printer

Choose your printer in the Serial Port popup menu, then click Connect .

Container maintenance

If OctoPrint informs you that a new version has become available:

$ cd ~/IOTstack
$ docker-compose build --no-cache --pull octoprint
$ docker-compose up -d octoprint
$ docker system prune -f

In words:

  1. Be in the right directory.
  2. Force a fresh download of the base image from DockerHub, and build a new local image by re-running the Dockerfile.
  3. Instantiate the newly-built local image as the running container.
  4. Clean up the old image.

If your 3D printer is powered on and connected over USB while you do this, the newly-instantiated container will not be "in sync". You will either have to power-cycle or disconnect/reconnect your 3D printer's USB cable to bring the container "in sync".

Working example

The platform I am using to run octoprint/octoprint is:

  1. 4GB Raspberry Pi 4 Model B Rev 1.4.

  2. Raspberry Pi "ribbon camera".

  3. Full 64-bit Debian GNU/Linux 11 Bullseye, built using PiBuilder with ENABLE_PI_CAMERA=legacy.

    Note: building your Raspberry Pi with PiBuilder installs IOTstack and all its dependencies as part of the process.

  4. AnyCubic I3 Mega 3D printer.

  5. Camera follows the 3D printer.

I have run this same setup on Buster. You can also use PiBuilder to construct a Buster system. In that situation, PiBuilder interprets ENABLE_PI_CAMERA=legacy as an instruction to enable the camera.

I have also run this on a Raspberry Pi 3B+ (Buster) but with mixed results. All the problems were due to power (not the power supply). The board itself couldn't deliver what even the beefiest power supply unit could provide. If you want to know more, see Checking your Raspberry Pi's view of its power supply.

test - named-printer rule set

Printer off. Confirm active rule-set:

$ ls /etc/udev/rules.d/88*.rules
/etc/udev/rules.d/88-AnyCubicI3Mega.rules

Watch the log (see also log permissions):

$ tail -f ~/IOTstack/volumes/octoprint/octoprint/logs/printerDidChange/current

Turn printer on:

yyyy-mm-dd hh:mm:ss.sss  linking /dev/AnyCubicI3Mega
yyyy-mm-dd hh:mm:ss.sss  linking /dev/video0
yyyy-mm-dd hh:mm:ss.sss  restarting OctoPrint service

Visual result:

  • Device appears in Serial Port popup as /dev/AnyCubicI3Mega.
  • Camera is enabled.
  • With Serial Port popup set to "Auto", clicking Connect connects to printer.

Turn printer off:

yyyy-mm-dd hh:mm:ss.sss  /host/dev/AnyCubicI3Mega does not exist
yyyy-mm-dd hh:mm:ss.sss  unlinking /dev/AnyCubicI3Mega
yyyy-mm-dd hh:mm:ss.sss  no printer, no shoes, no camera service
yyyy-mm-dd hh:mm:ss.sss  disabling /dev/video0
yyyy-mm-dd hh:mm:ss.sss  restarting OctoPrint service

Visual result:

  • Only option in Serial Port popup is Auto.
  • Camera is disabled.

test - Raspberry Pi reboot

Printer on. Confirm dynamic connection:

$ docker exec octoprint ls -l /dev/AnyCubicI3Mega /dev/video0
crw-r--r-- 1 root root 188, 0 mmm dd hh:mm /dev/AnyCubicI3Mega
crw-r--r-- 1 root root  81, 7 mmm dd hh:mm /dev/video0

Reboot. Wait for Raspberry Pi to come up. Re-connect via SSH. Check connection situation:

$ docker exec octoprint ls -l /dev/AnyCubicI3Mega /dev/video0
crw-r--r-- 1 root root 188, 0 mmm dd hh:mm /dev/AnyCubicI3Mega
crw-r--r-- 1 root root  81, 7 mmm dd hh:mm /dev/video0

Conclusion: dynamic device mapping across reboot handled correctly.

test - Raspberry Pi shutdown and power-up (printer on throughout)

Printer on. Confirm dynamic connection:

$ docker exec octoprint ls -l /dev/AnyCubicI3Mega /dev/video0
crw-r--r-- 1 root root 188, 0 mmm dd hh:mm /dev/AnyCubicI3Mega
crw-r--r-- 1 root root  81, 7 mmm dd hh:mm /dev/video0

Shutdown. Wait for Raspberry Pi to go quiescent. Remove power. Re-apply power. Re-connect via SSH. Check connection situation:

$ docker exec octoprint ls -l /dev/AnyCubicI3Mega /dev/video0
crw-r--r-- 1 root root 188, 0 mmm dd hh:mm /dev/AnyCubicI3Mega
crw-r--r-- 1 root root  81, 8 mmm dd hh:mm /dev/video0

Conclusion: dynamic device mapping across power-cycle handled correctly.

test - shutdown, disconnect printer, power-up

Printer on. Confirm dynamic connection:

$ docker exec octoprint ls -l /dev/AnyCubicI3Mega /dev/video0
crw-r--r-- 1 root root 188, 0 mmm dd hh:mm /dev/AnyCubicI3Mega
crw-r--r-- 1 root root  81, 8 mmm dd hh:mm /dev/video0

Shutdown. Wait for Raspberry Pi to go quiescent. Remove power. Disconnect printer. Re-apply power. Re-connect via SSH. Check connection situation.

$ docker exec octoprint ls -l /dev/AnyCubicI3Mega /dev/video0
ls: cannot access '/dev/AnyCubicI3Mega': No such file or directory
ls: cannot access '/dev/video0': No such file or directory

Reconnect printer. Re-check connection situation.

$ docker exec octoprint ls -l /dev/AnyCubicI3Mega /dev/video0
crw-r--r-- 1 root root 188, 0 mmm dd hh:mm /dev/AnyCubicI3Mega
crw-r--r-- 1 root root  81, 8 mmm dd hh:mm /dev/video0

Conclusion: It does not matter whether the printer is switched off before the Raspberry Pi shutdown, or vice versa, the container response is correct.

Known issues

container re-creation

Although testing suggests that this structure will survive Raspberry Pi reboots and shutdowns, there is no mechanism for forcing a newly-created container to synchronise with the actual state of the 3D printer. A container is "newly-created" when:

  1. Its service definition changes in docker-compose.yml and an up is performed.
  2. The local Dockerfile is changed and an up --build is performed.
  3. A new base image is pulled from DockerHub and a new local image constructed by running the Dockerfile (build --no-cache --pull), followed by an up.
  4. If the user stops and removes container, then does an up.
  5. If the user takes the stack down then up again.

The simplest approach whenever there's a lack of synchronisation is to disconnect and re-connect the 3D printer. That triggers the UDEV rule and forces synchronisation.

See also Container maintenance.

docker-compose.yml version directive

The device_cgroup_rules: directive is sensitive to the version directive in your compose file. In theory, the version directive is deprecated. In theory. If you want everything to work properly you need:

version: '3.6'

docker-compose version

The device_cgroup_rules: directive is also sensitive to the version of docker-compose being used.

If you have just built your Pi using PiBuilder then both docker and docker-compose will be up-to-date.

Conversely, if you are trying to run the OctoPrint container on top of an existing system, you may run into problems which will have as their root cause how docker and docker-compose were installed originally, and how they have been maintained since.

If you are in this situation and you don't want to rebuild your system from scratch using PiBuilder, try following the instructions in maintaining docker + docker-compose.

@grym3s
Copy link

grym3s commented Feb 9, 2022

Quick q, and excuse me if im very wrong here, but is this guide implying you need iot stack installed or that you install it as unfortunately I can see neither direction, nor did i see it in the YML

@Paraphraser
Copy link
Author

@grym3s – no, you're not wrong. It will probably make more sense if I tell you that I generally write gists in response to questions on the IOTstack Discord channel, or issues in the IOTstack GitHub repo. I tend to think of IOTstack as "assumed knowledge".

OK. The place to start is IOTstack. If you decide to use it, you'll find a link to the Discord channel near the bottom of the main README.

So, what is IOTstack? Well, it's less a system than it is a set of conventions:

$ git clone https://github.com/SensorsIot/IOTstack.git
…

$ ls -A1 IOTstack | heavy filtering
menu.sh
.templates

The .templates folder contains a bunch of folders (about 45) named for containers (Mosquitto, Node-RED, InfluxDB, Grafana, PiHole, WireGuard, OctoPrint, etc). Each one contains a service.yml. The basic idea is that you run the menu, choose the containers you want, the menu assembles all the service.yml files into a docker-compose.yml and you've got a "stack".

The two most important conventions employed in each service.yml are:

  1. The persistent storage for each container is in:

    ~/IOTstack/volumes/«containerName»
    

    The volumes/«containerName» paths get created by docker-compose the first time you bring up a container. Some containers also use services/«containerName» and those paths get created by the menu.

    If you've looked at compose file fragments scattered around the web you'll know that everyone rolls their own location for persistent storage. IOTstack standardises that. It makes backups slightly easier (although you still have to handle databases separately).

  2. A reasonable attempt has been made to avoid port conflicts.

    Sometimes conflicts can't be avoided, such as if you wanted to run PiHole and AdGuardHome at the same time. They both want external port 53 and there's not much point trying to fight that.

    Similarly, if a container demands host-mode then it's going to get first claim on ports so any non-host-mode containers will play second fiddle.

I don't want you to get the idea that IOTstack solves all possible problems. It doesn't. I regularly advise people to treat IOTstack as a great way of getting started with Docker on a Raspberry Pi. The IOTstack menu will build you a compose file with a set of containers that will pretty much just work. Thereafter, you're better off studying how it all hangs together and then doing by hand what a perfect menu system would do if anyone was able to write one (which hasn't happened yet in the IOTstack context).

The other advice I give all the time is to run a mile any time you see the word "override". IOTstack has its own override system separate from the one supported by "docker-compose". Let's just say that the people who insist on going down either override path tend to be overrepresented when it comes to issues and questions.

IOTstack isn't quite as "regular" as the above make it sound. Most containers are just service definitions pointing to DockerHub images and away you go. But there are some that use local Dockerfiles to customise DockerHub images. You quickly get the hang of it.

On the topic of OctoPrint, I'll advise starting with Raspbian Buster (not Bullseye) and running everything in 32-bit mode. All the camera support changed across the Buster/Bullseye divide and nothing worked when I tried it. I also tried Buster with the 64-bit kernel enabled but OctoPrint didn't seem to like it. I didn't try to dig into why. I just shrugged my shoulders and put it back to the 32-bit kernel.

Last thing. I build all my Pis with PiBuilder - Buster/Bullseye 32-/64-bit. The end result of a PiBuilder run is a rock solid platform with IOTstack installed, ready to either restore an IOTstack backup or run the IOTstack menu.

I keep meaning to submit a PR against IOTstack to cement all the stuff in this gist into place but other things keep getting in the way...

@Paraphraser
Copy link
Author

The recommendation for Buster is well and truly obsolete. I've been running OctoPrint with the mods in this gist on full 64-bit Bullseye for at least a year. The Pi "ribbon camera" works just fine.

@Flo2410
Copy link

Flo2410 commented Feb 2, 2024

Thanks for the great work.
I unfortunately have two problems.

1. Problem: printerDidChange

The printerDidChange script works if I run it inside the octoprint container, but it does not get triggered via the udev rules.
If I run the command

/usr/bin/docker exec octoprint printerDidChange megaS video0

on the host I get the following error:

exec /usr/local/bin/printerDidChange: exec format error

Also, there are no logs from the printerDidChange script.

2. Problem: mjpg-streamer

I want the camera to follow the printer, but unfortunately, it does not work.

If I turn on the printer and run the printerDidChange script inside the container to register the printer and the camera, I get the following error in the logs:

octoprint  | s6-supervise (child): fatal: unable to exec run: Exec format error
octoprint  | s6-supervise mjpg-streamer: warning: unable to spawn ./run - waiting 10 seconds

The camera works on the host with ffplay /dev/video0.

Some system infos:

  • printer: Anycubic i3 mega S
  • camera: some Microsoft webcam. Shows up with lsusb as: Microsoft Corp. LifeCam Cinema
  • Host: Linux 6.1.0-17-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.69-1 (2023-12-30) x86_64 GNU/Linux
  • docker version: Docker version 25.0.2, build 29cf629

My compose.yaml

version: "3.6"
services:
  octoprint:
    image: octoprint
    build:
      context: .
      args:
        - OCTOPRINT_BASE=octoprint/octoprint:latest
    container_name: octoprint
    restart: unless-stopped
    networks:
      - web
    # devices:
    #   - /dev/ttyUSB0:/dev/ttyUSB0
    #   - /dev/video0:/dev/video0
    device_cgroup_rules:
      - "c 188:* rw"
      - "c 81:* rw"
    volumes:
      - ./volumes/octoprint:/octoprint
      - /dev:/host/dev:ro

    environment:
      - TZ=Europe/Vienna
      - ENABLE_MJPG_STREAMER=true
      - MJPG_STREAMER_INPUT=-r 640x480 -f 10 -y
      - CAMERA_DEV=/dev/video0

networks:
  web:
    external: true

I would appreciate any help.

@Paraphraser
Copy link
Author

@Flo2410 Beats me. My own system (Raspberry Pi 4, Bullseye 64-bit) is working fine. I recently switched from a Raspberry Pi ribbon camera to an external USB camera. The only thing I had to change was the aspect ratio in MJPG_STREAMER_INPUT.

That said, a couple of things to try:

  1. I don't know if the blank line before environment: makes any difference. I only ever put blank lines between service definitions, not inside definitions. I don't hold out a lot of hope for this.
  2. I don't know what that networks: clause is doing. I only mention it because I don't have one of those. Again, I don't hold out much hope for this as an explanation.
  3. Have you double-checked the ownership and mode on the printerDidChange file in the template folder? Those permissions follow the file when it is copied into the image by the Dockerfile. However, I'd expect to see a different error (permissions).
  4. Did you use Unix text editors for this or were any Windows-based editors involved? If Windows might've added CR+LF line endings, try running the dos2unix command on all the text files in the templates folder. It's a command that works "in situ" (ie dos2unix fred fixes fred in place - no need to figure out input and output files).
  5. I don't rebuild the OctoPrint container all that much so, right now, it's version 1.9.2 which is 6 months old. It's possible that the most up-to-date version would exhibit this same problem on my system too. I've just had a look at the base repo on GitHub and I can't see anything obvious that would explain this but it's always possible something would show up in a test. Unfortunately, it's the middle of the night for me and, as you probably know, the AnyCubic makes a gad-awful noise on power-up. My wife will complain (rightly) if I wake her up by doing a rebuild and then try to test it.

Here's the service definition I'm using at the moment (just for the record):

  octoprint:
    container_name: octoprint
    build:
      context: ./.templates/octoprint/.
      args:
        - OCTOPRINT_BASE=octoprint/octoprint:latest
    restart: unless-stopped
    environment:
      - TZ=${TZ:-Etc/UTC}
      - ENABLE_MJPG_STREAMER=true
      - MJPG_STREAMER_INPUT=-r 1152x648 -f 10
      - CAMERA_DEV=/dev/video0
    ports:
      - "9980:80"
    device_cgroup_rules:
      - "c 188:* rw"
      - "c 81:* rw"
    volumes:
      - ./volumes/octoprint:/octoprint
      - /dev:/host/dev:ro

I've compared printerDidChange and Dockerfile listed above with the running versions on my system to make sure they're the same. They are (ie this isn't explained by something I found, fixed and forgot to update in the gist).

Your hardware is AMD but printerDidChange is just a bash script so I can't explain why that throws up an exec format error (I'd expect that if you were trying to run an ARM image on AMD or vice versa).

Other than that ... I'm back to "beats me". Sorry.

@Flo2410
Copy link

Flo2410 commented Feb 2, 2024

Thank you for the quick answer.

OMG am I dumb...
Your point with the line endings had me look at the files in more detail. There I noticed that I had the printerDidChange and the etc_services.d_mjpg-streamer_run files indented with two spaces. After fixing that and adding the -n flag to the MJPG_STREAMER_INPUT everything works. (I have no idea what the -n does, but it was in the original run command, and it made it work)

@Paraphraser
Copy link
Author

IMG_2469

But, as to what it means in this context (the streamer running inside a container), I have no idea. I haven't used that flag with either the ribbon camera or the external USB camera so, in my situation at least, it wasn't necessary. It isn't documented at community.octoprint.org which is my go-to source so I don't quite know what to make of this.

As to the "extraneous spaces" and/or line-endings problem, I should probably get off my backside and propose a PR to add all this to IOTstack. Then it could be safely downloaded either as part of a general clone of IOTstack or piecemeal for anyone who wants to just do this same kind of thing outside of IOTstack.

@Flo2410
Copy link

Flo2410 commented Feb 3, 2024

On the forum post you linked, there is a link to the recommended MJPG Streamer fork (bundled with OctoPi).
There to -n flag is documented as:

[-n | --no_dynctrl ]...: do not initalize dynctrls of Linux-UVC driver

This sounds correct, as the errors I was getting were related to dynctrls. (No idea what that is)

I found the -n flag while looking at the original mjpg-streamer/run file because I knew the camera worked before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment