From fb2d46ddcd3a0091730d5c61f5fbdeab972ec0d1 Mon Sep 17 00:00:00 2001 From: Quinn Damerell Date: Sun, 19 May 2024 10:53:05 -0700 Subject: [PATCH] Adding an official docker image for Bambu Connect! --- .github/workflows/docker-publish.yml | 60 +++++++++ .github/workflows/pylint.yml | 3 +- .vscode/settings.json | 2 + Dockerfile | 37 +++++ bambu_octoeverywhere/bambuclient.py | 11 +- docker-compose.yml | 28 ++++ docker-readme.md | 62 +++++++++ docker_octoeverywhere/__init__.py | 1 + docker_octoeverywhere/__main__.py | 195 +++++++++++++++++++++++++++ install.sh | 10 +- 10 files changed, 400 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker-readme.md create mode 100644 docker_octoeverywhere/__init__.py create mode 100644 docker_octoeverywhere/__main__.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..6fd5da9f --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,60 @@ +name: Publish Docker image + +# Only make and deploy new docker images on tagged releases. +on: + release: + types: [published] + +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + # This is needed for the attestation step + id-token: write + attestations: write + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + # Required for docker multi arch building. + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + # Required for docker multi arch building. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: octoeverywhere/octoeverywhere + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v5 + with: + context: . + # These are the platform our ubuntu image base supports + platforms: linux/amd64,linux/arm/v7,linux/arm64 + file: ./Dockerfile + push: true + tags: octoeverywhere/octoeverywhere:latest + labels: ${{ steps.meta.outputs.labels }} + + # This isn't working, so it's disabled for now. + # - name: Generate artifact attestation + # uses: actions/attest-build-provenance@v1 + # with: + # subject-name: octoeverywhere/octoeverywhere + # subject-digest: ${{ steps.push.outputs.digest }} + # push-to-registry: true diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 85520d08..cf938850 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -29,4 +29,5 @@ jobs: pylint ./moonraker_octoeverywhere/ pylint ./bambu_octoeverywhere/ pylint ./linux_host/ - pylint ./py_installer/ \ No newline at end of file + pylint ./py_installer/ + pylint ./docker_octoeverywhere/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 4ab50ede..f020be34 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,6 +23,7 @@ "boundarydonotcross", "brotli", "Buildroot", + "buildx", "certifi", "checkin", "classicwebcam", @@ -61,6 +62,7 @@ "Frontends", "frontendsetup", "fsensor", + "fstring", "gcode", "geteuid", "getpwnam", diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f5f99dd4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Start with the lastest ubuntu, for a solid base, +# since we need some advance binaries for things like pillow and ffmpeg. +FROM ubuntu:latest + +# We will base ourselves in root, becuase why not. +WORKDIR /root + +# Define some user vars we will use for the image. +# These are read in the docker_octoeverywhere module, so they must not change! +ENV USER=root +ENV REPO_DIR=/root/octoeverywhere +ENV VENV_DIR=/root/octoeverywhere-env +# This is a special dir that the user MUST mount to the host, so that the data is persisted. +# If this is not mounted, the printer will need to be re-linked everytime the container is remade. +ENV DATA_DIR=/data/ + +# Install the required packages. +# Any packages here should be mirrored in the install script - and any optaionl pillow packages done inline. +RUN apt update +RUN apt install -y curl ffmpeg jq python3 python3-pip python3-venv virtualenv libjpeg-dev zlib1g-dev python3-pil python3-pillow + +# +# We decided to not run the installer, since the point of the installer is to setup the env, build the launch args, and setup the serivce. +# Instead, we will manually run the smaller subset of commands that are requred to get the env setup in docker. +# Note that if this ever becomes too much of a hassle, we might want to revert back to using the installer, and supporting a headless install. +# +RUN virtualenv -p /usr/bin/python3 ${VENV_DIR} +RUN ${VENV_DIR}/bin/python -m pip install --upgrade pip + +# Copy the entire repo into the image, do this as late as possible to avoid rebuilding the image everytime the repo changes. +COPY ./ ${REPO_DIR}/ +RUN ${VENV_DIR}/bin/pip3 install --require-virtualenv --no-cache-dir -q -r ${REPO_DIR}/requirements.txt + +# For docker, we use our docker_octoeverywhere host to handle the runtime setup and launch of the serivce. +WORKDIR ${REPO_DIR} +# Use the full path to the venv, we msut use this [] notation for our ctlc handler to work in the contianer +ENTRYPOINT ["/root/octoeverywhere-env/bin/python", "-m", "docker_octoeverywhere"] \ No newline at end of file diff --git a/bambu_octoeverywhere/bambuclient.py b/bambu_octoeverywhere/bambuclient.py index f43d5c2a..bed8d0d1 100644 --- a/bambu_octoeverywhere/bambuclient.py +++ b/bambu_octoeverywhere/bambuclient.py @@ -48,11 +48,10 @@ def __init__(self, logger:logging.Logger, config:Config, stateTranslator) -> Non # Get the required args. self.Config = config - ipOrHostname = config.GetStr(Config.SectionCompanion, Config.CompanionKeyIpOrHostname, None) self.PortStr = config.GetStr(Config.SectionCompanion, Config.CompanionKeyPort, None) self.AccessToken = config.GetStr(Config.SectionBambu, Config.BambuAccessToken, None) self.PrinterSn = config.GetStr(Config.SectionBambu, Config.BambuPrinterSn, None) - if ipOrHostname is None or self.PortStr is None or self.AccessToken is None or self.PrinterSn is None: + if self.PortStr is None or self.AccessToken is None or self.PrinterSn is None: raise Exception("Missing required args from the config") # We use this var to keep track of consecutively failed connections @@ -147,7 +146,7 @@ def _ClientWorker(self): self.Logger.error(f"Failed to connect to the Bambu printer {ipOrHostname}:{self.PortStr} due to a timeout, we will retry in a bit. "+str(e)) else: # Random other errors. - Sentry.Exception("Failed to connect to the Bambu printer {ipOrHostname}:{self.PortStr}. We will retry in a bit.", e) + Sentry.Exception(f"Failed to connect to the Bambu printer {ipOrHostname}:{self.PortStr}. We will retry in a bit.", e) # Sleep for a bit between tries. # The main consideration here is to not log too much when the printer is off. But we do still want to connect quickly, when it's back on. @@ -333,13 +332,15 @@ def _GetIpOrHostnameToTry(self) -> str: self.ConsecutivelyFailedConnectionAttempts = 0 # On the first few attempts, use the expected IP. - # The first attempt will always be attempt 1, since it's reset to 0 and incremented before connecting + # The first attempt will always be attempt 1, since it's reset to 0 and incremented before connecting. + # The IP can be empty, like if the docker container is used, in which case we should always search for the printer. configIpOrHostname = self.Config.GetStr(Config.SectionCompanion, Config.CompanionKeyIpOrHostname, None) - if self.ConsecutivelyFailedConnectionAttempts < 3: + if configIpOrHostname is not None and len(configIpOrHostname) > 0 and self.ConsecutivelyFailedConnectionAttempts < 3: return configIpOrHostname # If we fail too many times, try to scan for the printer on the local subnet, the IP could have changed. # Since we 100% identify the printer by the access token and printer SN, we can try to scan for it. + self.Logger.info(f"Searching for your Bambu Lab printer {self.PrinterSn}") ips = NetworkSearch.ScanForInstances_Bambu(self.Logger, self.AccessToken, self.PrinterSn) # If we get an IP back, it is the printer. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..653737ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '2' +services: + octoeverywhere-bambu-connect: + image: octoeverywhere/octoeverywhere:latest + environment: + # https://octoeverywhere.com/s/access-code + - ACCESS_CODE=XXXXXXXX + # https://octoeverywhere.com/s/bambu-sn + - SERIAL_NUMBER=XXXXXXXXXXXXXXX + # Find using the printer's display + - PRINTER_IP=192.168.1.1 + volumes: + # Specify a path mapping for the required persistent storage + - /some/path/on/your/computer:/data + + # Add as many printers as you want! + # octoeverywhere-bambu-connect-2: + # image: octoeverywhere/octoeverywhere:latest + # environment: + # # https://octoeverywhere.com/s/access-code + # - ACCESS_CODE=XXXXXXXX + # # https://octoeverywhere.com/s/bambu-sn + # - SERIAL_NUMBER=XXXXXXXXXXXXXXX + # # Find using the printer's display + # - PRINTER_IP=192.168.1.2 + # volumes: + # # Specify a path mapping for the required persistent storage + # - /some/path/on/your/computer/printer2:/data \ No newline at end of file diff --git a/docker-readme.md b/docker-readme.md new file mode 100644 index 00000000..a8baa126 --- /dev/null +++ b/docker-readme.md @@ -0,0 +1,62 @@ +# Bambu Connect Docker Support + +OctoEverywhere's docker image only works for [Bambu Connect](https://octoeverywhere.com/bambu?source=github_docker_readme) for Bambu Lab 3D printers. If you are using OctoPrint or Klipper, [follow our getting started guide](https://octoeverywhere.com/getstarted?source=github_docker_readme) to install the OctoEverywhere plugin. + +Official Docker Image: https://hub.docker.com/r/octoeverywhere/octoeverywhere + +## Required Setup Environment Vars + +To use the Bambu Connect plugin, you need to get the following information. + +- Your printer's Access Code - https://octoeverywhere.com/s/access-code +- Your printer's Serial Number - https://octoeverywhere.com/s/bambu-sn +- Your printer's IP Address - (use the printer's display) + +These three values must be set at environment vars when you first run the container. Once the container is run, you don't need to include them again, unless you want to update the values. + +- ACCESS_CODE=(code) +- SERIAL_NUMBER=(serial number) +- PRINTER_IP=(ip address) + +## Required Persistent Storage + +You must map the `/data` folder in your docker container to a directory on your computer so the plugin can write data that will remain between runs. Failure to do this will require relinking the plugin when the container is destroyed or updated. + +## Linking Your Bambu Connect Plugin + +Once the docker container is running, you need to look at the logs to find the linking URL. + +Docker Compose: +`docker-compose logs | grep https://octoeverywhere.com/getstarted` + +Docker: +`docker logs bambu-connect | grep https://octoeverywhere.com/getstarted` + +# Running The Docker Image + +## Using Docker Compose + +Using docker compose is the easiest way to run OctoEverywhere's Bambu Connect using docker. + +- Install [Docker and Docker Compose](https://docs.docker.com/compose/install/linux/) +- Clone this repo +- Edit the `./docker-compose.yml` file to enter your environment vars +- Run `docker-compose up -d` +- Follow the "Linking Your Bambu Connect Plugin" to link the plugin to your account. + +## Using Docker + +Docker compose is a fancy wrapper to run docker containers. You can also run docker containers manually. + +Use a command like this example, but update the required vars. + +`docker pull octoeverywhere/octoeverywhere` +`docker run --name bambu-connect -e ACCESS_CODE= -e SERIAL_NUMBER= -e PRINTER_IP= -v /your/local/path:/data -d octoeverywhere/octoeverywhere` + +Follow the "Linking Your Bambu Connect Plugin" to link the plugin to your account. + +## Building The Image Locally + +You can build the docker image locally if you prefer, use the following command. + +`docker build -t octoeverywhere .` \ No newline at end of file diff --git a/docker_octoeverywhere/__init__.py b/docker_octoeverywhere/__init__.py new file mode 100644 index 00000000..564091d4 --- /dev/null +++ b/docker_octoeverywhere/__init__.py @@ -0,0 +1 @@ +# Need to make this a module diff --git a/docker_octoeverywhere/__main__.py b/docker_octoeverywhere/__main__.py new file mode 100644 index 00000000..863123a6 --- /dev/null +++ b/docker_octoeverywhere/__main__.py @@ -0,0 +1,195 @@ +import os +import sys +import json +import time +import signal +import base64 +import logging +import traceback +import subprocess + +# +# This docker host is the entry point for the docker container. +# Unlike the other host, this host doesn't run the service, it invokes the bambu or companion host. +# + +from linux_host.startup import Startup +from linux_host.config import Config + +# pylint: disable=logging-fstring-interpolation + +if __name__ == '__main__': + + # Setup a basic logger + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + std = logging.StreamHandler(sys.stdout) + std.setFormatter(formatter) + logger.addHandler(std) + + logger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + logger.info("Starting Docker OctoEverywhere Bootstrap") + logger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + + # This is a helper class, to keep the startup logic common. + s = Startup() + + # + # Helper functions + # + def LogException(msg:str, e:Exception) -> None: + tb = traceback.format_exc() + exceptionClassType = "unknown_type" + if e is not None: + exceptionClassType = e.__class__.__name__ + logger.error(f"{msg}; {str(exceptionClassType)} Exception: {str(e)}; {str(tb)}") + + def EnsureIsPath(path: str) -> str: + logger.info(f"Ensuring path exists: {path}") + if path is None or not os.path.exists(path): + raise Exception(f"Path does not exist: {path}") + return path + + def CreateDirIfNotExists(path: str) -> None: + if not os.path.exists(path): + os.makedirs(path) + + try: + # First, read the required env vars that are set in the dockerfile. + logger.info(f"Env Vars: {os.environ}") + virtualEnvPath = EnsureIsPath(os.environ.get("VENV_DIR", None)) + repoRootPath = EnsureIsPath(os.environ.get("REPO_DIR", None)) + dataPath = EnsureIsPath(os.environ.get("DATA_DIR", None)) + + # For Bambu Connect, the config sits int the data dir. + configPath = dataPath + + # Create the config object, which will read an existing config or make a new one. + # If this is the first run, there will be no config file, so we need to create one. + logger.info(f"Init config object: {configPath}") + config = Config(configPath) + + # If there is a arg passed, always update or set it. + # This allows users to update the values after the image has ran the first time. + accessCode = os.environ.get("ACCESS_CODE", None) + if accessCode is not None: + logger.info(f"Setting Access Code: {accessCode}") + config.SetStr(Config.SectionBambu, Config.BambuAccessToken, accessCode) + # Ensure something is set now. + if config.GetStr(Config.SectionBambu, Config.BambuAccessToken, None) is None: + logger.error("") + logger.error("") + logger.error("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + logger.error(" You must pass the printer's Access Code as an env var.") + logger.error(" Use `docker run -e ACCESS_CODE=` or add it to your docker-compose file.") + logger.error("") + logger.error(" To find your Access Code -> https://octoeverywhere.com/s/access-code") + logger.error("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + logger.error("") + logger.error("") + # Sleep some, so we don't restart super fast and then exit. + time.sleep(5.0) + sys.exit(1) + + printerSn = os.environ.get("SERIAL_NUMBER", None) + if printerSn is not None: + logger.info(f"Setting Serial Number: {printerSn}") + config.SetStr(Config.SectionBambu, Config.BambuPrinterSn, printerSn) + # Ensure something is set now. + if config.GetStr(Config.SectionBambu, Config.BambuPrinterSn, None) is None: + logger.error("") + logger.error("") + logger.error("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + logger.error(" You must pass the printer's Serial Number as an env var.") + logger.error("Use `docker run -e SERIAL_NUMBER=` or add it to your docker-compose file.") + logger.error("") + logger.error(" To find your Serial Number -> https://octoeverywhere.com/s/bambu-sn") + logger.error("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + logger.error("") + logger.error("") + # Sleep some, so we don't restart super fast and then exit. + time.sleep(5.0) + sys.exit(1) + + # + # If we got here, the access token and serial number are set or were already set. + # We should be able to launch! + # + + # TEMP - Until we fix the issue where the plugin doesn't know the local LAN network address range, we need the + # user to pass the printer's IP to the plugin, since the auto scanning doesn't work. + # When this is fixed, we no longer need it to be passed. + printerId = os.environ.get("PRINTER_IP", None) + if printerId is not None: + logger.info(f"Setting Printer IP: {printerId}") + config.SetStr(Config.SectionCompanion, Config.CompanionKeyIpOrHostname, printerId) + # Ensure something is set now. + if config.GetStr(Config.SectionCompanion, Config.CompanionKeyIpOrHostname, None) is None: + logger.error("") + logger.error("") + logger.error("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + logger.error(" You must pass the printer's IP Address as an env var.") + logger.error(" Use `docker run -e PRINTER_IP=` or add it to your docker-compose file.") + logger.error("") + logger.error(" To find your Ip Address, use the display on your printer.") + logger.error("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + logger.error("") + logger.error("") + # Sleep some, so we don't restart super fast and then exit. + time.sleep(5.0) + sys.exit(1) + + # The port is always the same, so we just set the known Bambu Lab printer port. + if config.GetStr(Config.SectionCompanion, Config.CompanionKeyPort, None) is None: + config.SetStr(Config.SectionCompanion, Config.CompanionKeyPort, "8883") + + # We don't set the IP address of the printer. The Bambu Connect plugin will automatically find the printer + # on the local network using the Access Token and SN. By not setting the value, it will force it to search first. + + # Create the rest of the required dirs based in the data dir, since it's persistent. + localStoragePath = os.path.join(dataPath, "octoeverywhere-store") + CreateDirIfNotExists(localStoragePath) + logDirPath = os.path.join(dataPath, "logs") + CreateDirIfNotExists(logDirPath) + + # Build the launch string + launchConfig = { + "ServiceName" : "octoeverywhere", # Since there's only once service, use the default name. + "CompanionInstanceIdStr" : "1", # Since there's only once service, use the default service id. + "VirtualEnvPath" : virtualEnvPath, + "RepoRootFolder" : repoRootPath, + "LocalFileStoragePath" : localStoragePath, + "LogFolder" : logDirPath, + "ConfigFolder" : configPath, + } + + # Convert the launch string into what's expected. + launchConfigStr = json.dumps(launchConfig) + logger.info(f"Launch config: {launchConfigStr}") + base64EncodedLaunchConfig = base64.urlsafe_b64encode(bytes(launchConfigStr, "utf-8")).decode("utf-8") + + # Setup a ctl-c handler, so the docker container can be closed easily. + def signal_handler(sig, frame): + logger.info("OctoEverywhere Bambu Connect docker container stop requested") + sys.exit(0) + signal.signal(signal.SIGINT, signal_handler) + + # Instead of running the plugin in our process, we decided to launch a different process so it's clean and runs + # just like the plugin normally runs. + pythonPath = os.path.join(virtualEnvPath, os.path.join("bin", "python3")) + logger.info(f"Launch PY path: {pythonPath}") + result:subprocess.CompletedProcess = subprocess.run([pythonPath, "-m", "bambu_octoeverywhere", base64EncodedLaunchConfig], check=False) + + # Normally the process shouldn't exit unless it hits a bad error. + if result.returncode == 0: + logger.info(f"Bambu Connect plugin exited. Result: {result.returncode}") + else: + logger.error(f"Bambu Connect plugin exited with an error. Result: {result.returncode}") + + except Exception as e: + LogException("Exception while bootstrapping up OctoEverywhere Bambu Connect.", e) + + # Sleep for a bit, so if we are restarted we don't do it instantly. + time.sleep(3) + sys.exit(1) diff --git a/install.sh b/install.sh index c693935a..0deb56aa 100755 --- a/install.sh +++ b/install.sh @@ -83,13 +83,14 @@ OE_ENV="${HOME}/octoeverywhere-env" # The virtualenv is for our virtual package env we create # The curl requirement is for some things in this bootstrap script. # python3-venv is required for teh virtualenv command to fully work. +# This must stay in sync with the dockerfile package installs PKGLIST="python3 python3-pip virtualenv python3-venv curl" # For the Creality OS, we only need to install these. # We don't override the default name, since that's used by the Moonraker installer # Note that we DON'T want to use the same name as above (not even in this comment) because some parsers might find it. # Note we exclude virtualenv python3-venv curl because they can't be installed on the sonic pad via the package manager. -CREALITY_DEP_LIST="python3 python3-pip" - +CREALITY_DEP_LIST="python3 python3-pip python3-pillow" +SONIC_PAD_DEP_LIST="python3 python3-pip" # # Console Write Helpers @@ -241,7 +242,7 @@ install_or_update_system_dependencies() # The sonic pad always has opkg installed, so we can make sure these packages are installed. # We have had users report issues where this install gets stuck, using the no cache dir flag seems to fix it. opkg update || true - opkg install ${CREALITY_DEP_LIST} || true + opkg install ${SONIC_PAD_DEP_LIST} || true pip3 install -q --no-cache-dir virtualenv else # It seems a lot of printer control systems don't have the date and time set correctly, and then the fail @@ -266,6 +267,9 @@ install_or_update_system_dependencies() log_info "Ensuring zlib is install for Pillow, it's ok if this package install fails." sudo apt install --yes zlib1g-dev 2> /dev/null || true sudo apt install --yes zlib-devel 2> /dev/null || true + sudo apt install --yes python-imaging 2> /dev/null || true + sudo apt install --yes python3-pil 2> /dev/null || true + sudo apt install --yes python3-pillow 2> /dev/null || true fi log_info "System package install complete."