diff --git a/README.md b/README.md index 7423e18..d6aa33a 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,9 @@ Configure the PDU in the config file as usual, then launch pdudaemon with the fo $ 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 diff --git a/pdudaemon/__init__.py b/pdudaemon/__init__.py index c712802..51007ae 100644 --- a/pdudaemon/__init__.py +++ b/pdudaemon/__init__.py @@ -142,6 +142,7 @@ async def main_async(): 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("--delay", dest="drivedelay", action="store", type=int, default=5) drive.add_argument("--port", dest="driveport", action="store", type=str) # Parse the command line @@ -181,6 +182,7 @@ async def main_async(): runner = PDURunner(config, options.drivehostname, options.driveretries) 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) diff --git a/pdudaemon/drivers/cleware.py b/pdudaemon/drivers/cleware.py index c711799..0c9cea6 100644 --- a/pdudaemon/drivers/cleware.py +++ b/pdudaemon/drivers/cleware.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 # Copyright 2022 Sjoerd Simons +# Copyright 2023 Sietze van Buuren # # Based on PDUDriver: # Copyright 2013 Linaro Limited @@ -22,33 +23,82 @@ # MA 02110-1301, USA. import logging -from pdudaemon.drivers.driver import PDUDriver -import hid 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 ClewareSwitch1Base(PDUDriver): +class ClewareBase(PDUDriver): + """ Base class for Cleware USB-Switch drivers """ + switch_pid = None connection = None 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) - + 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 = "Port should be in the range 1 - %d" % (self.port_count) + err = f"Port should be in the range 1 - {self.port_count}" log.error(err) raise RuntimeError(err) @@ -58,39 +108,24 @@ def port_interaction(self, command, port_number): elif command == "off": on = 0 else: - log.error("Unknown command %s." % (command)) + log.error("Unknown command %s.", (command)) return - d = hid.device() - d.open(CLEWARE_VID, CLEWARE_SWITCH1_PID, serial_number=self.serial) - d.write([0, 0, port, on]) - d.close() - - @classmethod - def accepts(cls, drivername): - return drivername.lower() == cls.__name__.lower() + with HIDDevice(path=self.device_path()) as dev: + dev.write([0, 0, port, on]) class ClewareUsbSwitch4(ClewareSwitch1Base): port_count = 4 -class ClewareContact00Base(PDUDriver): - connection = None - 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__() +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 = "Port should be in the range 1 - %d" % (self.port_count) + err = f"Port should be in the range 1 - {self.port_count}" log.error(err) raise RuntimeError(err) @@ -100,17 +135,11 @@ def port_interaction(self, command, port_number): elif command == "off": on = 0 else: - log.error("Unknown command %s." % (command)) + log.error("Unknown command %s.", (command)) return - d = hid.device() - d.open(CLEWARE_VID, CLEWARE_CONTACT00_PID, serial_number=self.serial) - d.write([0, 3, on >> 8, on & 0xff, port >> 8, port & 0xff]) - d.close() - - @classmethod - def accepts(cls, drivername): - return drivername.lower() == cls.__name__.lower() + with HIDDevice(path=self.device_path()) as dev: + dev.write([0, 3, on >> 8, on & 0xff, port >> 8, port & 0xff]) class ClewareUsbSwitch8(ClewareContact00Base): diff --git a/pdudaemon/drivers/conrad197720.py b/pdudaemon/drivers/conrad197720.py new file mode 100644 index 0000000..02a8b27 --- /dev/null +++ b/pdudaemon/drivers/conrad197720.py @@ -0,0 +1,324 @@ +#!/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 switchting 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): + 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, lenght must be a multiple of FRAME_SIZE + :return: True, if checksum(s) match + :rtype: boolean + """ + recv = "" + 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") diff --git a/pdudaemon/drivers/devantech.py b/pdudaemon/drivers/devantech.py index edfac35..51db626 100644 --- a/pdudaemon/drivers/devantech.py +++ b/pdudaemon/drivers/devantech.py @@ -38,6 +38,7 @@ def __init__(self, hostname, 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): @@ -60,11 +61,13 @@ def port_interaction(self, command, 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'\x20' + msg = b'\x21' if self.logic == "NC" else b'\x20' elif command == "off": - msg = b'\x21' + msg = b'\x20' if self.logic == "NC" else b'\x21' else: log.error("Unknown command %s." % (command)) return diff --git a/pdudaemon/drivers/esphome.py b/pdudaemon/drivers/esphome.py index 8523d0a..84b08fa 100644 --- a/pdudaemon/drivers/esphome.py +++ b/pdudaemon/drivers/esphome.py @@ -42,17 +42,9 @@ def __init__(self, hostname, settings): self.username = settings.get("username") self.password = settings.get("password") - self.switch_ids = settings.get("switch_ids") - if self.switch_ids is None: - raise RuntimeError( - "No switch entity ID defined for %s. Provide `switch_ids` configuration entry with a list of switch IDs." - % self.hostname - ) - self.port_count = len(self.switch_ids) - super().__init__() - def port_interaction(self, command, port_number): + def port_interaction(self, command, esphome_entity_id): esphome_cmd = "" if command == "on": esphome_cmd = "turn_on" @@ -61,12 +53,6 @@ def port_interaction(self, command, port_number): 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) - esphome_entity_id = self.switch_ids[port_number - 1] - # Build the POST request # url should be in the format http://{hostname}/switch/{id}/{cmd} url = "http://{}/switch/{}/{}".format( diff --git a/pdudaemon/drivers/gude1202.py b/pdudaemon/drivers/gude1202.py new file mode 100644 index 0000000..e5a8475 --- /dev/null +++ b/pdudaemon/drivers/gude1202.py @@ -0,0 +1,74 @@ +#!/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("Unkown 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") diff --git a/pdudaemon/drivers/hiddevice.py b/pdudaemon/drivers/hiddevice.py new file mode 100644 index 0000000..276f2e2 --- /dev/null +++ b/pdudaemon/drivers/hiddevice.py @@ -0,0 +1,51 @@ +#!/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): + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + 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) diff --git a/pdudaemon/drivers/ipower.py b/pdudaemon/drivers/ipower.py new file mode 100644 index 0000000..8de7e30 --- /dev/null +++ b/pdudaemon/drivers/ipower.py @@ -0,0 +1,96 @@ +#!/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" diff --git a/pdudaemon/drivers/modbustcp.py b/pdudaemon/drivers/modbustcp.py new file mode 100644 index 0000000..0408eb3 --- /dev/null +++ b/pdudaemon/drivers/modbustcp.py @@ -0,0 +1,47 @@ +#!/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() diff --git a/pdudaemon/drivers/netio4.py b/pdudaemon/drivers/netio4.py new file mode 100644 index 0000000..fe9b4fb --- /dev/null +++ b/pdudaemon/drivers/netio4.py @@ -0,0 +1,85 @@ +#!/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("Unkown 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") diff --git a/pdudaemon/drivers/strategies.py b/pdudaemon/drivers/strategies.py index 92a3424..e73bd31 100644 --- a/pdudaemon/drivers/strategies.py +++ b/pdudaemon/drivers/strategies.py @@ -32,6 +32,7 @@ from pdudaemon.drivers.apc7920 import APC7920 # pylint: disable=W0611 from pdudaemon.drivers.apc7921 import APC7921 # pylint: disable=W0611 from pdudaemon.drivers.cleware import ClewareUsbSwitch4 +from pdudaemon.drivers.conrad197720 import Conrad197720 from pdudaemon.drivers.ubiquity import Ubiquity3Port # pylint: disable=W0611 from pdudaemon.drivers.ubiquity import Ubiquity6Port # pylint: disable=W0611 from pdudaemon.drivers.ubiquity import Ubiquity8Port # pylint: disable=W0611 @@ -69,3 +70,7 @@ 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 diff --git a/pdudaemon/drivers/ykush.py b/pdudaemon/drivers/ykush.py index 0eeedcc..d8bc4bd 100644 --- a/pdudaemon/drivers/ykush.py +++ b/pdudaemon/drivers/ykush.py @@ -24,7 +24,7 @@ import logging from pdudaemon.drivers.driver import PDUDriver -import hid +from pdudaemon.drivers.hiddevice import HIDDevice import os log = logging.getLogger("pdud.drivers." + os.path.basename(__file__)) @@ -63,11 +63,9 @@ def port_interaction(self, command, port_number): log.error("Unknown command %s." % (command)) return - d = hid.device() - d.open(YKUSH_VID, self.ykush_pid, serial_number=self.serial) - d.write([byte, byte]) - d.read(64) - d.close() + with HIDDevice(YKUSH_VID, self.ykush_pid, serial=self.serial) as d: + d.write([byte, byte]) + d.read(64) @classmethod def accepts(cls, drivername): diff --git a/pdudaemon/listener.py b/pdudaemon/listener.py index d62c2f7..ffa0dfc 100644 --- a/pdudaemon/listener.py +++ b/pdudaemon/listener.py @@ -100,11 +100,11 @@ async def process_request(args, config, daemon): runner = daemon.runners[args.hostname] if args.request == "reboot": logger.debug("reboot requested, submitting off/on") - await runner.do_job_async(int(args.port), "off") + await runner.do_job_async(args.port, "off") await asyncio.sleep(int(args.delay)) - await runner.do_job_async(int(args.port), "on") + await runner.do_job_async(args.port, "on") return True else: await asyncio.sleep(int(args.delay)) - await runner.do_job_async(int(args.port), args.request) + await runner.do_job_async(args.port, args.request) return True diff --git a/requirements.txt b/requirements.txt index 2d26844..af99eec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ pyasn1 pyusb pytest pytest-mock +pymodbus diff --git a/share/pdudaemon.conf b/share/pdudaemon.conf index 9ad64b5..1ca6a79 100644 --- a/share/pdudaemon.conf +++ b/share/pdudaemon.conf @@ -89,11 +89,11 @@ }, "cleware-usb-switch-4": { "driver": "ClewareUsbSwitch4", - "serial": "12345" + "serial": 12345 }, "cleware-usb-switch-8": { "driver": "ClewareUsbSwitch8", - "serial": "54321" + "serial": 4321 }, "intellinet163682": { "driver": "intellinet", @@ -131,16 +131,25 @@ "esphome": { "driver": "esphome-http", "username": "admin", - "password": "web", - "switch_ids": [ - "relay_1" - ] + "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" } }, "aliases": {