pax_global_header00006660000000000000000000000064151562156260014523gustar00rootroot0000000000000052 comment=46270a5c1ac05efb52bfac98ada60932618e68a1 pdudaemon-pdudaemon-46270a5/000077500000000000000000000000001515621562600157235ustar00rootroot00000000000000pdudaemon-pdudaemon-46270a5/.github/000077500000000000000000000000001515621562600172635ustar00rootroot00000000000000pdudaemon-pdudaemon-46270a5/.github/dependabot.yml000066400000000000000000000003151515621562600221120ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" pdudaemon-pdudaemon-46270a5/.github/workflows/000077500000000000000000000000001515621562600213205ustar00rootroot00000000000000pdudaemon-pdudaemon-46270a5/.github/workflows/dockerhub.yaml000066400000000000000000000012171515621562600241530ustar00rootroot00000000000000name: Publish Docker image on: push: branches: ["main"] jobs: push_to_registry: name: Push Docker image to Docker Hub runs-on: ubuntu-latest steps: - name: Check out the repo uses: actions/checkout@v6 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile.dockerhub push: true tags: pdudaemon/pdudaemon:latest pdudaemon-pdudaemon-46270a5/.github/workflows/lint.yaml000066400000000000000000000014621515621562600231550ustar00rootroot00000000000000name: Run lint on: push: branches-ignore: - "gh-readonly-queue/**" workflow_dispatch: pull_request: merge_group: jobs: pre-commit-lint: runs-on: ubuntu-latest steps: - name: Check out repository code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.12' - name: Install pre-commit run: pip install pre-commit - name: Run pre-commit on all files run: pre-commit run --all-files --show-diff-on-failure - name: Cache the pre-commit plugins uses: actions/cache@v5 with: path: | ~/.cache/pre-commit key: ${{ runner.os }}-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} restore-keys: | ${{ runner.os }}-pre-commit- pdudaemon-pdudaemon-46270a5/.github/workflows/tests.yaml000066400000000000000000000051331515621562600233500ustar00rootroot00000000000000name: Run tests on: push: branches-ignore: - "gh-readonly-queue/**" tags: - "*" workflow_dispatch: pull_request: merge_group: jobs: tests: runs-on: ubuntu-latest steps: - name: Check out repository code uses: actions/checkout@v6 - name: Build container run: docker build -t pdudaemon-ci -f Dockerfile.dockerhub . - name: Run pytest run: docker run -v $(pwd):/p -w /p pdudaemon-ci /root/.local/pipx/venvs/pdudaemon/bin/pytest - name: Run functional tests run: docker run -v $(pwd):/p -w /p pdudaemon-ci sh -c "./share/pdudaemon-test.sh" tests-trixie: runs-on: ubuntu-latest steps: - name: check out repository code uses: actions/checkout@v6 - name: Build container run: docker build --build-arg DEBIAN_VERSION=trixie -t pdudaemon-ci -f Dockerfile.dockerhub . - name: Run pytest run: docker run -v $(pwd):/p -w /p pdudaemon-ci /root/.local/share/pipx/venvs/pdudaemon/bin/pytest - name: Run functional tests run: docker run -v $(pwd):/p -w /p pdudaemon-ci sh -c "./share/pdudaemon-test.sh" build: needs: - tests - tests-trixie runs-on: ubuntu-latest steps: - name: Check out repository code uses: actions/checkout@v6 with: # include tags and full history for setuptools_scm fetch-depth: 0 - name: Install build deps run: python -m pip install -U build - name: Build package run: python -m build - name: Upload dist uses: actions/upload-artifact@v6 with: name: dist path: dist/ pypi-publish-test: if: ${{ github.event_name == 'push' && (startsWith(github.ref, 'refs/tags') || github.ref == 'refs/heads/main') }} needs: build runs-on: ubuntu-latest permissions: id-token: write steps: - name: Download dist uses: actions/download-artifact@v7 with: name: dist path: dist/ - name: Publish distribution package to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ pypi-publish: if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }} needs: pypi-publish-test runs-on: ubuntu-latest permissions: id-token: write steps: - name: Download dist uses: actions/download-artifact@v7 with: name: dist path: dist/ - name: Publish distribution package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 pdudaemon-pdudaemon-46270a5/.gitignore000066400000000000000000000003161515621562600177130ustar00rootroot00000000000000dist/ .idea/ *.egg-info/ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # (linter) caches # ruff .ruff_cache # mypy .mypy_cache/ .dmypy.json dmypy.json # pytest .pytest_cache/ pdudaemon-pdudaemon-46270a5/.pre-commit-config.yaml000066400000000000000000000023111515621562600222010ustar00rootroot00000000000000--- repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: 'v0.12.4' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] # - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - id: mixed-line-ending - id: fix-byte-order-marker - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-symlinks - id: check-vcs-permalinks - id: debug-statements - id: check-yaml files: .*\.(yaml|yml)$ - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell name: codespell description: Checks for common misspellings in text files. entry: codespell language: python types: [text] args: [-L, 'Hart,hart,ons'] require_serial: false additional_dependencies: [tomli] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.17.0 hooks: - id: mypy additional_dependencies: - .[test] - types-requests - types-setuptools - types-paramiko pdudaemon-pdudaemon-46270a5/Dockerfile.dockerhub000066400000000000000000000014511515621562600216630ustar00rootroot00000000000000ARG DEBIAN_VERSION=bookworm FROM debian:$DEBIAN_VERSION ARG HTTP_PROXY ENV http_proxy=${HTTP_PROXY} ENV https_proxy=${HTTP_PROXY} ARG PYTHON=python3 ENV PIPX_BIN_DIR=/usr/local/bin ENV PIPX_DEFAULT_PYTHON=${PYTHON} RUN apt-get update && apt-get install -y \ curl \ cython3 \ git \ ipmitool \ libffi-dev \ libhidapi-dev \ libssl-dev \ libsystemd-dev \ libudev-dev \ libusb-1.0-0-dev \ pkg-config \ psmisc \ pipx \ python3-pycodestyle \ python3-setuptools \ python3-usb \ python3-wheel \ rustc \ supervisor \ telnet \ snmp RUN apt-get update && apt-get install -y \ ${PYTHON}-venv ${PYTHON}-dev RUN git config --global --add safe.directory '*' ADD share/pdudaemon.conf /config/ WORKDIR /pdudaemon COPY . . RUN pipx install .[test] CMD ["/usr/bin/supervisord", "-c", "/pdudaemon/share/supervisord.conf"] pdudaemon-pdudaemon-46270a5/MANIFEST.in000066400000000000000000000000201515621562600174510ustar00rootroot00000000000000include share/* pdudaemon-pdudaemon-46270a5/README.md000066400000000000000000000157611515621562600172140ustar00rootroot00000000000000# PDUDaemon Python daemon for controlling/sequentially executing commands to PDUs (Power Distribution Units) ## Why is this needed? #### Queueing Most PDUs have a very low power microprocessor, or low quality software, which cannot handle multiple requests at the same time. This quickly becomes an issue in environments that use power control frequently, such as board farms, and gets worse on PDUs that have a large number of controllable ports. #### Standardising Every PDU manufacturer has a different way of controlling their PDUs. Though many support SNMP, there's still no single simple way to communicate with all PDUs if you have a mix of brands. ## Supported devices list APC, Devantech and ACME are well supported, however there is no official list yet. The [strategies.py](https://github.com/pdudaemon/pdudaemon/blob/main/pdudaemon/drivers/strategies.py) file is a good place to see all the current drivers. ## Installing Debian packages are on the way, hopefully. For now, make sure the requirements are met and then: ```python3 setup.py install``` There is an official Docker container updated from tip: ``` $ docker pull pdudaemon/pdudaemon:latest $ vi pdudaemon.conf ``` To create a config file, use [share/pdudaemon.conf](https://github.com/pdudaemon/pdudaemon/blob/main/share/pdudaemon.conf) as a base, then mount your config file on top of the default: ``` $ docker run -v `pwd`/pdudaemon.conf:/config/pdudaemon.conf pdudaemon/pdudaemon:latest ``` Or you can build your own: ``` $ git clone https://github.com/pdudaemon/pdudaemon $ cd pdudaemon $ vi share/pdudaemon.conf - configure your PDUs $ sudo docker build -t pdudaemon --build-arg HTTP_PROXY=$http_proxy -f Dockerfile.dockerhub . $ docker run --rm -it -e http_proxy=$http_proxy -e https_proxy=$https_proxy -e NO_PROXY="$no_proxy" --net="host" pdudaemon:latest ``` ## Config file An example configuration file can be found [here](https://github.com/pdudaemon/pdudaemon/blob/main/share/pdudaemon.conf). The section `daemon` is pretty self explanatory. The interesting part is the `pdus` section, where all managed PDUs are listed and configured. For example: ```json "pdus": { "hostname_or_ip": { "driver": "driver_name", "additional_parameter": "42" }, "test": { "driver": "localcmdline", "cmd_on": "echo '%s on' >> /tmp/pdu", "cmd_off": "echo '%s off' >> /tmp/pdu" }, "energenie": { "driver": "EG-PMS", "device": "01:01:51:a4:c3" }, "192.168.0.42": { "driver": "brennenstuhl_wspl01_tasmota" } } ``` It is important to mention, that `hostname` can be an arbitrary name for a locally connected device (like `energenie` in this example). For some (or most) network connected devices, it needs to be the actual hostname or IP address the PDU responds to (see `query-string` in [next section](#making-a-power-control-request)). The correct value for `driver` is highly dependent on the used child class and the specific implementation. Check the imported [Python module](https://github.com/pdudaemon/pdudaemon/tree/main/pdudaemon/drivers) for that class and look for `drivername` to be sure. Some drivers require additional parameters (like a device ID). Which parameters are required can also be extracted from the associated python module and child class definition. It is also worth checking out the [share](https://github.com/pdudaemon/pdudaemon/tree/main/share) folder for some driver specific example configuration files and helpful scripts that can help prevent major headaches! ## Making a power control request - **HTTP** The daemon can accept requests over plain HTTP. The port is configurable, but defaults to 16421 There is no encryption or authentication, consider yourself warned. To enable, change the 'listener' setting in the 'daemon' section of the config file to 'http'. This will break 'pduclient' requests. An HTTP request URL has the following syntax: ```http://:/power/control/?``` Where: - pdudaemon-hostname is the hostname or IP address where pdudaemon is running (e.g.: localhost) - pdudaemon-port is the port used by pdudaemon (e.g.: 16421) - command is an action for the PDU to execute: - **on**: power on - **off**: power off - **reboot**: reboot - query-string can have 3 parameters (same as pduclient, see below) - **hostname**: the PDU hostname or IP address used in the [configuration file](https://github.com/pdudaemon/pdudaemon/blob/main/share/pdudaemon.conf) (e.g.: "192.168.10.2") - **port**: the PDU port number - **delay**: delay between power off and on during reboot (optional, by default 5 seconds) Some example requests would be: ``` $ curl "http://localhost:16421/power/control/on?hostname=192.168.10.2&port=1" $ curl "http://localhost:16421/power/control/off?hostname=192.168.10.2&port=1" $ curl "http://localhost:16421/power/control/reboot?hostname=192.168.10.2&port=1&delay=10" ``` ***Return Codes*** - HTTP 200 - Request Accepted - HTTP 503 - Invalid Request, Request not accepted - **TCP (legacy pduclient)** The bundled client is used when PDUDaemon is configured to listen to 'tcp' requests. TCP support is considered legacy but will remain functional. ``` Usage: pduclient --daemon deamonhostname --hostname pduhostname --port pduportnum --command pducommand PDUDaemon client Options: -h, --help show this help message and exit --daemon=PDUDAEMONHOSTNAME PDUDaemon listener hostname (ex: localhost) --hostname=PDUHOSTNAME PDU Hostname (ex: pdu05) --port=PDUPORTNUM PDU Portnumber (ex: 04) --command=PDUCOMMAND PDU command (ex: reboot|on|off) --delay=PDUDELAY Delay before command runs, or between off/on when rebooting (ex: 5) ``` - **non-daemon (also called drive)** If you would just like to use pdudaemon as an executable to drive a PDU without needing to run a daemon, you can use the --drive option. Configure the PDU in the config file as usual, then launch pdudaemon with the following options ``` $ pdudaemon --conf=share/pdudaemon.conf --drive --hostname pdu01 --port 1 --request reboot ``` If requesting reboot, the delay between turning the port off and on can be modified with `--delay` and is by default 5 seconds. ## Adding drivers Drivers are implemented children of the "PDUDriver" class and many example implementations can be found inside the [drivers](https://github.com/pdudaemon/pdudaemon/tree/main/pdudaemon/drivers) directory. Any new driver classes should be added to [strategies.py](https://github.com/pdudaemon/pdudaemon/blob/main/pdudaemon/drivers/strategies.py). External implementation of PDUDriver can also be registered using the python entry_points mechanism. For example add the following to your setup.cfg: ``` [options.entry_points] pdudaemon.driver = mypdu = mypdumod:MyPDUClass ``` ## Why can't PDUDaemon do $REQUIREMENT? Patches welcome, as long as it keeps the system simple and lightweight. pdudaemon-pdudaemon-46270a5/pduclient000077500000000000000000000102211515621562600176340ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import optparse def do_tcp_request(options): import socket if options.pdudelay: string = ("%s %s %s %s" % (options.pduhostname, options.pduportnum, options.pducommand, options.pdudelay)) else: string = ("%s %s %s" % (options.pduhostname, options.pduportnum, options.pducommand)) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) reply = "" try: sock.connect((options.pdudaemonhostname, 16421)) sock.send(string.encode('utf-8')) reply = sock.recv(16384).strip() # limit reply to 16K reply = reply.decode('utf-8') sock.close() except Exception: print ("Error sending command, wrong daemon hostname?") exit(1) if reply == "ack": print("Command sent successfully.") exit(0) else: print("Error sending command! %s replied: %s" % (options.pdudaemonhostname, reply)) exit(127) def do_http_request(options): import requests request = "http://%s:16421/power/control/%s?hostname=%s&port=%s" % ( options.pdudaemonhostname, options.pducommand, options.pduhostname, options.pduportnum) if options.pdudelay: request += "&delay=%s" % options.pdudelay try: reply = requests.get(request) except Exception: print ("Error sending command, wrong daemon hostname?") exit(1) if reply.ok: print("Command sent successfully.") exit(0) else: print("Error sending command! %s replied: %s" % (options.pdudaemonhostname, reply)) exit(127) if __name__ == '__main__': usage = "Usage: %prog --daemon deamonhostname --hostname pduhostname " \ "--port pduportnum --command pducommand" description = "PDUDaemon client" commands = ["reboot", "on", "off"] parser = optparse.OptionParser(usage=usage, description=description) parser.add_option("-H", "--http", dest="use_http", action="store_true", help="Use HTTP protocol") parser.add_option("--daemon", dest="pdudaemonhostname", action="store", type="string", help="PDUDaemon listener hostname (ex: localhost)") parser.add_option("--hostname", dest="pduhostname", action="store", type="string", help="PDU Hostname (ex: pdu05)") parser.add_option("--port", dest="pduportnum", action="store", type="string", help="PDU Portnumber (ex: 04)") parser.add_option("--command", dest="pducommand", action="store", type="string", help="PDU command (ex: reboot|on|off)") parser.add_option("--delay", dest="pdudelay", action="store", type="int", help="Delay before command runs, or between off/on " "when rebooting (ex: 5)") (options, args) = parser.parse_args() if not options.pdudaemonhostname \ or not options.pduhostname \ or not options.pduportnum \ or not options.pducommand: print("Missing option, try -h for help") exit(1) if options.pducommand not in commands: print("Unknown pdu command: %s" % options.pducommand) exit(1) if options.use_http: do_http_request (options) else: do_tcp_request (options) pdudaemon-pdudaemon-46270a5/pdudaemon/000077500000000000000000000000001515621562600176775ustar00rootroot00000000000000pdudaemon-pdudaemon-46270a5/pdudaemon/__init__.py000066400000000000000000000200471515621562600220130ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2018 Remi Duraffort # Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import argparse import asyncio import json import logging from logging.handlers import WatchedFileHandler import signal import sys from pdudaemon.tcplistener import TCPListener from pdudaemon.httplistener import HTTPListener from pdudaemon.pdurunner import PDURunner from pdudaemon.drivers.driver import PDUDriver assert PDUDriver, "Imported for user convenience." # type: ignore[truthy-function] ########### # Constants ########### CONFIGURATION_FILE = "/etc/pdudaemon/pdudaemon.conf" logging_FORMAT = "%(asctime)s - %(name)-30s - %(levelname)s %(message)s" logging_FORMAT_JOURNAL = "%(name)s.%(levelname)s %(message)s" logging_FILE = "/var/log/pdudaemon.log" ################## # Global logger ################## logger = logging.getLogger('pdud') def setup_logging(options, settings): logger = logging.getLogger("pdud") """ Setup the log handler and the log level """ if options.journal: from systemd.journal import JournalHandler handler = JournalHandler(SYSLOG_IDENTIFIER="pdudaemon") handler.setFormatter(logging.Formatter(logging_FORMAT_JOURNAL)) elif options.logfile == "-" or not options.logfile: handler = logging.StreamHandler(sys.stdout) handler.setFormatter(logging.Formatter(logging_FORMAT)) else: handler = WatchedFileHandler(options.logfile) handler.setFormatter(logging.Formatter(logging_FORMAT)) logger.addHandler(handler) settings_level = settings.get('daemon', {}).get('logging_level', None) if settings_level: options.loglevel = settings_level.upper() else: options.loglevel = options.loglevel.upper() if options.loglevel == "DEBUG": logger.setLevel(logging.DEBUG) elif options.loglevel == "INFO": logger.setLevel(logging.INFO) elif options.loglevel == "WARNING": logger.setLevel(logging.WARNING) else: logger.setLevel(logging.ERROR) class PDUDaemon: def __init__(self, options, settings): # Context self.runners = {} # Create the runners logger.info("Creating the runners") for hostname in settings["pdus"]: config = settings["pdus"][hostname] retries = config.get("retries", 5) retrydelay = config.get("retrydelay", 5) self.runners[hostname] = PDURunner(config, hostname, retries, retrydelay) # Start the listener logger.info("Starting the listener") if options.listener: listener = options.listener else: listener = settings['daemon'].get('listener', 'tcp') if listener == 'tcp': self.listener = TCPListener(settings, self) elif listener == 'http': self.listener = HTTPListener(settings, self) else: logging.error("Unknown listener configured") async def start(self): await self.listener.start() async def shutdown(self): logger.info("Shutting down listener...") await self.listener.shutdown() logger.info("Shutting down runners...") for runner in self.runners.values(): await runner.shutdown() logger.info("Stopping loop...") loop = asyncio.get_running_loop() loop.stop() async def main_async(): # Setup the parser parser = argparse.ArgumentParser() log = parser.add_argument_group("logging") log.add_argument("--journal", "-j", action="store_true", default=False, help="Log to the journal") log.add_argument("--logfile", dest="logfile", action="store", type=str, default="-", help="log file [%s]" % logging_FILE) log.add_argument("--loglevel", dest="loglevel", default="INFO", choices=["DEBUG", "ERROR", "INFO", "WARN"], type=str, help="logging level [INFO]") parser.add_argument("--conf", "-c", type=argparse.FileType("r"), default=CONFIGURATION_FILE, help="configuration file [%s]" % CONFIGURATION_FILE) parser.add_argument("--listener", type=str, help="PDUDaemon listener setting") conflict = parser.add_mutually_exclusive_group() conflict.add_argument("--alias", dest="alias", action="store", type=str) conflict.add_argument("--hostname", dest="drivehostname", action="store", type=str) drive = parser.add_argument_group("drive") drive.add_argument("--drive", action="store_true", default=False) drive.add_argument("--request", dest="driverequest", action="store", type=str) drive.add_argument("--retries", dest="driveretries", action="store", type=int, default=5) drive.add_argument("--retry-delay", dest="driveretrydelay", action="store", type=int, default=5) drive.add_argument("--delay", dest="drivedelay", action="store", type=int, default=5) drive.add_argument("--port", dest="driveport", action="store", type=str) # Parse the command line options = parser.parse_args() # Read the configuration file try: settings = json.loads(options.conf.read()) except Exception as exc: logging.error("Unable to read configuration file '%s': %s", options.conf.name, exc) return 1 # Setup logging setup_logging(options, settings) # Get handle to the currently running loop loop = asyncio.get_running_loop() if options.drive: # Driving a PDU directly, dont start any Listeners if options.alias: # Using alias support, get all pdu info from alias alias_settings = settings["aliases"].get(options.alias, False) if not alias_settings: logging.error("Alias requested but not found") sys.exit(1) options.drivehostname = settings["aliases"][options.alias]["hostname"] options.driveport = settings["aliases"][options.alias]["port"] # Check that the requested PDU has config config = settings["pdus"].get(options.drivehostname, False) if not config: logging.error("No config section for hostname: {}".format(options.drivehostname)) sys.exit(1) runner = PDURunner(config, options.drivehostname, options.driveretries, options.driveretrydelay) if options.driverequest == "reboot": result = await runner.do_job_async(options.driveport, "off") await asyncio.sleep(int(options.drivedelay)) result = await runner.do_job_async(options.driveport, "on") else: result = await runner.do_job_async(options.driveport, options.driverequest) await runner.shutdown() loop.stop() return result # Create daemon logger.info('PDUDaemon starting up') daemon = PDUDaemon(options, settings) # Setup signal handling def cleanup_handler(): logger.info("Signal received, shutting down...") asyncio.create_task(daemon.shutdown()) loop.add_signal_handler(signal.SIGINT, cleanup_handler) loop.add_signal_handler(signal.SIGTERM, cleanup_handler) await daemon.start() def main(): loop = asyncio.get_event_loop() loop.run_until_complete(main_async()) try: loop.run_forever() except KeyboardInterrupt: pass if __name__ == "__main__": # execute only if run as a script result = main() sys.exit(result) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/000077500000000000000000000000001515621562600213555ustar00rootroot00000000000000pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/__init__.py000066400000000000000000000014641515621562600234730ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/acme.py000066400000000000000000000041111515621562600226310ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2015 BayLibre SAS # Author Marc Titinger # # Based on apcxxx: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.acmebase import ACMEBase import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class ACME(ACMEBase): cmd = {'on': 'dut-switch-on', 'off': 'dut-switch-off', 'reboot': 'dut-hard-reset'} @classmethod def accepts(cls, drivername): if drivername == "acme": return True return False def _pdu_logout(self): self._back_to_main() log.debug("Logging out") self.connection.send("exit\r") def _back_to_main(self): self.connection.send("\r") self.connection.expect('#') def _enter_outlet(self, outlet, enter_needed=True): log.debug("Finished entering outlet (nop)") def _port_interaction(self, command, port_number): if command not in self.cmd: acme_command = 'echo "unknown command {}"'.format(command) else: acme_command = '{} {}'.format(self.cmd[command], port_number) log.debug("Attempting command: %s", acme_command) self.connection.send(acme_command) self._do_it() self.connection.expect("#") def _do_it(self): self.connection.send("\r") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/acmebase.py000066400000000000000000000056231515621562600234750ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2015 BayLibre SAS # Author Marc Titinger # # Based on ACPBase: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import pexpect from pdudaemon.drivers.driver import PDUDriver import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) # SSH connection, assuming the id_rsa.pub key for the owner of pdudaemon-runner # i.e. root, was added to the authorized_keys on the ACME device. # This may be changed to a simple telnet connection in the future. class ACMEBase(PDUDriver): connection = None def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.username = settings.get("username", "root") self.exec_string = "/usr/bin/ssh %s@%s" % (self.username, hostname) super(ACMEBase, self).__init__() @classmethod def accepts(cls, drivername): # pylint: disable=unused-argument return False def port_interaction(self, command, port_number): log.debug("Running port_interaction from ACMEBase") self.get_connection() self._port_interaction(command, # pylint: disable=no-member port_number) def get_connection(self): log.debug("Connecting to Baylibre ACME with: {}".format(self.exec_string)) # only uncomment this line for FULL debug when developing # self.connection = pexpect.spawn(self.exec_string, logfile=sys.stdout) self.connection = pexpect.spawn(self.exec_string) self._pdu_login(self.username, "") def _cleanup(self): self._pdu_logout() # pylint: disable=no-member def _bombout(self): log.debug("Bombing out of driver: {}".format(self.connection)) self.connection.close(force=True) del self def _pdu_login(self, username, password): log.debug("attempting login with username %s, password %s", username, password) index = self.connection.expect(['#', 'password', 'yes/no']) if index == 1: self.connection.send("%s\r" % password) elif index == 2: self.connection.send("yes\r") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/anelnetpwrctrl.py000066400000000000000000000060431515621562600247760ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2019 Stefan Wiehler # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # Protocol documentation (in German) available at: # https://forum.anel.eu/viewtopic.php?f=52&t=888&sid=a0aca2195ffff4eb28a11fd89898590b # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver, FailedRequestException import requests import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class AnelNETPwrCtrlBase(PDUDriver): def __init__(self, hostname, settings): self.hostname = hostname self.username = settings.get("username", "admin") self.password = settings.get("password", "anel") super().__init__() def port_interaction(self, command, port_number): if port_number > self.port_count or port_number < 1: err = "Port number must be in range 1 - {}".format(self.port_count) log.error(err) raise FailedRequestException(err) url = "http://{}?Sw_{}={},{}{}".format( self.hostname, command, port_number, self.username, self.password) log.debug("HTTP GET: {}".format(url)) r = requests.get(url) r.raise_for_status() if r.text == "User or password error.": log.error(r.text) raise FailedRequestException(r.text) log.debug('HTTP response: {}'.format(r.text)) @classmethod def accepts(cls, drivername): return False class AnelNETPwrCtrlHOME(AnelNETPwrCtrlBase): port_count = 3 @classmethod def accepts(cls, drivername): if drivername == "anel_netpwrctrlhome": return True return False class AnelNETPwrCtrlADV(AnelNETPwrCtrlBase): port_count = 8 @classmethod def accepts(cls, drivername): if drivername == "anel_netpwrctrladv": return True return False class AnelNETPwrCtrlIO(AnelNETPwrCtrlBase): port_count = 8 @classmethod def accepts(cls, drivername): if drivername == "anel_netpwrctrlio": return True return False class AnelNETPwrCtrlPRO(AnelNETPwrCtrlBase): port_count = 8 @classmethod def accepts(cls, drivername): if drivername == "anel_netpwrctrlpro": return True return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apc7900.py000066400000000000000000000042231515621562600230130ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2017 Matt Hart # Copyright 2020 Nobuhiro Iwamatsu # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.apc7952 import APC7952 import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APC7900(APC7952): @classmethod def accepts(cls, drivername): if drivername == "apc7900": return True return False def _port_interaction(self, command, port_number): # make sure in main menu here self._back_to_main() self.connection.send("\r") self.connection.expect("1- Device Manager") self.connection.expect("> ") log.debug("Entering Device Manager") self.connection.send("1\r") self.connection.expect("------- Device Manager") self.connection.send("3\r") self.connection.expect("3- Outlet Control/Configuration") self.connection.expect("> ") self._enter_outlet(port_number, False) self.connection.expect("1- Control Outlet") self.connection.send("1\r") self.connection.expect("> ") if command == "on": self.connection.send("1\r") self.connection.expect("Immediate On") self._do_it() elif command == "off": self.connection.send("2\r") self.connection.expect("Immediate Off") self._do_it() else: log.debug("Unknown command!") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apc7920.py000066400000000000000000000042131515621562600230140ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2017 Matt Hart # Copyright 2020 Sebastian Reichel # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.apc7952 import APC7952 import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APC7920(APC7952): @classmethod def accepts(cls, drivername): if drivername == "apc7920": return True return False def _port_interaction(self, command, port_number): # make sure in main menu here self._back_to_main() self.connection.send("\r") self.connection.expect("1- Device Manager") self.connection.expect("> ") log.debug("Entering Device Manager") self.connection.send("1\r") self.connection.expect("------- Device Manager") self.connection.expect("3- Outlet Control/Configuration") self.connection.expect("> ") self.connection.send("3\r") self._enter_outlet(port_number, False) self.connection.expect("1- Control Outlet") self.connection.send("1\r") self.connection.expect("> ") if command == "on": self.connection.send("1\r") self.connection.expect("Immediate On") self._do_it() elif command == "off": self.connection.send("2\r") self.connection.expect("Immediate Off") self._do_it() else: log.debug("Unknown command!") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apc7921.py000066400000000000000000000041741515621562600230230ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2017 Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.apc7952 import APC7952 import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APC7921(APC7952): @classmethod def accepts(cls, drivername): if drivername == "apc7921": return True return False def _port_interaction(self, command, port_number): # make sure in main menu here self._back_to_main() self.connection.send("\r") self.connection.expect("1- Device Manager") self.connection.expect("> ") log.debug("Entering Device Manager") self.connection.send("1\r") self.connection.expect("------- Device Manager") self.connection.send("2\r") self.connection.expect("1- Outlet Control/Configuration") self.connection.expect("> ") self.connection.send("1\r") self._enter_outlet(port_number, False) self.connection.expect("1- Control Outlet") self.connection.send("1\r") self.connection.expect("> ") if command == "on": self.connection.send("1\r") self.connection.expect("Immediate On") self._do_it() elif command == "off": self.connection.send("2\r") self.connection.expect("Immediate Off") self._do_it() else: log.debug("Unknown command!") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apc7932.py000066400000000000000000000041301515621562600230150ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2016 EfficiOS # Author Jonathan Rajotte-Julien # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.apc7952 import APC7952 import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APC7932(APC7952): @classmethod def accepts(cls, drivername): if drivername == "apc7932": return True return False def _port_interaction(self, command, port_number): # make sure in main menu here self._back_to_main() self.connection.send("\r") self.connection.expect("1- Device Manager") self.connection.expect("> ") log.debug("Entering Device Manager") self.connection.send("1\r") self.connection.expect("------- Device Manager") self.connection.send("2\r") self.connection.expect("1- Outlet Control/Configuration") self.connection.expect("> ") self.connection.send("1\r") self._enter_outlet(port_number, False) self.connection.expect("> ") if command == "on": self.connection.send("1\r") self.connection.expect("Immediate On") self._do_it() elif command == "off": self.connection.send("2\r") self.connection.expect("Immediate Off") self._do_it() else: log.debug("Unknown command!") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apc7952.py000066400000000000000000000072021515621562600230220ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.apcbase import APCBase import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APC7952(APCBase): @classmethod def accepts(cls, drivername): if drivername == "apc7952": return True return False def _pdu_logout(self): self._back_to_main() log.debug("Logging out") self.connection.send("4\r") def _back_to_main(self): log.debug("Returning to main menu") self.connection.send("\r") self.connection.expect('>') for _ in range(1, 20): self.connection.send("\x1B") self.connection.send("\r") res = self.connection.expect(["4- Logout", "> "]) if res == 0: log.debug("Back at main menu") break def _enter_outlet(self, outlet, enter_needed=True): outlet = "%s" % outlet log.debug("Attempting to enter outlet %s", outlet) if enter_needed: self.connection.expect("Press to continue...") log.debug("Sending enter") self.connection.send("\r") self.connection.expect("> ") log.debug("Sending outlet number") self.connection.send(outlet) self.connection.send("\r") log.debug("Finished entering outlet") def _port_interaction(self, command, port_number): # make sure in main menu here self._back_to_main() self.connection.send("\r") self.connection.expect("1- Device Manager") self.connection.expect("> ") log.debug("Entering Device Manager") self.connection.send("1\r") self.connection.expect("------- Device Manager") self.connection.send("2\r") self.connection.expect("1- Outlet Control/Configuration") self.connection.expect("> ") self.connection.send("1\r") self._enter_outlet(port_number, False) self.connection.expect("> ") self.connection.send("1\r") res = self.connection.expect(["> ", "Press to continue..."]) if res == 1: log.debug("Stupid paging thingmy detected, pressing enter") self.connection.send("\r") self.connection.send("\r") if command == "on": self.connection.send("1\r") self.connection.expect("Immediate On") self._do_it() elif command == "off": self.connection.send("2\r") self.connection.expect("Immediate Off") self._do_it() else: log.debug("Unknown command!") def _do_it(self): self.connection.expect("Enter 'YES' to continue or " " to cancel :") self.connection.send("YES\r") self.connection.expect("Press to continue...") self.connection.send("\r") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apc8959.py000066400000000000000000000033431515621562600230340ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.apcbase import APCBase import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APC8959(APCBase): pdu_commands = {"off": "olOff", "on": "olOn"} @classmethod def accepts(cls, drivername): if drivername == "apc8959": return True return False def _pdu_logout(self): log.debug("logging out") self.connection.send("\r") self.connection.send("exit") self.connection.send("\r") log.debug("done") def _pdu_get_to_prompt(self): self.connection.send("\r") self.connection.expect('apc>') def _port_interaction(self, command, port_number): self._pdu_get_to_prompt() self.connection.sendline(self.pdu_commands[command] + (" {}".format(port_number))) self.connection.expect("E000: Success") self._pdu_get_to_prompt() log.debug("done") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apc9210.py000066400000000000000000000042711515621562600230120ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.apc7952 import APC7952 import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APC9210(APC7952): @classmethod def accepts(cls, drivername): if drivername == "apc9210": return True return False def _port_interaction(self, command, port_number): # make sure in main menu here self._back_to_main() self.connection.send("\r") self.connection.expect("1- Outlet Manager") self.connection.expect("> ") log.debug("Entering Outlet Manager") self.connection.send("1\r") self.connection.expect("------- Outlet Manager") log.debug("Got to Device Manager") self._enter_outlet(port_number, False) self.connection.expect(["1- Control of Outlet", "1- Outlet Control/Configuration"]) self.connection.expect("> ") self.connection.send("1\r") self.connection.expect("Turn Outlet On") self.connection.expect("> ") if command == "on": self.connection.send("1\r") self.connection.expect("Turn Outlet On") self._do_it() elif command == "off": self.connection.send("2\r") self.connection.expect("Turn Outlet Off") self._do_it() else: log.debug("Unknown command!") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apc9218.py000066400000000000000000000051421515621562600230200ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.apc7952 import APC7952 import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APC9218(APC7952): @classmethod def accepts(cls, drivername): models = ["ap9606", "apc9606", "ap9218", "apc9218"] if drivername.lower() in models: return True return False def _port_interaction(self, command, port_number): # make sure in main menu here self._back_to_main() self.connection.send("\r") self.connection.expect("1- Device Manager") self.connection.expect("> ") log.debug("Entering Device Manager") self.connection.send("1\r") self.connection.expect("------- Device Manager") log.debug("Got to Device Manager") self._enter_outlet(port_number, False) self.connection.expect(["1- Control Outlet", "1- Outlet Control/Configuration"]) self.connection.expect("> ") self.connection.send("1\r") res = self.connection.expect(["> ", "Press to continue..."]) if res == 1: log.debug("Stupid paging thingmy detected, pressing enter") self.connection.send("\r") self.connection.send("\r") self.connection.expect(["Control Outlet %s" % port_number, "Control Outlet"]) self.connection.expect("3- Immediate Reboot") self.connection.expect("> ") if command == "on": self.connection.send("1\r") self.connection.expect("Immediate On") self._do_it() elif command == "off": self.connection.send("2\r") self.connection.expect("Immediate Off") self._do_it() else: log.debug("Unknown command!") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/apcbase.py000066400000000000000000000053771515621562600233410ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import pexpect from pdudaemon.drivers.driver import PDUDriver import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class APCBase(PDUDriver): connection = None def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.username = settings.get("username", "apc") self.password = settings.get("password", "apc") telnetport = settings.get('telnetport', 23) self.exec_string = "/usr/bin/telnet %s %d" % (hostname, telnetport) super(APCBase, self).__init__() @classmethod def accepts(cls, drivername): return False def port_interaction(self, command, port_number): log.debug("Running port_interaction from APCBase") self.get_connection() log.debug("Attempting command: {} port: {}".format(command, port_number)) self._port_interaction(command, # pylint: disable=no-member port_number) def get_connection(self): log.debug("Connecting to APC PDU with: %s", self.exec_string) # only uncomment this line for FULL debug when developing # self.connection = pexpect.spawn(self.exec_string, logfile=sys.stdout) self.connection = pexpect.spawn(self.exec_string) self._pdu_login(self.username, self.password) def _cleanup(self): if self.connection: self._pdu_logout() # pylint: disable=no-member self.connection.close() def _bombout(self): if self.connection: self.connection.close(force=True) def _pdu_login(self, username, password): log.debug("attempting login with username %s, password %s", username, password) self.connection.send("\r") self.connection.expect("User Name :") self.connection.send("%s\r" % username) self.connection.expect("Password :") self.connection.send("%s\r" % password) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/bcu.py000066400000000000000000000055631515621562600225110ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2020 NXP # Author Leonard Crestez # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. from pdudaemon.drivers.driver import PDUDriver import os import subprocess import logging log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class BCU(PDUDriver): """PDUDriver implementation using NXP BCU. The NXP BCU tool can be used for remote power control on some newer NXP development boards. See https://github.com/NXPmicro/bcu """ @classmethod def accepts(cls, drivername): return drivername == "bcu" def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings #: usb path (string "like 3-7.1") self.id = settings.get('id', None) #: board id (see `bcu lsboard`) self.board = settings.get('board', None) #: path to bcu executable (default assumes bcu is in $PATH) self.bcu_exe = settings.get('bcu_exe', 'bcu') #: gpio used for reset function (see `bcu lsgpio -gpio=$gpio`) self.reset_gpio = settings.get('reset_gpio', 'reset') #: if reset_gpio is active low (default True) self.reset_gpio_active_low = bool(int( settings.get('reset_gpio_active_low', '1'))) #: bootmode to initialize (see `bcu lsbootmode -board=$board`) self.bootmode = settings.get('bootmode', None) def _run(self, subcmd): cmd = [self.bcu_exe] cmd += subcmd if self.id: cmd += ['-id=' + self.id] if self.board: cmd += ['-board=' + self.board] log.info('RUN: %s', ' '.join(cmd)) return subprocess.run(cmd, check=True) def _init(self): return self._run(['init'] + [self.bootmode] if self.bootmode else []) def _set_gpio(self, gpio, value): return self._run(['set_gpio', gpio, value]) def port_on(self, port_number): self._init() self._set_gpio( self.reset_gpio, '1' if self.reset_gpio_active_low else '0') def port_off(self, port_number): self._init() self._set_gpio( self.reset_gpio, '0' if self.reset_gpio_active_low else '1') pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/cleware.py000066400000000000000000000112371515621562600233550ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2022 Sjoerd Simons # Copyright 2023 Sietze van Buuren # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os import time import hid from pdudaemon.drivers.driver import PDUDriver from pdudaemon.drivers.hiddevice import HIDDevice log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) CLEWARE_VID = 0x0d50 CLEWARE_SWITCH1_PID = 0x0008 CLEWARE_CONTACT00_PID = 0x0030 CLEWARE_NEW_SWITCH_SERIAL = 0x63813 class ClewareBase(PDUDriver): """Base class for Cleware USB-Switch drivers.""" switch_pid = None # type: int connection = None port_count = 0 def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.serial = int(settings.get("serial", u"")) log.debug("serial: %s", self.serial) super().__init__() def new_switch_serial(self, device_path): """Find the correct serial for novel Cleware USB Switch devices.""" with HIDDevice(path=device_path) as dev: serial = 0 for i in range(8, 15): b = int(chr(self.read_byte(dev, i)), 16) serial *= 16 serial += b return serial def device_path(self): """Search and return the matching device path.""" for dev_dict in hid.enumerate(CLEWARE_VID, self.switch_pid): device_path = dev_dict['path'] serial_compare = int(dev_dict["serial_number"], 16) if self.serial == serial_compare: return device_path if serial_compare == CLEWARE_NEW_SWITCH_SERIAL: serial_candidate = self.new_switch_serial(device_path) log.debug("Considering serial number match: %s", serial_candidate) if self.serial == serial_candidate: return device_path continue err = f"Cleware device with serial number {self.serial} not found!" log.error(err) raise RuntimeError(err) @staticmethod def read_byte(dev, addr): dev.write([0, 2, addr]) while True: data = dev.read(16) if data[4] == addr: return data[5] time.sleep(0.01) @classmethod def accepts(cls, drivername): return drivername.lower() == cls.__name__.lower() class ClewareSwitch1Base(ClewareBase): switch_pid = CLEWARE_SWITCH1_PID def port_interaction(self, command, port_number): port_number = int(port_number) if port_number > self.port_count or port_number < 1: err = f"Port should be in the range 1 - {self.port_count}" log.error(err) raise RuntimeError(err) port = 0x10 + port_number - 1 if command == "on": on = 1 elif command == "off": on = 0 else: log.error("Unknown command %s.", (command)) return with HIDDevice(path=self.device_path()) as dev: dev.write([0, 0, port, on]) class ClewareUsbSwitch4(ClewareSwitch1Base): port_count = 4 class ClewareContact00Base(ClewareBase): switch_pid = CLEWARE_CONTACT00_PID def port_interaction(self, command, port_number): port_number = int(port_number) if port_number > self.port_count or port_number < 1: err = f"Port should be in the range 1 - {self.port_count}" log.error(err) raise RuntimeError(err) port = 1 << (port_number - 1) if command == "on": on = port elif command == "off": on = 0 else: log.error("Unknown command %s.", (command)) return with HIDDevice(path=self.device_path()) as dev: dev.write([0, 3, on >> 8, on & 0xff, port >> 8, port & 0xff]) class ClewareUsbSwitch8(ClewareContact00Base): port_count = 8 pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/conrad197720.py000066400000000000000000000302531515621562600236720ustar00rootroot00000000000000#!/usr/bin/python3 """# # Copyright 2023 Joachim Schiffer . # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # SPDX-License-Identifier: GPL-2.0-or-later # # Script to control conrad components relais card 197720 # Up to 255 cards can be concatenated at one serial port # https://www.conrad.de/de/p/conrad-components-197720-relaiskarte-baustein-12-v-dc-24-v-dc-197720.html # """ import serial import os import logging from pdudaemon.drivers.driver import PDUDriver log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) # number of retries on communication error RETRY_COUNT = 1 # timeout while receiving SERIAL_TIMEOUT = .1 # given by hardware PORTS_PER_CARD = 8 MAX_CARDS = 255 FRAME_SIZE = 4 # as described in manual, address 0 for first card does not work ADDR_FIRST_CARD = 1 # valid commands CMD_INIT = 1 CMD_GETPORT = 2 CMD_SETPORT = 3 CMD_GETOPTION = 4 CMD_SETOPTION = 5 CMD_SETSINGLE = 6 CMD_DELSINGLE = 7 CMD_TOGGLESINGLE = 8 # parameter for CMD_SETOPTION / CMD_GETOPTION OPTION_BROADCAST_NO_SEND_AND_NO_BLOCK = 0 OPTION_BROADCAST_SEND_AND_NO_BLOCK = 1 # default OPTION_BROADCAST_NO_SEND_AND_BLOCK = 2 OPTION_BROADCAST_SEND_AND_BLOCK = 3 class Conrad197720(PDUDriver): """Driver for Conrad Components 197720 and 197730 relay card. https://www.conrad.com/p/conrad-components-197720-relay-card-component-12-v-dc-24-v-dc-197720 https://www.conrad.com/p/conrad-components-197730-relay-card-component-12-v-dc-197730 Up to 255 relay cards can be used on one serial port The difference between model 197720 and model 197730 is the switching power of the relays The protocol is the same for both card types """ def __init__(self, hostname, settings): self.com = serial.Serial() self.num_cards = 0 self.hostname = hostname self.__openConnection(settings.get("device", "/dev/ttyUSB0")) # /dev/ttyUSB0 is default self.__init() def __del__(self): """Cleanup on delete.""" if self.com.is_open: self.com.close() @classmethod def accepts(cls, drivername): return drivername == "conrad197720" def port_interaction(self, command, port_number): """Pdudaemon method for port interaction. :param self: The object itself :param command: The command string :param port_number: The port number 0...n """ port_number = int(port_number) self.__updateSingle(command, port_number) def getNumPorts(self): """Return amount of ports available. :param self: The object itself :return: number of ports :rtype: int """ return self.num_cards * PORTS_PER_CARD def __txFrame(self, tx_data): """Private function to send an array of data bytes. :param self: The object itself :param tx_data: The array of data bytes to send :raises RuntimeError: tx_data array too big """ if len(tx_data) != 3: raise RuntimeError("tx_data has more than 3 bytes") checksum = tx_data[0] ^ tx_data[1] ^ tx_data[2] tx_data.append(checksum) if not self.com.is_open: self.__openConnection(self.portname) self.com.write(tx_data) log.debug(f"sent: {tx_data}") def __txSingleByte(self): """Private function to send a single byte. The card(s) always send and receive frames of FRAME_SIZE bytes, if this is not in sync, this function can be used to send single bytes, until a correct answer is received. In theory this function should be called 3 times at most during script runtime :param self: The object itself """ byte = 0 if not self.com.is_open: self.__openConnection(self.portname) self.com.write(byte.to_bytes(1, byteorder='big')) log.debug("sent single byte") def __rxFrame(self, num_frames): """Private function to receive frame(s). :param self: The object itself :param num_frames: The number of frames to wait for (until timeout SERIAL_TIMEOUT occurs) :return: The received data :rtype: array """ rx_data = [] if not self.com.is_open: self.__openConnection(self.portname) for i in range(0, (num_frames * FRAME_SIZE)): recv = self.com.read(1) if len(recv) == 0: break recv = int.from_bytes(recv, 'little') rx_data.append(recv) log.debug(f"recv: {rx_data}") return rx_data def __checkFrameChecksum(self, data): """Private function to check the XOR checksum of the received frame. :param self: The object itself :param data: The array holding the received data bytes, length must be a multiple of FRAME_SIZE :return: True, if checksum(s) match :rtype: boolean """ if len(data) % FRAME_SIZE != 0 or len(data) == 0: return False for i in range(0, int(len(data) / FRAME_SIZE)): # check if checksum matches if (data[int(i / FRAME_SIZE) + 0] ^ data[int(i / FRAME_SIZE) + 1] ^ data[int(i / FRAME_SIZE) + 2]) != data[int(i / FRAME_SIZE) + 3]: print(f"checksum of frame {i} does not match") return False return True def __sendCommand(self, cmd_byte, addr_byte, data_byte): """Private function to send a command to the card(s) / retry / parse the response. :param self: The object itself :param cmd_byte: The command to the card(s) :param addr_byte: The card address, used when more than one relay card is connected to the same serial port :param data_byte: The data byte to be transmitted :return: the response data byte :rtype: int :raises RuntimeError: all retries failed, communication error """ for i in range(0, RETRY_COUNT): self.__txFrame([cmd_byte, addr_byte, data_byte]) num_frames = 1 # for init we might receive n+1 frames in case there n cards cascaded if cmd_byte == CMD_INIT: num_frames = MAX_CARDS + 1 recv_data = self.__rxFrame(num_frames) # check if received data is valid if len(recv_data) > 0 and self.__checkFrameChecksum(recv_data): # retry, if there is no valid reply on init command if cmd_byte == CMD_INIT: if (self.__checkNumCards(recv_data) <= 0): log.debug("__sendCommand retry reason: no valid reply on init command") continue # check if the addr and cmd in the reply is the correct answer if recv_data[0] == 255 - cmd_byte and recv_data[1] == addr_byte: return recv_data[2] # if not.. retry else: log.debug("__sendCommand retry reason: address of card in response frame invalid") continue else: # send up to FRAME_SIZE single bytes until we receive correct frames # since there might be wrong communication before, # especially in the beginning of communication for j in range(0, FRAME_SIZE): self.__txSingleByte() recv_data = self.__rxFrame(255) if self.__checkFrameChecksum(recv_data): break log.debug("__sendCommand retry reason: checksum mismatch") raise RuntimeError("All retries failed, communication error") def __checkNumCards(self, data): """Private function to parse the amount of concatenated card at this serial port. :param self: The object itself :return: number of cards :rtype: int """ self.num_cards = 0 for i in range(0, int(len(data) / FRAME_SIZE)): if (data[(i * FRAME_SIZE)] == 255 - CMD_INIT): self.num_cards = self.num_cards + 1 log.debug(f"found {self.num_cards} cards") return self.num_cards def __getNumCards(self): """Return amount of cards found after init(). :param self: The object itself :return: number of cards :rtype: int """ return self.num_cards def __openConnection(self, portname): """Open serial connection. :param self: The object itself :param portname: The string describing the serial device to open (e.g. "/dev/ttyUSB1" or "/dev/serial/by-id/...") """ self.portname = portname self.com.port = self.portname self.com.baudrate = 19200 self.com.bytesize = 8 self.com.parity = serial.PARITY_NONE self.com.stopbits = serial.STOPBITS_ONE self.com.timeout = SERIAL_TIMEOUT self.com.open() def __init(self): """Init all cards. :param self: The object itself :raises RuntimeError: all retries failed, communication error """ self.__sendCommand(CMD_INIT, ADDR_FIRST_CARD, 0) # make sure, all cards run with default setting for card_addr in range(1, self.__getNumCards() + 1): option = self.__sendCommand(CMD_GETOPTION, card_addr, 0) if option != OPTION_BROADCAST_SEND_AND_NO_BLOCK: # set card to default setting log.debug(f"set option to default for card: {card_addr}") self.__sendCommand(CMD_SETOPTION, card_addr, OPTION_BROADCAST_SEND_AND_NO_BLOCK) def __updateSingle(self, command, port): """Update a single port of a card. :param self: The object itself :param command: The command to execute, "on" "off" or "toggle" :param port: The port number 0..n, all ports of all cards are in one range, e.g. port 9 is the second port of the second card :return: True on success :rtype: boolean :raises RuntimeError: unknown command :raises RuntimeError: no cards present :raises RuntimeError: port number invalid :raises RuntimeError: all retries failed, communication error """ for i in range(0, RETRY_COUNT + 1): card_addr = int(port / PORTS_PER_CARD) + 1 port_index = int(port % PORTS_PER_CARD) if command == "off": card_command = CMD_DELSINGLE elif command == "on": card_command = CMD_SETSINGLE else: raise RuntimeError(f"Unknown command {command}") if 0 == self.__getNumCards(): # retry init self.__init() if 0 == self.__getNumCards(): raise RuntimeError("No conrad197720 compatible cards present") if port < 0 or card_addr > self.__getNumCards(): raise RuntimeError(f"Port number {port} has to be between 0 and {(self.__getNumCards() * PORTS_PER_CARD)-1}") recv = self.__sendCommand(CMD_GETPORT, card_addr, 0) old_state = (recv & 1 << port_index) >> port_index recv = self.__sendCommand(card_command, card_addr, 1 << port_index) new_state = (recv & 1 << port_index) >> port_index log.debug(f"__updateSingle card_addr {card_addr} port_index {port_index} command {command} state {old_state} -> {new_state}") if (command == "off" and new_state == 0) or \ (command == "on" and new_state == 1): return True log.debug("__updateSingle retry") raise RuntimeError("All retries failed, communication error") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/cyberpower81001.py000066400000000000000000000040311515621562600245000ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2024 # Author Antonin Godard # # TODO: # - use pysnmp instead of snmpset command-line tool # # Based on ip9258.py # Copyright 2016 BayLibre, Inc. # Author Kevin Hilman # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import subprocess from pdudaemon.drivers.localbase import LocalBase import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class Cyberpower81001(LocalBase): _actions = { "1": 1, "2": 2, "3": 3, "4": 4, "on": 1, "off": 2, "reboot": 3, "cancel": 4, } def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings @classmethod def accepts(cls, drivername): if drivername == "cyberpower81001": return True return False def _port_interaction(self, command, port_number): port_number = int(port_number) power_oid = f"SNMPv2-SMI::enterprises.3808.1.1.3.3.3.1.1.4.{port_number}" cmd_base = f"/usr/bin/snmpset -v 1 -c private {self.hostname} {power_oid} integer" if command not in self._actions: log.error(f"Unknown command {command}!") return cmd = f"{cmd_base} {self._actions[command]} >/dev/null" log.debug("running %s" % cmd) subprocess.call(cmd, shell=True) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/devantech.py000066400000000000000000000164121515621562600236740ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2016 Quentin Schulz # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver import socket import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class DevantechBase(PDUDriver): connection = None port_count = 0 def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.ip = settings["ip"] self.port = settings.get("port", 17494) self.password = settings.get("password") self.logic = settings.get("logic", "NO") super(DevantechBase, self).__init__() def connect(self): self.connection = socket.create_connection((self.ip, self.port)) if self.password: log.debug("Attempting connection to %s:%s with provided password.", self.hostname, self.port) msg = b'\x79' + self.password.encode("utf-8") ret = self.connection.sendall(msg) if ret: log.error("Failed to send message.") raise RuntimeError("Failed to send message.") ret = self.connection.recv(1) if ret != b'\x01': log.error("Authentication failed.") raise RuntimeError("Failed to authenticate. Verify your password.") def port_interaction(self, command, port_number): self.connect() port_number = int(port_number) if port_number > self.port_count: log.error("There are only %d ports. Provide a port number lesser than %d." % (self.port_count, self.port_count)) raise RuntimeError("There are only %d ports. Provide a port number lesser than %d." % (self.port_count, self.port_count)) if self.logic not in ["NO", "NC"]: log.error("Invalid logic setting: %s." % (self.logic)) return if command == "on": msg = b'\x21' if self.logic == "NC" else b'\x20' elif command == "off": msg = b'\x20' if self.logic == "NC" else b'\x21' else: log.error("Unknown command %s." % (command)) return msg += port_number.to_bytes(1, 'big') msg += b'\x00' log.debug("Attempting control: %s port: %d hostname: %s." % (command, port_number, self.hostname)) ret = self.connection.sendall(msg) if ret: log.error("Failed to send message.") raise RuntimeError("Failed to send message.") ret = self.connection.recv(1) if ret != b'\x00': log.error("Failed to send %s command on port %d of %s." % (command, port_number, self.hostname)) raise RuntimeError("Failed to send %s command on port %d of %s." % (command, port_number, self.hostname)) def _close_connection(self): # Logout log.debug("Closing connection.") if self.password: log.debug("Attempting to logout.") ret = self.connection.sendall(b'\x7B') if ret: log.error("Failed to send message.") raise RuntimeError("Failed to send message.") ret = self.connection.recv(1) if ret != b'\x00': log.error("Failed to logout of %s." % self.hostname) raise RuntimeError("Failed to logout of %s." % self.hostname) self.connection.close() def _cleanup(self): self._close_connection() def _bombout(self): self._close_connection() @classmethod def accepts(cls, drivername): return False class DevantechETH002(DevantechBase): port_count = 2 @classmethod def accepts(cls, drivername): if drivername == "devantech_eth002": return True return False class DevantechETH0621(DevantechBase): port_count = 2 @classmethod def accepts(cls, drivername): if drivername == "devantech_eth0621": return True return False class DevantechETH484(DevantechBase): port_count = 4 @classmethod def accepts(cls, drivername): if drivername == "devantech_eth484": return True return False class DevantechETH008(DevantechBase): port_count = 8 @classmethod def accepts(cls, drivername): if drivername == "devantech_eth008": return True return False class DevantechETH8020(DevantechBase): port_count = 20 @classmethod def accepts(cls, drivername): if drivername == "devantech_eth8020": return True return False class DevantechDSBase(PDUDriver): port_count = 0 connection = None def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings # the default hostname may vary. I.e. could be "ds378" for other modules. self.ip = settings.get("ip", "dS2824") self.port = settings.get("port", 17123) super().__init__() def connect(self): self.connection = socket.create_connection((self.ip, self.port)) def port_interaction(self, command, port_number): self.connect() port_number = int(port_number) if port_number > self.port_count: log.error("There are only %d ports. Provide a port number lesser than %d." % (self.port_count, self.port_count)) raise RuntimeError("There are only %d ports. Provide a port number lesser than %d." % (self.port_count, self.port_count)) msg = 'SR {} '.format(port_number) if command in ["on", "off"]: msg += command else: log.error("Unknown command %s." % (command)) return log.debug("Attempting control: %s port: %d hostname: %s." % (command, port_number, self.hostname)) ret = self.connection.sendall(msg.encode('ascii')) if ret: log.error("Failed to send message.") raise RuntimeError("Failed to send message.") def _close_connection(self): # Logout log.debug("Closing connection.") self.connection.close() def _cleanup(self): self._close_connection() def _bombout(self): self._close_connection() @classmethod def accepts(cls, drivername): return False class DevantechDS2824(DevantechDSBase): port_count = 8 @classmethod def accepts(cls, drivername): # Some dS2824 modules apparently report themselves as `Module Type: dS378` if drivername in ["devantech_ds378", "devantech_ds2824"]: return True return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/devantechusb.py000066400000000000000000000054321515621562600244060ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2017 Sjoerd Simons # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver import serial import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class DevantechusbBase(PDUDriver): connection = None port_count = 0 supported = [] # type: list[str] def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.device = settings.get("device", "/dev/ttyACM0") log.debug("device: %s" % self.device) super(DevantechusbBase, self).__init__() def port_interaction(self, command, port_number): port_number = int(port_number) if port_number > self.port_count or port_number < 1: err = "Port should be in the range 1 - %d" % (self.port_count) log.error(err) raise RuntimeError(err) if command == "on": byte = 0x64 + port_number elif command == "off": byte = 0x6e + port_number else: log.error("Unknown command %s." % (command)) return s = serial.serial_for_url(self.device, 9600) s.write([byte]) s.close() @classmethod def accepts(cls, drivername): return drivername in cls.supported class DevantechUSB2(DevantechusbBase): port_count = 2 supported = ["devantech_USB-RLY02", "devantech_USB-RLY82"] # 4 relay devices class DevantechUSB4(DevantechusbBase): port_count = 4 supported = ["devantech_USB-RLY04"] # 6 relay devices class DevantechUSB6(DevantechusbBase): port_count = 6 supported = ["devantech_USB-RLY06"] # Various 8 relay devices class DevantechUSB8(DevantechusbBase): port_count = 8 supported = ["devantech_USB-RLY08B", "devantech_USB-RLY16", "devantech_USB-RLY16L", "devantech_USB-OPTO-RLY88", "devantech_USB-OPTO-RLY816"] pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/driver.py000066400000000000000000000064121515621562600232250ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from importlib import metadata log = logging.getLogger("pdud.drivers") def get_named_entry_point(group, name): eps = metadata.entry_points() matches = list(eps.select(group=group, name=name)) if len(matches) > 1: raise RuntimeError(f"Multiple entry points for {group} under {name}") return matches[0] if matches else None class PDUDriver(object): connection = None hostname = "" def __init__(self): super(PDUDriver, self).__init__() @classmethod def select(cls, drivername): ep = get_named_entry_point('pdudaemon.driver', drivername) if ep: # Not clear why a driver would reject the driver # it is registered for but check anyway: c = ep.load() if not c.accepts(drivername): raise Exception('pdudaemon.driver entry_point {} did not accept configuration'.format(c)) return c candidates = cls.__subclasses__() # pylint: disable=no-member for subc in cls.__subclasses__(): # pylint: disable=no-member candidates = candidates + (subc.__subclasses__()) for subsubc in subc.__subclasses__(): candidates = candidates + (subsubc.__subclasses__()) willing = [c for c in candidates if c.accepts(drivername)] if len(willing) == 0: log.error("No driver accepted the configuration '%s'", drivername) os._exit(1) log.debug("%s accepted the request", willing[0]) return willing[0] def handle(self, request, port_number): log.debug("Driving PDU hostname: %s " "PORT: %s REQUEST: %s", self.hostname, port_number, request) if request == "on": self.port_on(port_number) elif request == "off": self.port_off(port_number) else: log.debug("Unknown request to handle - oops") raise UnknownCommandException( "Driver doesn't know how to %s " % request ) self._cleanup() def port_on(self, port_number): self.port_interaction("on", port_number) def port_off(self, port_number): self.port_interaction("off", port_number) def port_interaction(self, command, port_number): pass def _bombout(self): pass def _cleanup(self): pass class UnknownCommandException(Exception): pass class FailedRequestException(Exception): pass pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/egpms.py000066400000000000000000000111011515621562600230340ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2017 Sjoerd Simons Schulz # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver import socket from array import array import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class EgPMS(PDUDriver): port_count = 4 def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.ip = settings["ip"] self.password = array('B', settings["password"].encode("utf-8") + 8 * b' ')[:8] self.challenge = None def authresponse(self, part): response = self.challenge[part] ^ self.password[part + 2] response *= self.password[part] response ^= self.password[part + 4] << 8 | self.password[part + 6] response ^= self.challenge[part + 2] r = array('B', [response & 0xff, (response >> 8) & 0xff]) return r def encode_state(self, status): state = status ^ self.challenge[2] state += self.challenge[3] state ^= self.password[0] state += self.password[1] return state & 0xff def decode_state(self, state): state = state - self.password[1] state ^= self.password[0] state -= self.challenge[3] state ^= self.challenge[2] return state & 0xff def dump_status(self, status): status.reverse() for b in status: s = self.decode_state(b) log.debug("on: %d (raw: %x)" % (s >> 4 == 1, s)) def connect(self): self.socket = socket.create_connection((self.ip, 5000)) self.socket.send(b'\x11') self.challenge = array('B', self.socket.recv(4)) self.socket.send(self.authresponse(0) + self.authresponse(1)) status = array('B', self.socket.recv(self.port_count)) log.debug("Connected") self.dump_status(status) def disconnect(self): # From egctl: # Empirically found way to close session w/o 4 second timeout on # the device side is to send some invalid sequence. This helps # to avoid a hiccup on subsequent run of the utility. # # Other protocol documents explain that after the current settings the # device waits for a schedule update for a while (if any) but will go # back to the start state if it doesn't make sense. self.socket.send(b'\x11') self.socket.close() def port_interaction(self, command, port_number): SWITCH_ON = 0x1 SWITCH_OFF = 0x2 DONT_SWITCH = 0x4 port_number = int(port_number) if port_number > self.port_count or port_number < 1: err = "Port should be in the range 1 - %d" % (self.port_count) log.error(err) raise RuntimeError(err) if command == "on": on = True elif command == "off": on = False else: log.error("Unknown command %s." % (command)) return self.connect() log.debug("Attempting control: {} port: {} hostname: {}.".format(command, port_number, self.hostname)) update = array('B') for s in range(1, self.port_count + 1): if s != port_number: update.append(self.encode_state(DONT_SWITCH)) else: update.append( self.encode_state(SWITCH_ON if on else SWITCH_OFF)) # States get send in reverse port order bytewise update.reverse() self.socket.send(update) status = array('B', self.socket.recv(self.port_count)) self.disconnect() log.debug("Updated") self.dump_status(status) @classmethod def accepts(cls, drivername): if drivername == 'egpms': return True return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/energenieusb.py000066400000000000000000000125121515621562600244030ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2019 Martyn Welch # # Based on devantechusb: # Copyright 2017 Sjoerd Simons # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver import usb.core import usb.util import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class EnerGenieUSB(PDUDriver): supported = ["EG-PMS", "EG-PM2"] def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.device = settings.get("device", "") log.debug("device: %s" % self.device) def port_interaction(self, command, port_number): devices = self.connect() if len(devices) == 0: err = "No device found" log.error(err) raise RuntimeError(err) dev = None for d in devices: if self.getid(d) == self.device: dev = d break if dev is None: err = "Device with id {} not found".format(self.device) log.error(err) raise RuntimeError(err) port_number = int(port_number) if port_number > 4 or port_number < 1: err = "Port should be in the range 1 - 4" log.error(err) raise RuntimeError(err) if command == "on": self.switchon(dev, port_number) elif command == "off": self.switchoff(dev, port_number) else: log.error("Unknown command %s." % (command)) return @classmethod def accepts(cls, drivername): return drivername in cls.supported # Following (modified) routines taken from pysispm: # # Copyright (c) 2016, Heinrich Schuchardt # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. def connect(self): """Returns the list of compatible devices. @return: device list """ ret = list() ret += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd10)) ret += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd11)) ret += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd12)) ret += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd13)) ret += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd15)) return ret def getid(self, dev): """Gets the id of a device. @return: id """ buf = bytes([0x00, 0x00, 0x00, 0x00, 0x00]) id = dev.ctrl_transfer(0xa1, 0x01, 0x0301, 0, buf, 500) if (len(id) == 0): return None ret = '' sep = '' for x in id: ret += sep ret += format(x, '02x') sep = ':' return ret def switchoff(self, dev, i): """Switches outlet i of the device off. @param dev: device @param i: outlet """ buf = bytes([3 * i, 0x00, 0x00, 0x00, 0x00]) buf = dev.ctrl_transfer(0x21, 0x09, 0x0300 + 3 * i, 0, buf, 500) def switchon(self, dev, i): """Switches outlet i of the device on. @param dev: device @param i: outlet """ buf = bytes([3 * i, 0x03, 0x00, 0x00, 0x00]) buf = dev.ctrl_transfer(0x21, 0x09, 0x0300 + 3 * i, 0, buf, 500) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/esphome.py000066400000000000000000000052611515621562600233730ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2022 Christopher Obbard # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from pdudaemon.drivers.driver import PDUDriver, FailedRequestException import requests from requests.auth import HTTPDigestAuth log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class ESPHomeHTTP(PDUDriver): def __init__(self, hostname, settings): """Communicate with custom devices flashed with ESPHome firmware, specifically Switch components (e.g. relays, GPIO). To use this driver, the `web_server` stanza must be defined in the ESPHome configuration. """ self.hostname = hostname self.port = settings.get("port", 80) self.username = settings.get("username") self.password = settings.get("password") super().__init__() def port_interaction(self, command, esphome_entity_id): esphome_cmd = "" if command == "on": esphome_cmd = "turn_on" elif command == "off": esphome_cmd = "turn_off" else: raise FailedRequestException("Unknown command %s" % (command)) # Build the POST request # url should be in the format http://{hostname}:{port}/switch/{id}/{cmd} url = "http://{}:{}/switch/{}/{}".format( self.hostname, self.port, esphome_entity_id, esphome_cmd ) log.debug("HTTP POST: {}".format(url)) auth = None if self.username and self.password: auth = HTTPDigestAuth(self.username, self.password) response = requests.post(url, auth=auth) log.debug( "Response code for request to {}: {}".format( self.hostname, response.status_code ) ) response.raise_for_status() @classmethod def accepts(cls, drivername): return drivername == "esphome-http" pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/gude1202.py000066400000000000000000000046321515621562600231650ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright (c) 2023 Koninklijke Philips N.V. # Author Julian Haller # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import pexpect from pdudaemon.drivers.driver import PDUDriver import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class Gude1202(PDUDriver): connection = None def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings telnetport = settings.get("telnetport", 23) self.exec_string = "/usr/bin/telnet %s %d" % (hostname, telnetport) super(Gude1202, self).__init__() @classmethod def accepts(cls, drivername): if drivername == "gude1202": return True return False def port_interaction(self, command, port_number): log.debug("Running port_interaction") self.get_connection() log.debug("Attempting command: {} port: {}".format(command, port_number)) if command == "on": self.connection.send("port %s state set 1\r" % port_number) elif command == "off": self.connection.send("port %s state set 0\r" % port_number) else: log.error("Unknown command") self.connection.expect("OK.") def get_connection(self): log.debug("Connecting to Gude1202 PDU with: %s", self.exec_string) self.connection = pexpect.spawn(self.exec_string) def _cleanup(self): if self.connection: self._pdu_logout() self.connection.close() def _bombout(self): if self.connection: self.connection.close(force=True) def _pdu_logout(self): log.debug("Logging out") self.connection.send("quit\r") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/hiddevice.py000066400000000000000000000033021515621562600236510ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2023 Sietze van Buuren # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import os import logging import hid log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class HIDDevice: def __init__(self, vid=None, pid=None, serial=None, path=None): self.__dev = hid.device() if path: self.__dev.open_path(path) elif serial: self.__dev.open(vid, pid, serial) elif pid and vid: self.__dev.open(vid, pid, None) else: err = "Unable to open HID device" log.error(err) raise RuntimeError(err) def __enter__(self): """Open and Return HID Device.""" return self def __exit__(self, exc_type, exc_value, exc_traceback): """Close HID Device.""" self.__dev.close() def write(self, buff): return self.__dev.write(buff) def read(self, max_length, timeout_ms=0): return self.__dev.read(max_length, timeout_ms) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/homeassistant.py000066400000000000000000000051271515621562600246160ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2022 Christopher Obbard # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from pdudaemon.drivers.driver import PDUDriver, FailedRequestException import requests log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class HomeAssistantHTTP(PDUDriver): def __init__(self, hostname, settings): """Communicate with devices managed by a Home Assistant instance, specifically Switch types. To use this driver, a long-lived API key must be defined as `api_key` in the host configuration. """ self.hostname = settings.get("hostname", hostname) self.port = settings.get("port", 80) self.api_key = settings.get("api_key") super().__init__() def port_interaction(self, command, ha_entity_id): if command == "on": ha_cmd = "turn_on" elif command == "off": ha_cmd = "turn_off" else: raise FailedRequestException("Unknown command %s" % (command)) # Build the POST request # url should be in the format http://{hostname}:{port}/api/services/switch/{cmd} url = "http://{}:{}/api/services/switch/{}".format( self.hostname, self.port, ha_cmd ) log.debug("HTTP POST: {}".format(url)) headers = { "Authorization": "Bearer {}".format(self.api_key) } body = { "entity_id": "switch.{}".format(ha_entity_id) } response = requests.post(url, headers=headers, json=body) log.debug( "Response code for request to {}: {}".format( self.hostname, response.status_code ) ) response.raise_for_status() @classmethod def accepts(cls, drivername): return drivername == "home-assistant-http" pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/intellinet.py000066400000000000000000000060751515621562600241060ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2021 Mark Ferry # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver, FailedRequestException import requests import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) __manual__ = "https://s3.amazonaws.com/assets.mhint/downloads/61413/INT_163682_UM_0819_REV_5.03.pdf" class Intellinet(PDUDriver): """Intellinet 163682 support. See also https://github.com/01programs/Intellinet_163682_IP_smart_PDU_API """ port_count = 8 endpoints = { "outlet": "control_outlet.htm", } def __init__(self, hostname, settings): self.hostname = hostname self.port = settings.get("port", 80) self.username = settings.get("username", "admin") self.password = settings.get("password", "admin") self.auth = self._auth(self.username, self.password) super().__init__() def _auth(self, username, password): return requests.auth.HTTPBasicAuth(username, password) def _api(self, page, params): url = "http://{}:{}/{}".format(self.hostname, self.port, page) log.debug("HTTP GET: {}".format(url)) r = requests.get(url, params, auth=self.auth) r.raise_for_status() if r.text.find("401") != -1: log.error(r.text) raise FailedRequestException(r.text) log.debug("HTTP response: {}".format(r.text)) def port_interaction(self, command, port_number): port_number = int(port_number) if port_number >= self.port_count or port_number < 0: err = "Port number must be in range 0 - {}".format(self.port_count - 1) log.error(err) raise FailedRequestException(err) command_map = {"on": 0, "off": 1, "reboot": 2} if command not in command_map: log.error("Unknown command %s." % (command)) raise FailedRequestException(err) endpoint = self.endpoints['outlet'] params = {"outlet{}".format(port_number): 1} params["op"] = command_map[command] params["submit"] = "Anwenden" self._api(endpoint, params) @classmethod def accepts(cls, drivername): if drivername == "intellinet": return True return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/ip9258.py000066400000000000000000000037571515621562600227030ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2016 BayLibre, Inc. # Author Kevin Hilman # # TODO: # - use pysnmp instead of snmpset command-line tool # # Based on localcmdline.py # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import subprocess from pdudaemon.drivers.localbase import LocalBase import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class IP9258(LocalBase): def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings @classmethod def accepts(cls, drivername): if drivername == "ip9258": return True return False def _port_interaction(self, command, port_number): port_number = int(port_number) power_oid = '1.3.6.1.4.1.92.58.2.%d.0' % (port_number) cmd_base = '/usr/bin/snmpset -v 1 -c public %s %s integer' \ % (self.hostname, power_oid) cmd = None if command == "on": cmd = cmd_base + ' %d > /dev/null' % (1) elif command == "off": cmd = cmd_base + ' %d > /dev/null' % (0) else: logging.debug("Unknown command!") if cmd: log.debug("running %s" % cmd) subprocess.call(cmd, shell=True) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/ip9850.py000066400000000000000000000047331515621562600226740ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2019 Stefan Wiehler # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver, FailedRequestException import requests import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class ip9850(PDUDriver): port_count = 4 def __init__(self, hostname, settings): self.hostname = hostname self.username = settings.get("username") self.password = settings.get("password") super().__init__() def port_interaction(self, command, port_number): port_number = int(port_number) if port_number > self.port_count or port_number < 1: err = "Port number must be in range 1 - {}".format(self.port_count) log.error(err) raise FailedRequestException(err) if command == "on": pwr = "1" elif command == "off": pwr = "0" else: log.error("Unknown command %s." % (command)) return params = { "user": self.username, "pass": self.password, "cmd": "setpower", "p6{}".format(port_number): "{}".format(pwr) } url = "http://{}/set.cmd".format(self.hostname) log.debug("HTTP GET: {}".format(url)) r = requests.get(url, params) r.raise_for_status() if r.text.find("401") != -1: log.error(r.text) raise FailedRequestException(r.text) log.debug('HTTP response: {}'.format(r.text)) @classmethod def accepts(cls, drivername): if drivername == "ip9850": return True return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/ipower.py000066400000000000000000000070571515621562600232450ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2023 Christopher Obbard # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from pdudaemon.drivers.driver import PDUDriver, FailedRequestException import requests from requests.auth import HTTPBasicAuth log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) # The following driver has been tested with the hardware: # Model No 32657 # Firmware Version s4.82-091012-1cb08s # (find out by going to the web interface Information > System) # # Port 1 refers to Outlet A, port 2 is Outlet B and so on. # # To change the status of a single port, the PDU contains two cgi scripts, # `ons.cgi` and `offs.cgi`. This script accepts a GET request with an `led` # parameter. The led parameter should be a string representing a binary # number, with one bit for each of the outlets, with the MSB representing # outlet A. Only the bits which are set have their state changed. # # For instance: # ons.cgi?leds=10000000 turns on outlet A # offs.cgi?leds=10000000 turns off outlet A # ons.cgi?leds=11000000 turns on outlet A and B # offs.cgi?leds=11000000 turns off outlet A and B # # The JavaScript firmware in the WebUI pads the binary number with 0s to 24 # bits, presumably for compatibility with other models. class LindyIPowerClassic8(PDUDriver): def __init__(self, hostname, settings): self.hostname = hostname self.port = settings.get("port", 80) self.username = settings.get("username") self.password = settings.get("password") self.port_count = 8 super().__init__() def port_interaction(self, command, port_number): script = "" if command == "on": script = "ons.cgi" elif command == "off": script = "offs.cgi" else: raise FailedRequestException("Unknown command %s" % (command)) if int(port_number) > self.port_count or int(port_number) < 1: err = "Port number must be in range 1 - {}".format(self.port_count) log.error(err) raise FailedRequestException(err) # Pad the value to 24 bits and set a single bit port_value = 1 << (24 - int(port_number)) port_value = "{0:024b}".format(port_value) params = {'led': port_value} url = "http://{}/{}".format(self.hostname, script) log.debug("HTTP GET: {}, params={}".format(url, params)) auth = None if self.username and self.password: auth = HTTPBasicAuth(self.username, self.password) response = requests.get(url, params=params, auth=auth) log.debug( "Response code for request to {}: {}".format( self.hostname, response.status_code ) ) response.raise_for_status() @classmethod def accepts(cls, drivername): return drivername == "LindyIPowerClassic8" pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/localbase.py000066400000000000000000000032301515621562600236520ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class LocalBase(PDUDriver): connection = None def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings super(LocalBase, self).__init__() @classmethod def accepts(cls, drivername): return False def port_interaction(self, command, port_number): log.debug("Running port_interaction from LocalBase") log.debug("Attempting control: {} port: {}".format(command, port_number)) self._port_interaction(command, port_number) def _bombout(self): log.debug("Bombing out of driver: %s" % self.connection) del (self) def _cleanup(self): log.debug("Cleaning up driver: %s" % self.connection) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/localcmdline.py000066400000000000000000000033711515621562600243610ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from subprocess import call from pdudaemon.drivers.localbase import LocalBase import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class LocalCmdline(LocalBase): def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.cmd_on = settings.get("cmd_on", None) self.cmd_off = settings.get("cmd_off", None) @classmethod def accepts(cls, drivername): if drivername == "localcmdline": return True return False def _port_interaction(self, command, port_number): cmd = None if command == "on" and self.cmd_on: cmd = self.cmd_on % port_number elif command == "off" and self.cmd_off: cmd = self.cmd_off % port_number else: log.debug("Unknown command!") if cmd: log.debug("running %s" % cmd) call(cmd, shell=True) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/modbustcp.py000066400000000000000000000032271515621562600237330ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2023 Bob Clough # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver from pymodbus.client import ModbusTcpClient import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class ModBusTCP(PDUDriver): def __init__(self, hostname, settings): self.hostname = hostname self.port = settings.get("port", 502) self.unit = settings.get("unit", settings.get("slave", 1)) self._client = ModbusTcpClient(host=self.hostname, port=self.port) self._client.connect() super().__init__() def port_interaction(self, command, port_number): port_number = int(port_number) self._client.write_coil(address=port_number, value=(command == "on"), slave=self.unit) def _cleanup(self): self._client.close() @classmethod def accepts(cls, drivername): return drivername == cls.__name__.lower() pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/netio4.py000066400000000000000000000056451515621562600231430ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright (c) 2023 Koninklijke Philips N.V. # Author Julian Haller # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import pexpect from pdudaemon.drivers.driver import PDUDriver import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class Netio4(PDUDriver): connection = None def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.username = settings.get("username", "admin") self.password = settings.get("password", "admin") telnetport = settings.get("telnetport", 1234) self.exec_string = "/usr/bin/telnet %s %d" % (hostname, telnetport) super(Netio4, self).__init__() @classmethod def accepts(cls, drivername): if drivername == "netio4": return True return False def port_interaction(self, command, port_number): log.debug("Running port_interaction") self.get_connection() log.debug("Attempting command: {} port: {}".format(command, port_number)) if command == "on": self.connection.send("port %s 1\r" % port_number) elif command == "off": self.connection.send("port %s 0\r" % port_number) else: log.error("Unknown command") self.connection.expect("250 OK") def get_connection(self): log.debug("Connecting to Netio4 PDU with: %s", self.exec_string) self.connection = pexpect.spawn(self.exec_string) self._pdu_login(self.username, self.password) def _cleanup(self): if self.connection: self._pdu_logout() self.connection.close() def _bombout(self): if self.connection: self.connection.close(force=True) def _pdu_login(self, username, password): log.debug("attempting login with username %s, password %s", username, password) self.connection.send("\r") self.connection.expect("502 UNKNOWN COMMAND") self.connection.send("login %s %s\r" % (username, password)) self.connection.expect("250 OK") def _pdu_logout(self): log.debug("Logging out") self.connection.send("quit\r") self.connection.expect("110 BYE") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/netiojson.py000066400000000000000000000060141515621562600237400ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright (c) 2025 JUMO GmbH & Co. KG # codespell:ignore # Author Semin Buljevic # codespell:ignore # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import json import requests import logging import os from pdudaemon.drivers.driver import PDUDriver, FailedRequestException, UnknownCommandException log = logging.getLogger('pdud.drivers.' + os.path.basename(__file__)) class NetioJson(PDUDriver): def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.ip = settings.get('ip', self.hostname) self.port = settings.get('port', 80) self.username = settings.get('username', 'admin') self.password = settings.get('password', 'admin') self.number_of_outputs = settings.get('number_of_outputs', 4) super(NetioJson, self).__init__() @classmethod def accepts(cls, drivername): return drivername == 'netiojson' def port_interaction(self, command, port_number): if not 1 <= int(port_number) <= int(self.number_of_outputs): raise RuntimeError(f'Requested port no. {port_number} is out of range.') url = f'http://{self.username}:{self.password}@{self.ip}:{self.port}/netio.json' log.debug(f'Calling API URL: {url}') if command == 'on': self._do_request(url, port_number, action=1) elif command == 'off': self._do_request(url, port_number, action=0) else: raise UnknownCommandException(f'Unknown command: {command}') def _do_request(self, url, port_number, action): response = requests.post(url, json=self._get_request_json(port_number, action)) if not self._is_request_successful(response, port_number, action): raise FailedRequestException(f'Request failed. Received following response:\n{response.text}') def _get_request_json(self, port_number, action): data = f'{{"Outputs":[{{"ID":{port_number},"Action":{action}}}]}}' return json.loads(data) def _is_request_successful(self, response, port_number, action): if response.ok: output_states = response.json()['Outputs'] for output_state in output_states: if int(output_state['ID']) == int(port_number): return action == output_state['State'] return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/numatousb.py000066400000000000000000000102601515621562600237430ustar00rootroot00000000000000# Copyright 2020 Leonard Crestez # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver import serial import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class NumatoUSB(PDUDriver): """Supports various numato USB relay modules. Comes in many variants with similar protocols Protocol documentation link: https://numato.com/docs/16-channel-usb-relay-module/#the-command-set-23 There are slight differences for 32 and 64-channel modules """ def __init__(self, hostname, settings): super(NumatoUSB, self).__init__() self.hostname = hostname self.settings = settings self.device = settings.get("device", "/dev/ttyACM0") log.debug("device: %s" % self.device) self.serial_port = None def _read_reply(self): msg = self.serial_port.read_until(b'>').decode('utf8') x = msg.find('\n\r') return msg[x + 2:-3] def _open(self): self.serial_port = serial.serial_for_url(self.device, timeout=1) log.debug("open: %s for %s", self.serial_port.port, self.device) # resync prompt self.serial_port.reset_input_buffer() self.serial_port.write(b'\r') self.serial_port.read_until(b'>') # show relay module info self.serial_port.write(b'ver\r') log.info("ver: %s", self._read_reply()) self.serial_port.write(b'id get\r') log.info("id: %s", self._read_reply()) def port_interaction(self, command, port_number): port_number = int(port_number) if port_number > self.port_count or port_number < 1: err = "Port should be in the range 1 - %d" % (self.port_count) raise RuntimeError(err) if command == "on": msg = 'relay on %s\r' % (self.format_portid(port_number - 1),) elif command == "off": msg = 'relay off %s\r' % (self.format_portid(port_number - 1),) else: raise RuntimeError("Unknown command %s" % (command)) try: if not self.serial_port: self._open() log.debug("write %r on %s", msg, self.serial_port.port) self.serial_port.write(msg.encode('utf8')) self.serial_port.read_until(b'>') except (serial.serialutil.SerialException, OSError) as e: log.error(e) self.serial_port = None raise def format_portid(self, portid): # See https://numato.com/docs/16-channel-usb-relay-module/#the-command-set-23 return '%x' % portid @classmethod def accepts(cls, drivername): if not getattr(cls, 'port_count', None): return False return drivername == "NumatoUSB%d" % cls.port_count class NumatoUSB1(NumatoUSB): port_count = 1 class NumatoUSB2(NumatoUSB): port_count = 2 class NumatoUSB4(NumatoUSB): port_count = 4 class NumatoUSB8(NumatoUSB): port_count = 8 class NumatoUSB16(NumatoUSB): port_count = 16 class NumatoUSB32(NumatoUSB): port_count = 32 def format_portid(self, portid): # See https://numato.com/docs/32-channel-usb-relay-module/#the-command-set-23 if portid < 10: return '%d' % portid else: return chr(ord('A') + portid - 10) class NumatoUSB64(NumatoUSB): port_count = 64 def format_portid(self, portid): # See https://numato.com/docs/64-channel-usb-relay-module-user-guide/#the-commands-set-4 return '%02d' % portid pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/sainsmart.py000066400000000000000000000040641515621562600237340ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2016 BayLibre, Inc. # Author Kevin Hilman # # Based on localcmdline.py # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.localbase import LocalBase import requests import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class Sainsmart(LocalBase): def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.ip = settings.get("ip", self.hostname) self.url_base = "http://%s/30000/" % self.ip log.debug(self.url_base) @classmethod def accepts(cls, drivername): if drivername == "sainsmart": return True return False def _port_interaction(self, command, port_number): val = -1 if command == "on": log.debug("Attempting control: {} port: {}".format(command, port_number)) val = (port_number - 1) * 2 + 1 elif command == "off": log.debug("Attempting control: {} port: {}".format(command, port_number)) val = (port_number - 1) * 2 else: log.debug("Unknown command!") if (val >= 0): url = self.url_base + "%02d" % val log.debug("HTTP GET at %s" % url) requests.get(url) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/servertechpro2.py000066400000000000000000000046061515621562600247120ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2020 Arm Limited # Author Malcolm Brooks # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import json import logging import os import requests from pdudaemon.drivers.localbase import LocalBase log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class ServerTechPro2(LocalBase): def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.username = settings.get("username", "admin") self.password = settings.get("password", "admin") self.insecure = bool(settings.get("insecure", False)) protocol = settings.get("protocol", "http") server_address = settings.get("ip", self.hostname) self.url_base = "{}://{}/jaws/control/outlets/".format( protocol, server_address) @classmethod def accepts(cls, drivername): if drivername == "servertechpro2": return True return False def _port_interaction(self, command, port_number): log.debug("Attempting command: '{}' port: '{}'".format(command, port_number)) if command in ("on", "off"): if self.insecure: requests.packages.urllib3.disable_warnings() url = "{}{}".format(self.url_base, port_number) data = {"control_action": command} r = requests.patch( url=url, data=json.dumps(data), auth=(self.username, self.password), headers={'Content-Type': 'application/json'}, verify=not self.insecure ) r.raise_for_status() log.debug("Done") else: log.debug("Unknown command!") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/servo.py000066400000000000000000000050771515621562600230760ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2022 Laura Nao # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from pdudaemon.drivers.driver import PDUDriver from xmlrpc import client log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class Servo(PDUDriver): supported_ctrls = {'power_state': 'active_high', 'warm_reset': 'active_low', 'cold_reset': 'active_low'} def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.ctrls = settings.get("ctrls", ["cold_reset"]) if not isinstance(self.ctrls, list): self.ctrls = [self.ctrls] ip = settings.get("ip", "localhost") port = settings.get("port", 9999) self.remote_uri = "http://{}:{}".format(ip, port) log.debug("Servod server: %s" % self.remote_uri) super().__init__() def port_interaction(self, command, port_number): if command not in ('on', 'off'): log.error("Unknown command %s." % (command)) return for ctrl in self.ctrls: if ctrl not in self.supported_ctrls: err = (f'Unknown control {ctrl}. ' f'Available controls: {list(self.supported_ctrls.keys())}') raise ValueError(err) for ctrl in self.ctrls: if self.supported_ctrls[ctrl] == 'active_low': val = 'on' if command == 'off' else 'off' else: val = command log.debug("Setting %s:%s" % (ctrl, val)) with client.ServerProxy(self.remote_uri) as proxy: proxy.set(ctrl, val) @classmethod def accepts(cls, drivername): return drivername == cls.__name__.upper() pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/shelly_gen2.py000066400000000000000000000051531515621562600241460ustar00rootroot00000000000000#!/usr/bin/python3 # Shelly Gen2+ driver to control second generation and later SmartPlugs, etc. # made by Shelly. # # This does not support the secure requests through the authentication process # described in https://shelly-api-docs.shelly.cloud/gen2/General/Authentication. # Thus, this must be turned off in your Shelly device. # # Based on the official Gen 2+ Device API documentation: # https://shelly-api-docs.shelly.cloud/gen2/General/RPCProtocol # # Copyright 2025 Jonas Jelonek # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from pdudaemon.drivers.driver import PDUDriver import requests log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class ShellyGen2(PDUDriver): def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings super(ShellyGen2, self).__init__() def jsonrpc_call(self, method, params): r = requests.post( f"http://{self.hostname}/rpc", json = { "jsonrpc":"2.0", "id": 1, "method": method, "params": params, } ) r.raise_for_status() return r.json()["result"] def port_get(self, port_number): r = self.jsonrpc_call("Switch.GetStatus", { "id": int(port_number) }) return r["output"] def port_interaction(self, command, port_number): if not 0 <= port_number <= 3: raise ValueError("Invalid port number") if command == "get": return self.port_get(port_number) port_status = False if command == "on": port_status = True self.jsonrpc_call("Switch.Set", { "id": int(port_number), "on": port_status }) @classmethod def accepts(cls, drivername): if drivername == "shelly_gen2": return True return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/snmp.py000066400000000000000000000131111515621562600227010ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2018 Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import asyncio import logging from pysnmp.hlapi.v3arch.asyncio import ( set_cmd, SnmpEngine, UsmUserData, UdpTransportTarget, ContextData, CommunityData, ObjectType, ObjectIdentity, Integer32, ) import pysnmp.hlapi.v3arch.asyncio as pysnmp_api from pdudaemon.drivers.driver import PDUDriver, FailedRequestException, UnknownCommandException import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class SNMP(PDUDriver): def __init__(self, hostname, settings): self.hostname = hostname self.version = settings['driver'] self.mib = settings.get('mib', None) self.oid = settings.get('oid', None) self.authpass = settings.get('authpassphrase', None) self.privpass = settings.get('privpassphrase', None) self.community = settings.get('community', None) self.controlpoint = settings.get('controlpoint', None) self.username = settings.get('username', None) self.onsetting = settings['onsetting'] self.offsetting = settings['offsetting'] self.inside_number = settings.get('inside_number', None) self.static_ending = settings.get('static_ending', None) self.auth_protocol = settings.get('auth_protocol', None) self.priv_protocol = settings.get('priv_protocol', None) super(SNMP, self).__init__() @classmethod def accepts(cls, drivername): if drivername in ['snmpv3', 'snmpv1']: return True return False def validate(self): pass def port_interaction(self, command, port_number): if command == "on": set_bit = self.onsetting elif command == "off": set_bit = self.offsetting else: raise UnknownCommandException("Unknown command %s." % (command)) set_bit = int(set_bit) asyncio.run(self._port_interaction_async(set_bit, port_number)) async def _port_interaction_async(self, set_bit, port_number): transport = await UdpTransportTarget.create((self.hostname, 161)) if self.mib is not None and self.oid is None: objecttype = self._get_objecttype_mib(set_bit, port_number) elif self.oid is not None and self.mib is None: objecttype = self._get_objecttype_oid(set_bit, port_number) else: raise FailedRequestException("Either 'mib' or 'oid' setting must be configured") if self.version == 'snmpv3': if not self.username: raise FailedRequestException("No username set for snmpv3") protocols = {} if self.auth_protocol is not None: a_protocol = getattr(pysnmp_api, self.auth_protocol) protocols['authProtocol'] = a_protocol if self.priv_protocol is not None: p_protocol = getattr(pysnmp_api, self.priv_protocol) protocols['privProtocol'] = p_protocol userdata = UsmUserData(self.username, self.authpass, self.privpass, **protocols) elif self.version == 'snmpv1': if not self.community: raise FailedRequestException("No community set for snmpv1") userdata = CommunityData(self.community) else: raise FailedRequestException("Unknown snmp version") with SnmpEngine() as snmp_engine: errorIndication, errorStatus, errorIndex, varBinds = await set_cmd( snmp_engine, userdata, transport, ContextData(), objecttype, ) if errorIndication: raise FailedRequestException(errorIndication) elif errorStatus: raise FailedRequestException(errorStatus) else: for varBind in varBinds: log.debug(' = '.join([x.prettyPrint() for x in varBind])) return True def _get_objecttype_mib(self, set_bit, port_number): if self.inside_number: # It is possible to pass 2 or 3 snmp argument values filled_controlpoint = self.controlpoint.replace('*', str(port_number)) indexed_object_list = [self.mib, filled_controlpoint] # If there is a key static_ending available, add a static ending value if self.static_ending is not None: indexed_object_list.append(int(self.static_ending)) else: indexed_object_list = [self.mib, self.controlpoint, port_number] objecttype = ObjectType( ObjectIdentity(*indexed_object_list).add_asn1_mib_source( 'https://mibs.pysnmp.com/asn1/@mib@'), set_bit) return objecttype def _get_objecttype_oid(self, set_bit, port_number): oid = self.oid.replace('*', str(port_number)) objecttype = ObjectType(ObjectIdentity(oid), Integer32(set_bit)) return objecttype pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/strategies.py000066400000000000000000000133141515621562600241030ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from pdudaemon.drivers.acme import ACME from pdudaemon.drivers.anelnetpwrctrl import AnelNETPwrCtrlHOME from pdudaemon.drivers.anelnetpwrctrl import AnelNETPwrCtrlADV from pdudaemon.drivers.anelnetpwrctrl import AnelNETPwrCtrlIO from pdudaemon.drivers.anelnetpwrctrl import AnelNETPwrCtrlPRO from pdudaemon.drivers.apc7900 import APC7900 from pdudaemon.drivers.apc7932 import APC7932 from pdudaemon.drivers.apc7952 import APC7952 from pdudaemon.drivers.apc9218 import APC9218 from pdudaemon.drivers.apc8959 import APC8959 from pdudaemon.drivers.apc9210 import APC9210 from pdudaemon.drivers.apc7920 import APC7920 from pdudaemon.drivers.apc7921 import APC7921 from pdudaemon.drivers.cleware import ClewareUsbSwitch4 from pdudaemon.drivers.conrad197720 import Conrad197720 from pdudaemon.drivers.ubiquity import Ubiquity3Port from pdudaemon.drivers.ubiquity import Ubiquity6Port from pdudaemon.drivers.ubiquity import Ubiquity8Port from pdudaemon.drivers.localcmdline import LocalCmdline from pdudaemon.drivers.ip9258 import IP9258 from pdudaemon.drivers.sainsmart import Sainsmart from pdudaemon.drivers.devantech import DevantechETH002 from pdudaemon.drivers.devantech import DevantechETH0621 from pdudaemon.drivers.devantech import DevantechETH484 from pdudaemon.drivers.devantech import DevantechETH008 from pdudaemon.drivers.devantech import DevantechETH8020 from pdudaemon.drivers.devantech import DevantechDS2824 from pdudaemon.drivers.devantechusb import DevantechUSB2 from pdudaemon.drivers.devantechusb import DevantechUSB8 from pdudaemon.drivers.numatousb import NumatoUSB1 from pdudaemon.drivers.numatousb import NumatoUSB2 from pdudaemon.drivers.numatousb import NumatoUSB4 from pdudaemon.drivers.numatousb import NumatoUSB8 from pdudaemon.drivers.numatousb import NumatoUSB16 from pdudaemon.drivers.numatousb import NumatoUSB32 from pdudaemon.drivers.numatousb import NumatoUSB64 from pdudaemon.drivers.servertechpro2 import ServerTechPro2 from pdudaemon.drivers.synaccess import SynNetBooter from pdudaemon.drivers.tasmota import SonoffS20Tasmota from pdudaemon.drivers.tasmota import BrennenstuhlWSPL01Tasmota from pdudaemon.drivers.tasmota import GenericTasmota from pdudaemon.drivers.egpms import EgPMS from pdudaemon.drivers.ykush import YkushXS from pdudaemon.drivers.ykush import Ykush from pdudaemon.drivers.energenieusb import EnerGenieUSB from pdudaemon.drivers.bcu import BCU from pdudaemon.drivers.vusbhid import VUSBHID from pdudaemon.drivers.tplink import TPLink from pdudaemon.drivers.ip9850 import ip9850 from pdudaemon.drivers.intellinet import Intellinet from pdudaemon.drivers.esphome import ESPHomeHTTP from pdudaemon.drivers.servo import Servo from pdudaemon.drivers.ipower import LindyIPowerClassic8 from pdudaemon.drivers.modbustcp import ModBusTCP from pdudaemon.drivers.gude1202 import Gude1202 from pdudaemon.drivers.netio4 import Netio4 from pdudaemon.drivers.netiojson import NetioJson from pdudaemon.drivers.cyberpower81001 import Cyberpower81001 from pdudaemon.drivers.homeassistant import HomeAssistantHTTP from pdudaemon.drivers.ubus import Ubus from pdudaemon.drivers.shelly_gen2 import ShellyGen2 __all__ = [ ACME.__name__, AnelNETPwrCtrlHOME.__name__, AnelNETPwrCtrlADV.__name__, AnelNETPwrCtrlIO.__name__, AnelNETPwrCtrlPRO.__name__, APC7900.__name__, APC7932.__name__, APC7952.__name__, APC9218.__name__, APC8959.__name__, APC9210.__name__, APC7920.__name__, APC7921.__name__, ClewareUsbSwitch4.__name__, Conrad197720.__name__, Ubiquity3Port.__name__, Ubiquity6Port.__name__, Ubiquity8Port.__name__, LocalCmdline.__name__, IP9258.__name__, Sainsmart.__name__, DevantechETH002.__name__, DevantechETH0621.__name__, DevantechETH484.__name__, DevantechETH008.__name__, DevantechETH8020.__name__, DevantechDS2824.__name__, DevantechUSB2.__name__, DevantechUSB8.__name__, NumatoUSB1.__name__, NumatoUSB2.__name__, NumatoUSB4.__name__, NumatoUSB8.__name__, NumatoUSB16.__name__, NumatoUSB32.__name__, NumatoUSB64.__name__, ServerTechPro2.__name__, SynNetBooter.__name__, SonoffS20Tasmota.__name__, BrennenstuhlWSPL01Tasmota.__name__, GenericTasmota.__name__, EgPMS.__name__, YkushXS.__name__, Ykush.__name__, EnerGenieUSB.__name__, BCU.__name__, VUSBHID.__name__, TPLink.__name__, ip9850.__name__, Intellinet.__name__, ESPHomeHTTP.__name__, Servo.__name__, LindyIPowerClassic8.__name__, ModBusTCP.__name__, Gude1202.__name__, Netio4.__name__, NetioJson.__name__, Cyberpower81001.__name__, HomeAssistantHTTP.__name__, Ubus.__name__, ShellyGen2.__name__, ] log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) try: from pdudaemon.drivers.snmp import SNMP __all__.append(SNMP.__name__) except ModuleNotFoundError: log.warning("disabling snmp drivers due to missing modules") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/synaccess.py000066400000000000000000000102071515621562600237220ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2016 Broadcom # Author Christian Daudt # Based on apcbase+apc8959 by: # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import pexpect from pdudaemon.drivers.driver import PDUDriver import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class SynBase(PDUDriver): connection = None def __init__(self, hostname, settings): self.hostname = hostname log.debug(settings) self.settings = settings self.username = "admin" self.password = "admin" telnetport = 23 if "telnetport" in settings: telnetport = settings["telnetport"] if "username" in settings: self.username = settings["username"] if "password" in settings: self.password = settings["password"] self.exec_string = "/usr/bin/telnet %s %d" % (hostname, telnetport) log.debug("Telnet command: [%s]" % self.exec_string) super(SynBase, self).__init__() @classmethod def accepts(cls, drivername): return False def port_interaction(self, command, port_number): log.debug("Running port_interaction from SynBase") self.get_connection() log.debug("Attempting {} on port {}".format(command, port_number)) self._port_interaction(command, # pylint: disable=no-member port_number) def get_connection(self): log.debug("Connecting to Syn PDU with: %s", self.exec_string) # only uncomment this line for FULL debug when developing # self.connection = pexpect.spawn(self.exec_string, logfile=sys.stdout) self.connection = pexpect.spawn(self.exec_string) self._pdu_login(self.username, self.password) def _cleanup(self): self._pdu_logout() # pylint: disable=no-member def _bombout(self): log.debug("Bombing out of driver: %s", self.connection) self.connection.close(force=True) del self def _pdu_login(self, username, password): # Expected sequence: # >login # User ID: admin # Password:****** # > log.debug("attempting login with username %s, password %s", username, password) self.connection.expect(">") self.connection.send("login\r") self.connection.expect("User ID:") self.connection.send("%s\r" % username) self.connection.expect("Password:") self.connection.send("%s\r" % password) self.connection.expect(">") # # Only Synaccess product support at this point # Synaccess Networks netBooter Series B # Login identifies as: System Model: NP-08B class SynNetBooter(SynBase): pdu_commands = {"off": "pset %s 0", "on": "pset %s 1"} @classmethod def accepts(cls, drivername): if drivername == "synnetbooter": return True return False def _pdu_logout(self): log.debug("logging out") self.connection.send("\r") self.connection.send("exit") self.connection.send("\r") log.debug("done") def _pdu_get_to_prompt(self): self.connection.send("\r") self.connection.expect('>') def _port_interaction(self, command, port_number): self._pdu_get_to_prompt() self.connection.sendline(self.pdu_commands[command] % (port_number)) self._pdu_get_to_prompt() log.debug("done") pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/tasmota.py000066400000000000000000000065241515621562600234060ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2019 Stefan Wiehler # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # Protocol documentation available at: # https://tasmota.github.io/docs/#/Commands # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from pdudaemon.drivers.driver import PDUDriver, FailedRequestException import requests log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class TasmotaBase(PDUDriver): port_count = None # type: int def __init__(self, hostname, settings): self.hostname = settings.get("ip", hostname) self.username = settings.get("username") self.password = settings.get("password") super().__init__() def port_interaction(self, command, port_number): if int(port_number) > self.port_count or int(port_number) < 1: err = "Port number must be in range 1 - {}".format(self.port_count) log.error(err) raise FailedRequestException(err) params = { "cmnd": "Power{} {}".format(port_number, command), } if self.username is not None and self.password is not None: params["user"] = self.username params["password"] = self.password url = "http://{}/cm".format(self.hostname) log.debug("HTTP GET: {}".format(url)) r = requests.get(url, params) # check response response = r.json() r.raise_for_status() expected_response_key = "POWER{}".format(port_number) try: if response[expected_response_key] != command.upper(): log.error(response) raise FailedRequestException(response) except BaseException: log.error(response) raise FailedRequestException(response) log.debug('HTTP response: {}'.format(response)) @classmethod def accepts(cls, drivername): return False class SonoffS20Tasmota(TasmotaBase): port_count = 1 @classmethod def accepts(cls, drivername): if drivername == "sonoff_s20_tasmota": return True return False class BrennenstuhlWSPL01Tasmota(TasmotaBase): port_count = 2 @classmethod def accepts(cls, drivername): if drivername == "brennenstuhl_wspl01_tasmota": return True return False class GenericTasmota(TasmotaBase): def __init__(self, hostname, settings): self.port_count = int(settings.get("port_count", 1)) super().__init__(hostname, settings) @classmethod def accepts(cls, drivername): return drivername == "tasmota" pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/test_numatousb.py000066400000000000000000000014101515621562600247770ustar00rootroot00000000000000from unittest import mock from .numatousb import NumatoUSB4 from .numatousb import NumatoUSB32 from .numatousb import NumatoUSB64 def test_numato4(): with mock.patch('serial.serial_for_url') as m: dev = NumatoUSB4('h', dict(device='/dev/fake')) dev.port_on(3) m.return_value.write.assert_called_with(b'relay on 2\r') def test_numato32(): with mock.patch('serial.serial_for_url') as m: dev = NumatoUSB32('h', dict(device='/dev/fake')) dev.port_on(32) m.return_value.write.assert_called_with(b'relay on V\r') def test_numato64(): with mock.patch('serial.serial_for_url') as m: dev = NumatoUSB64('h', dict(device='/dev/fake')) dev.port_on(32) m.return_value.write.assert_called_with(b'relay on 31\r') pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/tplink.py000066400000000000000000000102671515621562600232360ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2020 # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. # # Confirmed devices # - KP303: https://www.tp-link.com/uk/home-networking/smart-plug/kp303/ # - KP105: https://www.tp-link.com/uk/home-networking/smart-plug/kp105/ # - HS105: https://www.tp-link.com/us/home-networking/smart-plug/hs105/ import logging import json import socket from pdudaemon.drivers.driver import PDUDriver from struct import pack import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class TPLink(PDUDriver): connection = None def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.childinfo = {} self.getinfo() super(TPLink, self).__init__() @classmethod def accepts(cls, drivername): if drivername == "tplink": return True return False def encrypt(self, string): key = 171 result = pack(">I", len(string)) for i in string: a = key ^ ord(i) key = a result += bytes([a]) return result def decrypt(self, string): key = 171 result = "" for i in string: a = key ^ i key = i result += chr(a) return result def getinfo(self): datadict = { 'system': { 'get_sysinfo': { } } } res = self.send_command(json.dumps(datadict)) if res: resdict = json.loads(res) self.childinfo = resdict.get("system", {}).get("get_sysinfo", {}).get("children", {}) def send_command(self, json_string): try: sock_tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock_tcp.settimeout(10) sock_tcp.connect((self.hostname, 9999)) sock_tcp.settimeout(None) sock_tcp.send(self.encrypt(json_string)) data = sock_tcp.recv(2048) sock_tcp.close() decrypted = self.decrypt(data[4:]) return (decrypted) except socket.error: log.error(f"Could not connect to host {self.hostname}:9999") # "port" is a string. We can match that against the alias or if it can # be converted to an integer treat it as the port index. We need to send the port ID. def get_id(self, port): for child in self.childinfo: if child['alias'] == port: return ({"child_ids": [child["id"]]}) if port.isdigit(): if int(port) < 1 or int(port) > len(self.childinfo): return False return ({"child_ids": [self.childinfo[int(port) - 1]["id"]]}) return False def port_interaction(self, command, port): state = 0 context = None if command == "on": state = 1 if not self.childinfo: log.error("Device not yet initialised, can't send command.") raise RuntimeError("Device not yet initialised, can't send command.") context = self.get_id(port) if not context: log.error("Invalid port index or non-existent alias.") raise RuntimeError("Invalid port index or non-existent alias.") datadict = { 'context': context, 'system': { 'set_relay_state': { 'state': state, } } } log.debug(datadict) self.send_command(json.dumps(datadict)) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/ubiquity.py000066400000000000000000000077501515621562600236130ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2015 Alexander Couzens # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from paramiko import SSHClient from paramiko.ssh_exception import SSHException from paramiko import RejectPolicy, WarningPolicy from pdudaemon.drivers.driver import PDUDriver import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class UbiquityBase(PDUDriver): client = None # overwrite power_count port_count = 0 def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.sshport = 22 self.username = "ubnt" self.password = "ubnt" # verify ssh hostkey? unknown hostkey will make this job fail self.verify_hostkey = True if "sshport" in settings: self.sshport = settings["sshport"] if "username" in settings: self.username = settings["username"] if "password" in settings: self.password = settings["password"] if "verify_hostkey" in settings: self.verify_hostkey = settings["verify_hostkey"] super(UbiquityBase, self).__init__() def connect(self): log.info("Connecting to Ubiquity mfi %s@%s:%d", self.username, self.hostname, self.sshport) self.client = SSHClient() self.client.load_system_host_keys() if self.verify_hostkey: self.client.set_missing_host_key_policy(RejectPolicy()) else: self.client.set_missing_host_key_policy(WarningPolicy()) self.client.connect(hostname=self.hostname, port=self.sshport, username=self.username, password=self.password) def port_interaction(self, command, port_number): port_number = int(port_number) log.debug("Running port_interaction from UbiquityBase") self.connect() if port_number > self.port_count: raise RuntimeError("We only have ports 1 - %d. %d > maxPorts (%d)" % self.port_count, port_number, self.port_count) if command == "on": command = "sh -c 'echo 1 > /proc/power/relay%d'" % port_number elif command == "off": command = "sh -c 'echo 0 > /proc/power/relay%d'" % port_number try: stdin, stdout, stderr = self.client.exec_command(command, bufsize=-1, timeout=3) stdin.close() except SSHException: pass def _cleanup(self): self.client.close() def _bombout(self): self.client.close() @classmethod def accepts(cls, drivername): return False class Ubiquity3Port(UbiquityBase): port_count = 3 @classmethod def accepts(cls, drivername): if drivername == "ubntmfi3port": return True return False class Ubiquity6Port(UbiquityBase): port_count = 6 @classmethod def accepts(cls, drivername): if drivername == "ubntmfi6port": return True return False class Ubiquity8Port(UbiquityBase): port_count = 8 @classmethod def accepts(cls, drivername): if drivername == "ubntmfi8port": return True return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/ubus.py000066400000000000000000000051041515621562600227050ustar00rootroot00000000000000#!/usr/bin/python3 # ubus jsonrpc interface for PoE management on OpenWrt devices. This comes in # handy if devices are connected to a PoE switch running OpenWrt. # # The host must accept unauthenticated ubus calls for the two poe calls 'info' # and 'manage'. # # Copyright 2025 Paul Spooren # Copyright 2025 Jonas Jelonek # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging import os from pdudaemon.drivers.driver import PDUDriver import requests log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) class Ubus(PDUDriver): def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings super(Ubus, self).__init__() def jsonrpc_call(self, path, method, message): r = requests.post( f"http://{self.hostname}/ubus", json = { "jsonrpc": "2.0", "id": 1, "method": "call", "params": [ "00000000000000000000000000000000", path, method, message ], }, ) r.raise_for_status() return r.json()["result"] def port_get(self, port_number): poe_info = self.jsonrpc_call("poe", "info", { }) if f"lan{port_number}" not in poe_info["ports"]: raise RuntimeError(f"Port lan{port_number} not found in {poe_info['ports']}") return poe_info["ports"][f"lan{port_number}"] != "Disabled" def port_interaction(self, command, port_number): if command == "get": return self.port_get(port_number) self.jsonrpc_call( "poe", "manage", { "port": f"lan{port_number}", "enable": True if command == "on" else False } ) @classmethod def accepts(cls, drivername): if drivername == "ubus": return True return False pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/vusbhid.py000066400000000000000000000075261515621562600234050ustar00rootroot00000000000000#!/usr/bin/python3 # # Copyright 2020 Joshua Watt # # A driver for V-USB HID based low cost relays # # See: http://vusb.wikidot.com/project:driver-less-usb-relays-hid-interface # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver import usb.core import usb.util import array import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) VENDOR_ID = 0x16C0 PRODUCT_ID = 0x05DF USB_TYPE_CLASS = 0x20 USB_ENDPOINT_OUT = 0x00 USB_ENDPOINT_IN = 0x80 USB_RECIP_DEVICE = 0x00 GET_REPORT = 0x1 SET_REPORT = 0x9 USB_HID_REPORT_TYPE_FEATURE = 3 MAIN_REPORT = 0 class VUSBHID(PDUDriver): def __init__(self, hostname, settings): self.settings = settings self.serial = settings.get("serial", "") self.invert = settings.get("invert", False) log.debug("serial: %s" % self.serial) def port_interaction(self, command, port_number): def xor(a, b): return bool(a) != bool(b) devices = self.connect() if len(devices) == 0: err = "No device found" log.error(err) raise RuntimeError(err) dev = None for d in devices: product = usb.util.get_string(d, d.iProduct) if not product.startswith("USBRelay"): continue data = self.get_report(d, MAIN_REPORT, 8) serial = data[0:5].tobytes().decode("utf-8") if serial == self.serial: dev = d break else: err = "Device with serial {} not found".format(self.serial) log.error(err) raise RuntimeError(err) port_number = int(port_number) # The end of the product string indicates the number of relays max_ports = int(product[8:]) if port_number > max_ports or port_number < 1: err = "Port should be in the range 1 - {}".format(max_ports) log.error(err) raise RuntimeError(err) if command == "on": self.set_state(dev, port_number, xor(True, self.invert)) elif command == "off": self.set_state(dev, port_number, xor(False, self.invert)) else: log.error("Unknown command %s." % (command)) return @classmethod def accepts(cls, drivername): return drivername == "vusbhid" def connect(self): ret = list() ret += list(usb.core.find(find_all=True, idVendor=0x16C0, idProduct=0x05DF)) return ret def set_state(self, dev, port, state): buf = array.array("B") buf.append(0xFF if state else 0xFD) buf.append(port) dev.ctrl_transfer( USB_TYPE_CLASS | USB_RECIP_DEVICE | USB_ENDPOINT_OUT, SET_REPORT, (USB_HID_REPORT_TYPE_FEATURE << 8) | MAIN_REPORT, 0, buf, 5000, ) def get_report(self, dev, report, size): return dev.ctrl_transfer( USB_TYPE_CLASS | USB_RECIP_DEVICE | USB_ENDPOINT_IN, GET_REPORT, (USB_HID_REPORT_TYPE_FEATURE << 8) | report, 0, size, 5000, ) pdudaemon-pdudaemon-46270a5/pdudaemon/drivers/ykush.py000066400000000000000000000050041515621562600230710ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2018 Sjoerd Simons # Stefan Kempe # # Based on PDUDriver: # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import logging from pdudaemon.drivers.driver import PDUDriver from pdudaemon.drivers.hiddevice import HIDDevice import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) YKUSH_VID = 0x04d8 YKUSH_PID = 0xf2f7 YKUSH_XS_PID = 0xf0cd YKUSH3_PID = 0xf11b class YkushBase(PDUDriver): connection = None ykush_pid = None # type: int port_count = 0 def __init__(self, hostname, settings): self.hostname = hostname self.settings = settings self.serial = settings.get("serial", u"") log.debug("serial: %s" % self.serial) super().__init__() def port_interaction(self, command, port_number): port_number = int(port_number) if port_number > self.port_count or port_number < 1: err = "Port should be in the range 1 - %d" % (self.port_count) log.error(err) raise RuntimeError(err) if command == "on": byte = 0x10 + port_number elif command == "off": byte = 0x00 + port_number else: log.error("Unknown command %s." % (command)) return with HIDDevice(YKUSH_VID, self.ykush_pid, serial=self.serial) as d: d.write([byte, byte]) d.read(64) @classmethod def accepts(cls, drivername): return drivername == cls.__name__.upper() class YkushXS(YkushBase): ykush_pid = YKUSH_XS_PID port_count = 1 class Ykush(YkushBase): ykush_pid = YKUSH_PID port_count = 3 class Ykush3(YkushBase): ykush_pid = YKUSH3_PID port_count = 3 pdudaemon-pdudaemon-46270a5/pdudaemon/httplistener.py000066400000000000000000000051111515621562600227740ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2018 Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import urllib.parse as urlparse import logging import pdudaemon.listener as listener from aiohttp import web logger = logging.getLogger('pdud.http') class HTTPListener: def __init__(self, config, daemon): self.config = config self.daemon = daemon self.settings = config["daemon"] self.app = web.Application() self.app.add_routes([ web.get('/power/control/on', self.handle), web.get('/power/control/off', self.handle), web.get('/power/control/reboot', self.handle), ]) self.apprunner = None async def start(self): logger.info("Starting the HTTP server") self.apprunner = web.AppRunner(self.app) await self.apprunner.setup() listen_host = self.settings["hostname"] listen_port = self.settings.get("port", 16421) site = web.TCPSite(self.apprunner, host=listen_host, port=listen_port) await site.start() logger.info("Listening on %s:%s", listen_host, listen_port) async def shutdown(self): if self.apprunner: await self.apprunner.cleanup() self.apprunner = None async def handle(self, request): logger.info("Handling HTTP request from %s: %s", request.remote, request.path_qs) data = urlparse.parse_qs(urlparse.urlparse(request.path_qs).query) path = urlparse.urlparse(request.path_qs).path res = await self.insert_request(data, path) if res: return web.Response(status=200, text="OK - accepted request\n") else: return web.Response(status=500, text="Invalid request\n") async def insert_request(self, data, path): args = listener.parse_http(data, path) if args: return await listener.process_request(args, self.config, self.daemon) pdudaemon-pdudaemon-46270a5/pdudaemon/listener.py000066400000000000000000000074551515621562600221110ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2019 Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import asyncio import logging logger = logging.getLogger('pdud.listener') class Args(object): hostname = None alias = None request = None port = None pass def parse_tcp(data): args = Args() delay = None array = data.split(" ") if (len(array) < 3) or (len(array) > 4): logger.info("Wrong data size") raise Exception("Unexpected data") if len(array) == 4: delay = int(array[3]) args.hostname = array[0] args.port = int(array[1]) args.request = array[2] args.delay = delay return args def parse_http(data, path): args = Args() entry = path.lstrip('/').split('/') if len(entry) != 3: logger.info("Request path was invalid: %s", entry) return False if not (entry[0] == 'power' and entry[1] == 'control'): logger.info("Unknown request, path was %s", path) return False # everything comes back from the http library in a 1 sized list args.alias = data.get('alias', [None])[0] args.hostname = data.get('hostname', [None])[0] args.port = data.get('port', [None])[0] args.request = entry[2] args.delay = data.get('delay', [None])[0] return args async def process_request(args, config, daemon): if args.request in ["on", "off"] and args.delay is not None: logger.warn("delay parameter is deprecated for on/off commands") if args.delay is not None: logger.debug("using custom delay as requested") else: # this has been a default since the start, it should be smaller but I # can't really change expected behaviour now if args.request == "reboot": args.delay = 5 else: args.delay = 0 if args.alias: if args.hostname or args.port: logger.error("Trying to use and alias and also a hostname/port") return False # Using alias support, get all pdu info from alias alias_settings = (config.get('aliases', {})).get(args.alias, False) if not alias_settings: logger.error("Alias requested but not found") return False args.hostname = config["aliases"][args.alias]["hostname"] args.port = config["aliases"][args.alias]["port"] if not args.hostname or not args.port or not args.request: logger.info("One of hostname,port,request was not set") return False if args.hostname not in config['pdus']: logger.info("PDU was not found in config") return False if args.request not in ["reboot", "on", "off"]: logger.info("Unknown request: %s", args.request) return False runner = daemon.runners[args.hostname] if args.request == "reboot": logger.debug("reboot requested, submitting off/on") if not await runner.do_job_async(args.port, "off"): return False await asyncio.sleep(int(args.delay)) return await runner.do_job_async(args.port, "on") else: await asyncio.sleep(int(args.delay)) return await runner.do_job_async(args.port, args.request) pdudaemon-pdudaemon-46270a5/pdudaemon/pdurunner.py000066400000000000000000000054221515621562600222760ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import asyncio import logging import time import traceback import pexpect import concurrent.futures from pdudaemon.drivers.driver import PDUDriver import pdudaemon.drivers.strategies assert pdudaemon.drivers.strategies, "Subclasses are iterated to find all drivers" class PDURunner: def __init__(self, config, hostname, retries, retrydelay): self.config = config self.hostname = hostname self.retries = retries self.retrydelay = retrydelay self.logger = logging.getLogger("pdud.pdu.%s" % hostname) self.driver = self.driver_from_hostname(hostname) # use single-worker ThreadPoolExecutor to serialize execution self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix=hostname) async def shutdown(self): self.executor.shutdown(wait=True, cancel_futures=True) def driver_from_hostname(self, hostname): drivername = self.config['driver'] driver = PDUDriver.select(drivername)(hostname, self.config) return driver def do_job(self, port, request): self.logger.info("Processing job for PDU %s: (%s %s)", self.hostname, request, port) retries = self.retries while retries > 0: try: self.driver.handle(request, port) return True except (OSError, pexpect.exceptions.EOF, Exception): # pylint: disable=broad-except self.logger.warn(traceback.format_exc()) self.logger.warn("Failed to execute job: {} {} (attempts left {})".format(port, request, retries - 1)) if self.driver: self.driver._bombout() # pylint: disable=W0212,E1101 time.sleep(self.retrydelay) retries -= 1 continue return False async def do_job_async(self, port, request): loop = asyncio.get_running_loop() return await loop.run_in_executor(self.executor, self.do_job, port, request) pdudaemon-pdudaemon-46270a5/pdudaemon/tcplistener.py000066400000000000000000000056351515621562600226160ustar00rootroot00000000000000#!/usr/bin/python3 # Copyright 2013 Linaro Limited # Author Matt Hart # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. import asyncio import logging import socket import pdudaemon.listener as listener logger = logging.getLogger('pdud.tcp') class TCPListener: def __init__(self, config, daemon): self.config = config self.daemon = daemon self.settings = config["daemon"] self.server = None async def start(self): listen_host = self.settings["hostname"] listen_port = self.settings.get("port", 16421) logger.info("listening on %s:%s", listen_host, listen_port) self.server = await asyncio.start_server( client_connected_cb=self.handle, host=listen_host, port=listen_port, reuse_address=True, ) async def shutdown(self): if self.server: self.server.close() await self.server.wait_closed() self.server = None async def insert_request(self, data): args = listener.parse_tcp(data) if args: return await listener.process_request(args, self.config, self.daemon) async def handle(self, reader, writer): request_ip = writer.get_extra_info('peername')[0] loop = asyncio.get_running_loop() try: data = await reader.read(16384) data = data.decode('utf-8') data = data.strip() try: fut = loop.run_in_executor(None, socket.gethostbyaddr, request_ip) request_host = (await asyncio.wait_for(fut, timeout=2))[0] except (socket.herror, asyncio.TimeoutError): request_host = request_ip logger.info("Received a request from %s: '%s'", request_host, data) res = await self.insert_request(data) if res: writer.write("ack\n".encode('utf-8')) else: writer.write("nack\n".encode('utf-8')) except Exception as global_error: # pylint: disable=broad-except logger.debug(global_error.__class__) logger.debug(global_error) writer.write(str(global_error).encode('utf-8')) writer.close() await writer.wait_closed() pdudaemon-pdudaemon-46270a5/pyproject.toml000066400000000000000000000043671515621562600206510ustar00rootroot00000000000000[project] name = "pdudaemon" description = "Control and Queueing daemon for PDUs" maintainers = [ { name = "Matt Hart", email = "matt@mattface.org" }, ] readme = "README.md" license = "GPL-2.0-or-later" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", "Topic :: Communications", "Topic :: Software Development :: Testing", "Topic :: System :: Networking", ] requires-python = ">= 3.10" dependencies = [ "aiohttp", "requests", "pexpect", "systemd_python", "paramiko", "pyserial", "hidapi", "pysnmp>=7.1.22", "pyasn1>=0.6.2", "pyusb", "pymodbus", ] dynamic = ["version"] # via setuptools_scm [project.optional-dependencies] test = [ "pytest >= 4.6", "pytest-asyncio", "pytest-mock", ] [tool.setuptools] packages = [ "pdudaemon", "pdudaemon.drivers", ] [tool.setuptools_scm] local_scheme = "no-local-version" [project.urls] Homepage = "https://github.com/pdudaemon/pdudaemon.git" [project.scripts] pdudaemon = "pdudaemon:main" [build-system] requires = [ "setuptools>=61.0", "setuptools_scm[toml]", ] build-backend = "setuptools.build_meta" [tool.ruff] select = [ "E", # pycodestyle "W", # pycodestyle "D", # pydocstyle "F", # pyflakes # "I", # isort # "UP", # pyupgrade ] ignore = [ "D100", # Missing docstring in public module "D101", # Missing docstring in public class "D102", # Missing docstring in public method "D103", # Missing docstring in public function "D104", # Missing docstring in public package "D107", # Missing docstring in `__init__` "D205", # 1 blank line required between summary line and description "E501", # Line too long ] src = ["pdudaemon", "tests"] line-length = 88 # Match black # Minimum Python 3.10. target-version = "py310" [tool.ruff.isort] known-first-party = ["pdudaemon", "tests"] [tool.ruff.pydocstyle] convention = "google" pdudaemon-pdudaemon-46270a5/share/000077500000000000000000000000001515621562600170255ustar00rootroot00000000000000pdudaemon-pdudaemon-46270a5/share/80-cleware.rules000066400000000000000000000003021515621562600217430ustar00rootroot00000000000000SUBSYSTEM=="usb", ATTR{idVendor}=="0d50", ATTR{idProduct}=="0008", GROUP="pdudaemon", MODE="660" SUBSYSTEM=="usb", ATTR{idVendor}=="0d50", ATTR{idProduct}=="0030", GROUP="pdudaemon", MODE="660" pdudaemon-pdudaemon-46270a5/share/80-energenieusb.rules000066400000000000000000000007451515621562600230070ustar00rootroot00000000000000SUBSYSTEM=="usb", ATTR{idVendor}=="04b4", ATTR{idProduct}=="fd10", GROUP="pdudaemon", MODE="660" SUBSYSTEM=="usb", ATTR{idVendor}=="04b4", ATTR{idProduct}=="fd11", GROUP="pdudaemon", MODE="660" SUBSYSTEM=="usb", ATTR{idVendor}=="04b4", ATTR{idProduct}=="fd12", GROUP="pdudaemon", MODE="660" SUBSYSTEM=="usb", ATTR{idVendor}=="04b4", ATTR{idProduct}=="fd13", GROUP="pdudaemon", MODE="660" SUBSYSTEM=="usb", ATTR{idVendor}=="04b4", ATTR{idProduct}=="fd15", GROUP="pdudaemon", MODE="660" pdudaemon-pdudaemon-46270a5/share/80-vusbhid.rules000066400000000000000000000001431515621562600217700ustar00rootroot00000000000000# V-USB HID Relays SUBSYSTEM=="usb", ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="05df", MODE="666" pdudaemon-pdudaemon-46270a5/share/80-ykush.rules000066400000000000000000000002611515621562600214700ustar00rootroot00000000000000ACTION=="remove", GOTO="ykush_end" SUBSYSTEM!="usb", GOTO="ykush_end" ENV{ID_MODEL}=="YKUSH_XS", OWNER="pdudaemon" ENV{ID_MODEL}=="YKUSH", OWNER="pdudaemon" LABEL="ykush_end" pdudaemon-pdudaemon-46270a5/share/expected_output.txt000066400000000000000000000001771515621562600230140ustar00rootroot00000000000000http 2 off http 2 on httpalias 1 off httpalias 1 on tcp 3 off tcp 3 on drive 4 off drive 4 on drivealias 2 off drivealias 2 on pdudaemon-pdudaemon-46270a5/share/find-energenie-serial.py000077500000000000000000000016271515621562600235440ustar00rootroot00000000000000#!/usr/bin/python3 # It turns out that the device ID of the EnerGenie devices isn't the same # as the serial number printed on the unit. This script will return the # IDs of the devices found connected. The ID needs to be used in the # "device" field when configuring pdudaemon.conf to use an energenie PDU. import usb.core dev = list() dev += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd10)) dev += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd11)) dev += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd12)) dev += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd13)) dev += list(usb.core.find(find_all=True, idVendor=0x04b4, idProduct=0xfd15)) for d in dev: buf = bytes([0x00, 0x00, 0x00, 0x00, 0x00]) ret = d.ctrl_transfer(0xa1, 0x01, 0x0301, 0, buf, 500) id = ":".join(format(x, '02x') for x in ret.tolist()) print(id) pdudaemon-pdudaemon-46270a5/share/pdudaemon-test.sh000077500000000000000000000031001515621562600223070ustar00rootroot00000000000000#!/bin/bash PDUD_BINARY=pdudaemon TMPFILE=/tmp/pdu rm $TMPFILE # support the -l option for running the tests locally while getopts l option do case "${option}" in l) LOCAL=true ;; esac done #empty the tempfile, helpful if running locally if [ $LOCAL ] then cp pdudaemon/__init__.py ./pdudaemon-test-bin chmod +x ./pdudaemon-test-bin PDUD_BINARY=./pdudaemon-test-bin fi $PDUD_BINARY --loglevel=DEBUG --conf=share/pdudaemon.conf & PDU_PID=$! sleep 3 # Test standard HTTP request curl -q "http://localhost:16421/power/control/reboot?hostname=http&port=2&delay=1" &> /dev/null sleep 10 # Test alias HTTP request curl -q "http://localhost:16421/power/control/reboot?alias=aliastesthttp01&delay=5" &> /dev/null sleep 10 kill $PDU_PID sleep 10 # Test TCP listener $PDUD_BINARY --loglevel=DEBUG --listener tcp --conf=share/pdudaemon.conf & PDU_PID=$! sleep 3 ./pduclient --daemon localhost --hostname tcp --port 3 --command reboot --delay 1 sleep 10 if [ $LOCAL ] then # kill the running daemon after first test, helpful if running locally kill $PDU_PID sleep 5 fi # Test drive feature $PDUD_BINARY --loglevel=DEBUG --conf=share/pdudaemon.conf --drive --hostname drive --port 4 --request reboot # Test drive feature with alias feature $PDUD_BINARY --loglevel=DEBUG --conf=share/pdudaemon.conf --drive --alias aliastestdrive02 --request reboot if [ $LOCAL ] then rm $PDUD_BINARY fi echo "#### Created output ####" cat $TMPFILE echo "" echo "#### Expected output ####" cat share/expected_output.txt echo "" diff -q -u share/expected_output.txt $TMPFILE exit $? pdudaemon-pdudaemon-46270a5/share/pdudaemon.conf000066400000000000000000000120001515621562600216410ustar00rootroot00000000000000{ "daemon": { "hostname": "0.0.0.0", "port": 16421, "logging_level": "DEBUG", "listener": "http" }, "pdus": { "test": { "driver": "localcmdline", "cmd_on": "echo '%s on' >> /tmp/pdu", "cmd_off": "echo '%s off' >> /tmp/pdu" }, "drive": { "driver": "localcmdline", "cmd_on": "echo 'drive %s on' >> /tmp/pdu", "cmd_off": "echo 'drive %s off' >> /tmp/pdu" }, "drivealias": { "driver": "localcmdline", "cmd_on": "echo 'drivealias %s on' >> /tmp/pdu", "cmd_off": "echo 'drivealias %s off' >> /tmp/pdu" }, "http": { "driver": "localcmdline", "cmd_on": "echo 'http %s on' >> /tmp/pdu", "cmd_off": "echo 'http %s off' >> /tmp/pdu" }, "httpalias": { "driver": "localcmdline", "cmd_on": "echo 'httpalias %s on' >> /tmp/pdu", "cmd_off": "echo 'httpalias %s off' >> /tmp/pdu" }, "tcp": { "driver": "localcmdline", "cmd_on": "echo 'tcp %s on' >> /tmp/pdu", "cmd_off": "echo 'tcp %s off' >> /tmp/pdu" }, "baylibre-acme.local": { "driver": "acme" }, "apc9210": { "driver": "apc9210" }, "apc7952": { "driver": "apc7952", "telnetport": 5023 }, "apc8959": { "driver": "apc8959" }, "apc7952-retries": { "driver": "apc7952", "telnetport": 23, "retries": 2 }, "ubntmfi3port": { "driver": "ubntmfi3port", "username": "ubnt", "password": "ubnt", "sshport": 22, "verify_hostkey": true }, "apc-snmpv3-withauth": { "driver": "snmpv3", "username": "pdudaemon", "authpassphrase": "pdudaemonauthpassphrase", "privpassphrase": "pdudaemonprivpassphrase", "mib": "PowerNet-MIB", "controlpoint": "sPDUOutletCtl", "onsetting": "1", "offsetting": 2 }, "apc-snmpv3-noauth": { "driver": "snmpv3", "username": "pdudaemon-noauth", "mib": "PowerNet-MIB", "controlpoint": "sPDUOutletCtl", "onsetting": "1", "offsetting": 2 }, "apc-snmpv1-private": { "driver": "snmpv1", "community": "private", "mib": "PowerNet-MIB", "controlpoint": "sPDUOutletCtl", "onsetting": "1", "offsetting": 2 }, "cbs350-poe-switch": { "driver": "snmpv1", "community": "private", "oid": ".1.3.6.1.2.1.105.1.1.1.3.1.*", "onsetting": 1, "offsetting": 2 }, "cleware-usb-switch-4": { "driver": "ClewareUsbSwitch4", "serial": 12345 }, "cleware-usb-switch-8": { "driver": "ClewareUsbSwitch8", "serial": 4321 }, "intellinet163682": { "driver": "intellinet", "username": "admin", "password": "admin", "port": 80 }, "energenie": { "driver": "EG-PMS", "device": "aa:bb:cc:xx:yy" }, "127.0.0.1": { "driver": "localcmdline" }, "servertechpro2": { "driver": "servertechpro2", "ip": "192.168.10.4", "protocol": "https", "insecure": true, "username": "testuser", "password": "testuser" }, "tplink_kp303": { "driver": "tplink" }, "tplink_kp105": { "driver": "tplink" }, "ip9850": { "driver": "ip9850", "ip": "192.168.1.5", "username": "testuser", "password": "testpass" }, "esphome": { "driver": "esphome-http", "username": "admin", "password": "web" }, "servo": { "driver": "SERVO", "ip": "0.0.0.0", "port": "9902", "ctrls": "cold_reset" }, "IPOWER" : { "driver": "LindyIPowerClassic8", "username": "snmp", "password": "1234" }, "Devantech": { "driver": "devantech_eth008", "ip": "192.168.56.101", "port": "17494", "password": "password", "logic": "NO" }, "TasmotaPowerStrip": { "driver": "tasmota", "ip": "192.168.42.101", "port_count": 4, "username": "user", "password": "password" } }, "aliases": { "aliastesthttp01": { "hostname": "httpalias", "port": 1 }, "aliastestdrive02": { "hostname": "drivealias", "port": 2 } } } pdudaemon-pdudaemon-46270a5/share/pdudaemon.service000066400000000000000000000004201515621562600223570ustar00rootroot00000000000000[Unit] Description=Control and Queueing daemon for PDUs [Service] ExecStart=/usr/sbin/pdudaemon --journal --conf=/etc/pdudaemon/pdudaemon.conf Type=simple DynamicUser=yes StateDirectory=pdudaemon ProtectHome=true Restart=on-abnormal [Install] WantedBy=multi-user.target pdudaemon-pdudaemon-46270a5/share/supervisord.conf000066400000000000000000000003721515621562600222630ustar00rootroot00000000000000[supervisord] nodaemon=true [program:pdudaemon] command=/usr/local/bin/pdudaemon --conf=/config/pdudaemon.conf autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 pdudaemon-pdudaemon-46270a5/tests/000077500000000000000000000000001515621562600170655ustar00rootroot00000000000000pdudaemon-pdudaemon-46270a5/tests/test_intellinet.py000066400000000000000000000014051515621562600226450ustar00rootroot00000000000000from unittest.mock import ANY import pytest from pdudaemon.drivers.driver import FailedRequestException from pdudaemon.drivers.intellinet import Intellinet @pytest.fixture def pdu(): return Intellinet("dummy", {}) @pytest.fixture(name="api_mock") def fixture_api_mock(mocker): return mocker.patch("pdudaemon.drivers.intellinet.Intellinet._api") def test_port_interaction(pdu, api_mock): pdu.port_interaction("on", 0) api_mock.assert_called_once_with(pdu.endpoints["outlet"], ANY) def test_port_interaction_port_in_range(pdu): pdu = Intellinet("dummy", {}) with pytest.raises(FailedRequestException): pdu.port_interaction("on", pdu.port_count) with pytest.raises(FailedRequestException): pdu.port_interaction("on", -1) pdudaemon-pdudaemon-46270a5/tests/test_snmp.py000066400000000000000000000167421515621562600214650ustar00rootroot00000000000000import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from pdudaemon.drivers.driver import FailedRequestException, UnknownCommandException from pdudaemon.drivers.snmp import SNMP SNMPV1_SETTINGS = { "driver": "snmpv1", "mib": "PowerNet-MIB", "controlpoint": "sPDUOutletCtl", "onsetting": "1", "offsetting": "2", "community": "private", } SNMPV3_SETTINGS = { "driver": "snmpv3", "mib": "PowerNet-MIB", "controlpoint": "sPDUOutletCtl", "onsetting": "1", "offsetting": "2", "username": "testuser", "authpassphrase": "authpass123", "privpassphrase": "privpass123", } @pytest.fixture def snmpv1_pdu(): return SNMP("192.168.1.100", SNMPV1_SETTINGS) @pytest.fixture def snmpv3_pdu(): return SNMP("192.168.1.100", SNMPV3_SETTINGS) class TestAccepts: def test_accepts_snmpv1(self): assert SNMP.accepts("snmpv1") is True def test_accepts_snmpv3(self): assert SNMP.accepts("snmpv3") is True def test_rejects_unknown(self): assert SNMP.accepts("snmpv99") is False assert SNMP.accepts("telnet") is False class TestInit: def test_snmpv1_init(self, snmpv1_pdu): assert snmpv1_pdu.hostname == "192.168.1.100" assert snmpv1_pdu.version == "snmpv1" assert snmpv1_pdu.community == "private" assert snmpv1_pdu.mib == "PowerNet-MIB" assert snmpv1_pdu.controlpoint == "sPDUOutletCtl" assert snmpv1_pdu.onsetting == "1" assert snmpv1_pdu.offsetting == "2" def test_snmpv3_init(self, snmpv3_pdu): assert snmpv3_pdu.version == "snmpv3" assert snmpv3_pdu.username == "testuser" assert snmpv3_pdu.authpass == "authpass123" assert snmpv3_pdu.privpass == "privpass123" def test_optional_settings_default_none(self, snmpv1_pdu): assert snmpv1_pdu.inside_number is None assert snmpv1_pdu.static_ending is None assert snmpv1_pdu.auth_protocol is None assert snmpv1_pdu.priv_protocol is None assert snmpv1_pdu.username is None class TestPortInteraction: def test_unknown_command_raises(self, snmpv1_pdu): with pytest.raises(UnknownCommandException): snmpv1_pdu.port_interaction("reboot", 1) @patch("pdudaemon.drivers.snmp.asyncio.run") def test_on_command(self, mock_run, snmpv1_pdu): snmpv1_pdu.port_interaction("on", 1) mock_run.assert_called_once() # The first positional arg to asyncio.run is the coroutine coro = mock_run.call_args[0][0] # It should be a coroutine created with onsetting assert asyncio.iscoroutine(coro) coro.close() # prevent RuntimeWarning @patch("pdudaemon.drivers.snmp.asyncio.run") def test_off_command(self, mock_run, snmpv1_pdu): snmpv1_pdu.port_interaction("off", 1) mock_run.assert_called_once() coro = mock_run.call_args[0][0] assert asyncio.iscoroutine(coro) coro.close() class TestPortInteractionAsync: """Test the async _port_interaction_async method directly.""" @pytest.fixture def mock_set_cmd(self): with patch("pdudaemon.drivers.snmp.set_cmd", new_callable=AsyncMock) as m: m.return_value = (None, 0, 0, []) yield m @pytest.fixture def mock_transport(self): with patch("pdudaemon.drivers.snmp.UdpTransportTarget") as m: m.create = AsyncMock(return_value=MagicMock()) yield m @pytest.fixture def mock_engine(self): with patch("pdudaemon.drivers.snmp.SnmpEngine") as m: engine_instance = MagicMock() m.return_value = engine_instance yield engine_instance @pytest.mark.asyncio async def test_snmpv1_set_cmd_called(self, snmpv1_pdu, mock_set_cmd, mock_transport, mock_engine): result = await snmpv1_pdu._port_interaction_async("1", 1) assert result is True mock_set_cmd.assert_awaited_once() @pytest.mark.asyncio async def test_snmpv3_set_cmd_called(self, snmpv3_pdu, mock_set_cmd, mock_transport, mock_engine): result = await snmpv3_pdu._port_interaction_async("1", 1) assert result is True mock_set_cmd.assert_awaited_once() @pytest.mark.asyncio async def test_snmpv3_no_username_raises(self, mock_set_cmd, mock_transport, mock_engine): settings = {**SNMPV3_SETTINGS, "username": None} pdu = SNMP("192.168.1.100", settings) # username is explicitly None pdu.username = None with pytest.raises(FailedRequestException, match="No username"): await pdu._port_interaction_async("1", 1) @pytest.mark.asyncio async def test_snmpv1_no_community_raises(self, mock_set_cmd, mock_transport, mock_engine): settings = {**SNMPV1_SETTINGS, "community": None} pdu = SNMP("192.168.1.100", settings) pdu.community = None with pytest.raises(FailedRequestException, match="No community"): await pdu._port_interaction_async("1", 1) @pytest.mark.asyncio async def test_unknown_version_raises(self, mock_set_cmd, mock_transport, mock_engine): settings = {**SNMPV1_SETTINGS, "driver": "snmpv99"} pdu = SNMP("192.168.1.100", settings) with pytest.raises(FailedRequestException, match="Unknown snmp version"): await pdu._port_interaction_async("1", 1) @pytest.mark.asyncio async def test_error_indication_raises(self, snmpv1_pdu, mock_set_cmd, mock_transport, mock_engine): mock_set_cmd.return_value = ("some error", 0, 0, []) with pytest.raises(FailedRequestException): await snmpv1_pdu._port_interaction_async("1", 1) @pytest.mark.asyncio async def test_error_status_raises(self, snmpv1_pdu, mock_set_cmd, mock_transport, mock_engine): mock_set_cmd.return_value = (None, "badValue", 1, []) with pytest.raises(FailedRequestException): await snmpv1_pdu._port_interaction_async("1", 1) @pytest.mark.asyncio async def test_engine_closed_on_error(self, snmpv1_pdu, mock_set_cmd, mock_transport, mock_engine): mock_set_cmd.side_effect = RuntimeError("connection failed") with pytest.raises(RuntimeError): await snmpv1_pdu._port_interaction_async("1", 1) @pytest.mark.asyncio async def test_inside_number_controlpoint(self, mock_set_cmd, mock_transport, mock_engine): settings = { **SNMPV1_SETTINGS, "controlpoint": "outlet*.0", "inside_number": True, } pdu = SNMP("192.168.1.100", settings) await pdu._port_interaction_async("1", 5) mock_set_cmd.assert_awaited_once() @pytest.mark.asyncio async def test_inside_number_with_static_ending(self, mock_set_cmd, mock_transport, mock_engine): settings = { **SNMPV1_SETTINGS, "controlpoint": "outlet*.0", "inside_number": True, "static_ending": "42", } pdu = SNMP("192.168.1.100", settings) await pdu._port_interaction_async("1", 3) mock_set_cmd.assert_awaited_once() @pytest.mark.asyncio async def test_snmpv3_with_auth_priv_protocols(self, mock_set_cmd, mock_transport, mock_engine): settings = { **SNMPV3_SETTINGS, "auth_protocol": "USM_AUTH_HMAC96_SHA", "priv_protocol": "USM_PRIV_CFB128_AES", } pdu = SNMP("192.168.1.100", settings) result = await pdu._port_interaction_async("1", 1) assert result is True mock_set_cmd.assert_awaited_once()