diff --git a/.vscode/settings.json b/.vscode/settings.json index 0cc07d9..33b4dfc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,7 @@ "bootstraper", "boundarydonotcross", "brotli", + "Buildroot", "certifi", "checkin", "classicwebcam", @@ -27,6 +28,7 @@ "crypo", "Damerell", "deps", + "devel", "devs", "DGRAM", "didnt", @@ -48,6 +50,7 @@ "gcode", "geteuid", "getpwnam", + "Guilouz", "hacky", "handshakesyn", "hostcommon", @@ -116,6 +119,7 @@ "octowebstreamhttphelperimpl", "octowebstreamimpl", "octowebstreamwshelper", + "openwrt", "opkg", "oprint", "ostype", diff --git a/install.sh b/install.sh index c09538e..0646269 100755 --- a/install.sh +++ b/install.sh @@ -25,16 +25,37 @@ # Set this to terminate on error. set -e -# Set if we are running the Creality OS or not. -# We use the presence of opkg as they key -IS_CREALITY_OS=false -if command -v opkg &> /dev/null + +# +# First things first, we need to detect what kind of OS we are running on. The script works by default with all +# Debian OSs, but some printers with embedded computers run strange embedded OSs, that have a lot of restrictions. +# These must stay in sync with update.sh and uninstall.sh! +# + +# The K1 and K1 Max run an OS called Buildroot. We detect that by looking at the os-release file. +# Quick note about bash vars, to support all OSs, we use the most compilable var version. This means we use ints +# where 1 is true and 0 is false, and we use comparisons like this [[ $IS_K1_OS -eq 1 ]] +IS_K1_OS=0 +if grep -Fqs "ID=buildroot" /etc/os-release +then + IS_K1_OS=1 + # On the K1, we always want the path to be /usr/data + # /usr/share has very limited space, so we don't want to use it. + # This is also where the github script installs moonraker and everything. + HOME="/usr/data" +fi + +# Next, we try to detect if this OS is the Sonic Pad OS. +# The Sonic Pad runs openwrt. We detect that by looking at the os-release file. +IS_SONIC_PAD_OS=0 +if grep -Fqs "sonic" /etc/openwrt_release then - IS_CREALITY_OS=true - # We install everything at this path, which is a fixed path where moonraker and klipper are also installed. + IS_SONIC_PAD_OS=1 + # On the K1, we always want the path to be /usr/share, this is where the rest of the klipper stuff is. HOME="/usr/share" fi + # Get the root path of the repo, aka, where this script is executing OCTOAPP_REPO_DIR=$(readlink -f $(dirname "$0")) @@ -54,7 +75,8 @@ OCTOAPP_ENV="${HOME}/octoapp-env" PKGLIST="python3 python3-pip virtualenv 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 -CREALITY_PKGLIST="python3 python3-pip" +# Note that we DON'T want to use the same name as above (not even in this comment) because some parsers might find it. +SONIC_PAD_DEP_LIST="python3 python3-pip" # @@ -89,38 +111,52 @@ log_info() echo -e "${c_green}$1${c_default}" } +log_blue() +{ + echo -e "${c_cyan}$1${c_default}" +} + log_blank() { echo "" } # -# It's important for consistency that the repo root is in /usr/share on Creality OS. +# It's important for consistency that the repo root is in set $HOME for the K1 and Sonic Pad # To enforce that, we will move the repo where it should be. -# ensure_creality_os_right_repo_path() { - if $IS_CREALITY_OS + # TODO - re-enable this for the || [[ $IS_K1_OS -eq 1 ]] after the github script updates. + if [[ $IS_SONIC_PAD_OS -eq 1 ]] then - EXPECT='/usr/share/' - if [[ "$OCTOAPP_REPO_DIR" != *"$EXPECT"* ]]; then - log_error "For the Creality OS this repo must be cloned into /usr/share/octoapp." + # Due to the K1 shell, we have to use grep rather than any bash string contains syntax. + if echo $OCTOAPP_REPO_DIR |grep "$HOME" - > /dev/null + then + return + else + log_info "Current path $OCTOAPP_REPO_DIR" + log_error "For the Creality devices the OctoEverywhere repo must be cloned into $HOME/octoapp" log_important "Moving the repo and running the install again..." - cd /usr/share + cd $HOME # Send errors to null, if the folder already exists this will fail. - git clone https://github.com/QuinnDamerell/OctoPrint-OctoApp octoapp 2>/dev/null || true - cd /usr/share/octoapp + git clone https://github.com/crysxd/OctoApp-Plugin octoapp 2>/dev/null || true + cd $HOME/octoapp # Ensure state git reset --hard git checkout master git pull # Run the install, if it fails, still do the clean-up of this repo. - ./install.sh "$@" || true + if [[ $IS_K1_OS -eq 1 ]] + then + sh ./install.sh "$@" || true + else + ./install.sh "$@" || true + fi installExit=$? # Delete this folder. rm -fr $OCTOAPP_REPO_DIR # Take the user back to the new install folder. - cd /usr/share/ + cd $HOME # Exit. exit $installExit fi @@ -133,9 +169,10 @@ ensure_creality_os_right_repo_path() ensure_py_venv() { log_header "Checking Python Virtual Environment For OctoApp..." - # If the service is already running, we can't recreate the virtual env - # so if it exists, don't try to create it. - if [ -d $OCTOAPP_ENV ]; then + # If the service is already running, we can't recreate the virtual env so if it exists, don't try to create it. + # Note that we check the bin folder exists in the path, since we mkdir the folder below but virtualenv might fail and leave it empty. + OCTOAPP_BIN_PATH="$OCTOAPP_ENV/bin" + if [ -d $OCTOAPP_BIN_PATH ]; then # This virtual env refresh fails on some devices when the service is already running, so skip it for now. # This only refreshes the virtual environment package anyways, so it's not super needed. #log_info "Virtual environment found, updating to the latest version of python." @@ -145,7 +182,14 @@ ensure_py_venv() log_info "No virtual environment found, creating one now." mkdir -p "${OCTOAPP_ENV}" - virtualenv -p /usr/bin/python3 --system-site-packages "${OCTOAPP_ENV}" + if [[ $IS_K1_OS -eq 1 ]] + then + # The K1 requires we setup the virtualenv like this. + python3 /usr/lib/python3.8/site-packages/virtualenv.py -p /usr/bin/python3 --system-site-packages "${OCTOAPP_ENV}" + else + # Everything else can use this more modern style command. + virtualenv -p /usr/bin/python3 --system-site-packages "${OCTOAPP_ENV}" + fi } # @@ -155,10 +199,17 @@ install_or_update_system_dependencies() { log_header "Checking required system packages are installed..." - if $IS_CREALITY_OS + if [[ $IS_K1_OS -eq 1 ]] + then + # The K1 by default doesn't have any package manager. In some cases + # the user might install opkg via the 3rd party moonraker installer script. + # But in general, PY will already be installed, so there's no need to try. + # On the K1, the only we thing we ensure is that virtualenv is installed via pip. + pip3 install virtualenv + elif [[ $IS_SONIC_PAD_OS -eq 1 ]] then - # On the Creality OS, we only need to run these installers - opkg install ${CREALITY_PKGLIST} + # The sonic pad always has opkg installed, so we can make sure these packages are installed. + opkg install ${SONIC_PAD_DEP_LIST} pip3 install virtualenv else log_important "You might be asked for your system password - this is required to install the required system packages." @@ -211,7 +262,7 @@ install_or_update_python_env() # check_for_octoprint() { - if $IS_CREALITY_OS + if [[ $IS_SONIC_PAD_OS -eq 1 ]] || [[ $IS_K1_OS -eq 1 ]] then # Skip, there's no need and we don't have curl. return @@ -316,9 +367,14 @@ log_header " Based on the OctoEverywhere Companion" log_blank log_blank -if $IS_CREALITY_OS +# These are helpful for debugging. +if [[ $IS_SONIC_PAD_OS -eq 1 ]] +then + echo "Running in Sonic Pad OS mode" +fi +if [[ $IS_K1_OS -eq 1 ]] then - echo "Running in Creality OS mode" + echo "Running in K1 and K1 Max OS mode" fi # Before anything, make sure this repo is cloned into the correct path on Creality OS devices. @@ -357,7 +413,7 @@ cd ${OCTOAPP_REPO_DIR} > /dev/null # Disable the PY cache files (-B), since they will be written as sudo, since that's what we launch the PY # installer as. The PY installer must be sudo to write the service files, but we don't want the # complied files to stay in the repo with sudo permissions. -if $IS_CREALITY_OS +if [[ $IS_SONIC_PAD_OS -eq 1 ]] || [[ $IS_K1_OS -eq 1 ]] then # Creality OS only has a root user and we can't use sudo. ${OCTOAPP_ENV}/bin/python3 -B -m moonraker_installer ${PY_LAUNCH_JSON} diff --git a/moonraker_installer/Configure.py b/moonraker_installer/Configure.py index e6a5559..1304641 100644 --- a/moonraker_installer/Configure.py +++ b/moonraker_installer/Configure.py @@ -14,7 +14,7 @@ # The goal of this class is the take the context object from the Discovery Gen2 phase to the Phase 3. class Configure: - # This is the common service prefix we use for all of our service file names. + # This is the common service prefix (or word used in the file name) we use for all of our service file names. # This MUST be used for all instances running on this device, both local plugins and companions. # This also MUST NOT CHANGE, as it's used by the Updater logic to find all of the locally running services. c_ServiceCommonNamePrefix = "octoapp" @@ -28,11 +28,14 @@ def Run(self, context:Context): # For observers, we use the observer id, with a unique prefix to separate it from any possible local moonraker installs # Note that the moonraker service suffix can be numbers or letters, so we use the same rules. serviceSuffixStr = f"-companion{context.ObserverInstanceId}" - elif context.IsCrealityOs(): - # For Creality OS, we know the format of the service file is a bit different. - # It is moonraker_service or moonraker_service. + elif context.OsType == OsTypes.SonicPad: + # For Sonic Pad, we know the format of the service file is a bit different. + # For the SonicIt is moonraker_service or moonraker_service. if "." in context.MoonrakerServiceFileName: serviceSuffixStr = context.MoonrakerServiceFileName.split(".")[1] + elif context.OsType == OsTypes.K1: + # For the k1, there's only every one moonraker instance, so this isn't needed. + pass else: # Now we need to figure out the instance suffix we need to use. # To keep with Kiauh style installs, each moonraker instances will be named moonraker-.service. @@ -70,6 +73,7 @@ def Run(self, context:Context): # For now we assume the folder structure is the standard Klipper folder config, # thus the full moonraker config path will be .../something_data/config/moonraker.conf # Based on that, we will define the config folder and the printer data root folder. + # Note that the K1 uses this standard folder layout as well. context.PrinterDataConfigFolder = Util.GetParentDirectory(context.MoonrakerConfigFilePath) context.PrinterDataFolder = Util.GetParentDirectory(context.PrinterDataConfigFolder) Logger.Debug("Printer data folder: "+context.PrinterDataFolder) @@ -77,19 +81,24 @@ def Run(self, context:Context): # This is the name of our service we create. If the port is the default port, use the default name. # Otherwise, add the port to keep services unique. - if context.IsCrealityOs(): - # For creality os, since the service is setup differently, follow the conventions of it. + if context.OsType == OsTypes.SonicPad: + # For Sonic Pad, since the service is setup differently, follow the conventions of it. # Both the service name and the service file name must match. # The format is _service # NOTE! For the Update class to work, the name must start with Configure.c_ServiceCommonNamePrefix - context.ServiceName = Configure.c_ServiceCommonNamePrefix + "_service" + context.ServiceName = Configure.c_ServiceCommonName + "_service" if len(serviceSuffixStr) != 0: context.ServiceName= context.ServiceName + "." + serviceSuffixStr context.ServiceFilePath = os.path.join(Paths.CrealityOsServiceFilePath, context.ServiceName) + elif context.OsType == OsTypes.K1: + # For the k1, there's only ever one moonraker and we know the exact service naming convention. + # Note we use 66 to ensure we start after moonraker. + context.ServiceName = f"S66{Configure.c_ServiceCommonName}_service" + context.ServiceFilePath = os.path.join(Paths.CrealityOsServiceFilePath, context.ServiceName) else: # For normal setups, use the convention that Klipper users # NOTE! For the Update class to work, the name must start with Configure.c_ServiceCommonNamePrefix - context.ServiceName = Configure.c_ServiceCommonNamePrefix + serviceSuffixStr + context.ServiceName = Configure.c_ServiceCommonName + serviceSuffixStr context.ServiceFilePath = os.path.join(Paths.SystemdServiceFilePath, context.ServiceName+".service") # Since the moonraker config folder is unique to the moonraker instance, we will put our storage in it. diff --git a/moonraker_installer/Context.py b/moonraker_installer/Context.py index b100c09..31272e7 100644 --- a/moonraker_installer/Context.py +++ b/moonraker_installer/Context.py @@ -1,6 +1,5 @@ import os import json -import subprocess from enum import Enum from .Logging import Logger @@ -11,7 +10,7 @@ class OsTypes(Enum): Debian = 1 SonicPad = 2 - K1 = 2 # Both the K1 and K1 Max + K1 = 3 # Both the K1 and K1 Max # This class holds the context of the installer, meaning all of the target vars and paths @@ -66,6 +65,9 @@ def __init__(self) -> None: # Parsed from the command line args, if set, the plugin install should be in update mode. self.IsUpdateMode:bool = False + # Parsed from the command line args, if set, the plugin install should be in uninstall mode. + self.IsUninstallMode:bool = False + # # Generation 2 @@ -230,6 +232,9 @@ def ParseCmdLineArgs(self): elif rawArg.lower() == "update": Logger.Info("Setup running in update mode.") self.IsUpdateMode = True + elif rawArg.lower() == "uninstall": + Logger.Info("Setup running in uninstall mode.") + self.IsUninstallMode = True else: raise Exception("Unknown argument found. Use install.sh -help for options.") @@ -257,21 +262,32 @@ def _ValidateString(self, s:str, error:str): def DetectOsType(self): # - # Note! This should closely resemble the ostype.py class in the plugin. + # Note! This should closely resemble the ostype.py class in the plugin and the logic in the ./install.sh script! # - # We use the presence of opkg to figure out if we are running no Creality OS - # This is the same thing we do in the install and update scripts. - result = subprocess.run("command -v opkg", check=False, shell=True, capture_output=True, text=True) - if result.returncode == 0: - # This is a Creality OS. - # Now we need to detect if it's a Sonic Pad or a K1 - if os.path.exists(Paths.CrealityOsUserDataPath_SonicPad): - self.OsType = OsTypes.SonicPad - return - if os.path.exists(Paths.CrealityOsUserDataPath_K1): - self.OsType = OsTypes.K1 - return - raise Exception("We detected a Creality OS, but can't determine the device type. Please contact support.") + + # For the k1 and k1 max, we look for the "buildroot" OS. + if os.path.exists("/etc/os-release"): + with open("/etc/os-release", "r", encoding="utf-8") as osInfo: + lines = osInfo.readlines() + for l in lines: + if "ID=buildroot" in l: + # If we find it, make sure the user data path is where we expect it to be, and we are good. + if os.path.exists(Paths.CrealityOsUserDataPath_K1): + self.OsType = OsTypes.K1 + return + raise Exception("We detected a K1 or K1 Max OS, but can't determine the data path. Please contact support.") + + # For the Sonic Pad, we look for the openwrt os + if os.path.exists("/etc/openwrt_release"): + with open("/etc/openwrt_release", "r", encoding="utf-8") as osInfo: + lines = osInfo.readlines() + for l in lines: + if "sonic" in l: + # If we find it, make sure the user data path is where we expect it to be, and we are good. + if os.path.exists(Paths.CrealityOsUserDataPath_SonicPad): + self.OsType = OsTypes.SonicPad + return + raise Exception("We detected a Sonic Pad, but can't determine the data path. Please contact support.") # The OS is debian self.OsType = OsTypes.Debian diff --git a/moonraker_installer/Discovery.py b/moonraker_installer/Discovery.py index 83e7c26..567194e 100644 --- a/moonraker_installer/Discovery.py +++ b/moonraker_installer/Discovery.py @@ -37,9 +37,12 @@ def FindTargetMoonrakerFiles(self, context:Context): # If we are here, we either have no service file name but a config path, or neither. pairList = [] - if context.IsCrealityOs(): - # For the Creality OS, we know exactly where the files are, so we don't need to do a lot of searching. - pairList = self._CrealityOsFindAllServiceFilesAndPairings(context) + if context.OsType == OsTypes.SonicPad: + # For the Sonic Pad, we know exactly where the files are, so we don't need to do a lot of searching. + pairList = self._SonicPadFindAllServiceFilesAndPairings(context) + elif context.OsType == OsTypes.K1: + # For the K1 and K1 max, we know exactly where the files are, so we don't need to do a lot of searching. + pairList = self._K1FindAllServiceFilesAndPairings(context) else: # To start, we will enumerate all moonraker service files we can find and their possible moonraker config parings. # For details about why we need these, read the readme.py file in this module. @@ -74,7 +77,7 @@ def FindTargetMoonrakerFiles(self, context:Context): Logger.Warn("An instance of OctoApp must be installed for every Moonraker instance, so this installer must be ran for each instance individually.") Logger.Blank() if context.IsCrealityOs(): - Logger.Header("Creality Users - If you only have one printer setup, select 1) moonraker_service") + Logger.Header("Sonic Pad Users - If you're only using one printer, select number 1") Logger.Blank() # Print the config files found. @@ -140,10 +143,10 @@ def _FindAllServiceFilesAndPairings(self) -> list: return results - # A special function for Creality OS installs, since the location of the printer data is much more well known. + # A special function for Sonic Pad installs, since the location of the printer data is much more well known. # Note this must return the same result list as _FindAllServiceFilesAndPairings - def _CrealityOsFindAllServiceFilesAndPairings(self, context:Context): - # For the Creality OS, we know the name of the service files and the path. + def _SonicPadFindAllServiceFilesAndPairings(self, context:Context): + # For the Sonic Pad, we know the name of the service files and the path. # They will be named moonraker_service and moonraker_service.* serviceFiles = self._FindAllFiles(Paths.CrealityOsServiceFilePath, "moonraker_service") @@ -158,13 +161,7 @@ def _CrealityOsFindAllServiceFilesAndPairings(self, context:Context): numberSuffix = moonrakerServiceFileName.split(".")[1] # Figure out the possible path by the OS type. - moonrakerConfigFilePath = "" - if context.OsType == OsTypes.SonicPad: - moonrakerConfigFilePath = f"{Paths.CrealityOsUserDataPath_SonicPad}/printer_config{numberSuffix}/moonraker.conf" - elif context.OsType == OsTypes.K1: - # Check for the file using the k1 path. - moonrakerConfigFilePath = f"{Paths.CrealityOsUserDataPath_K1}/printer_data{numberSuffix}/config/moonraker.conf" - + moonrakerConfigFilePath = f"{Paths.CrealityOsUserDataPath_SonicPad}/printer_config{numberSuffix}/moonraker.conf" if os.path.exists(moonrakerConfigFilePath): Logger.Debug(f"Found moonraker config file {moonrakerConfigFilePath}") results.append(ServiceFileConfigPathPair(moonrakerServiceFileName, moonrakerConfigFilePath)) @@ -174,6 +171,53 @@ def _CrealityOsFindAllServiceFilesAndPairings(self, context:Context): return results + # A special function for K1 and K1 max installs. + # Note this must return the same result list as _FindAllServiceFilesAndPairings + def _K1FindAllServiceFilesAndPairings(self, context:Context): + + # The K1 doesn't have moonraker by default, but most users use a 3rd party script to install it. + # For now we will just assume the setup that the script produces. + moonrakerServiceFileName = None + moonrakerConfigFilePath = None + + # The service file should be something like this "/etc/init.d/S56moonraker_service" + for fileOrDirName in os.listdir(Paths.CrealityOsServiceFilePath): + fullFileOrDirPath = os.path.join(Paths.CrealityOsServiceFilePath, fileOrDirName) + if os.path.isfile(fullFileOrDirPath) and os.path.islink(fullFileOrDirPath) is False: + if "moonraker" in fileOrDirName.lower(): + Logger.Debug(f"Found service file: {fullFileOrDirPath}") + moonrakerServiceFileName = fileOrDirName + break + + # The moonraker config file should be here: "/usr/data/printer_data/config/moonraker.conf" + moonrakerConfigFilePath = "/usr/data/printer_data/config/moonraker.conf" + if os.path.isfile(moonrakerConfigFilePath): + Logger.Debug(f"Found moonraker config file: {moonrakerConfigFilePath}") + else: + moonrakerConfigFilePath = None + + # Check if we are missing either. If so, the user most likely didn't install Moonraker. + if moonrakerConfigFilePath is None or moonrakerServiceFileName is None: + Logger.Blank() + Logger.Blank() + Logger.Blank() + Logger.Header("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + Logger.Error( " Moonraker Not Found! ") + Logger.Warn( " The K1 and K1 Max don't have Moonraker or a web frontend installed by default. ") + Logger.Warn( " Moonraker and a frontend like Fluidd or Mainsail are required for OctoEverywhere. ") + Logger.Blank() + Logger.Purple(" We have a step-by-step tutorial on how to install them in 30 seconds. ") + Logger.Purple(" Follow this link: https://oe.ink/s/k1 ") + Logger.Blank() + Logger.Header("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + Logger.Blank() + Logger.Blank() + Logger.Blank() + raise Exception("Moonraker isn't installed on this K1 or K1 Max. Use the guide on this link to install it: https://oe.ink/s/k1") + + return [ ServiceFileConfigPathPair(moonrakerServiceFileName, moonrakerConfigFilePath) ] + + def _TryToFindMatchingMoonrakerConfig(self, serviceFilePath:str) -> str or None: try: # Using the service file to try to find the moonraker config that's associated. diff --git a/moonraker_installer/Frontend.py b/moonraker_installer/Frontend.py index 87e4aa7..f6f05e8 100644 --- a/moonraker_installer/Frontend.py +++ b/moonraker_installer/Frontend.py @@ -15,6 +15,7 @@ class KnownFrontends(Enum): Unknown = 1 Mainsail = 2 Fluidd = 3 + Creality = 4 # This is Creality's K1 default web interface (not nearly as good as the others) # Makes to str() cast not to include the class name. def __str__(self): @@ -200,8 +201,8 @@ def _DiscoverKnownFrontends(self, ipOrHostname:str): # We can't scan all ports, it will take to long. Instead we will scan known ports used by known common setups. # Note these ports are in order of importance, where ports near the top are more likely to be what the user wants. knownFrontendPorts = [ - 4409, # On the K1, this is the mainsail port. - 4408, # On the K1, this is the Fluidd port. + 4408, # On the K1, this is the Fluidd port. (This is set by the install script from GitHub Guilouz/Creality-K1-and-K1-Max) + 4409, # On the K1, this is the mainsail port. (This is set by the install script from GitHub Guilouz/Creality-K1-and-K1-Max) 80, # On most devices, this is the port the frontend is on. But note on the K1, this is Creality's own special frontend, most users don't want. 81, # A common port for an secondary frontend to run on, like Fluidd or Mainsail. 443, # Not ideal, but https might be here. @@ -246,6 +247,8 @@ def CheckIfValidFrontend(self, ipOrHostname:str, portStr:str, timeoutSec:float = frontend = KnownFrontends.Mainsail elif "fluidd" in htmlLower: frontend = KnownFrontends.Fluidd + elif "creality" in htmlLower: + frontend = KnownFrontends.Creality else: Logger.Debug(f"Unknown frontend type. html: {result.text}") except Exception as e: diff --git a/moonraker_installer/Installer.py b/moonraker_installer/Installer.py index 85a6f48..a9ef73b 100644 --- a/moonraker_installer/Installer.py +++ b/moonraker_installer/Installer.py @@ -11,6 +11,7 @@ from .Updater import Updater from .Permissions import Permissions from .Frontend import Frontend +from .Uninstall import Uninstall class Installer: @@ -100,6 +101,12 @@ def _RunInternal(self): update.DoUpdate(context) return + # If we are running as an uninstaller, run that logic and exit. + if context.IsUninstallMode: + uninstall = Uninstall() + uninstall.DoUninstall(context) + return + # Next step is to discover and fill out the moonraker config file path and service file name. # If we are doing an observer setup, we need the user to help us input the details to the external moonraker IP. # This is the hardest part of the setup, because it's highly dependent on the system and different moonraker setups. diff --git a/moonraker_installer/Permissions.py b/moonraker_installer/Permissions.py index 1df34ac..1bc72dc 100644 --- a/moonraker_installer/Permissions.py +++ b/moonraker_installer/Permissions.py @@ -31,7 +31,7 @@ def EnsureRunningAsRootOrSudo(self, context:Context) -> None: # IT'S NOT OK TO INSTALL AS ROOT for the normal klipper setup. # This is because the moonraker updater system needs to get able to access the .git repo. # If the repo is owned by the root, it can't do that. - # For the Creality OS setup, the only user is root, so it's ok. + # For the Sonic Pad and K1 setup, the only user is root, so it's ok. if context.IsObserverSetup is False and context.IsCrealityOs() is False: if context.UserName.lower() == Permissions.c_RootUserName: raise Exception("The installer was ran under the root user, this will cause problems with Moonraker. Please run the installer script as a non-root user, usually that's the `pi` user.") diff --git a/moonraker_installer/Service.py b/moonraker_installer/Service.py index d081219..9bd0904 100644 --- a/moonraker_installer/Service.py +++ b/moonraker_installer/Service.py @@ -43,6 +43,8 @@ def Install(self, context:Context): self._InstallDebian(context, argsJsonBase64) elif context.OsType == OsTypes.SonicPad: self._InstallSonicPad(context, argsJsonBase64) + elif context.OsType == OsTypes.K1: + self._InstallK1(context, argsJsonBase64) else: raise Exception("Service install is not supported for this OS type yet. Contact support!") @@ -84,8 +86,7 @@ def _InstallDebian(self, context:Context, argsJsonBase64): # Stop and start to restart any running services. Logger.Info("Starting service...") - Util.RunShellCommand("systemctl stop "+context.ServiceName) - Util.RunShellCommand("systemctl start "+context.ServiceName) + Service.RestartDebianService(context.ServiceName) Logger.Info("Service setup and start complete!") @@ -130,10 +131,126 @@ def _InstallSonicPad(self, context:Context, argsJsonBase64): Util.RunShellCommand(f"chmod +x {context.ServiceFilePath}") Logger.Info("Starting the service...") - # These some times fail depending on the state of the service, which is fine. - Util.RunShellCommand(f"{context.ServiceFilePath} stop", False) - Util.RunShellCommand(f"{context.ServiceFilePath} reload", False) - Util.RunShellCommand(f"{context.ServiceFilePath} enable" , False) - Util.RunShellCommand(f"{context.ServiceFilePath} start") + Service.RestartSonicPadService(context.ServiceFilePath) + + Logger.Info("Service setup and start complete!") + + + # Install for k1 and k1 max + def _InstallK1(self, context:Context, argsJsonBase64): + # On the K1 start-stop-daemon is used to run services. + # But, to launch our service, we have to use the py module run, which requires a environment var to be + # set for PYTHONPATH. The command can't set the env, so we write this script to our store, where we then run + # the service from. + runScriptFilePath = os.path.join(context.LocalFileStorageFolder, "run-octoeverywhere-service.sh") + runScriptContents = f'''\ +#!/bin/sh +# +# Runs OctoEverywhere service on the K1 and K1 max. +# The start-stop-daemon can't handle setting env vars, but the python module run command needs PYTHONPATH to be set +# to find the module correctly. Thus we point the service to this script, which sets the env and runs py. +# +# Don't edit this script, it's generated by the ./install.sh script during the OE install and update.. +# +PYTHONPATH={context.RepoRootFolder} {context.VirtualEnvPath}/bin/python3 -m moonraker_octoeverywhere "{argsJsonBase64}" +exit $? +''' + # Write the required service file, make it point to our run script. + serviceFileContents = '''\ +#!/bin/sh +# +# Starts OctoEverywhere service. +# + +PID_FILE=/var/run/octoeverywhere.pid + +start() { + HOME=/root start-stop-daemon -S -q -b -m -p $PID_FILE --exec '''+runScriptFilePath+''' +} +stop() { + start-stop-daemon -K -q -p $PID_FILE +} +restart() { + stop + sleep 1 + start +} + +case "$1" in + start) + start + ;; + stop) + stop + ;; + restart|reload) + restart + ;; + *) + echo "Usage: $0 {start|stop|restart}" + exit 1 +esac + +exit $? +}} +''' + if context.SkipSudoActions: + Logger.Warn("Skipping service file creation, registration, and starting due to skip sudo actions flag.") + return + + # Write the run script + Logger.Debug("Run script file contents to write: "+runScriptContents) + Logger.Info("Creating service run script...") + with open(runScriptFilePath, "w", encoding="utf-8") as runScript: + runScript.write(runScriptContents) + + # Make the script executable. + Logger.Info("Making the run script executable...") + Util.RunShellCommand(f"chmod +x {runScriptFilePath}") + + # The file name is specific to the K1 and it's set in the Configure step. + Logger.Debug("Service config file contents to write: "+serviceFileContents) + Logger.Info("Creating service file "+context.ServiceFilePath+"...") + with open(context.ServiceFilePath, "w", encoding="utf-8") as serviceFile: + serviceFile.write(serviceFileContents) + + # Make the script executable. + Logger.Info("Making the service executable...") + Util.RunShellCommand(f"chmod +x {context.ServiceFilePath}") + + # Use the common restart logic. + Logger.Info("Starting the service...") + Service.RestartK1Service(context.ServiceFilePath) Logger.Info("Service setup and start complete!") + + + @staticmethod + def RestartK1Service(serviceFilePath:str, throwOnBadReturnCode = True): + # These some times fail depending on the state of the service, which is fine. + Util.RunShellCommand(f"{serviceFilePath} stop", False) + + # Using this start-stop-daemon system, if we issue too many start, stop, restarts in quickly, the PID file gets out of + # sync and multiple process can spawn. That's bad because the websockets will disconnect each other. + # So we will run this run command to ensure that all of the process are dead, before we start a new one. + Util.RunShellCommand("ps -ef | grep 'moonraker_octoeverywhere' | grep -v grep | awk '{print $1}' | xargs -r kill -9", throwOnBadReturnCode) + Util.RunShellCommand(f"{serviceFilePath} start", throwOnBadReturnCode) + + + @staticmethod + def RestartSonicPadService(serviceFilePath:str, throwOnBadReturnCode = True): + # These some times fail depending on the state of the service, which is fine. + Util.RunShellCommand(f"{serviceFilePath} stop", False) + Util.RunShellCommand(f"{serviceFilePath} reload", False) + Util.RunShellCommand(f"{serviceFilePath} enable" , False) + Util.RunShellCommand(f"{serviceFilePath} start", throwOnBadReturnCode) + + + @staticmethod + def RestartDebianService(serviceName:str, throwOnBadReturnCode = True): + (returnCode, output, errorOut) = Util.RunShellCommand("systemctl stop "+serviceName, throwOnBadReturnCode) + if returnCode != 0: + Logger.Warn(f"Service {serviceName} might have failed to stop. Output: {output} Error: {errorOut}") + (returnCode, output, errorOut) = Util.RunShellCommand("systemctl start "+serviceName, throwOnBadReturnCode) + if returnCode != 0: + Logger.Warn(f"Service {serviceName} might have failed to start. Output: {output} Error: {errorOut}") diff --git a/moonraker_installer/Uninstall.py b/moonraker_installer/Uninstall.py new file mode 100644 index 0000000..8f4b8cc --- /dev/null +++ b/moonraker_installer/Uninstall.py @@ -0,0 +1,161 @@ +import os +import shutil + +from .Context import Context +from .Context import OsTypes +from .Logging import Logger +from .Configure import Configure +from .Paths import Paths +from .Util import Util + +class Uninstall: + + def DoUninstall(self, context:Context): + + Logger.Blank() + Logger.Blank() + Logger.Header("You're about to uninstall OctoEverywhere.") + Logger.Info ("This printer ID will be deleted, but you can always reinstall the plugin and re-add this printer.") + Logger.Blank() + r = input("Are you want to uninstall? [y/n]") + r = r.lower().strip() + if r != "y": + Logger.Info("Uninstall canceled.") + Logger.Blank() + return + Logger.Blank() + Logger.Blank() + Logger.Header("Starting OctoEverywhere uninstall") + + # Since all service names must use the same identifier in them, we can find any services using the same search. + foundOeServices = [] + fileAndDirList = sorted(os.listdir(Paths.GetServiceFileFolderPath(context))) + for fileOrDirName in fileAndDirList: + Logger.Debug(f" Searching for OE services to remove, found: {fileOrDirName}") + if Configure.c_ServiceCommonName in fileOrDirName.lower(): + foundOeServices.append(fileOrDirName) + + if len(foundOeServices) == 0: + Logger.Warn("No local plugins or companions were found to remove.") + return + + # TODO - We need to cleanup more, but for now, just make sure any services are shutdown. + Logger.Info("Stopping services...") + for serviceFileName in foundOeServices: + if context.OsType == OsTypes.SonicPad: + # We need to build the fill name path + serviceFilePath = os.path.join(Paths.CrealityOsServiceFilePath, serviceFileName) + Logger.Debug(f"Full service path: {serviceFilePath}") + Logger.Info(f"Stopping and deleting {serviceFileName}...") + Util.RunShellCommand(f"{serviceFilePath} stop", False) + Util.RunShellCommand(f"{serviceFilePath} disable", False) + os.remove(serviceFilePath) + elif context.OsType == OsTypes.K1: + # We need to build the fill name path + serviceFilePath = os.path.join(Paths.CrealityOsServiceFilePath, serviceFileName) + Logger.Debug(f"Full service path: {serviceFilePath}") + Logger.Info(f"Stopping and deleting {serviceFileName}...") + Util.RunShellCommand(f"{serviceFilePath} stop", False) + Util.RunShellCommand("ps -ef | grep 'moonraker_octoeverywhere' | grep -v grep | awk '{print $1}' | xargs -r kill -9", False) + os.remove(serviceFilePath) + elif context.OsType == OsTypes.Debian: + Logger.Info(f"Stopping and deleting {serviceFileName}...") + Util.RunShellCommand("systemctl stop "+serviceFileName, False) + Util.RunShellCommand("systemctl disable "+serviceFileName, False) + else: + raise Exception("This OS type doesn't support uninstalling at this time.") + + # For now, systems that have fixed setups, set will remove files + # TODO - We need to do a total cleanup of all files. + if context.OsType == OsTypes.K1: + self.DoK1FileCleanup() + + Logger.Blank() + Logger.Blank() + Logger.Header("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + Logger.Info( " OctoEverywhere Uninstall Complete ") + Logger.Info( " We will miss you, please come back anytime! ") + Logger.Header("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + Logger.Blank() + Logger.Blank() + + + # For the K1, we know a the fixed paths where everything must be installed. + # There's only one instance of moonraker, so there's no need to worry about multiple setups. + def DoK1FileCleanup(self): + # In modern setups, the env is here. In very few early installs, it's in /usr/share + self.DeleteDirOrFileIfExists("/usr/data/octoeverywhere-env") + self.DeleteDirOrFileIfExists("/usr/share/octoeverywhere-env") + + # For all installs, the storage folder will be here + self.DeleteDirOrFileIfExists("/usr/data/printer_data/octoeverywhere-store") + # Delete any log files we have, there might be some rolling backups. + self.DeleteAllFilesContaining("/usr/data/printer_data/logs", "octoeverywhere") + # Delete any config files. + self.DeleteAllFilesContaining("/usr/data/printer_data/config", "octoeverywhere") + # Remove our system config file include in the moonraker file, if there is one. + self.RemoveOctoEverywhereSystemCfgInclude("/usr/data/printer_data/config/moonraker.conf") + # Delete the installer file if it's still there + self.DeleteDirOrFileIfExists("/usr/data/octoeverywhere-installer.log") + + # Finally, remove the repo root. Note that /usr/share was used in very few early installs. + self.DeleteDirOrFileIfExists("/usr/data/octoeverywhere") + self.DeleteDirOrFileIfExists("/usr/share/octoeverywhere") + + + # Deletes a file or directory, if it exists. + def DeleteDirOrFileIfExists(self, path:str): + Logger.Debug(f"Deleting file or dir [{path}]") + try: + if os.path.exists(path) is False: + return + if os.path.isdir(path): + shutil.rmtree(path) + elif os.path.isfile(path): + os.remove(path) + else: + Logger.Error(f"DeleteDirOrFileIfExists can delete file type {path}") + except Exception as e: + Logger.Error(f"DeleteDirOrFileIfExists failed to delete {path} - {e}") + + + # Deletes any in the dir that match the search string. + def DeleteAllFilesContaining(self, path:str, searchStr:str): + try: + searchLower = searchStr.lower() + for fileName in os.listdir(path): + fullpath = os.path.join(path, fileName) + if os.path.isfile(fullpath): + if searchLower in fileName.lower(): + Logger.Debug(f"Deleting matched file: {fullpath}") + os.remove(fullpath) + except Exception as e: + Logger.Error(f"DeleteAllFilesContaining failed to delete {path} - {e}") + + + # Deletes the octoEverywhere-system.cfg file include if it exists in the moonraker config. + def RemoveOctoEverywhereSystemCfgInclude(self, moonrakerConfigPath:str): + try: + Logger.Debug(f"Looking for OE system config include in {moonrakerConfigPath}") + output = [] + lineFound = False + with open(moonrakerConfigPath, encoding="utf-8") as f: + lines = f.readlines() + for l in lines: + if "octoeverywhere-system.cfg" in l.lower(): + lineFound = True + else: + output.append(l) + + if lineFound is False: + Logger.Debug("system config include not found.") + return + + # Output the file without the line. + with open(moonrakerConfigPath, encoding="utf-8", mode="w") as f: + for o in output: + f.write(f"{o}") + + Logger.Debug(f"Removed octoeverywhere system config from {moonrakerConfigPath}") + except Exception as e: + Logger.Error(f"DeleteAllFilesContaining failed to delete {moonrakerConfigPath} - {e}") diff --git a/moonraker_installer/Updater.py b/moonraker_installer/Updater.py index 7973701..dcca0e6 100644 --- a/moonraker_installer/Updater.py +++ b/moonraker_installer/Updater.py @@ -7,8 +7,8 @@ from .Context import OsTypes from .Logging import Logger from .Configure import Configure -from .Util import Util from .Paths import Paths +from .Service import Service # # This class is responsible for doing updates for all plugins and companions on this local system. @@ -27,14 +27,14 @@ class Updater: def DoUpdate(self, context:Context): Logger.Header("Starting Update Logic") - # Enumerate all service file to find any local plugins, Creality OS, and companion service files, since all service files use the same common prefix. + # Enumerate all service file to find any local plugins, Sonic Pad plugins, and companion service files, since all service files contain this name. # Note GetServiceFileFolderPath will return dynamically based on the OsType detected. # Use sorted, so the results are in a nice user presentable order. foundOeServices = [] fileAndDirList = sorted(os.listdir(Paths.GetServiceFileFolderPath(context))) for fileOrDirName in fileAndDirList: Logger.Debug(f" Searching for OE services to update, found: {fileOrDirName}") - if fileOrDirName.lower().startswith(Configure.c_ServiceCommonNamePrefix): + if Configure.c_ServiceCommonName in fileOrDirName.lower(): foundOeServices.append(fileOrDirName) if len(foundOeServices) == 0: @@ -51,11 +51,14 @@ def DoUpdate(self, context:Context): # We need to build the fill name path serviceFilePath = os.path.join(Paths.CrealityOsServiceFilePath, s) Logger.Debug(f"Full service path: {serviceFilePath}") - Util.RunShellCommand(f"{serviceFilePath} restart") + Service.RestartSonicPadService(serviceFilePath, False) + elif context.OsType == OsTypes.K1: + # We need to build the fill name path + serviceFilePath = os.path.join(Paths.CrealityOsServiceFilePath, s) + Logger.Debug(f"Full service path: {serviceFilePath}") + Service.RestartK1Service(serviceFilePath, False) elif context.OsType == OsTypes.Debian: - (returnCode, output, errorOut) = Util.RunShellCommand("systemctl restart "+s) - if returnCode != 0: - Logger.Warn(f"Service {s} might have failed to restart. Output: {output} Error: {errorOut}") + Service.RestartDebianService(s, False) else: raise Exception("This OS type doesn't support updating at this time.") @@ -82,6 +85,12 @@ def DoUpdate(self, context:Context): def PlaceUpdateScriptInRoot(self, context:Context) -> bool: try: # Create the script file with any optional args we might need. + + # For the k1, we need to use the prefix sh for the script run + updateCmdPrefix = "" + if context.OsType == OsTypes.K1: + updateCmdPrefix = "sh " + s = f'''\ #!/bin/bash @@ -97,11 +106,11 @@ def PlaceUpdateScriptInRoot(self, context:Context) -> bool: # So just cd and execute our update script! Easy peasy! startingDir=$(pwd) cd {context.RepoRootFolder} -./update.sh +{updateCmdPrefix}./update.sh cd $startingDir ''' # Target the user home unless this is a Creality install. - # For Creality OS the user home will be set differently, but we want to put this script where the user logs in, aka root. + # For Sonic Pad and K1 the user home will be set differently, but we want to put this script where the user logs in, aka root. targetPath = context.UserHomePath if context.IsCrealityOs(): targetPath="/root" diff --git a/moonraker_octoapp/moonrakerhost.py b/moonraker_octoapp/moonrakerhost.py index a8919f5..60166de 100644 --- a/moonraker_octoapp/moonrakerhost.py +++ b/moonraker_octoapp/moonrakerhost.py @@ -141,7 +141,7 @@ def RunBlocking(self, klipperConfigDir, isObserverMode, localStorageDir, service # Setup the snapshot helper self.MoonrakerWebcamHelper = MoonrakerWebcamHelper(self.Config) - WebcamHelper.Init(self.MoonrakerWebcamHelper) + WebcamHelper.Init(self.MoonrakerWebcamHelper, localStorageDir) # Setup our smart pause helper SmartPause.Init() diff --git a/moonraker_octoapp/moonrakerwebcamhelper.py b/moonraker_octoapp/moonrakerwebcamhelper.py index 246fea4..3541e38 100644 --- a/moonraker_octoapp/moonrakerwebcamhelper.py +++ b/moonraker_octoapp/moonrakerwebcamhelper.py @@ -398,11 +398,11 @@ def _ValidateAndFixupWebCamSettings(self, webcamSettings:WebcamSettingItem) -> b # This is a fix for a Fluidd bug or something a user might do. The Fluidd UI defaults to /webcam?action... which results in nginx redirecting to /webcam/?action... # That's ok, but it makes us take an entire extra trip for webcam calls. So if we see it, we will correct it. # It also can break our local snapshot getting, if we don't follow redirects. (we didn't in the past but we do now.) - fixedStreamUrl = WebcamHelper.FixMissingSlashInWebcamUrlIfNeeded(self.Logger, webcamSettings.StreamUrl) + fixedStreamUrl = WebcamHelper.FixMissingSlashInWebcamUrlIfNeeded(webcamSettings.StreamUrl) if fixedStreamUrl is not None: webcamSettings.StreamUrl = fixedStreamUrl if webcamSettings.SnapshotUrl is not None: - fixedSnapshotUrl = WebcamHelper.FixMissingSlashInWebcamUrlIfNeeded(self.Logger, webcamSettings.SnapshotUrl) + fixedSnapshotUrl = WebcamHelper.FixMissingSlashInWebcamUrlIfNeeded(webcamSettings.SnapshotUrl) if fixedSnapshotUrl is not None: webcamSettings.SnapshotUrl = fixedSnapshotUrl diff --git a/octoapp/ostypeidentifier.py b/octoapp/ostypeidentifier.py index 559f431..970d366 100644 --- a/octoapp/ostypeidentifier.py +++ b/octoapp/ostypeidentifier.py @@ -1,5 +1,4 @@ import os -import subprocess import platform from .Proto import OsType @@ -8,17 +7,9 @@ class OsTypeIdentifier: # - # Note! All of this logic and vars should stay in sync with the Moonraker installer OsType logic in Context.py + # Note! All of this logic and vars should stay in sync with the Moonraker installer OsType logic in Context.py and the ./install.sh script! # - # For the Sonic Pad, this is the path we know we will find the printer configs and printer log locations. - # The printer data will not be standard setup, so it will be like /printer_config, /printer_logs - CrealityOsUserDataPath_SonicPad = "/mnt/UDISK" - - # For the K1/K1Max, this is the path we know we will find the printer configs and printer log locations. - # They will be the standard Klipper setup, such as printer_data/config, printer_data/logs, etc. - CrealityOsUserDataPath_K1 = "/usr/data" - @staticmethod def DetectOsType() -> OsType: # Do a quick check for windows first. @@ -26,19 +17,21 @@ def DetectOsType() -> OsType: if platform.system().lower == "windows": return OsType.OsType.Windows - # We use the presence of opkg to figure out if we are running no Creality OS - # This is the same thing we do in the installer and update scripts. - result = subprocess.run("command -v opkg", check=False, shell=True, capture_output=True, text=True) - if result.returncode == 0: - # This is a Creality OS. - # Now we need to detect if it's a Sonic Pad or a K1 - if os.path.exists(OsTypeIdentifier.CrealityOsUserDataPath_SonicPad): - # Note that this type implies that the system can't self update. - return OsType.OsType.CrealitySonicPad - if os.path.exists(OsTypeIdentifier.CrealityOsUserDataPath_K1): - # Note that this type implies that the system can't self update. - return OsType.OsType.CrealityK1 - return OsType.OsType.Unknown - - # The OS is debian + # For the k1 and k1 max, we look for the "buildroot" OS. + if os.path.exists("/etc/os-release"): + with open("/etc/os-release", "r", encoding="utf-8") as osInfo: + lines = osInfo.readlines() + for l in lines: + if "ID=buildroot" in l: + return OsType.OsType.CrealityK1 + + # For the Sonic Pad, we look for the openwrt os + if os.path.exists("/etc/openwrt_release"): + with open("/etc/openwrt_release", "r", encoding="utf-8") as osInfo: + lines = osInfo.readlines() + for l in lines: + if "sonic" in l: + return OsType.OsType.CrealitySonicPad + + # Default the OS to debian. return OsType.OsType.Debian diff --git a/octoapp/webcamhelper.py b/octoapp/webcamhelper.py index b35bf14..59f1ef5 100644 --- a/octoapp/webcamhelper.py +++ b/octoapp/webcamhelper.py @@ -1,4 +1,6 @@ import logging +import os +import json from .sentry import Sentry from .octohttprequest import OctoHttpRequest @@ -57,8 +59,8 @@ class WebcamHelper: @staticmethod - def Init(webcamPlatformHelperInterface): - WebcamHelper._Instance = WebcamHelper(webcamPlatformHelperInterface) + def Init(webcamPlatformHelperInterface, pluginDataFolderPath): + WebcamHelper._Instance = WebcamHelper(webcamPlatformHelperInterface, pluginDataFolderPath) @staticmethod @@ -66,8 +68,11 @@ def Get(): return WebcamHelper._Instance - def __init__(self, webcamPlatformHelperInterface): + def __init__(self, webcamPlatformHelperInterface, pluginDataFolderPath:str): self.WebcamPlatformHelperInterface = webcamPlatformHelperInterface + self.SettingsFilePath = os.path.join(pluginDataFolderPath, "webcam-settings.json") + self.DefaultCameraName = None + self._LoadDefaultCameraName() # Returns the snapshot URL from the settings. @@ -167,7 +172,7 @@ def _GetWebcamStreamInternal(self, cameraName:str) -> OctoHttpRequest.Result: # We use the allow redirects flag to make the API more robust, since some webcam images might need that. # # Whatever this returns, the rest of the request system will handle it, since it's expecting the OctoHttpRequest object - return OctoHttpRequest.MakeHttpCall(self.Logger, webcamStreamUrl, OctoHttpRequest.GetPathType(webcamStreamUrl), "GET", {}, allowRedirects=True) + return OctoHttpRequest.MakeHttpCall(webcamStreamUrl, OctoHttpRequest.GetPathType(webcamStreamUrl), "GET", {}, allowRedirects=True) # If we can't get the webcam stream URL, return None to fail out the request. return None @@ -193,7 +198,7 @@ def _GetSnapshotInternal(self, cameraName:str) -> OctoHttpRequest.Result: # Where to actually make the webcam request in terms of IP and port. # We use the allow redirects flag to make the API more robust, since some webcam images might need that. Sentry.Debug("Webcam Helper", "Trying to get a snapshot using url: %s" % snapshotUrl) - octoHttpResult = OctoHttpRequest.MakeHttpCall(self.Logger, snapshotUrl, OctoHttpRequest.GetPathType(snapshotUrl), "GET", {}, allowRedirects=True) + octoHttpResult = OctoHttpRequest.MakeHttpCall(snapshotUrl, OctoHttpRequest.GetPathType(snapshotUrl), "GET", {}, allowRedirects=True) # If the result was successful, we are done. if octoHttpResult is not None and octoHttpResult.Result is not None and octoHttpResult.Result.status_code == 200: return octoHttpResult @@ -212,7 +217,7 @@ def _GetSnapshotFromStream(self, url) -> OctoHttpRequest.Result: # This is required because knowing the port to connect to might be tricky. # We use the allow redirects flag to make the API more robust, since some webcam images might need that. Sentry.Debug("Webcam Helper", "_GetSnapshotFromStream - Trying to get a snapshot using THE STREAM URL: %s" % url) - octoHttpResult = OctoHttpRequest.MakeHttpCall(self.Logger, url, OctoHttpRequest.GetPathType(url), "GET", {}, allowRedirects=True) + octoHttpResult = OctoHttpRequest.MakeHttpCall(url, OctoHttpRequest.GetPathType(url), "GET", {}, allowRedirects=True) if octoHttpResult is None or octoHttpResult.Result is None: Sentry.Debug("Webcam Helper", "_GetSnapshotFromStream - Failed to make web request.") return None @@ -357,8 +362,11 @@ def _GetWebcamSettingObj(self, cameraName:str = None): a = self.ListWebcams() if a is None or len(a) == 0: return None - # If we have a target camera name, find it. - if cameraName is not None and len(cameraName) != 0: + # If a camera name wasn't passed, see if there's a default. + if cameraName is None or len(cameraName) == 0: + cameraName = self.GetDefaultCameraName() + # If we have a target name, see if we can find it. + if cameraName is not None: cameraNameLower = cameraName.lower() for i in a: if i.Name.lower() == cameraNameLower: @@ -564,7 +572,7 @@ def DetectCameraStreamerWebRTCStreamUrlAndTranslate(streamUrl:str) -> str: # If the slash is detected to be missing, this function will return the URL with the slash added correctly. # Otherwise, it returns None. @staticmethod - def FixMissingSlashInWebcamUrlIfNeeded(logger:logging.Logger, webcamUrl:str) -> str: + def FixMissingSlashInWebcamUrlIfNeeded(webcamUrl:str) -> str: # First, the stream must have webcam* and ?action= in it, otherwise, we don't care. streamUrlLower = webcamUrl.lower() webcamLocation = streamUrlLower.find("webcam") @@ -583,5 +591,51 @@ def FixMissingSlashInWebcamUrlIfNeeded(logger:logging.Logger, webcamUrl:str) -> # We know there is no slash before action, add it. newWebcamUrl = webcamUrl[:actionLocation] + "/" + webcamUrl[actionLocation:] - logger.info(f"Found incorrect webcam url, updating. [{webcamUrl}] -> [{newWebcamUrl}]") + Sentry.Info("Webcam Helper", f"Found incorrect webcam url, updating. [{webcamUrl}] -> [{newWebcamUrl}]") return newWebcamUrl + + + # + # Default camera name logic. + # + + # Sets the default camera name and writes it to the settings file. + def SetDefaultCameraName(self, name:str) -> None: + name = name.lower() + self.DefaultCameraName = name + try: + settings = { + "DefaultWebcamName" : self.DefaultCameraName + } + with open(self.SettingsFilePath, encoding="utf-8", mode="w") as f: + f.write(json.dumps(settings)) + except Exception as e: + Sentry.Error("Webcam Helper", "SetDefaultCameraName failed "+str(e)) + + + # Returns the default camera name or None + def GetDefaultCameraName(self) -> str: + return self.DefaultCameraName + + + # Loads the current name from our settings file. + def _LoadDefaultCameraName(self) -> None: + try: + # Default the setting. + self.DefaultCameraName = None + + # First check if there's a file. + if os.path.exists(self.SettingsFilePath) is False: + return + + # Try to open it and get the key. Any failure will null out the key. + with open(self.SettingsFilePath, encoding="utf-8") as f: + data = json.load(f) + + name = data["DefaultWebcamName"] + if name is None or len(name) == 0: + return + self.DefaultCameraName = name + Sentry.Info("Webcam Helper", f"Webcam settings loaded. Default camera name: {self.DefaultCameraName}") + except Exception as e: + Sentry.Error("Webcam Helper", "_LoadDefaultCameraName failed "+str(e)) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1e57b93..506a399 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,8 +11,8 @@ websocket_client>=1.6.0,<1.6.99 requests>=2.24.0 octoflatbuffers==2.0.5 pillow -certifi>=2022.12.7 -rsa>=4.0 +certifi>=2023.7.22 +rsa>=4.9 dnspython>=2.3.0 httpx==0.24.0 urllib3>=1.26.15,<1.27.0 diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..bdb27a7 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,42 @@ +#!/bin/bash + + + +# +# OctoEverywhere for Klipper! +# +# This script works for any setup of OctoEverywhere, the normal plugin install, companion install, or a Creality install. +# The script will automatically find all OctoEverywhere instances on this device and help you remove them. +# +# If you need help, feel free to contact us at support@octoeverywhere.com +# + + +# These must stay in sync with ./install.sh! +IS_K1_OS=0 +if grep -Fqs "ID=buildroot" /etc/os-release +then + IS_K1_OS=1 +fi + +c_default=$(echo -en "\e[39m") +c_green=$(echo -en "\e[92m") +c_yellow=$(echo -en "\e[93m") +c_magenta=$(echo -en "\e[35m") +c_red=$(echo -en "\e[91m") +c_cyan=$(echo -en "\e[96m") + +echo "" +echo "" +echo -e "${c_yellow}Starting The OctoEverywhere Uninstaller${c_default}" +echo "" +echo "" + +# Our installer script has all of the logic to update system deps, py deps, and the py environment. +# So we use it with a special flag to do updating. +if [[ $IS_K1_OS -eq 1 ]] +then + sh ./install.sh -uninstall +else + ./install.sh -uninstall +fi \ No newline at end of file diff --git a/update.sh b/update.sh index 5bf5b19..3fafeda 100755 --- a/update.sh +++ b/update.sh @@ -12,13 +12,16 @@ # - -# Set if we are running the Creality OS or not. -# We use the presence of opkg as they key -IS_CREALITY_OS=false -if command -v opkg &> /dev/null +# These must stay in sync with ./install.sh! +IS_K1_OS=0 +if grep -Fqs "ID=buildroot" /etc/os-release +then + IS_K1_OS=1 +fi +IS_SONIC_PAD_OS=0 +if grep -Fqs "sonic" /etc/openwrt_release then - IS_CREALITY_OS=true + IS_SONIC_PAD_OS=1 fi c_default=$(echo -en "\e[39m") @@ -38,9 +41,9 @@ echo "" # when we run the git commands, we need to make sure we are the right user. runAsRepoOwner() { - # For Creality OS, we can't use stat or whoami, but there's only one user anyways, root. + # For the sonic pad and k1, we can't use stat or whoami, but there's only one user anyways, root. # So always just run it. - if $IS_CREALITY_OS + if [[ $IS_SONIC_PAD_OS -eq 1 ]] || [[ $IS_K1_OS -eq 1 ]] then eval $1 return @@ -75,4 +78,9 @@ runAsRepoOwner "git pull --quiet" # Our installer script has all of the logic to update system deps, py deps, and the py environment. # So we use it with a special flag to do updating. echo "Running the update..." -./install.sh -update +if [[ $IS_K1_OS -eq 1 ]] +then + sh ./install.sh -update +else + ./install.sh -update +fi \ No newline at end of file