Several components throughout Moonraker determine whether or not Klipper is printing or is ready before taking action. This centralizes queries in one area. The checks do not query Klipper directly but rather rely on subscriptions to push state to Moonraker. Signed-off-by: Eric Callahan <arksine.code@gmail.com>
1392 lines
55 KiB
Python
1392 lines
55 KiB
Python
# 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.
|
|
|
|
from __future__ import annotations
|
|
import logging
|
|
import json
|
|
import struct
|
|
import socket
|
|
import asyncio
|
|
import time
|
|
|
|
# Annotation imports
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Type,
|
|
List,
|
|
Any,
|
|
Optional,
|
|
Dict,
|
|
Coroutine,
|
|
Union,
|
|
cast
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from confighelper import ConfigHelper
|
|
from websockets import WebRequest
|
|
from .machine import Machine
|
|
from . import klippy_apis
|
|
from .mqtt import MQTTClient
|
|
from .template import JinjaTemplate
|
|
from .http_client import HttpClient
|
|
from klippy_connection import KlippyConnection
|
|
APIComp = klippy_apis.KlippyAPI
|
|
|
|
class PrinterPower:
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
self.server = config.get_server()
|
|
self.devices: Dict[str, PowerDevice] = {}
|
|
prefix_sections = config.get_prefix_sections("power")
|
|
logging.info(f"Power component loading devices: {prefix_sections}")
|
|
dev_types = {
|
|
"gpio": GpioDevice,
|
|
"klipper_device": KlipperDevice,
|
|
"tplink_smartplug": TPLinkSmartPlug,
|
|
"tasmota": Tasmota,
|
|
"shelly": Shelly,
|
|
"homeseer": HomeSeer,
|
|
"homeassistant": HomeAssistant,
|
|
"loxonev1": Loxonev1,
|
|
"rf": RFDevice,
|
|
"mqtt": MQTTDevice,
|
|
"smartthings": SmartThings,
|
|
"hue": HueDevice
|
|
}
|
|
|
|
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}")
|
|
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)
|
|
continue
|
|
self.devices[dev.get_name()] = dev
|
|
|
|
self.server.register_endpoint(
|
|
"/machine/device_power/devices", ['GET'],
|
|
self._handle_list_devices)
|
|
self.server.register_endpoint(
|
|
"/machine/device_power/status", ['GET'],
|
|
self._handle_batch_power_request)
|
|
self.server.register_endpoint(
|
|
"/machine/device_power/on", ['POST'],
|
|
self._handle_batch_power_request)
|
|
self.server.register_endpoint(
|
|
"/machine/device_power/off", ['POST'],
|
|
self._handle_batch_power_request)
|
|
self.server.register_endpoint(
|
|
"/machine/device_power/device", ['GET', 'POST'],
|
|
self._handle_single_power_request)
|
|
self.server.register_remote_method(
|
|
"set_device_power", self.set_device_power)
|
|
self.server.register_event_handler(
|
|
"server:klippy_shutdown", self._handle_klippy_shutdown)
|
|
self.server.register_event_handler(
|
|
"job_queue:job_queue_changed", self._handle_job_queued)
|
|
self.server.register_notification("power:power_changed")
|
|
|
|
async def component_init(self) -> None:
|
|
for dev in self.devices.values():
|
|
if not dev.initialize():
|
|
self.server.add_warning(
|
|
f"Power device '{dev.get_name()}' failed to initialize"
|
|
)
|
|
|
|
def _handle_klippy_shutdown(self) -> None:
|
|
for dev in self.devices.values():
|
|
dev.process_klippy_shutdown()
|
|
|
|
async def _handle_job_queued(self, queue_info: Dict[str, Any]) -> None:
|
|
if queue_info["action"] != "jobs_added":
|
|
return
|
|
for name, dev in self.devices.items():
|
|
if dev.should_turn_on_when_queued():
|
|
queue: List[Dict[str, Any]]
|
|
queue = queue_info.get("updated_queue", [])
|
|
fname = "unknown"
|
|
if len(queue):
|
|
fname = queue[0].get("filename", "unknown")
|
|
logging.info(
|
|
f"Power Device {name}: Job '{fname}' queued, powering on"
|
|
)
|
|
await dev.process_request("on")
|
|
|
|
async def _handle_list_devices(self,
|
|
web_request: WebRequest
|
|
) -> Dict[str, Any]:
|
|
dev_list = [d.get_device_info() for d in self.devices.values()]
|
|
output = {"devices": dev_list}
|
|
return output
|
|
|
|
async def _handle_single_power_request(self,
|
|
web_request: WebRequest
|
|
) -> Dict[str, Any]:
|
|
dev_name: str = web_request.get_str('device')
|
|
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 dev.process_request(action)
|
|
return {dev_name: result}
|
|
|
|
async def _handle_batch_power_request(self,
|
|
web_request: WebRequest
|
|
) -> Dict[str, Any]:
|
|
args = web_request.get_args()
|
|
ep = web_request.get_endpoint()
|
|
if not args:
|
|
raise self.server.error("No arguments provided")
|
|
requested_devs = {k: self.devices.get(k, None) for k in args}
|
|
result = {}
|
|
req = ep.split("/")[-1]
|
|
for name, device in requested_devs.items():
|
|
if device is not None:
|
|
result[name] = await device.process_request(req)
|
|
else:
|
|
result[name] = "device_not_found"
|
|
return result
|
|
|
|
def set_device_power(
|
|
self, device: str, state: Union[bool, str], force: bool = False
|
|
) -> None:
|
|
request: str = ""
|
|
if isinstance(state, bool):
|
|
request = "on" if state else "off"
|
|
elif isinstance(state, str):
|
|
request = state.lower()
|
|
if request in ["true", "false"]:
|
|
request = "on" if request == "true" else "off"
|
|
if request not in ["on", "off", "toggle"]:
|
|
logging.info(f"Invalid state received: {state}")
|
|
return
|
|
if device not in self.devices:
|
|
logging.info(f"No device found: {device}")
|
|
return
|
|
event_loop = self.server.get_event_loop()
|
|
event_loop.register_callback(
|
|
self.devices[device].process_request, request, force=force
|
|
)
|
|
|
|
async def add_device(self, name: str, device: PowerDevice) -> None:
|
|
if name in self.devices:
|
|
raise self.server.error(
|
|
f"Device [{name}] already configured")
|
|
success = device.initialize()
|
|
if asyncio.iscoroutine(success):
|
|
success = await success # type: ignore
|
|
if not success:
|
|
self.server.add_warning(
|
|
f"Power device '{device.get_name()}' failed to initialize"
|
|
)
|
|
return
|
|
self.devices[name] = device
|
|
|
|
async def close(self) -> None:
|
|
for device in self.devices.values():
|
|
ret = device.close()
|
|
if ret is not None:
|
|
await ret
|
|
|
|
|
|
class PowerDevice:
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
name_parts = config.get_name().split(maxsplit=1)
|
|
if len(name_parts) != 2:
|
|
raise config.error(f"Invalid Section Name: {config.get_name()}")
|
|
self.server = config.get_server()
|
|
self.name = name_parts[1]
|
|
self.type: str = config.get('type')
|
|
self.state: str = "init"
|
|
self.request_lock = asyncio.Lock()
|
|
self.init_task: Optional[asyncio.Task] = None
|
|
self.locked_while_printing = config.getboolean(
|
|
'locked_while_printing', False)
|
|
self.off_when_shutdown = config.getboolean('off_when_shutdown', False)
|
|
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
|
|
self.restart_delay = 1.
|
|
self.klipper_restart = config.getboolean(
|
|
'restart_klipper_when_powered', False)
|
|
if self.klipper_restart:
|
|
self.restart_delay = config.getfloat(
|
|
'restart_delay', 1., above=.000001
|
|
)
|
|
self.server.register_event_handler(
|
|
"server:klippy_started", self._schedule_firmware_restart
|
|
)
|
|
self.bound_services: List[str] = []
|
|
bound_services: List[str] = config.getlist('bound_services', [])
|
|
if config.has_option('bound_service'):
|
|
# The `bound_service` option is deprecated, however this minimal
|
|
# change does not require a warning as it can be reliably resolved
|
|
bound_services.append(config.get('bound_service'))
|
|
for svc in bound_services:
|
|
if svc.endswith(".service"):
|
|
svc = svc.rsplit(".", 1)[0]
|
|
if svc in self.bound_services:
|
|
continue
|
|
self.bound_services.append(svc)
|
|
self.need_scheduled_restart = False
|
|
self.on_when_queued = config.getboolean('on_when_job_queued', False)
|
|
if config.has_option('on_when_upload_queued'):
|
|
self.on_when_queued = config.getboolean('on_when_upload_queued',
|
|
False, deprecate=True)
|
|
self.initial_state: Optional[bool] = config.getboolean(
|
|
'initial_state', None
|
|
)
|
|
|
|
def _schedule_firmware_restart(self, state: str = "") -> None:
|
|
if not self.need_scheduled_restart:
|
|
return
|
|
self.need_scheduled_restart = False
|
|
if state == "ready":
|
|
logging.info(
|
|
f"Power Device {self.name}: Klipper reports 'ready', "
|
|
"aborting FIRMWARE_RESTART"
|
|
)
|
|
return
|
|
logging.info(
|
|
f"Power Device {self.name}: Sending FIRMWARE_RESTART command "
|
|
"to Klippy"
|
|
)
|
|
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")
|
|
|
|
def get_name(self) -> str:
|
|
return self.name
|
|
|
|
def get_device_info(self) -> Dict[str, Any]:
|
|
return {
|
|
'device': self.name,
|
|
'status': self.state,
|
|
'locked_while_printing': self.locked_while_printing,
|
|
'type': self.type
|
|
}
|
|
|
|
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()
|
|
if self.bound_services:
|
|
machine_cmp: Machine = self.server.lookup_component("machine")
|
|
action = "start" if self.state == "on" else "stop"
|
|
for svc in self.bound_services:
|
|
logging.info(
|
|
f"Power Device {self.name}: Performing {action} action "
|
|
f"on bound service {svc}"
|
|
)
|
|
await machine_cmp.do_service_action(action, svc)
|
|
if self.state == "on" and self.klipper_restart:
|
|
self.need_scheduled_restart = True
|
|
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.
|
|
return
|
|
self._schedule_firmware_restart(klippy_state)
|
|
|
|
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")
|
|
|
|
def should_turn_on_when_queued(self) -> bool:
|
|
return self.on_when_queued and self.state == "off"
|
|
|
|
def _setup_bound_services(self) -> None:
|
|
if not self.bound_services:
|
|
return
|
|
machine_cmp: Machine = self.server.lookup_component("machine")
|
|
sys_info = machine_cmp.get_system_info()
|
|
avail_svcs: List[str] = sys_info.get('available_services', [])
|
|
for svc in self.bound_services:
|
|
if machine_cmp.unit_name == svc:
|
|
raise self.server.error(
|
|
f"Power Device {self.name}: Cannot bind to Moonraker "
|
|
f"service {svc}."
|
|
)
|
|
if svc not in avail_svcs:
|
|
raise self.server.error(
|
|
f"Bound Service {svc} is not available"
|
|
)
|
|
svcs = ", ".join(self.bound_services)
|
|
logging.info(f"Power Device '{self.name}' bound to services: {svcs}")
|
|
|
|
def init_state(self) -> Optional[Coroutine]:
|
|
return None
|
|
|
|
def initialize(self) -> bool:
|
|
self._setup_bound_services()
|
|
ret = self.init_state()
|
|
if ret is not None:
|
|
eventloop = self.server.get_event_loop()
|
|
self.init_task = eventloop.create_task(ret)
|
|
return self.state != "error"
|
|
|
|
async def process_request(self, req: str, force: bool = False) -> str:
|
|
if self.state == "init" and self.request_lock.locked():
|
|
# return immediately if the device is initializing,
|
|
# otherwise its possible for this to block indefinitely
|
|
# while the device holds the lock
|
|
return self.state
|
|
async with self.request_lock:
|
|
base_state: str = self.state
|
|
ret = self.refresh_status()
|
|
if ret is not None:
|
|
await ret
|
|
cur_state: str = self.state
|
|
if req == "toggle":
|
|
req = "on" if cur_state == "off" else "off"
|
|
if req in ["on", "off"]:
|
|
if req == cur_state:
|
|
# device is already in requested state, do nothing
|
|
if base_state != cur_state:
|
|
self.notify_power_changed()
|
|
return cur_state
|
|
if not force:
|
|
kconn: KlippyConnection
|
|
kconn = self.server.lookup_component("klippy_connection")
|
|
if self.locked_while_printing and kconn.is_printing():
|
|
raise self.server.error(
|
|
f"Unable to change power for {self.name} "
|
|
"while printing")
|
|
ret = self.set_power(req)
|
|
if ret is not None:
|
|
await ret
|
|
cur_state = self.state
|
|
await self.process_power_changed()
|
|
elif req != "status":
|
|
raise self.server.error(f"Unsupported power request: {req}")
|
|
elif base_state != cur_state:
|
|
self.notify_power_changed()
|
|
return cur_state
|
|
|
|
def refresh_status(self) -> Optional[Coroutine]:
|
|
raise NotImplementedError
|
|
|
|
def set_power(self, state: str) -> Optional[Coroutine]:
|
|
raise NotImplementedError
|
|
|
|
def close(self) -> Optional[Coroutine]:
|
|
if self.init_task is not None:
|
|
self.init_task.cancel()
|
|
self.init_task = None
|
|
return None
|
|
|
|
class HTTPDevice(PowerDevice):
|
|
def __init__(self,
|
|
config: ConfigHelper,
|
|
default_port: int = -1,
|
|
default_user: str = "",
|
|
default_password: str = "",
|
|
default_protocol: str = "http"
|
|
) -> None:
|
|
super().__init__(config)
|
|
self.client: HttpClient = self.server.lookup_component("http_client")
|
|
self.addr: str = config.get("address")
|
|
self.port = config.getint("port", default_port)
|
|
self.user = config.load_template("user", default_user).render()
|
|
self.password = config.load_template(
|
|
"password", default_password).render()
|
|
self.protocol = config.get("protocol", default_protocol)
|
|
|
|
async def init_state(self) -> None:
|
|
async with self.request_lock:
|
|
last_err: Exception = Exception()
|
|
while True:
|
|
try:
|
|
state = await self._send_status_request()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
if type(last_err) != type(e) or last_err.args != e.args:
|
|
logging.info(f"Device Init Error: {self.name}\n{e}")
|
|
last_err = e
|
|
await asyncio.sleep(5.)
|
|
continue
|
|
else:
|
|
self.init_task = None
|
|
self.state = state
|
|
if (
|
|
self.initial_state is not None and
|
|
state in ["on", "off"]
|
|
):
|
|
new_state = "on" if self.initial_state else "off"
|
|
if new_state != state:
|
|
logging.info(
|
|
f"Power Device {self.name}: setting initial "
|
|
f"state to {new_state}"
|
|
)
|
|
await self.set_power(new_state)
|
|
self.notify_power_changed()
|
|
return
|
|
|
|
async def _send_http_command(self,
|
|
url: str,
|
|
command: str,
|
|
retries: int = 3
|
|
) -> Dict[str, Any]:
|
|
url = self.client.escape_url(url)
|
|
response = await self.client.get(
|
|
url, request_timeout=20., attempts=retries,
|
|
retry_pause_time=1., enable_cache=False)
|
|
response.raise_for_status(
|
|
f"Error sending '{self.type}' command: {command}")
|
|
data = cast(dict, response.json())
|
|
return data
|
|
|
|
async def _send_power_request(self, state: str) -> str:
|
|
raise NotImplementedError(
|
|
"_send_power_request must be implemented by children")
|
|
|
|
async def _send_status_request(self) -> str:
|
|
raise NotImplementedError(
|
|
"_send_status_request must be implemented by children")
|
|
|
|
async def refresh_status(self) -> None:
|
|
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
|
|
|
|
async def set_power(self, state):
|
|
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
|
|
|
|
|
|
class GpioDevice(PowerDevice):
|
|
def __init__(self,
|
|
config: ConfigHelper,
|
|
initial_val: Optional[int] = None
|
|
) -> None:
|
|
super().__init__(config)
|
|
if self.initial_state is None:
|
|
self.initial_state = False
|
|
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
|
|
if initial_val is None:
|
|
initial_val = int(self.initial_state)
|
|
self.gpio_out = config.getgpioout('pin', initial_value=initial_val)
|
|
|
|
def init_state(self) -> None:
|
|
assert self.initial_state is not None
|
|
self.set_power("on" if self.initial_state else "off")
|
|
|
|
def refresh_status(self) -> None:
|
|
pass
|
|
|
|
def set_power(self, state) -> None:
|
|
if self.timer_handle is not None:
|
|
self.timer_handle.cancel()
|
|
self.timer_handle = None
|
|
try:
|
|
self.gpio_out.write(int(state == "on"))
|
|
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
|
|
self._check_timer()
|
|
|
|
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):
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config)
|
|
if self.off_when_shutdown:
|
|
raise config.error(
|
|
"Option 'off_when_shutdown' in section "
|
|
f"[{config.get_name()}] is unsupported for 'klipper_device'")
|
|
if self.klipper_restart:
|
|
raise config.error(
|
|
"Option 'restart_klipper_when_powered' in section "
|
|
f"[{config.get_name()}] is unsupported for 'klipper_device'")
|
|
for svc in self.bound_services:
|
|
if svc.startswith("klipper"):
|
|
# Klipper devices cannot be bound to an instance of klipper or
|
|
# klipper_mcu
|
|
raise config.error(
|
|
f"Option 'bound_services' must not contain service '{svc}'"
|
|
f" for 'klipper_device' [{config.get_name()}]")
|
|
self.is_shutdown: bool = False
|
|
self.update_fut: Optional[asyncio.Future] = None
|
|
self.timer: Optional[float] = config.getfloat(
|
|
'timer', None, above=0.000001)
|
|
self.timer_handle: Optional[asyncio.TimerHandle] = None
|
|
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":
|
|
raise config.error(
|
|
"Klipper object must be either 'output_pin' or 'gcode_macro' "
|
|
f"for option 'object_name' in section [{config.get_name()}]")
|
|
|
|
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)
|
|
|
|
def get_device_info(self) -> Dict[str, Any]:
|
|
dev_info = super().get_device_info()
|
|
dev_info['is_shutdown'] = self.is_shutdown
|
|
return dev_info
|
|
|
|
async def _handle_ready(self) -> None:
|
|
kapis: APIComp = self.server.lookup_component('klippy_apis')
|
|
sub: Dict[str, Optional[List[str]]] = {self.object_name: None}
|
|
data = await kapis.subscribe_objects(sub, None)
|
|
if not self._validate_data(data):
|
|
self.state == "error"
|
|
else:
|
|
assert data is not None
|
|
self._set_state_from_data(data)
|
|
if (
|
|
self.initial_state is not None and
|
|
self.state in ["on", "off"]
|
|
):
|
|
new_state = "on" if self.initial_state else "off"
|
|
if new_state != self.state:
|
|
logging.info(
|
|
f"Power Device {self.name}: setting initial "
|
|
f"state to {new_state}"
|
|
)
|
|
await self.set_power(new_state)
|
|
self.notify_power_changed()
|
|
|
|
async def _handle_disconnect(self) -> None:
|
|
self.is_shutdown = False
|
|
self._set_state("init")
|
|
self._reset_timer()
|
|
|
|
def process_klippy_shutdown(self) -> None:
|
|
self.is_shutdown = True
|
|
self._set_state("error")
|
|
self._reset_timer()
|
|
|
|
async def refresh_status(self) -> None:
|
|
if self.is_shutdown or self.state in ["on", "off", "init"]:
|
|
return
|
|
kapis: APIComp = self.server.lookup_component('klippy_apis')
|
|
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)
|
|
|
|
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")
|
|
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:
|
|
if self.object_name not in data:
|
|
return
|
|
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
|
|
last_state = self.state
|
|
self.state = state
|
|
if last_state not in [state, "init"] and not in_event:
|
|
self.notify_power_changed()
|
|
|
|
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 _reset_timer(self):
|
|
if self.timer_handle is not None:
|
|
self.timer_handle.cancel()
|
|
self.timer_handle = None
|
|
|
|
def close(self) -> None:
|
|
if self.timer_handle is not None:
|
|
self.timer_handle.cancel()
|
|
self.timer_handle = None
|
|
|
|
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):
|
|
super().__init__(config, initial_val=0)
|
|
self.on = config.get("on_code").zfill(24)
|
|
self.off = config.get("off_code").zfill(24)
|
|
|
|
def _transmit_digit(self, waveform) -> None:
|
|
self.gpio_out.write(1)
|
|
time.sleep(waveform[0]*RFDevice.PULSE_LEN)
|
|
self.gpio_out.write(0)
|
|
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
|
|
self._check_timer()
|
|
|
|
|
|
# This implementation based off the work tplink_smartplug
|
|
# script by Lubomir Stroetmann available at:
|
|
#
|
|
# https://github.com/softScheck/tplink-smartplug
|
|
#
|
|
# Copyright 2016 softScheck GmbH
|
|
class TPLinkSmartPlug(PowerDevice):
|
|
START_KEY = 0xAB
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config)
|
|
self.timer = config.get("timer", "")
|
|
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)
|
|
self.port = config.getint("port", 9999)
|
|
|
|
async def _send_tplink_command(self,
|
|
command: str
|
|
) -> Dict[str, Any]:
|
|
out_cmd: Dict[str, Any] = {}
|
|
if command in ["on", "off"]:
|
|
out_cmd = {
|
|
'system': {'set_relay_state': {'state': int(command == "on")}}
|
|
}
|
|
# TPLink device controls multiple devices
|
|
if self.output_id is not None:
|
|
sysinfo = await self._send_tplink_command("info")
|
|
dev_id = sysinfo["system"]["get_sysinfo"]["deviceId"]
|
|
out_cmd["context"] = {
|
|
'child_ids': [f"{dev_id}{self.output_id:02}"]
|
|
}
|
|
elif command == "info":
|
|
out_cmd = {'system': {'get_sysinfo': {}}}
|
|
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'}}
|
|
}
|
|
else:
|
|
raise self.server.error(f"Invalid tplink command: {command}")
|
|
reader, writer = await asyncio.open_connection(
|
|
self.addr, self.port, family=socket.AF_INET)
|
|
try:
|
|
writer.write(self._encrypt(out_cmd))
|
|
await writer.drain()
|
|
data = await reader.read(2048)
|
|
length: int = struct.unpack(">I", data[:4])[0]
|
|
data = data[4:]
|
|
retries = 5
|
|
remaining = length - len(data)
|
|
while remaining and retries:
|
|
data += await reader.read(remaining)
|
|
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:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
return json.loads(self._decrypt(data))
|
|
|
|
def _encrypt(self, outdata: Dict[str, Any]) -> bytes:
|
|
data = json.dumps(outdata)
|
|
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
|
|
|
|
def _decrypt(self, data: bytes) -> str:
|
|
key: int = self.START_KEY
|
|
res: str = ""
|
|
for c in data:
|
|
val = key ^ c
|
|
key = c
|
|
res += chr(val)
|
|
return res
|
|
|
|
async def _send_info_request(self) -> int:
|
|
res = await self._send_tplink_command("info")
|
|
if self.output_id is not None:
|
|
# TPLink device controls multiple devices
|
|
children: Dict[int, Any]
|
|
children = res['system']['get_sysinfo']['children']
|
|
return children[self.output_id]['state']
|
|
else:
|
|
return res['system']['get_sysinfo']['relay_state']
|
|
|
|
async def init_state(self) -> None:
|
|
async with self.request_lock:
|
|
last_err: Exception = Exception()
|
|
while True:
|
|
try:
|
|
state: int = await self._send_info_request()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
if type(last_err) != type(e) or last_err.args != e.args:
|
|
logging.info(f"Device Init Error: {self.name}\n{e}")
|
|
last_err = e
|
|
await asyncio.sleep(5.)
|
|
continue
|
|
else:
|
|
self.init_task = None
|
|
self.state = "on" if state else "off"
|
|
if (
|
|
self.initial_state is not None and
|
|
self.state in ["on", "off"]
|
|
):
|
|
new_state = "on" if self.initial_state else "off"
|
|
if new_state != self.state:
|
|
logging.info(
|
|
f"Power Device {self.name}: setting initial "
|
|
f"state to {new_state}"
|
|
)
|
|
await self.set_power(new_state)
|
|
self.notify_power_changed()
|
|
return
|
|
|
|
async def refresh_status(self) -> None:
|
|
try:
|
|
state: int = await self._send_info_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 = "on" if state else "off"
|
|
|
|
async def set_power(self, state) -> None:
|
|
err: int
|
|
try:
|
|
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']
|
|
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
|
|
|
|
|
|
class Tasmota(HTTPDevice):
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config, default_password="")
|
|
self.output_id = config.getint("output_id", 1)
|
|
self.timer = config.get("timer", "")
|
|
|
|
async def _send_tasmota_command(self,
|
|
command: str,
|
|
password: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
if command in ["on", "off"]:
|
|
out_cmd = f"Power{self.output_id} {command}"
|
|
if self.timer != "" and command == "off":
|
|
out_cmd = f"Backlog Delay {self.timer}0; {out_cmd}"
|
|
elif command == "info":
|
|
out_cmd = f"Power{self.output_id}"
|
|
else:
|
|
raise self.server.error(f"Invalid tasmota command: {command}")
|
|
|
|
url = f"http://{self.addr}/cm?user=admin&password=" \
|
|
f"{self.password}&cmnd={out_cmd}"
|
|
return await self._send_http_command(url, command)
|
|
|
|
async def _send_status_request(self) -> str:
|
|
res = await self._send_tasmota_command("info")
|
|
try:
|
|
state: str = res[f"POWER{self.output_id}"].lower()
|
|
except KeyError as e:
|
|
if self.output_id == 1:
|
|
state = res[f"POWER"].lower()
|
|
else:
|
|
raise KeyError(e)
|
|
return state
|
|
|
|
async def _send_power_request(self, state: str) -> str:
|
|
res = await self._send_tasmota_command(state)
|
|
if self.timer == "" or state != "off":
|
|
try:
|
|
state = res[f"POWER{self.output_id}"].lower()
|
|
except KeyError as e:
|
|
if self.output_id == 1:
|
|
state = res[f"POWER"].lower()
|
|
else:
|
|
raise KeyError(e)
|
|
return state
|
|
|
|
|
|
class Shelly(HTTPDevice):
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config, default_user="admin", default_password="")
|
|
self.output_id = config.getint("output_id", 0)
|
|
self.timer = config.get("timer", "")
|
|
|
|
async def _send_shelly_command(self, command: str) -> Dict[str, Any]:
|
|
if command == "on":
|
|
out_cmd = f"relay/{self.output_id}?turn={command}"
|
|
elif command == "off":
|
|
if self.timer != "":
|
|
out_cmd = f"relay/{self.output_id}?turn=on&timer={self.timer}"
|
|
else:
|
|
out_cmd = f"relay/{self.output_id}?turn={command}"
|
|
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}"
|
|
return await self._send_http_command(url, command)
|
|
|
|
async def _send_status_request(self) -> str:
|
|
res = await self._send_shelly_command("info")
|
|
state: str = res[f"ison"]
|
|
timer_remaining = res[f"timer_remaining"] if self.timer != "" else 0
|
|
return "on" if state and timer_remaining == 0 else "off"
|
|
|
|
async def _send_power_request(self, state: str) -> str:
|
|
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"
|
|
|
|
|
|
class SmartThings(HTTPDevice):
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config, default_port=443, default_protocol="https")
|
|
self.device: str = config.get("device", "")
|
|
self.token: str = config.gettemplate("token").render()
|
|
|
|
async def _send_smartthings_command(self,
|
|
command: str
|
|
) -> Dict[str, Any]:
|
|
body: Optional[List[Dict[str, Any]]] = None
|
|
if (command == "on" or command == "off"):
|
|
method = "POST"
|
|
url = (f"{self.protocol}://{self.addr}"
|
|
f"/v1/devices/{self.device}/commands")
|
|
body = [
|
|
{
|
|
"component": "main",
|
|
"capability": "switch",
|
|
"command": command
|
|
}
|
|
]
|
|
elif command == "info":
|
|
method = "GET"
|
|
url = (f"{self.protocol}://{self.addr}/v1/devices/{self.device}/"
|
|
"components/main/capabilities/switch/status")
|
|
else:
|
|
raise self.server.error(
|
|
f"Invalid SmartThings command: {command}")
|
|
|
|
headers = {
|
|
'Authorization': f'Bearer {self.token}'
|
|
}
|
|
url = self.client.escape_url(url)
|
|
response = await self.client.request(
|
|
method, url, body=body, headers=headers,
|
|
attempts=3, enable_cache=False
|
|
)
|
|
msg = f"Error sending SmartThings command: {command}"
|
|
response.raise_for_status(msg)
|
|
data = cast(dict, response.json())
|
|
return data
|
|
|
|
async def _send_status_request(self) -> str:
|
|
res = await self._send_smartthings_command("info")
|
|
return res["switch"]["value"].lower()
|
|
|
|
async def _send_power_request(self, state: str) -> str:
|
|
res = await self._send_smartthings_command(state)
|
|
acknowledgment = res["results"][0]["status"].lower()
|
|
return state if acknowledgment == "accepted" else "error"
|
|
|
|
|
|
class HomeSeer(HTTPDevice):
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config, default_user="admin", default_password="")
|
|
self.device = config.getint("device")
|
|
|
|
async def _send_homeseer(self,
|
|
request: str,
|
|
additional: str = ""
|
|
) -> Dict[str, Any]:
|
|
url = (f"http://{self.user}:{self.password}@{self.addr}"
|
|
f"/JSON?user={self.user}&pass={self.password}"
|
|
f"&request={request}&ref={self.device}&{additional}")
|
|
return await self._send_http_command(url, request)
|
|
|
|
async def _send_status_request(self) -> str:
|
|
res = await self._send_homeseer("getstatus")
|
|
return res[f"Devices"][0]["status"].lower()
|
|
|
|
async def _send_power_request(self, state: str) -> str:
|
|
if state == "on":
|
|
state_hs = "On"
|
|
elif state == "off":
|
|
state_hs = "Off"
|
|
res = await self._send_homeseer("controldevicebylabel",
|
|
f"label={state_hs}")
|
|
return state
|
|
|
|
|
|
class HomeAssistant(HTTPDevice):
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config, default_port=8123)
|
|
self.device: str = config.get("device")
|
|
self.token: str = config.gettemplate("token").render()
|
|
self.domain: str = config.get("domain", "switch")
|
|
self.status_delay: float = config.getfloat("status_delay", 1.)
|
|
|
|
async def _send_homeassistant_command(self,
|
|
command: str
|
|
) -> Dict[str, Any]:
|
|
body: Optional[Dict[str, Any]] = None
|
|
if command in ["on", "off"]:
|
|
out_cmd = f"api/services/{self.domain}/turn_{command}"
|
|
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}")
|
|
url = f"{self.protocol}://{self.addr}:{self.port}/{out_cmd}"
|
|
headers = {
|
|
'Authorization': f'Bearer {self.token}'
|
|
}
|
|
data: Dict[str, Any] = {}
|
|
url = self.client.escape_url(url)
|
|
response = await self.client.request(
|
|
method, url, body=body, headers=headers,
|
|
attempts=3, enable_cache=False
|
|
)
|
|
msg = f"Error sending homeassistant command: {command}"
|
|
response.raise_for_status(msg)
|
|
if method == "GET":
|
|
data = cast(dict, response.json())
|
|
return data
|
|
|
|
async def _send_status_request(self) -> str:
|
|
res = await self._send_homeassistant_command("info")
|
|
return res[f"state"]
|
|
|
|
async def _send_power_request(self, state: str) -> str:
|
|
await self._send_homeassistant_command(state)
|
|
await asyncio.sleep(self.status_delay)
|
|
res = await self._send_status_request()
|
|
return res
|
|
|
|
class Loxonev1(HTTPDevice):
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config, default_user="admin",
|
|
default_password="admin")
|
|
self.output_id = config.get("output_id", "")
|
|
|
|
async def _send_loxonev1_command(self, command: str) -> Dict[str, Any]:
|
|
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)
|
|
|
|
async def _send_status_request(self) -> str:
|
|
res = await self._send_loxonev1_command("info")
|
|
state = res[f"LL"][f"value"]
|
|
return "on" if int(state) == 1 else "off"
|
|
|
|
async def _send_power_request(self, state: str) -> str:
|
|
res = await self._send_loxonev1_command(state)
|
|
state = res[f"LL"][f"value"]
|
|
return "on" if int(state) == 1 else "off"
|
|
|
|
|
|
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.)
|
|
self.state_response = config.load_template('state_response_template',
|
|
"{payload}")
|
|
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.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_lock.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_lock:
|
|
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")
|
|
if (
|
|
self.initial_state is not None and
|
|
self.state in ["on", "off"]
|
|
):
|
|
new_state = "on" if self.initial_state else "off"
|
|
if new_state != self.state:
|
|
logging.info(
|
|
f"Power Device {self.name}: setting initial "
|
|
f"state to {new_state}"
|
|
)
|
|
await self.set_power(new_state)
|
|
# Don't reset on next connection
|
|
self.initial_state = None
|
|
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_lock:
|
|
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)
|
|
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)
|
|
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)
|
|
|
|
|
|
class HueDevice(HTTPDevice):
|
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
super().__init__(config)
|
|
self.device_id = config.get("device_id")
|
|
|
|
async def _send_power_request(self, state: str) -> str:
|
|
new_state = True if state == "on" else False
|
|
url = (
|
|
f"http://{self.addr}/api/{self.user}/lights/{self.device_id}/state"
|
|
)
|
|
url = self.client.escape_url(url)
|
|
ret = await self.client.request("PUT", url, body={"on": new_state})
|
|
resp = cast(List[Dict[str, Dict[str, Any]]], ret.json())
|
|
return (
|
|
"on" if resp[0]["success"][f"/lights/{self.device_id}/state/on"]
|
|
else "off"
|
|
)
|
|
|
|
async def _send_status_request(self) -> str:
|
|
url = (
|
|
f"http://{self.addr}/api/{self.user}/lights/{self.device_id}"
|
|
)
|
|
url = self.client.escape_url(url)
|
|
ret = await self.client.request("GET", url)
|
|
resp = cast(Dict[str, Dict[str, Any]], ret.json())
|
|
return "on" if resp["state"]["on"] else "off"
|
|
|
|
|
|
# The power component has multiple configuration sections
|
|
def load_component(config: ConfigHelper) -> PrinterPower:
|
|
return PrinterPower(config)
|