LedFx/ledfx/devices/__init__.py

390 lines
13 KiB
Python

from ledfx.utils import BaseRegistry, RegistryLoader, generate_id
from ledfx.config import save_config
from ledfx.events import DeviceUpdateEvent, Event
from abc import abstractmethod
import voluptuous as vol
import numpy as np
import requests
import zeroconf
import logging
import asyncio
import socket
_LOGGER = logging.getLogger(__name__)
@BaseRegistry.no_registration
class Device(BaseRegistry):
CONFIG_SCHEMA = vol.Schema(
{
vol.Required(
"name", description="Friendly name for the device"
): str,
vol.Optional(
"max_brightness",
description="Max brightness for the device",
default=1.0,
): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional(
"center_offset",
description="Number of pixels from the perceived center of the device",
default=0,
): int,
vol.Optional(
"refresh_rate",
description="Rate that pixels are sent to the device",
default=60,
): int,
vol.Optional(
"force_refresh",
description="Force the device to always refresh",
default=False,
): bool,
vol.Optional(
"preview_only",
description="Preview the pixels without updating the device",
default=False,
): bool,
}
)
_active = False
_output_thread = None
_active_effect = None
_fadeout_effect = None
def __init__(self, ledfx, config):
self._ledfx = ledfx
self._config = config
# the multiplier to fade in/out of an effect. -ve values mean fading
# in, +ve mean fading out
self.fade_timer = 0
def __del__(self):
if self._active:
self.deactivate()
@property
def pixel_count(self):
pass
def set_effect(self, effect, start_pixel=None, end_pixel=None):
self.fade_duration = (
self._config["refresh_rate"] * self._ledfx.config["fade"]
)
self.fade_timer = self.fade_duration
if self._active_effect is not None:
self._fadeout_effect = self._active_effect
self._ledfx.loop.call_later(
self._ledfx.config["fade"], self.clear_fadeout_effect
)
self._active_effect = effect
self._active_effect.activate(self.pixel_count)
# What does this do? Other than break stuff.
# self._active_effect.setDirtyCallback(self.process_active_effect)
if not self._active:
self.activate()
def clear_effect(self):
self.fade_duration = (
self._config["refresh_rate"] * self._ledfx.config["fade"]
)
self.fade_timer = -self.fade_duration
self._ledfx.loop.call_later(
self._ledfx.config["fade"], self.clear_frame
)
def clear_fadeout_effect(self):
if self._fadeout_effect is not None:
self._fadeout_effect.deactivate()
self._fadeout_effect = None
def clear_frame(self):
if self._active_effect is not None:
self._active_effect.deactivate()
self._active_effect = None
if self._active:
# Clear all the pixel data before deactivating the device
self.assembled_frame = np.zeros((self.pixel_count, 3))
self.flush(self.assembled_frame)
self._ledfx.events.fire_event(
DeviceUpdateEvent(self.id, self.assembled_frame)
)
self.deactivate()
@property
def active_effect(self):
return self._active_effect
def process_active_effect(self):
# Assemble the frame if necessary, if nothing changed just sleep
self.assembled_frame = self.assemble_frame()
if self.assembled_frame is not None:
if not self._config["preview_only"]:
self.flush(self.assembled_frame)
def trigger_device_update_event():
self._ledfx.events.fire_event(
DeviceUpdateEvent(self.id, self.assembled_frame)
)
self._ledfx.loop.call_soon_threadsafe(trigger_device_update_event)
def thread_function(self):
# TODO: Evaluate switching over to asyncio with UV loop optimization
# instead of spinning a separate thread.
sleep_interval = 1 / self._config["refresh_rate"]
if self._active:
self._ledfx.loop.call_later(sleep_interval, self.thread_function)
self.process_active_effect()
# while self._active:
# start_time = time.time()
# self.process_active_effect()
# # Calculate the time to sleep accounting for potential heavy
# # frame assembly operations
# time_to_sleep = sleep_interval - (time.time() - start_time)
# if time_to_sleep > 0:
# time.sleep(time_to_sleep)
# _LOGGER.info("Output device thread terminated.")
def assemble_frame(self):
"""
Assembles the frame to be flushed. Currently this will just return
the active channels pixels, but will eventually handle things like
merging multiple segments segments and alpha blending channels
"""
frame = None
if self._active_effect._dirty:
# Get and process active effect frame
pixels = self._active_effect.get_pixels()
frame = np.clip(
pixels * self._config["max_brightness"],
0,
255,
)
if self._config["center_offset"]:
frame = np.roll(frame, self._config["center_offset"], axis=0)
self._active_effect._dirty = self._config["force_refresh"]
# Handle fading effect in/out if just turned on or off
if self.fade_timer == 0:
pass
elif self.fade_timer > 0:
# if +ve fade timer, fade in the effect
frame *= 1 - (self.fade_timer / self.fade_duration)
self.fade_timer -= 1
elif self.fade_timer < 0:
# if -ve fade timer, fade out the effect
frame *= -self.fade_timer / self.fade_duration
self.fade_timer += 1
# This part handles blending two effects together
fadeout_frame = None
if self._fadeout_effect:
if self._fadeout_effect._dirty:
# Get and process fadeout effect frame
fadeout_frame = np.clip(
self._fadeout_effect.pixels
* self._config["max_brightness"],
0,
255,
)
if self._config["center_offset"]:
fadeout_frame = np.roll(
fadeout_frame,
self._config["center_offset"],
axis=0,
)
self._fadeout_effect._dirty = self._config["force_refresh"]
# handle fading out the fadeout frame
if self.fade_timer:
fadeout_frame *= self.fade_timer / self.fade_duration
# Blend both frames together
if (fadeout_frame is not None) and (frame is not None):
frame += fadeout_frame
return frame
def activate(self):
self._active = True
# self._device_thread = Thread(target = self.thread_function)
# self._device_thread.start()
self._device_thread = None
self.thread_function()
def deactivate(self):
self._active = False
if self._device_thread:
self._device_thread.join()
self._device_thread = None
@abstractmethod
def flush(self, data):
"""
Flushes the provided data to the device. This abstract method must be
overwritten by the device implementation.
"""
@property
def name(self):
return self._config["name"]
@property
def max_brightness(self):
return self._config["max_brightness"] * 256
@property
def refresh_rate(self):
return self._config["refresh_rate"]
class Devices(RegistryLoader):
"""Thin wrapper around the device registry that manages devices"""
PACKAGE_NAME = "ledfx.devices"
def __init__(self, ledfx):
super().__init__(ledfx, Device, self.PACKAGE_NAME)
def cleanup_effects(e):
self.clear_all_effects()
self._ledfx.events.add_listener(cleanup_effects, Event.LEDFX_SHUTDOWN)
def create_from_config(self, config):
for device in config:
_LOGGER.info("Loading device from config: {}".format(device))
self._ledfx.devices.create(
id=device["id"],
type=device["type"],
config=device["config"],
ledfx=self._ledfx,
)
if "effect" in device:
try:
effect = self._ledfx.effects.create(
ledfx=self._ledfx,
type=device["effect"]["type"],
config=device["effect"]["config"],
)
self._ledfx.devices.get_device(device["id"]).set_effect(
effect
)
except vol.MultipleInvalid:
_LOGGER.warning(
"Effect schema changed. Not restoring effect"
)
def clear_all_effects(self):
for device in self.values():
device.clear_frame()
def get_device(self, device_id):
for device in self.values():
if device_id == device.id:
return device
return None
async def find_wled_devices(self):
# Scan the LAN network that match WLED using zeroconf - Multicast DNS
# Service Discovery Library
_LOGGER.info("Scanning for WLED devices...")
zeroconf_obj = zeroconf.Zeroconf()
listener = MyListener(self._ledfx)
browser = zeroconf.ServiceBrowser(
zeroconf_obj, "_wled._tcp.local.", listener
)
try:
await asyncio.sleep(10)
finally:
_LOGGER.info("Scan Finished")
zeroconf_obj.close()
class MyListener:
def __init__(self, _ledfx):
self._ledfx = _ledfx
def remove_service(self, zeroconf_obj, type, name):
_LOGGER.info(f"Service {name} removed")
def add_service(self, zeroconf_obj, type, name):
info = zeroconf_obj.get_service_info(type, name)
if info:
address = socket.inet_ntoa(info.addresses[0])
url = f"http://{address}/json/info"
# For each WLED device found, based on the WLED IPv4 address, do a
# GET requests
response = requests.get(url)
b = response.json()
# For each WLED json response, format from WLED payload to LedFx payload.
# Note, set universe_size to 510 if LED 170 or less, If you have
# more than 170 LED, set universe_size to 510
wledled = b["leds"]
wledname = b["name"]
wledcount = wledled["count"]
# We need to use a universe size of 510 if there are more than 170
# pixels to prevent spanning pixel data across sequential universes
if wledcount > 170:
unisize = 510
else:
unisize = 512
device_id = generate_id(wledname)
device_type = "e131"
device_config = {
"max_brightness": 1,
"refresh_rate": 60,
"universe": 1,
"universe_size": unisize,
"name": wledname,
"pixel_count": wledcount,
"ip_address": address,
}
# Check this device doesn't share IP with any other device
for device in self._ledfx.devices.values():
if device.config["ip_address"] == address:
return
# Create the device
_LOGGER.info(
"Adding device of type {} with config {}".format(
device_type, device_config
)
)
device = self._ledfx.devices.create(
id=device_id,
type=device_type,
config=device_config,
ledfx=self._ledfx,
)
# Update and save the configuration
self._ledfx.config["devices"].append(
{
"id": device.id,
"type": device.type,
"config": device.config,
}
)
save_config(
config=self._ledfx.config,
config_dir=self._ledfx.config_dir,
)