2020-07-27 21:08:48 +03:00
|
|
|
# Raspberry Pi Power Control
|
|
|
|
#
|
|
|
|
# Copyright (C) 2020 Jordan Ruthe <jordanruthe@gmail.com>
|
|
|
|
#
|
|
|
|
# This file may be distributed under the terms of the GNU GPLv3 license.
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
from __future__ import annotations
|
2020-07-27 21:08:48 +03:00
|
|
|
import logging
|
2020-11-16 20:36:28 +03:00
|
|
|
import json
|
|
|
|
import struct
|
|
|
|
import socket
|
2021-07-10 18:20:05 +03:00
|
|
|
import asyncio
|
2021-10-01 09:45:15 +03:00
|
|
|
import time
|
2020-11-21 00:44:16 +03:00
|
|
|
from tornado.httpclient import AsyncHTTPClient
|
|
|
|
from tornado.escape import json_decode
|
2020-07-27 21:08:48 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
# Annotation imports
|
|
|
|
from typing import (
|
|
|
|
TYPE_CHECKING,
|
|
|
|
Type,
|
|
|
|
List,
|
|
|
|
Any,
|
|
|
|
Optional,
|
|
|
|
Dict,
|
|
|
|
Coroutine,
|
|
|
|
Union,
|
|
|
|
)
|
2021-05-26 16:08:27 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
from confighelper import ConfigHelper
|
|
|
|
from websockets import WebRequest
|
2021-10-23 15:55:02 +03:00
|
|
|
from .machine import Machine
|
2021-05-15 13:53:34 +03:00
|
|
|
from . import klippy_apis
|
2021-06-27 03:19:13 +03:00
|
|
|
from .mqtt import MQTTClient
|
|
|
|
from .template import JinjaTemplate
|
2021-05-15 13:53:34 +03:00
|
|
|
APIComp = klippy_apis.KlippyAPI
|
|
|
|
|
2020-07-27 21:08:48 +03:00
|
|
|
class PrinterPower:
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2020-08-06 04:06:52 +03:00
|
|
|
self.server = config.get_server()
|
2021-05-15 13:53:34 +03:00
|
|
|
self.devices: Dict[str, PowerDevice] = {}
|
2021-01-06 22:39:33 +03:00
|
|
|
prefix_sections = config.get_prefix_sections("power")
|
2021-03-18 15:23:40 +03:00
|
|
|
logging.info(f"Power component loading devices: {prefix_sections}")
|
2021-05-05 03:59:23 +03:00
|
|
|
dev_types = {
|
|
|
|
"gpio": GpioDevice,
|
2022-01-29 15:53:30 +03:00
|
|
|
"klipper_device": KlipperDevice,
|
2021-05-05 03:59:23 +03:00
|
|
|
"tplink_smartplug": TPLinkSmartPlug,
|
|
|
|
"tasmota": Tasmota,
|
|
|
|
"shelly": Shelly,
|
|
|
|
"homeseer": HomeSeer,
|
|
|
|
"homeassistant": HomeAssistant,
|
2021-10-01 09:45:15 +03:00
|
|
|
"loxonev1": Loxonev1,
|
2021-06-27 03:19:13 +03:00
|
|
|
"rf": RFDevice,
|
|
|
|
"mqtt": MQTTDevice
|
2021-05-05 03:59:23 +03:00
|
|
|
}
|
2021-11-15 15:04:15 +03:00
|
|
|
|
|
|
|
for section in prefix_sections:
|
|
|
|
cfg = config[section]
|
|
|
|
dev_type: str = cfg.get("type")
|
|
|
|
dev_class: Optional[Type[PowerDevice]]
|
|
|
|
dev_class = dev_types.get(dev_type)
|
|
|
|
if dev_class is None:
|
|
|
|
raise config.error(f"Unsupported Device Type: {dev_type}")
|
2021-11-26 01:14:43 +03:00
|
|
|
try:
|
|
|
|
dev = dev_class(cfg)
|
|
|
|
except Exception as e:
|
|
|
|
msg = f"Failed to load power device [{cfg.get_name()}]\n{e}"
|
|
|
|
self.server.add_warning(msg)
|
2021-11-15 15:04:15 +03:00
|
|
|
continue
|
|
|
|
self.devices[dev.get_name()] = dev
|
2021-01-06 22:39:33 +03:00
|
|
|
|
2020-07-27 21:08:48 +03:00
|
|
|
self.server.register_endpoint(
|
2020-11-16 16:46:05 +03:00
|
|
|
"/machine/device_power/devices", ['GET'],
|
2020-07-27 21:08:48 +03:00
|
|
|
self._handle_list_devices)
|
|
|
|
self.server.register_endpoint(
|
2020-11-16 16:46:05 +03:00
|
|
|
"/machine/device_power/status", ['GET'],
|
2021-05-10 03:01:35 +03:00
|
|
|
self._handle_batch_power_request)
|
2020-07-27 21:08:48 +03:00
|
|
|
self.server.register_endpoint(
|
2020-11-16 16:46:05 +03:00
|
|
|
"/machine/device_power/on", ['POST'],
|
2021-05-10 03:01:35 +03:00
|
|
|
self._handle_batch_power_request)
|
2020-07-27 21:08:48 +03:00
|
|
|
self.server.register_endpoint(
|
2020-11-16 16:46:05 +03:00
|
|
|
"/machine/device_power/off", ['POST'],
|
2021-05-10 03:01:35 +03:00
|
|
|
self._handle_batch_power_request)
|
|
|
|
self.server.register_endpoint(
|
|
|
|
"/machine/device_power/device", ['GET', 'POST'],
|
|
|
|
self._handle_single_power_request)
|
2020-11-01 19:44:54 +03:00
|
|
|
self.server.register_remote_method(
|
|
|
|
"set_device_power", self.set_device_power)
|
2021-01-03 03:38:24 +03:00
|
|
|
self.server.register_event_handler(
|
|
|
|
"server:klippy_shutdown", self._handle_klippy_shutdown)
|
2021-11-07 18:01:31 +03:00
|
|
|
self.server.register_event_handler(
|
|
|
|
"file_manager:upload_queued", self._handle_upload_queued)
|
2021-02-17 16:16:49 +03:00
|
|
|
self.server.register_notification("power:power_changed")
|
2020-07-27 21:08:48 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _check_klippy_printing(self) -> bool:
|
|
|
|
kapis: APIComp = self.server.lookup_component('klippy_apis')
|
|
|
|
result: Dict[str, Any] = await kapis.query_objects(
|
2021-01-22 04:40:09 +03:00
|
|
|
{'print_stats': None}, default={})
|
|
|
|
pstate = result.get('print_stats', {}).get('state', "").lower()
|
|
|
|
return pstate == "printing"
|
|
|
|
|
2021-11-02 21:46:42 +03:00
|
|
|
async def component_init(self) -> None:
|
2021-07-18 19:28:40 +03:00
|
|
|
event_loop = self.server.get_event_loop()
|
2021-10-23 15:55:02 +03:00
|
|
|
# Wait up to 5 seconds for the machine component to init
|
|
|
|
machine_cmp: Machine = self.server.lookup_component("machine")
|
|
|
|
await machine_cmp.wait_for_init(5.)
|
2021-07-18 19:28:40 +03:00
|
|
|
cur_time = event_loop.get_loop_time()
|
|
|
|
endtime = cur_time + 120.
|
2021-11-02 21:46:42 +03:00
|
|
|
query_devs = list(self.devices.values())
|
2021-07-18 19:28:40 +03:00
|
|
|
failed_devs: List[PowerDevice] = []
|
|
|
|
while cur_time < endtime:
|
|
|
|
for dev in query_devs:
|
|
|
|
ret = dev.initialize()
|
|
|
|
if ret is not None:
|
|
|
|
await ret
|
|
|
|
if dev.get_state() == "error":
|
|
|
|
failed_devs.append(dev)
|
|
|
|
if not failed_devs:
|
|
|
|
logging.debug("All power devices initialized")
|
|
|
|
return
|
|
|
|
query_devs = failed_devs
|
|
|
|
failed_devs = []
|
|
|
|
await asyncio.sleep(2.)
|
|
|
|
cur_time = event_loop.get_loop_time()
|
|
|
|
if failed_devs:
|
|
|
|
failed_names = [d.get_name() for d in failed_devs]
|
|
|
|
self.server.add_warning(
|
|
|
|
"The following power devices failed init:"
|
|
|
|
f" {failed_names}")
|
2020-07-27 21:08:48 +03:00
|
|
|
|
2022-01-12 01:32:10 +03:00
|
|
|
def _handle_klippy_shutdown(self) -> None:
|
|
|
|
for dev in self.devices.values():
|
|
|
|
dev.process_klippy_shutdown()
|
2021-05-15 13:53:34 +03:00
|
|
|
|
2021-11-07 18:01:31 +03:00
|
|
|
async def _handle_upload_queued(self, filename: str) -> None:
|
|
|
|
for name, dev in self.devices.items():
|
|
|
|
if dev.has_on_when_queued():
|
|
|
|
if dev.get_state() == "on":
|
|
|
|
# device already on
|
|
|
|
continue
|
|
|
|
logging.debug(
|
|
|
|
f"File '{filename}' queued, powering on device [{name}]")
|
|
|
|
await self._process_request(dev, "on")
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _handle_list_devices(self,
|
|
|
|
web_request: WebRequest
|
|
|
|
) -> Dict[str, Any]:
|
2020-11-16 16:27:09 +03:00
|
|
|
dev_list = [d.get_device_info() for d in self.devices.values()]
|
|
|
|
output = {"devices": dev_list}
|
2020-07-27 21:08:48 +03:00
|
|
|
return output
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _handle_single_power_request(self,
|
|
|
|
web_request: WebRequest
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
dev_name: str = web_request.get_str('device')
|
2021-05-10 03:01:35 +03:00
|
|
|
req_action = web_request.get_action()
|
|
|
|
if dev_name not in self.devices:
|
|
|
|
raise self.server.error(f"No valid device named {dev_name}")
|
|
|
|
dev = self.devices[dev_name]
|
|
|
|
if req_action == 'GET':
|
|
|
|
action = "status"
|
|
|
|
elif req_action == "POST":
|
|
|
|
action = web_request.get_str('action').lower()
|
|
|
|
if action not in ["on", "off", "toggle"]:
|
|
|
|
raise self.server.error(
|
|
|
|
f"Invalid requested action '{action}'")
|
|
|
|
result = await self._process_request(dev, action)
|
|
|
|
return {dev_name: result}
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _handle_batch_power_request(self,
|
|
|
|
web_request: WebRequest
|
|
|
|
) -> Dict[str, Any]:
|
2020-11-09 15:02:22 +03:00
|
|
|
args = web_request.get_args()
|
|
|
|
ep = web_request.get_endpoint()
|
2020-11-16 16:27:09 +03:00
|
|
|
if not args:
|
|
|
|
raise self.server.error("No arguments provided")
|
2021-04-22 15:47:40 +03:00
|
|
|
requested_devs = {k: self.devices.get(k, None) for k in args}
|
2020-07-27 21:08:48 +03:00
|
|
|
result = {}
|
2020-11-09 15:02:22 +03:00
|
|
|
req = ep.split("/")[-1]
|
2021-04-22 15:47:40 +03:00
|
|
|
for name, device in requested_devs.items():
|
2020-11-16 16:27:09 +03:00
|
|
|
if device is not None:
|
|
|
|
result[name] = await self._process_request(device, req)
|
2020-11-07 21:00:59 +03:00
|
|
|
else:
|
2020-11-16 16:27:09 +03:00
|
|
|
result[name] = "device_not_found"
|
2020-07-27 21:08:48 +03:00
|
|
|
return result
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _process_request(self,
|
|
|
|
device: PowerDevice,
|
|
|
|
req: str
|
|
|
|
) -> str:
|
2021-06-27 03:19:13 +03:00
|
|
|
base_state: str = device.get_state()
|
2021-05-10 02:36:31 +03:00
|
|
|
ret = device.refresh_status()
|
2021-05-15 13:53:34 +03:00
|
|
|
if ret is not None:
|
2021-05-10 02:36:31 +03:00
|
|
|
await ret
|
2021-06-27 03:19:13 +03:00
|
|
|
cur_state: str = device.get_state()
|
2021-05-10 03:01:35 +03:00
|
|
|
if req == "toggle":
|
2021-06-27 03:19:13 +03:00
|
|
|
req = "on" if cur_state == "off" else "off"
|
2020-11-01 19:44:54 +03:00
|
|
|
if req in ["on", "off"]:
|
2021-01-27 14:31:29 +03:00
|
|
|
if req == cur_state:
|
|
|
|
# device is already in requested state, do nothing
|
2021-06-27 03:19:13 +03:00
|
|
|
if base_state != cur_state:
|
|
|
|
device.notify_power_changed()
|
2021-01-27 14:31:29 +03:00
|
|
|
return cur_state
|
2021-01-22 04:40:09 +03:00
|
|
|
printing = await self._check_klippy_printing()
|
|
|
|
if device.get_locked_while_printing() and printing:
|
2021-01-22 19:20:36 +03:00
|
|
|
raise self.server.error(
|
|
|
|
f"Unable to change power for {device.get_name()} "
|
|
|
|
"while printing")
|
2020-11-16 16:27:09 +03:00
|
|
|
ret = device.set_power(req)
|
2021-05-15 13:53:34 +03:00
|
|
|
if ret is not None:
|
2020-11-15 19:08:25 +03:00
|
|
|
await ret
|
2021-06-27 03:19:13 +03:00
|
|
|
cur_state = device.get_state()
|
|
|
|
await device.process_power_changed()
|
2021-05-10 02:36:31 +03:00
|
|
|
elif req != "status":
|
2020-11-16 16:27:09 +03:00
|
|
|
raise self.server.error(f"Unsupported power request: {req}")
|
2021-06-27 03:19:13 +03:00
|
|
|
elif base_state != cur_state:
|
|
|
|
device.notify_power_changed()
|
|
|
|
return cur_state
|
2020-10-20 05:22:39 +03:00
|
|
|
|
2021-06-27 03:19:13 +03:00
|
|
|
def set_device_power(self, device: str, state: Union[bool, str]) -> None:
|
|
|
|
request: str = ""
|
2020-11-01 19:44:54 +03:00
|
|
|
if isinstance(state, bool):
|
2021-06-27 03:19:13 +03:00
|
|
|
request = "on" if state else "off"
|
2020-11-01 19:44:54 +03:00
|
|
|
elif isinstance(state, str):
|
2021-06-27 03:19:13 +03:00
|
|
|
request = state.lower()
|
|
|
|
if request in ["true", "false"]:
|
|
|
|
request = "on" if request == "true" else "off"
|
|
|
|
if request not in ["on", "off", "toggle"]:
|
2020-11-01 19:44:54 +03:00
|
|
|
logging.info(f"Invalid state received: {state}")
|
|
|
|
return
|
2020-11-16 16:27:09 +03:00
|
|
|
if device not in self.devices:
|
|
|
|
logging.info(f"No device found: {device}")
|
|
|
|
return
|
2021-07-10 18:20:05 +03:00
|
|
|
event_loop = self.server.get_event_loop()
|
|
|
|
event_loop.register_callback(
|
2021-06-27 03:19:13 +03:00
|
|
|
self._process_request, self.devices[device], request)
|
2020-11-01 19:44:54 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def add_device(self, name: str, device: PowerDevice) -> None:
|
2020-11-14 16:24:54 +03:00
|
|
|
if name in self.devices:
|
|
|
|
raise self.server.error(
|
|
|
|
f"Device [{name}] already configured")
|
2020-11-15 19:08:25 +03:00
|
|
|
ret = device.initialize()
|
2021-05-15 13:53:34 +03:00
|
|
|
if ret is not None:
|
2020-11-15 19:08:25 +03:00
|
|
|
await ret
|
2020-11-14 16:24:54 +03:00
|
|
|
self.devices[name] = device
|
2020-11-07 21:00:59 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def close(self) -> None:
|
2020-11-15 19:08:25 +03:00
|
|
|
for device in self.devices.values():
|
2021-05-15 13:53:34 +03:00
|
|
|
ret = device.close()
|
|
|
|
if ret is not None:
|
|
|
|
await ret
|
2020-11-07 21:00:59 +03:00
|
|
|
|
2020-07-27 21:08:48 +03:00
|
|
|
|
2021-01-22 04:40:09 +03:00
|
|
|
class PowerDevice:
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2021-01-22 04:40:09 +03:00
|
|
|
name_parts = config.get_name().split(maxsplit=1)
|
|
|
|
if len(name_parts) != 2:
|
|
|
|
raise config.error(f"Invalid Section Name: {config.get_name()}")
|
2021-01-27 14:31:29 +03:00
|
|
|
self.server = config.get_server()
|
2021-01-22 04:40:09 +03:00
|
|
|
self.name = name_parts[1]
|
2021-05-15 13:53:34 +03:00
|
|
|
self.type: str = config.get('type')
|
|
|
|
self.state: str = "init"
|
2021-01-22 19:20:36 +03:00
|
|
|
self.locked_while_printing = config.getboolean(
|
|
|
|
'locked_while_printing', False)
|
2021-01-22 04:40:09 +03:00
|
|
|
self.off_when_shutdown = config.getboolean('off_when_shutdown', False)
|
2022-01-12 01:32:10 +03:00
|
|
|
self.off_when_shutdown_delay = 0.
|
|
|
|
if self.off_when_shutdown:
|
|
|
|
self.off_when_shutdown_delay = config.getfloat(
|
|
|
|
'off_when_shutdown_delay', 0., minval=0.)
|
|
|
|
self.shutdown_timer_handle: Optional[asyncio.TimerHandle] = None
|
2021-01-27 14:31:29 +03:00
|
|
|
self.restart_delay = 1.
|
|
|
|
self.klipper_restart = config.getboolean(
|
2021-01-28 15:36:21 +03:00
|
|
|
'restart_klipper_when_powered', False)
|
2021-01-27 14:31:29 +03:00
|
|
|
if self.klipper_restart:
|
|
|
|
self.restart_delay = config.getfloat('restart_delay', 1.)
|
|
|
|
if self.restart_delay < .000001:
|
|
|
|
raise config.error("Option 'restart_delay' must be above 0.0")
|
2021-10-23 15:55:02 +03:00
|
|
|
self.bound_service: Optional[str] = config.get('bound_service', None)
|
|
|
|
self.need_scheduled_restart = False
|
2021-11-07 18:01:31 +03:00
|
|
|
self.on_when_queued = config.getboolean('on_when_upload_queued', False)
|
2021-10-23 15:55:02 +03:00
|
|
|
|
|
|
|
def _is_bound_to_klipper(self):
|
|
|
|
return (
|
|
|
|
self.bound_service is not None and
|
|
|
|
self.bound_service.startswith("klipper") and
|
|
|
|
not self.bound_service.startswith("klipper_mcu")
|
|
|
|
)
|
|
|
|
|
|
|
|
def _schedule_firmware_restart(self, state: str = "") -> None:
|
|
|
|
if not self.need_scheduled_restart:
|
|
|
|
return
|
|
|
|
self.need_scheduled_restart = False
|
2021-11-09 15:42:30 +03:00
|
|
|
if state == "ready":
|
2021-11-09 18:17:52 +03:00
|
|
|
logging.info("Klipper reports 'ready', aborting FIRMWARE_RESTART")
|
2021-11-09 15:42:30 +03:00
|
|
|
return
|
2021-10-23 15:55:02 +03:00
|
|
|
event_loop = self.server.get_event_loop()
|
|
|
|
kapis: APIComp = self.server.lookup_component("klippy_apis")
|
|
|
|
event_loop.delay_callback(
|
|
|
|
self.restart_delay, kapis.do_restart,
|
|
|
|
"FIRMWARE_RESTART")
|
2021-01-22 04:40:09 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def get_name(self) -> str:
|
2021-01-22 04:40:09 +03:00
|
|
|
return self.name
|
|
|
|
|
2021-07-18 19:28:40 +03:00
|
|
|
def get_state(self) -> str:
|
|
|
|
return self.state
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def get_device_info(self) -> Dict[str, Any]:
|
2021-01-22 04:40:09 +03:00
|
|
|
return {
|
|
|
|
'device': self.name,
|
2021-01-22 04:43:34 +03:00
|
|
|
'status': self.state,
|
2021-05-10 14:22:57 +03:00
|
|
|
'locked_while_printing': self.locked_while_printing,
|
|
|
|
'type': self.type
|
2021-01-22 04:40:09 +03:00
|
|
|
}
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def get_locked_while_printing(self) -> bool:
|
2021-01-22 04:40:09 +03:00
|
|
|
return self.locked_while_printing
|
|
|
|
|
2021-06-27 03:19:13 +03:00
|
|
|
def notify_power_changed(self) -> None:
|
|
|
|
dev_info = self.get_device_info()
|
|
|
|
self.server.send_event("power:power_changed", dev_info)
|
|
|
|
|
|
|
|
async def process_power_changed(self) -> None:
|
|
|
|
self.notify_power_changed()
|
2021-10-23 15:55:02 +03:00
|
|
|
if self.bound_service is not None:
|
|
|
|
machine_cmp: Machine = self.server.lookup_component("machine")
|
|
|
|
action = "start" if self.state == "on" else "stop"
|
|
|
|
await machine_cmp.do_service_action(action, self.bound_service)
|
2021-01-27 14:31:29 +03:00
|
|
|
if self.state == "on" and self.klipper_restart:
|
2021-10-23 15:55:02 +03:00
|
|
|
self.need_scheduled_restart = True
|
2021-11-09 18:17:52 +03:00
|
|
|
klippy_state = self.server.get_klippy_state()
|
|
|
|
if klippy_state in ["disconnected", "startup"]:
|
|
|
|
# If klippy is currently disconnected or hasn't proceeded past
|
|
|
|
# the startup state, schedule the restart in the
|
|
|
|
# "klippy_started" event callback.
|
2021-10-23 15:55:02 +03:00
|
|
|
return
|
|
|
|
self._schedule_firmware_restart()
|
2021-05-15 13:53:34 +03:00
|
|
|
|
2022-01-12 01:32:10 +03:00
|
|
|
def process_klippy_shutdown(self) -> None:
|
|
|
|
if not self.off_when_shutdown:
|
|
|
|
return
|
|
|
|
if self.off_when_shutdown_delay == 0.:
|
|
|
|
self._power_off_on_shutdown()
|
|
|
|
else:
|
|
|
|
if self.shutdown_timer_handle is not None:
|
|
|
|
self.shutdown_timer_handle.cancel()
|
|
|
|
self.shutdown_timer_handle = None
|
|
|
|
event_loop = self.server.get_event_loop()
|
|
|
|
self.shutdown_timer_handle = event_loop.delay_callback(
|
|
|
|
self.off_when_shutdown_delay, self._power_off_on_shutdown)
|
|
|
|
|
|
|
|
def _power_off_on_shutdown(self) -> None:
|
|
|
|
if self.server.get_klippy_state() != "shutdown":
|
|
|
|
return
|
|
|
|
logging.info(
|
|
|
|
f"Powering off device '{self.name}' due to klippy shutdown")
|
|
|
|
power: PrinterPower = self.server.lookup_component("power")
|
|
|
|
power.set_device_power(self.name, "off")
|
2021-05-15 13:53:34 +03:00
|
|
|
|
2021-11-07 18:01:31 +03:00
|
|
|
def has_on_when_queued(self) -> bool:
|
|
|
|
return self.on_when_queued
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def initialize(self) -> Optional[Coroutine]:
|
2021-10-23 15:55:02 +03:00
|
|
|
if self.bound_service is None:
|
|
|
|
return None
|
|
|
|
if self.bound_service.startswith("moonraker"):
|
|
|
|
raise self.server.error(
|
|
|
|
f"Cannot bind to '{self.bound_service}' "
|
|
|
|
"service")
|
|
|
|
machine_cmp: Machine = self.server.lookup_component("machine")
|
|
|
|
sys_info = machine_cmp.get_system_info()
|
|
|
|
avail_svcs: List[str] = sys_info.get('available_services', [])
|
|
|
|
if self.bound_service not in avail_svcs:
|
|
|
|
raise self.server.error(
|
|
|
|
f"Bound Service {self.bound_service} is not available")
|
|
|
|
if self._is_bound_to_klipper() and self.klipper_restart:
|
|
|
|
# Schedule the Firmware Restart after Klipper reconnects
|
|
|
|
logging.info(f"Power Device '{self.name}' bound to "
|
|
|
|
f"klipper service '{self.bound_service}'")
|
|
|
|
self.server.register_event_handler(
|
|
|
|
"server:klippy_started",
|
|
|
|
self._schedule_firmware_restart
|
|
|
|
)
|
|
|
|
return None
|
2021-05-15 13:53:34 +03:00
|
|
|
|
|
|
|
def refresh_status(self) -> Optional[Coroutine]:
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
def set_power(self, state: str) -> Optional[Coroutine]:
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
def close(self) -> Optional[Coroutine]:
|
|
|
|
pass
|
2021-01-27 14:31:29 +03:00
|
|
|
|
2021-05-10 14:22:57 +03:00
|
|
|
class HTTPDevice(PowerDevice):
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self,
|
|
|
|
config: ConfigHelper,
|
|
|
|
default_port: int = -1,
|
|
|
|
default_user: str = "",
|
2021-05-29 17:08:24 +03:00
|
|
|
default_password: str = "",
|
|
|
|
default_protocol: str = "http"
|
2021-05-15 13:53:34 +03:00
|
|
|
) -> None:
|
2021-05-10 14:22:57 +03:00
|
|
|
super().__init__(config)
|
|
|
|
self.client = AsyncHTTPClient()
|
2021-07-10 18:20:05 +03:00
|
|
|
self.request_mutex = asyncio.Lock()
|
2021-05-15 13:53:34 +03:00
|
|
|
self.addr: str = config.get("address")
|
2021-05-10 14:22:57 +03:00
|
|
|
self.port = config.getint("port", default_port)
|
2021-12-24 03:53:34 +03:00
|
|
|
self.user = config.load_template("user", default_user).render()
|
|
|
|
self.password = config.load_template(
|
|
|
|
"password", default_password).render()
|
2021-05-29 17:08:24 +03:00
|
|
|
self.protocol = config.get("protocol", default_protocol)
|
2021-05-10 14:22:57 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def initialize(self) -> None:
|
2021-10-23 15:55:02 +03:00
|
|
|
super().initialize()
|
2021-05-10 14:22:57 +03:00
|
|
|
await self.refresh_status()
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_http_command(self,
|
|
|
|
url: str,
|
2022-01-28 01:00:08 +03:00
|
|
|
command: str,
|
|
|
|
retries: int = 3
|
2021-05-15 13:53:34 +03:00
|
|
|
) -> Dict[str, Any]:
|
2022-01-28 01:00:08 +03:00
|
|
|
for i in range(retries):
|
|
|
|
try:
|
|
|
|
response = await self.client.fetch(
|
|
|
|
url, connect_timeout=5., request_timeout=20.)
|
|
|
|
data = json_decode(response.body)
|
|
|
|
except Exception as e:
|
|
|
|
if i == retries - 1:
|
|
|
|
msg = f"Error sending '{self.type}' command: {command}"
|
|
|
|
raise self.server.error(msg) from e
|
2022-01-28 14:12:56 +03:00
|
|
|
await asyncio.sleep(1.0)
|
2022-01-28 01:00:08 +03:00
|
|
|
else:
|
|
|
|
break
|
2021-05-10 14:22:57 +03:00
|
|
|
return data
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_power_request(self, state: str) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
raise NotImplementedError(
|
|
|
|
"_send_power_request must be implemented by children")
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_status_request(self) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
raise NotImplementedError(
|
|
|
|
"_send_status_request must be implemented by children")
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def refresh_status(self) -> None:
|
2021-05-10 14:25:56 +03:00
|
|
|
async with self.request_mutex:
|
|
|
|
try:
|
|
|
|
state = await self._send_status_request()
|
|
|
|
except Exception:
|
|
|
|
self.state = "error"
|
|
|
|
msg = f"Error Refeshing Device Status: {self.name}"
|
|
|
|
logging.exception(msg)
|
|
|
|
raise self.server.error(msg) from None
|
|
|
|
self.state = state
|
2021-05-10 14:22:57 +03:00
|
|
|
|
|
|
|
async def set_power(self, state):
|
2021-05-10 14:25:56 +03:00
|
|
|
async with self.request_mutex:
|
|
|
|
try:
|
|
|
|
state = await self._send_power_request(state)
|
|
|
|
except Exception:
|
|
|
|
self.state = "error"
|
|
|
|
msg = f"Error Setting Device Status: {self.name} to {state}"
|
|
|
|
logging.exception(msg)
|
|
|
|
raise self.server.error(msg) from None
|
|
|
|
self.state = state
|
2021-05-10 14:22:57 +03:00
|
|
|
|
|
|
|
|
2021-01-22 04:40:09 +03:00
|
|
|
class GpioDevice(PowerDevice):
|
2021-11-15 15:04:15 +03:00
|
|
|
def __init__(self,
|
|
|
|
config: ConfigHelper,
|
|
|
|
initial_val: Optional[int] = None
|
|
|
|
) -> None:
|
2021-01-22 04:40:09 +03:00
|
|
|
super().__init__(config)
|
2021-05-15 13:53:34 +03:00
|
|
|
self.initial_state = config.getboolean('initial_state', False)
|
2021-10-14 02:10:45 +03:00
|
|
|
self.timer: Optional[float] = config.getfloat('timer', None)
|
|
|
|
if self.timer is not None and self.timer < 0.000001:
|
|
|
|
raise config.error(
|
|
|
|
f"Option 'timer' in section [{config.get_name()}] must "
|
|
|
|
"be above 0.0")
|
|
|
|
self.timer_handle: Optional[asyncio.TimerHandle] = None
|
2021-11-15 15:04:15 +03:00
|
|
|
if initial_val is None:
|
|
|
|
initial_val = int(self.initial_state)
|
2021-11-20 19:24:13 +03:00
|
|
|
self.gpio_out = config.getgpioout('pin', initial_value=initial_val)
|
2020-11-07 21:00:59 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def initialize(self) -> None:
|
2021-10-23 15:55:02 +03:00
|
|
|
super().initialize()
|
2021-01-06 22:39:33 +03:00
|
|
|
self.set_power("on" if self.initial_state else "off")
|
2020-11-07 21:00:59 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def refresh_status(self) -> None:
|
2021-05-17 23:53:28 +03:00
|
|
|
pass
|
2020-11-15 19:08:25 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def set_power(self, state) -> None:
|
2021-10-14 02:10:45 +03:00
|
|
|
if self.timer_handle is not None:
|
|
|
|
self.timer_handle.cancel()
|
|
|
|
self.timer_handle = None
|
2020-11-15 19:08:25 +03:00
|
|
|
try:
|
2021-11-15 15:04:15 +03:00
|
|
|
self.gpio_out.write(int(state == "on"))
|
2020-11-15 19:08:25 +03:00
|
|
|
except Exception:
|
|
|
|
self.state = "error"
|
|
|
|
msg = f"Error Toggling Device Power: {self.name}"
|
|
|
|
logging.exception(msg)
|
|
|
|
raise self.server.error(msg) from None
|
|
|
|
self.state = state
|
2021-11-15 15:05:08 +03:00
|
|
|
self._check_timer()
|
|
|
|
|
2022-01-29 15:53:30 +03:00
|
|
|
def _check_timer(self):
|
|
|
|
if self.state == "on" and self.timer is not None:
|
|
|
|
event_loop = self.server.get_event_loop()
|
|
|
|
power: PrinterPower = self.server.lookup_component("power")
|
|
|
|
self.timer_handle = event_loop.delay_callback(
|
|
|
|
self.timer, power.set_device_power, self.name, "off")
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
if self.timer_handle is not None:
|
|
|
|
self.timer_handle.cancel()
|
|
|
|
self.timer_handle = None
|
|
|
|
|
|
|
|
class KlipperDevice(PowerDevice):
|
2022-02-02 23:42:41 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
|
|
super().__init__(config)
|
|
|
|
if self.off_when_shutdown:
|
2022-01-29 15:53:30 +03:00
|
|
|
raise config.error(
|
|
|
|
"Option 'off_when_shutdown' in section "
|
|
|
|
f"[{config.get_name()}] is unsupported for 'klipper_device'")
|
2022-02-02 23:42:41 +03:00
|
|
|
if self.klipper_restart:
|
2022-01-29 15:53:30 +03:00
|
|
|
raise config.error(
|
2022-02-02 23:42:41 +03:00
|
|
|
"Option 'restart_klipper_when_powered' in section "
|
2022-01-29 15:53:30 +03:00
|
|
|
f"[{config.get_name()}] is unsupported for 'klipper_device'")
|
2022-02-02 23:42:41 +03:00
|
|
|
if (
|
|
|
|
self.bound_service is not None and
|
|
|
|
self.bound_service.startswith("klipper")
|
|
|
|
):
|
|
|
|
# Klipper devices cannot be bound to an instance of klipper or
|
|
|
|
# klipper_mcu
|
2022-01-29 15:53:30 +03:00
|
|
|
raise config.error(
|
2022-02-02 23:42:41 +03:00
|
|
|
f"Option 'bound_service' cannot be set to {self.bound_service}"
|
|
|
|
f" for 'klipper_device' [{config.get_name()}]")
|
|
|
|
self.is_shutdown: bool = False
|
|
|
|
self.update_fut: Optional[asyncio.Future] = None
|
|
|
|
self.request_mutex = asyncio.Lock()
|
|
|
|
self.timer: Optional[float] = config.getfloat(
|
|
|
|
'timer', None, above=0.000001)
|
2022-01-29 15:53:30 +03:00
|
|
|
self.timer_handle: Optional[asyncio.TimerHandle] = None
|
2022-02-02 23:42:41 +03:00
|
|
|
self.object_name = config.get('object_name')
|
|
|
|
obj_parts = self.object_name.split()
|
|
|
|
self.gc_cmd = f"SET_PIN PIN={obj_parts[-1]} "
|
|
|
|
if obj_parts[0] == "gcode_macro":
|
|
|
|
self.gc_cmd = obj_parts[-1]
|
|
|
|
elif obj_parts[0] != "output_pin":
|
2022-01-29 15:53:30 +03:00
|
|
|
raise config.error(
|
2022-02-02 23:42:41 +03:00
|
|
|
"Klipper object must be either 'output_pin' or 'gcode_macro' "
|
|
|
|
f"for option 'object_name' in section [{config.get_name()}]")
|
2022-01-29 15:53:30 +03:00
|
|
|
|
|
|
|
self.server.register_event_handler(
|
|
|
|
"server:status_update", self._status_update)
|
|
|
|
self.server.register_event_handler(
|
|
|
|
"server:klippy_ready", self._handle_ready)
|
|
|
|
self.server.register_event_handler(
|
|
|
|
"server:klippy_disconnect", self._handle_disconnect)
|
|
|
|
|
|
|
|
def _status_update(self, data: Dict[str, Any]) -> None:
|
|
|
|
self._set_state_from_data(data)
|
|
|
|
|
2022-02-02 23:42:41 +03:00
|
|
|
def get_device_info(self) -> Dict[str, Any]:
|
|
|
|
dev_info = super().get_device_info()
|
|
|
|
dev_info['is_shutdown'] = self.is_shutdown
|
|
|
|
return dev_info
|
|
|
|
|
2022-01-29 15:53:30 +03:00
|
|
|
async def _handle_ready(self) -> None:
|
|
|
|
kapis: APIComp = self.server.lookup_component('klippy_apis')
|
|
|
|
sub: Dict[str, Optional[List[str]]] = {self.object_name: None}
|
2022-02-02 23:42:41 +03:00
|
|
|
data = await kapis.subscribe_objects(sub, None)
|
|
|
|
if not self._validate_data(data):
|
|
|
|
self.state == "error"
|
|
|
|
else:
|
|
|
|
assert data is not None
|
2022-01-29 15:53:30 +03:00
|
|
|
self._set_state_from_data(data)
|
|
|
|
|
|
|
|
async def _handle_disconnect(self) -> None:
|
2022-02-02 23:42:41 +03:00
|
|
|
self.is_shutdown = False
|
2022-01-29 15:53:30 +03:00
|
|
|
self._set_state("init")
|
2022-02-02 23:42:41 +03:00
|
|
|
self._reset_timer()
|
2022-01-29 15:53:30 +03:00
|
|
|
|
|
|
|
def process_klippy_shutdown(self) -> None:
|
2022-02-02 23:42:41 +03:00
|
|
|
self.is_shutdown = True
|
|
|
|
self._set_state("error")
|
|
|
|
self._reset_timer()
|
2022-01-29 15:53:30 +03:00
|
|
|
|
2022-02-02 23:42:41 +03:00
|
|
|
async def refresh_status(self) -> None:
|
|
|
|
if self.is_shutdown or self.state in ["on", "off", "init"]:
|
|
|
|
return
|
|
|
|
async with self.request_mutex:
|
2022-01-29 15:53:30 +03:00
|
|
|
kapis: APIComp = self.server.lookup_component('klippy_apis')
|
2022-02-02 23:42:41 +03:00
|
|
|
req: Dict[str, Optional[List[str]]] = {self.object_name: None}
|
|
|
|
data: Optional[Dict[str, Any]]
|
|
|
|
data = await kapis.query_objects(req, None)
|
|
|
|
if not self._validate_data(data):
|
|
|
|
self.state = "error"
|
|
|
|
else:
|
|
|
|
assert data is not None
|
|
|
|
self._set_state_from_data(data)
|
2022-01-29 15:53:30 +03:00
|
|
|
|
2022-02-02 23:42:41 +03:00
|
|
|
async def set_power(self, state: str) -> None:
|
|
|
|
if self.is_shutdown:
|
|
|
|
raise self.server.error(
|
|
|
|
f"Power Device {self.name}: Cannot set power for device "
|
|
|
|
f"when Klipper is shutdown")
|
|
|
|
async with self.request_mutex:
|
|
|
|
self._reset_timer()
|
|
|
|
eventloop = self.server.get_event_loop()
|
|
|
|
self.update_fut = eventloop.create_future()
|
|
|
|
try:
|
|
|
|
kapis: APIComp = self.server.lookup_component('klippy_apis')
|
|
|
|
value = "1" if state == "on" else "0"
|
|
|
|
await kapis.run_gcode(f"{self.gc_cmd} VALUE={value}")
|
|
|
|
await asyncio.wait_for(self.update_fut, 1.)
|
|
|
|
except TimeoutError:
|
|
|
|
self.state = "error"
|
|
|
|
raise self.server.error(
|
|
|
|
f"Power device {self.name}: Timeout "
|
|
|
|
"waiting for device state update")
|
|
|
|
except Exception:
|
|
|
|
self.state = "error"
|
|
|
|
msg = f"Error Toggling Device Power: {self.name}"
|
|
|
|
logging.exception(msg)
|
|
|
|
raise self.server.error(msg) from None
|
|
|
|
finally:
|
|
|
|
self.update_fut = None
|
|
|
|
self._check_timer()
|
|
|
|
|
|
|
|
def _validate_data(self, data: Optional[Dict[str, Any]]) -> bool:
|
|
|
|
if data is None:
|
|
|
|
logging.info("Error querying klipper object: "
|
|
|
|
f"{self.object_name}")
|
|
|
|
elif self.object_name not in data:
|
|
|
|
logging.info(
|
|
|
|
f"[power]: Invalid Klipper Device {self.object_name}, "
|
|
|
|
f"no response returned from subscription.")
|
|
|
|
elif 'value' not in data[self.object_name]:
|
|
|
|
logging.info(
|
|
|
|
f"[power]: Invalid Klipper Device {self.object_name}, "
|
|
|
|
f"response does not contain a 'value' parameter")
|
|
|
|
else:
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
|
|
def _set_state_from_data(self, data: Dict[str, Any]) -> None:
|
2022-01-29 15:53:30 +03:00
|
|
|
if self.object_name not in data:
|
|
|
|
return
|
2022-02-02 23:42:41 +03:00
|
|
|
value = data[self.object_name].get('value')
|
|
|
|
if value is not None:
|
|
|
|
state = "on" if value else "off"
|
|
|
|
self._set_state(state)
|
|
|
|
if self.update_fut is not None:
|
|
|
|
self.update_fut.set_result(state)
|
|
|
|
|
|
|
|
def _set_state(self, state: str) -> None:
|
|
|
|
in_event = self.update_fut is not None
|
2022-01-29 15:53:30 +03:00
|
|
|
last_state = self.state
|
|
|
|
self.state = state
|
2022-02-02 23:42:41 +03:00
|
|
|
if last_state != state and not in_event:
|
2022-01-29 15:53:30 +03:00
|
|
|
self.notify_power_changed()
|
|
|
|
|
2021-11-15 15:05:08 +03:00
|
|
|
def _check_timer(self):
|
2021-10-14 02:10:45 +03:00
|
|
|
if self.state == "on" and self.timer is not None:
|
|
|
|
event_loop = self.server.get_event_loop()
|
|
|
|
power: PrinterPower = self.server.lookup_component("power")
|
|
|
|
self.timer_handle = event_loop.delay_callback(
|
|
|
|
self.timer, power.set_device_power, self.name, "off")
|
2020-11-15 19:08:25 +03:00
|
|
|
|
2022-02-02 23:42:41 +03:00
|
|
|
def _reset_timer(self):
|
|
|
|
if self.timer_handle is not None:
|
|
|
|
self.timer_handle.cancel()
|
|
|
|
self.timer_handle = None
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def close(self) -> None:
|
2021-10-14 02:10:45 +03:00
|
|
|
if self.timer_handle is not None:
|
|
|
|
self.timer_handle.cancel()
|
|
|
|
self.timer_handle = None
|
2020-11-07 21:00:59 +03:00
|
|
|
|
2021-10-01 09:45:15 +03:00
|
|
|
class RFDevice(GpioDevice):
|
|
|
|
|
|
|
|
# Protocol definition
|
|
|
|
# [1, 3] means HIGH is set for 1x pulse_len and LOW for 3x pulse_len
|
|
|
|
ZERO_BIT = [1, 3] # zero bit
|
|
|
|
ONE_BIT = [3, 1] # one bit
|
|
|
|
SYNC_BIT = [1, 31] # sync between
|
|
|
|
PULSE_LEN = 0.00035 # length of a single pulse
|
|
|
|
RETRIES = 10 # send the code this many times
|
|
|
|
|
|
|
|
def __init__(self, config: ConfigHelper):
|
2021-11-15 15:04:15 +03:00
|
|
|
super().__init__(config, initial_val=0)
|
2021-10-01 09:45:15 +03:00
|
|
|
self.on = config.get("on_code").zfill(24)
|
|
|
|
self.off = config.get("off_code").zfill(24)
|
|
|
|
|
|
|
|
def _transmit_digit(self, waveform) -> None:
|
2021-11-15 15:04:15 +03:00
|
|
|
self.gpio_out.write(1)
|
2021-10-01 09:45:15 +03:00
|
|
|
time.sleep(waveform[0]*RFDevice.PULSE_LEN)
|
2021-11-15 15:04:15 +03:00
|
|
|
self.gpio_out.write(0)
|
2021-10-01 09:45:15 +03:00
|
|
|
time.sleep(waveform[1]*RFDevice.PULSE_LEN)
|
|
|
|
|
|
|
|
def _transmit_code(self, code) -> None:
|
|
|
|
for _ in range(RFDevice.RETRIES):
|
|
|
|
for i in code:
|
|
|
|
if i == "1":
|
|
|
|
self._transmit_digit(RFDevice.ONE_BIT)
|
|
|
|
elif i == "0":
|
|
|
|
self._transmit_digit(RFDevice.ZERO_BIT)
|
|
|
|
self._transmit_digit(RFDevice.SYNC_BIT)
|
|
|
|
|
|
|
|
def set_power(self, state) -> None:
|
|
|
|
try:
|
|
|
|
if state == "on":
|
|
|
|
code = self.on
|
|
|
|
else:
|
|
|
|
code = self.off
|
|
|
|
self._transmit_code(code)
|
|
|
|
except Exception:
|
|
|
|
self.state = "error"
|
|
|
|
msg = f"Error Toggling Device Power: {self.name}"
|
|
|
|
logging.exception(msg)
|
|
|
|
raise self.server.error(msg) from None
|
|
|
|
self.state = state
|
2021-11-15 15:05:08 +03:00
|
|
|
self._check_timer()
|
2021-10-01 09:45:15 +03:00
|
|
|
|
2020-11-16 20:36:28 +03:00
|
|
|
|
|
|
|
# This implementation based off the work tplink_smartplug
|
|
|
|
# script by Lubomir Stroetmann available at:
|
|
|
|
#
|
|
|
|
# https://github.com/softScheck/tplink-smartplug
|
|
|
|
#
|
|
|
|
# Copyright 2016 softScheck GmbH
|
2021-01-22 04:40:09 +03:00
|
|
|
class TPLinkSmartPlug(PowerDevice):
|
2020-11-16 20:36:28 +03:00
|
|
|
START_KEY = 0xAB
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2021-01-22 04:40:09 +03:00
|
|
|
super().__init__(config)
|
2021-10-12 13:35:32 +03:00
|
|
|
self.timer = config.get("timer", "")
|
2021-07-10 18:20:05 +03:00
|
|
|
self.request_mutex = asyncio.Lock()
|
2022-02-02 18:45:47 +03:00
|
|
|
addr_and_output_id = config.get("address").split('/')
|
|
|
|
self.addr = addr_and_output_id[0]
|
|
|
|
if (len(addr_and_output_id) > 1):
|
|
|
|
self.server.add_warning(
|
|
|
|
f"Power Device {self.name}: Including the output id in the"
|
|
|
|
" address is deprecated, use the 'output_id' option")
|
|
|
|
self.output_id: Optional[int] = int(addr_and_output_id[1])
|
|
|
|
else:
|
|
|
|
self.output_id = config.getint("output_id", None)
|
2020-11-16 20:36:28 +03:00
|
|
|
self.port = config.getint("port", 9999)
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_tplink_command(self,
|
|
|
|
command: str
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
out_cmd: Dict[str, Any] = {}
|
2020-11-16 20:36:28 +03:00
|
|
|
if command in ["on", "off"]:
|
2021-03-27 21:53:28 +03:00
|
|
|
out_cmd = {
|
|
|
|
'system': {'set_relay_state': {'state': int(command == "on")}}
|
|
|
|
}
|
2021-04-01 03:32:57 +03:00
|
|
|
# TPLink device controls multiple devices
|
2022-02-02 18:45:47 +03:00
|
|
|
if self.output_id is not None:
|
2021-04-01 03:32:57 +03:00
|
|
|
sysinfo = await self._send_tplink_command("info")
|
|
|
|
dev_id = sysinfo["system"]["get_sysinfo"]["deviceId"]
|
|
|
|
out_cmd["context"] = {
|
2022-02-02 18:45:47 +03:00
|
|
|
'child_ids': [f"{dev_id}{self.output_id:02}"]
|
2021-04-01 03:32:57 +03:00
|
|
|
}
|
2020-11-16 20:36:28 +03:00
|
|
|
elif command == "info":
|
|
|
|
out_cmd = {'system': {'get_sysinfo': {}}}
|
2021-10-12 13:35:32 +03:00
|
|
|
elif command == "clear_rules":
|
|
|
|
out_cmd = {'count_down': {'delete_all_rules': None}}
|
|
|
|
elif command == "count_off":
|
|
|
|
out_cmd = {
|
|
|
|
'count_down': {'add_rule':
|
|
|
|
{'enable': 1, 'delay': int(self.timer),
|
|
|
|
'act': 0, 'name': 'turn off'}}
|
|
|
|
}
|
2020-11-16 20:36:28 +03:00
|
|
|
else:
|
|
|
|
raise self.server.error(f"Invalid tplink command: {command}")
|
2021-12-09 20:25:02 +03:00
|
|
|
reader, writer = await asyncio.open_connection(
|
2022-02-02 18:45:47 +03:00
|
|
|
self.addr, self.port, family=socket.AF_INET)
|
2020-11-16 20:36:28 +03:00
|
|
|
try:
|
2021-12-09 20:25:02 +03:00
|
|
|
writer.write(self._encrypt(out_cmd))
|
|
|
|
await writer.drain()
|
|
|
|
data = await reader.read(2048)
|
2021-05-15 13:53:34 +03:00
|
|
|
length: int = struct.unpack(">I", data[:4])[0]
|
2020-11-16 20:36:28 +03:00
|
|
|
data = data[4:]
|
|
|
|
retries = 5
|
|
|
|
remaining = length - len(data)
|
|
|
|
while remaining and retries:
|
2021-12-09 20:25:02 +03:00
|
|
|
data += await reader.read(remaining)
|
2020-11-16 20:36:28 +03:00
|
|
|
remaining = length - len(data)
|
|
|
|
retries -= 1
|
|
|
|
if not retries:
|
|
|
|
raise self.server.error("Unable to read tplink packet")
|
|
|
|
except Exception:
|
|
|
|
msg = f"Error sending tplink command: {command}"
|
|
|
|
logging.exception(msg)
|
|
|
|
raise self.server.error(msg)
|
|
|
|
finally:
|
2021-12-09 20:25:02 +03:00
|
|
|
writer.close()
|
|
|
|
await writer.wait_closed()
|
2020-11-16 20:36:28 +03:00
|
|
|
return json.loads(self._decrypt(data))
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def _encrypt(self, outdata: Dict[str, Any]) -> bytes:
|
|
|
|
data = json.dumps(outdata)
|
2020-11-16 20:36:28 +03:00
|
|
|
key = self.START_KEY
|
|
|
|
res = struct.pack(">I", len(data))
|
|
|
|
for c in data:
|
|
|
|
val = key ^ ord(c)
|
|
|
|
key = val
|
|
|
|
res += bytes([val])
|
|
|
|
return res
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
def _decrypt(self, data: bytes) -> str:
|
|
|
|
key: int = self.START_KEY
|
|
|
|
res: str = ""
|
2020-11-16 20:36:28 +03:00
|
|
|
for c in data:
|
|
|
|
val = key ^ c
|
|
|
|
key = c
|
|
|
|
res += chr(val)
|
|
|
|
return res
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def initialize(self) -> None:
|
2021-10-23 15:55:02 +03:00
|
|
|
super().initialize()
|
2020-11-16 20:36:28 +03:00
|
|
|
await self.refresh_status()
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def refresh_status(self) -> None:
|
2021-05-10 14:25:56 +03:00
|
|
|
async with self.request_mutex:
|
|
|
|
try:
|
2021-05-15 13:53:34 +03:00
|
|
|
state: str
|
2021-05-10 14:25:56 +03:00
|
|
|
res = await self._send_tplink_command("info")
|
2022-02-02 18:45:47 +03:00
|
|
|
if self.output_id is not None:
|
2021-05-10 14:25:56 +03:00
|
|
|
# TPLink device controls multiple devices
|
2021-05-15 13:53:34 +03:00
|
|
|
children: Dict[int, Any]
|
2021-05-10 14:25:56 +03:00
|
|
|
children = res['system']['get_sysinfo']['children']
|
2022-02-02 18:45:47 +03:00
|
|
|
state = children[self.output_id]['state']
|
2021-05-10 14:25:56 +03:00
|
|
|
else:
|
|
|
|
state = res['system']['get_sysinfo']['relay_state']
|
|
|
|
except Exception:
|
|
|
|
self.state = "error"
|
|
|
|
msg = f"Error Refeshing Device Status: {self.name}"
|
|
|
|
logging.exception(msg)
|
|
|
|
raise self.server.error(msg) from None
|
|
|
|
self.state = "on" if state else "off"
|
2020-11-16 20:36:28 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def set_power(self, state) -> None:
|
2021-05-10 14:25:56 +03:00
|
|
|
async with self.request_mutex:
|
2021-05-15 13:53:34 +03:00
|
|
|
err: int
|
2021-05-10 14:25:56 +03:00
|
|
|
try:
|
2021-10-12 13:35:32 +03:00
|
|
|
if self.timer != "" and state == "off":
|
|
|
|
await self._send_tplink_command("clear_rules")
|
|
|
|
res = await self._send_tplink_command("count_off")
|
|
|
|
err = res['count_down']['add_rule']['err_code']
|
|
|
|
else:
|
|
|
|
res = await self._send_tplink_command(state)
|
|
|
|
err = res['system']['set_relay_state']['err_code']
|
2021-05-10 14:25:56 +03:00
|
|
|
except Exception:
|
|
|
|
err = 1
|
|
|
|
logging.exception(f"Power Toggle Error: {self.name}")
|
|
|
|
if err:
|
|
|
|
self.state = "error"
|
|
|
|
raise self.server.error(
|
|
|
|
f"Error Toggling Device Power: {self.name}")
|
|
|
|
self.state = state
|
2020-11-16 20:36:28 +03:00
|
|
|
|
2020-11-21 00:44:16 +03:00
|
|
|
|
2021-05-10 14:22:57 +03:00
|
|
|
class Tasmota(HTTPDevice):
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2021-05-10 14:22:57 +03:00
|
|
|
super().__init__(config, default_password="")
|
2020-11-21 00:44:16 +03:00
|
|
|
self.output_id = config.getint("output_id", 1)
|
2021-05-09 15:16:14 +03:00
|
|
|
self.timer = config.get("timer", "")
|
2020-11-21 00:44:16 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_tasmota_command(self,
|
|
|
|
command: str,
|
|
|
|
password: Optional[str] = None
|
|
|
|
) -> Dict[str, Any]:
|
2020-11-21 00:44:16 +03:00
|
|
|
if command in ["on", "off"]:
|
|
|
|
out_cmd = f"Power{self.output_id}%20{command}"
|
2021-04-07 00:58:51 +03:00
|
|
|
if self.timer != "" and command == "off":
|
|
|
|
out_cmd = f"Backlog%20Delay%20{self.timer}0%3B%20{out_cmd}"
|
2020-11-21 00:44:16 +03:00
|
|
|
elif command == "info":
|
2020-12-26 03:30:21 +03:00
|
|
|
out_cmd = f"Power{self.output_id}"
|
2020-11-21 00:44:16 +03:00
|
|
|
else:
|
|
|
|
raise self.server.error(f"Invalid tasmota command: {command}")
|
2020-12-26 03:30:21 +03:00
|
|
|
|
|
|
|
url = f"http://{self.addr}/cm?user=admin&password=" \
|
|
|
|
f"{self.password}&cmnd={out_cmd}"
|
2021-05-10 14:22:57 +03:00
|
|
|
return await self._send_http_command(url, command)
|
2020-11-21 00:44:16 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_status_request(self) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
res = await self._send_tasmota_command("info")
|
2020-11-21 00:44:16 +03:00
|
|
|
try:
|
2021-05-15 13:53:34 +03:00
|
|
|
state: str = res[f"POWER{self.output_id}"].lower()
|
2021-05-10 14:22:57 +03:00
|
|
|
except KeyError as e:
|
|
|
|
if self.output_id == 1:
|
|
|
|
state = res[f"POWER"].lower()
|
|
|
|
else:
|
|
|
|
raise KeyError(e)
|
|
|
|
return state
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_power_request(self, state: str) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
res = await self._send_tasmota_command(state)
|
|
|
|
if self.timer == "" or state != "off":
|
2021-05-09 15:16:14 +03:00
|
|
|
try:
|
2021-04-02 14:45:45 +03:00
|
|
|
state = res[f"POWER{self.output_id}"].lower()
|
|
|
|
except KeyError as e:
|
2021-05-09 15:16:14 +03:00
|
|
|
if self.output_id == 1:
|
2021-04-02 14:45:45 +03:00
|
|
|
state = res[f"POWER"].lower()
|
|
|
|
else:
|
|
|
|
raise KeyError(e)
|
2021-05-10 14:22:57 +03:00
|
|
|
return state
|
2020-11-21 00:44:16 +03:00
|
|
|
|
2021-02-07 13:02:09 +03:00
|
|
|
|
2021-05-10 14:22:57 +03:00
|
|
|
class Shelly(HTTPDevice):
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2021-05-10 14:22:57 +03:00
|
|
|
super().__init__(config, default_user="admin", default_password="")
|
2021-02-07 13:02:09 +03:00
|
|
|
self.output_id = config.getint("output_id", 0)
|
2021-05-09 15:16:14 +03:00
|
|
|
self.timer = config.get("timer", "")
|
2021-02-07 13:02:09 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_shelly_command(self, command: str) -> Dict[str, Any]:
|
2021-04-22 15:47:40 +03:00
|
|
|
if command == "on":
|
|
|
|
out_cmd = f"relay/{self.output_id}?turn={command}"
|
|
|
|
elif command == "off":
|
2021-04-07 00:58:51 +03:00
|
|
|
if self.timer != "":
|
2021-04-22 15:47:40 +03:00
|
|
|
out_cmd = f"relay/{self.output_id}?turn=on&timer={self.timer}"
|
2021-04-07 00:58:51 +03:00
|
|
|
else:
|
|
|
|
out_cmd = f"relay/{self.output_id}?turn={command}"
|
2021-02-07 13:02:09 +03:00
|
|
|
elif command == "info":
|
|
|
|
out_cmd = f"relay/{self.output_id}"
|
|
|
|
else:
|
|
|
|
raise self.server.error(f"Invalid shelly command: {command}")
|
|
|
|
if self.password != "":
|
|
|
|
out_pwd = f"{self.user}:{self.password}@"
|
|
|
|
else:
|
|
|
|
out_pwd = f""
|
|
|
|
url = f"http://{out_pwd}{self.addr}/{out_cmd}"
|
2021-05-10 14:22:57 +03:00
|
|
|
return await self._send_http_command(url, command)
|
2021-02-07 13:02:09 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_status_request(self) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
res = await self._send_shelly_command("info")
|
2021-05-15 13:53:34 +03:00
|
|
|
state: str = res[f"ison"]
|
2021-05-10 14:22:57 +03:00
|
|
|
timer_remaining = res[f"timer_remaining"] if self.timer != "" else 0
|
|
|
|
return "on" if state and timer_remaining == 0 else "off"
|
2021-02-07 13:02:09 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_power_request(self, state: str) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
res = await self._send_shelly_command(state)
|
|
|
|
state = res[f"ison"]
|
|
|
|
timer_remaining = res[f"timer_remaining"] if self.timer != "" else 0
|
|
|
|
return "on" if state and timer_remaining == 0 else "off"
|
2021-02-07 13:02:09 +03:00
|
|
|
|
|
|
|
|
2021-05-10 14:22:57 +03:00
|
|
|
class HomeSeer(HTTPDevice):
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2021-05-10 14:22:57 +03:00
|
|
|
super().__init__(config, default_user="admin", default_password="")
|
2021-03-12 02:54:16 +03:00
|
|
|
self.device = config.getint("device")
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_homeseer(self,
|
|
|
|
request: str,
|
|
|
|
additional: str = ""
|
|
|
|
) -> Dict[str, Any]:
|
2021-03-12 02:54:16 +03:00
|
|
|
url = (f"http://{self.user}:{self.password}@{self.addr}"
|
|
|
|
f"/JSON?user={self.user}&pass={self.password}"
|
|
|
|
f"&request={request}&ref={self.device}&{additional}")
|
2021-05-10 14:22:57 +03:00
|
|
|
return await self._send_http_command(url, request)
|
2021-03-12 02:54:16 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_status_request(self) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
res = await self._send_homeseer("getstatus")
|
|
|
|
return res[f"Devices"][0]["status"].lower()
|
2021-03-12 02:54:16 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_power_request(self, state: str) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
if state == "on":
|
|
|
|
state_hs = "On"
|
|
|
|
elif state == "off":
|
|
|
|
state_hs = "Off"
|
|
|
|
res = await self._send_homeseer("controldevicebylabel",
|
|
|
|
f"label={state_hs}")
|
|
|
|
return state
|
2021-03-12 02:54:16 +03:00
|
|
|
|
|
|
|
|
2021-05-10 14:22:57 +03:00
|
|
|
class HomeAssistant(HTTPDevice):
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2021-05-10 14:22:57 +03:00
|
|
|
super().__init__(config, default_port=8123)
|
2021-05-15 13:53:34 +03:00
|
|
|
self.device: str = config.get("device")
|
2021-12-24 03:53:34 +03:00
|
|
|
self.token: str = config.gettemplate("token").render()
|
2021-06-13 21:37:01 +03:00
|
|
|
self.domain: str = config.get("domain", "switch")
|
2021-11-15 18:59:46 +03:00
|
|
|
self.status_delay: float = config.getfloat("status_delay", 1.)
|
2021-05-05 14:14:25 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_homeassistant_command(self,
|
|
|
|
command: str
|
|
|
|
) -> Dict[Union[str, int], Any]:
|
2021-05-05 14:14:25 +03:00
|
|
|
if command == "on":
|
2021-06-13 21:37:01 +03:00
|
|
|
out_cmd = f"api/services/{self.domain}/turn_on"
|
2021-05-05 14:14:25 +03:00
|
|
|
body = {"entity_id": self.device}
|
|
|
|
method = "POST"
|
|
|
|
elif command == "off":
|
2021-06-13 21:37:01 +03:00
|
|
|
out_cmd = f"api/services/{self.domain}/turn_off"
|
2021-05-05 14:14:25 +03:00
|
|
|
body = {"entity_id": self.device}
|
|
|
|
method = "POST"
|
|
|
|
elif command == "info":
|
|
|
|
out_cmd = f"api/states/{self.device}"
|
|
|
|
method = "GET"
|
|
|
|
else:
|
|
|
|
raise self.server.error(
|
|
|
|
f"Invalid homeassistant command: {command}")
|
2021-05-29 17:08:24 +03:00
|
|
|
url = f"{self.protocol}://{self.addr}:{self.port}/{out_cmd}"
|
2021-05-05 14:14:25 +03:00
|
|
|
headers = {
|
|
|
|
'Authorization': f'Bearer {self.token}',
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
}
|
|
|
|
try:
|
|
|
|
if (method == "POST"):
|
2021-05-10 14:22:57 +03:00
|
|
|
response = await self.client.fetch(
|
2021-05-05 14:14:25 +03:00
|
|
|
url, method="POST", body=json.dumps(body), headers=headers)
|
|
|
|
else:
|
2021-05-10 14:22:57 +03:00
|
|
|
response = await self.client.fetch(
|
2021-05-05 14:14:25 +03:00
|
|
|
url, method="GET", headers=headers)
|
2021-05-15 13:53:34 +03:00
|
|
|
data: Dict[Union[str, int], Any] = json_decode(response.body)
|
2021-05-05 14:14:25 +03:00
|
|
|
except Exception:
|
|
|
|
msg = f"Error sending homeassistant command: {command}"
|
|
|
|
logging.exception(msg)
|
|
|
|
raise self.server.error(msg)
|
|
|
|
return data
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_status_request(self) -> str:
|
2021-05-10 14:22:57 +03:00
|
|
|
res = await self._send_homeassistant_command("info")
|
|
|
|
return res[f"state"]
|
2021-05-05 14:14:25 +03:00
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_power_request(self, state: str) -> str:
|
2021-05-29 17:36:23 +03:00
|
|
|
await self._send_homeassistant_command(state)
|
2021-11-15 18:59:46 +03:00
|
|
|
await asyncio.sleep(self.status_delay)
|
2021-05-29 17:36:23 +03:00
|
|
|
res = await self._send_status_request()
|
|
|
|
return res
|
2021-05-05 14:14:25 +03:00
|
|
|
|
2021-05-10 14:41:33 +03:00
|
|
|
class Loxonev1(HTTPDevice):
|
2021-05-15 13:53:34 +03:00
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2021-05-10 14:41:33 +03:00
|
|
|
super().__init__(config, default_user="admin",
|
|
|
|
default_password="admin")
|
|
|
|
self.output_id = config.get("output_id", "")
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_loxonev1_command(self, command: str) -> Dict[str, Any]:
|
2021-05-10 14:41:33 +03:00
|
|
|
if command in ["on", "off"]:
|
|
|
|
out_cmd = f"jdev/sps/io/{self.output_id}/{command}"
|
|
|
|
elif command == "info":
|
|
|
|
out_cmd = f"jdev/sps/io/{self.output_id}"
|
|
|
|
else:
|
|
|
|
raise self.server.error(f"Invalid loxonev1 command: {command}")
|
|
|
|
if self.password != "":
|
|
|
|
out_pwd = f"{self.user}:{self.password}@"
|
|
|
|
else:
|
|
|
|
out_pwd = f""
|
|
|
|
url = f"http://{out_pwd}{self.addr}/{out_cmd}"
|
|
|
|
return await self._send_http_command(url, command)
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_status_request(self) -> str:
|
2021-05-10 14:41:33 +03:00
|
|
|
res = await self._send_loxonev1_command("info")
|
|
|
|
state = res[f"LL"][f"value"]
|
|
|
|
return "on" if int(state) == 1 else "off"
|
|
|
|
|
2021-05-15 13:53:34 +03:00
|
|
|
async def _send_power_request(self, state: str) -> str:
|
2021-05-10 14:41:33 +03:00
|
|
|
res = await self._send_loxonev1_command(state)
|
|
|
|
state = res[f"LL"][f"value"]
|
|
|
|
return "on" if int(state) == 1 else "off"
|
|
|
|
|
2021-05-05 14:14:25 +03:00
|
|
|
|
2021-06-27 03:19:13 +03:00
|
|
|
class MQTTDevice(PowerDevice):
|
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
|
|
super().__init__(config)
|
|
|
|
self.mqtt: MQTTClient = self.server.load_component(config, 'mqtt')
|
|
|
|
self.eventloop = self.server.get_event_loop()
|
|
|
|
self.cmd_topic: str = config.get('command_topic')
|
|
|
|
self.cmd_payload: JinjaTemplate = config.gettemplate('command_payload')
|
|
|
|
self.retain_cmd_state = config.getboolean('retain_command_state', False)
|
|
|
|
self.query_topic: Optional[str] = config.get('query_topic', None)
|
|
|
|
self.query_payload = config.gettemplate('query_payload', None)
|
|
|
|
self.must_query = config.getboolean('query_after_command', False)
|
|
|
|
if self.query_topic is not None:
|
|
|
|
self.must_query = False
|
|
|
|
|
|
|
|
self.state_topic: str = config.get('state_topic')
|
|
|
|
self.state_timeout = config.getfloat('state_timeout', 2.)
|
2021-12-24 03:53:34 +03:00
|
|
|
self.state_response = config.load_template('state_response_template',
|
|
|
|
"{payload}")
|
2021-06-27 03:19:13 +03:00
|
|
|
self.qos: Optional[int] = config.getint('qos', None, minval=0, maxval=2)
|
|
|
|
self.mqtt.subscribe_topic(
|
|
|
|
self.state_topic, self._on_state_update, self.qos)
|
|
|
|
self.query_response: Optional[asyncio.Future] = None
|
|
|
|
self.request_mutex = asyncio.Lock()
|
|
|
|
self.server.register_event_handler(
|
|
|
|
"mqtt:connected", self._on_mqtt_connected)
|
|
|
|
self.server.register_event_handler(
|
|
|
|
"mqtt:disconnected", self._on_mqtt_disconnected)
|
|
|
|
|
|
|
|
def _on_state_update(self, payload: bytes) -> None:
|
|
|
|
last_state = self.state
|
|
|
|
in_request = self.request_mutex.locked()
|
|
|
|
err: Optional[Exception] = None
|
|
|
|
context = {
|
|
|
|
'payload': payload.decode()
|
|
|
|
}
|
|
|
|
try:
|
|
|
|
response = self.state_response.render(context)
|
|
|
|
except Exception as e:
|
|
|
|
err = e
|
|
|
|
self.state = "error"
|
|
|
|
else:
|
|
|
|
response = response.lower()
|
|
|
|
if response not in ["on", "off"]:
|
|
|
|
err_msg = "Invalid State Received. " \
|
|
|
|
f"Raw Payload: '{payload.decode()}', Rendered: '{response}"
|
|
|
|
logging.info(f"MQTT Power Device {self.name}: {err_msg}")
|
|
|
|
err = self.server.error(err_msg, 500)
|
|
|
|
self.state = "error"
|
|
|
|
else:
|
|
|
|
self.state = response
|
|
|
|
if not in_request and last_state != self.state:
|
|
|
|
logging.info(f"MQTT Power Device {self.name}: External Power "
|
|
|
|
f"event detected, new state: {self.state}")
|
|
|
|
self.notify_power_changed()
|
|
|
|
if (
|
|
|
|
self.query_response is not None and
|
|
|
|
not self.query_response.done()
|
|
|
|
):
|
|
|
|
if err is not None:
|
|
|
|
self.query_response.set_exception(err)
|
|
|
|
else:
|
|
|
|
self.query_response.set_result(response)
|
|
|
|
|
|
|
|
async def _on_mqtt_connected(self) -> None:
|
|
|
|
async with self.request_mutex:
|
|
|
|
if self.state in ["on", "off"]:
|
|
|
|
return
|
|
|
|
self.state = "init"
|
|
|
|
success = False
|
|
|
|
while self.mqtt.is_connected():
|
|
|
|
self.query_response = self.eventloop.create_future()
|
|
|
|
try:
|
|
|
|
await self._wait_for_update(self.query_response)
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
# Only wait once if no query topic is set.
|
|
|
|
# Assume that the MQTT device has set the retain
|
|
|
|
# flag on the state topic, and therefore should get
|
|
|
|
# an immediate response upon subscription.
|
|
|
|
if self.query_topic is None:
|
|
|
|
logging.info(f"MQTT Power Device {self.name}: "
|
|
|
|
"Initialization Timed Out")
|
|
|
|
break
|
|
|
|
except Exception:
|
|
|
|
logging.exception(f"MQTT Power Device {self.name}: "
|
|
|
|
"Init Failed")
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
success = True
|
|
|
|
break
|
|
|
|
await asyncio.sleep(2.)
|
|
|
|
self.query_response = None
|
|
|
|
if not success:
|
|
|
|
self.state = "error"
|
|
|
|
else:
|
|
|
|
logging.info(
|
|
|
|
f"MQTT Power Device {self.name} initialized")
|
|
|
|
self.notify_power_changed()
|
|
|
|
|
|
|
|
async def _on_mqtt_disconnected(self):
|
|
|
|
if (
|
|
|
|
self.query_response is not None and
|
|
|
|
not self.query_response.done()
|
|
|
|
):
|
|
|
|
self.query_response.set_exception(
|
|
|
|
self.server.error("MQTT Disconnected", 503))
|
|
|
|
async with self.request_mutex:
|
|
|
|
self.state = "error"
|
|
|
|
self.notify_power_changed()
|
|
|
|
|
|
|
|
async def refresh_status(self) -> None:
|
|
|
|
if (
|
|
|
|
self.query_topic is not None and
|
|
|
|
(self.must_query or self.state not in ["on", "off"])
|
|
|
|
):
|
|
|
|
if not self.mqtt.is_connected():
|
|
|
|
raise self.server.error(
|
|
|
|
f"MQTT Power Device {self.name}: "
|
|
|
|
"MQTT Not Connected", 503)
|
|
|
|
async with self.request_mutex:
|
|
|
|
self.query_response = self.eventloop.create_future()
|
|
|
|
try:
|
|
|
|
await self._wait_for_update(self.query_response)
|
|
|
|
except Exception:
|
|
|
|
logging.exception(f"MQTT Power Device {self.name}: "
|
|
|
|
"Failed to refresh state")
|
|
|
|
self.state = "error"
|
|
|
|
self.query_response = None
|
|
|
|
|
|
|
|
async def _wait_for_update(self, fut: asyncio.Future,
|
|
|
|
do_query: bool = True
|
|
|
|
) -> str:
|
|
|
|
if self.query_topic is not None and do_query:
|
|
|
|
payload: Optional[str] = None
|
|
|
|
if self.query_payload is not None:
|
|
|
|
payload = self.query_payload.render()
|
|
|
|
await self.mqtt.publish_topic(self.query_topic, payload,
|
|
|
|
self.qos)
|
|
|
|
return await asyncio.wait_for(fut, timeout=self.state_timeout)
|
|
|
|
|
|
|
|
async def set_power(self, state: str) -> None:
|
|
|
|
if not self.mqtt.is_connected():
|
|
|
|
raise self.server.error(
|
|
|
|
f"MQTT Power Device {self.name}: "
|
|
|
|
"MQTT Not Connected", 503)
|
|
|
|
async with self.request_mutex:
|
|
|
|
self.query_response = self.eventloop.create_future()
|
|
|
|
new_state = "error"
|
|
|
|
try:
|
|
|
|
payload = self.cmd_payload.render({'command': state})
|
|
|
|
await self.mqtt.publish_topic(
|
|
|
|
self.cmd_topic, payload, self.qos,
|
|
|
|
retain=self.retain_cmd_state)
|
|
|
|
new_state = await self._wait_for_update(
|
|
|
|
self.query_response, do_query=self.must_query)
|
|
|
|
except Exception:
|
|
|
|
logging.exception(
|
|
|
|
f"MQTT Power Device {self.name}: Failed to set state")
|
|
|
|
new_state = "error"
|
|
|
|
self.query_response = None
|
|
|
|
self.state = new_state
|
|
|
|
if self.state == "error":
|
|
|
|
raise self.server.error(
|
|
|
|
f"MQTT Power Device {self.name}: Failed to set "
|
|
|
|
f"device to state '{state}'", 500)
|
|
|
|
|
|
|
|
|
2021-03-18 15:23:40 +03:00
|
|
|
# The power component has multiple configuration sections
|
2021-09-30 02:49:47 +03:00
|
|
|
def load_component(config: ConfigHelper) -> PrinterPower:
|
2020-08-06 04:06:52 +03:00
|
|
|
return PrinterPower(config)
|