diff --git a/editor-blender/.env.development b/editor-blender/.env.development index 4b0d93d27..e39cd90a2 100644 --- a/editor-blender/.env.development +++ b/editor-blender/.env.development @@ -3,3 +3,4 @@ HTTP_PATH="/api" GRAPHQL_PATH="/graphql" GRAPHQL_WS_PATH="/graphql-websocket" FILE_SERVER_URL="http://localhost:8081" +CONTROLLER_WS_URL="ws://localhost:8082" diff --git a/editor-blender/api/command_agent.py b/editor-blender/api/command_agent.py new file mode 100644 index 000000000..2aa8124d9 --- /dev/null +++ b/editor-blender/api/command_agent.py @@ -0,0 +1,21 @@ +import json +from dataclasses import dataclass + +from ..client import client +from ..graphqls.command import ToControllerServerPartial + + +@dataclass +class CommandAgent: + async def send_to_controller_server(self, msg_partial: ToControllerServerPartial): + msg_full = json.dumps( + { + **(msg_partial.to_dict()), + "from": "controlPanel", + "statusCode": 0, + } + ) + await client.send_command(msg_full) + + +command_agent = CommandAgent() diff --git a/editor-blender/client/__init__.py b/editor-blender/client/__init__.py index 0cf0e153c..2e8eb62fb 100644 --- a/editor-blender/client/__init__.py +++ b/editor-blender/client/__init__.py @@ -1,4 +1,5 @@ import asyncio +import json from asyncio import Task from inspect import isclass from typing import Any, AsyncGenerator, Dict, Optional, Type, TypeVar, Union @@ -10,9 +11,15 @@ from gql.transport.aiohttp import AIOHTTPTransport from gql.transport.websockets import WebsocketsTransport from graphql import DocumentNode +from websockets.client import WebSocketClientProtocol, connect from ..core.config import config from ..core.states import state +from ..graphqls.command import ( + FromControllerServer, + FromControllerServerBoardInfo, + FromControllerServerCommandResponse, +) from .cache import InMemoryCache, query_defs_to_field_table GQLSession = Union[AsyncClientSession, ReconnectingAsyncClientSession] @@ -79,6 +86,7 @@ def __init__(self): self.client: Optional[GQLSession] = None self.sub_client: Optional[GQLSession] = None self.file_client: Optional[ClientSession] = None + self.command_client: Optional[WebSocketClientProtocol] = None self.cache = InMemoryCache() @@ -220,6 +228,32 @@ async def execute( return response + async def subscribe_command(self) -> AsyncGenerator[FromControllerServer, None]: + if self.command_client is None: + raise Exception("Command client is not initialized") + async for data in self.command_client: + if isinstance(data, str): + data_dict = json.loads(data) + match data_dict["topic"]: + case "boardInfo": + sub_data: FromControllerServer = deserialize( + FromControllerServerBoardInfo, data_dict + ) + case "command": + sub_data: FromControllerServer = deserialize( + FromControllerServerCommandResponse, data_dict + ) + case _: + raise Exception("Invalid command data recieved") + yield sub_data + else: + raise Exception("Invalid command data recieved") + + async def send_command(self, data: str): + if self.command_client is None: + raise Exception("Command client is not initialized") + await self.command_client.send(data) + async def open_http(self) -> None: await self.close_http() @@ -290,5 +324,22 @@ async def restart_graphql(self) -> None: await self.close_graphql() await self.open_graphql() + async def open_command(self): + self.command_client = await connect( + uri=config.CONTROLLER_WS_URL, + extra_headers=[("token", state.token)], + ) + print("Command client opened") + + async def close_command(self): + if self.command_client is not None: + await self.command_client.close() + else: + return + + async def restart_command(self): + await self.close_command() + await self.open_command() + client = Clients() diff --git a/editor-blender/client/subscription.py b/editor-blender/client/subscription.py index 739a309c1..122afb78c 100644 --- a/editor-blender/client/subscription.py +++ b/editor-blender/client/subscription.py @@ -3,7 +3,10 @@ from ..client import Clients, client from ..client.cache import Modifiers +from ..core.actions.property.command import set_command_status +from ..core.actions.state.app_state import set_requesting from ..core.actions.state.color_map import add_color, delete_color, update_color +from ..core.actions.state.command import read_board_info_payload, read_command_response from ..core.actions.state.control_map import ( add_control, delete_control, @@ -26,6 +29,7 @@ pos_frame_query_to_state, pos_frame_sub_to_query, ) +from ..core.utils.notification import notify from ..graphqls.queries import ( QueryColorMapData, QueryColorMapPayloadItem, @@ -375,3 +379,39 @@ async def subscribe(): print("Reconnecting subscription...") await asyncio.sleep(3) + + +async def sub_controller_server(client: Clients): + async for controller_data in client.subscribe_command(): + match controller_data.topic: + case "boardInfo": + notify("INFO", "Board info updated") + print("Board info from controller server") + read_board_info_payload(controller_data.payload) + case "command": + notify("INFO", "Command response received") + print(f"Command response from controller server: {controller_data}") + read_command_response(controller_data) + + +async def subscribe_command(): + while True: + try: + print("Subscribing controller server...") + + tasks = [ + asyncio.create_task(sub_controller_server(client)), + ] + + await asyncio.gather(*tasks) + + except asyncio.CancelledError: + print("Subscription cancelled.") + break + + except Exception as e: + print("Subscription closed with error:", e) + + set_command_status(False) + print("Reconnecting subscription...") + await asyncio.sleep(3) diff --git a/editor-blender/core/actions/property/command.py b/editor-blender/core/actions/property/command.py new file mode 100644 index 000000000..c0e59a0ad --- /dev/null +++ b/editor-blender/core/actions/property/command.py @@ -0,0 +1,92 @@ +import asyncio +from typing import Any, List, Optional + +import bpy + +from ....properties.ui.types import CommandCenterRPiStatusType, CommandCenterStatusType +from ...asyncio import AsyncTask +from ...states import state + + +class Countdown_task_class: + task: Optional[asyncio.Task[Any]] = None + + +countdown_task = Countdown_task_class() + + +def set_command_status(connected: bool): + command_status: CommandCenterStatusType = getattr( + bpy.context.window_manager, "ld_ui_command_center" + ) + command_status.connected = connected + + +def set_RPi_props_from_state(): + rpi_props: List[CommandCenterRPiStatusType] = getattr( + bpy.context.window_manager, "ld_ui_rpi_status" + ) + + rpi_status = state.rpi_status + if not rpi_props: + for dancer_name, dancer_item in rpi_status.items(): + rpi_item: CommandCenterRPiStatusType = getattr( + bpy.context.window_manager, "ld_ui_rpi_status" + ).add() + rpi_item.name = dancer_name + if dancer_item.ethernet.connected == dancer_item.wifi.connected: + rpi_item.interface_type = "ethernet" + interface_status = dancer_item.ethernet + elif dancer_item.ethernet.connected: + rpi_item.interface_type = "ethernet" + interface_status = dancer_item.ethernet + else: + rpi_item.interface_type = "wifi" + interface_status = dancer_item.wifi + + rpi_item.IP = interface_status.IP + rpi_item.MAC = interface_status.MAC + rpi_item.connected = interface_status.connected + rpi_item.message = interface_status.message + rpi_item.statusCode = interface_status.statusCode + rpi_item.selected = False + else: + for dancer_name, dancer_item in rpi_status.items(): + rpi_item: CommandCenterRPiStatusType = next( + item for item in rpi_props if item.name == dancer_name + ) + if dancer_item.ethernet.connected == dancer_item.wifi.connected: + interface_status = dancer_item.ethernet + elif dancer_item.ethernet.connected: + interface_status = dancer_item.ethernet + else: + interface_status = dancer_item.wifi + rpi_item.connected = interface_status.connected + rpi_item.message = interface_status.message + rpi_item.statusCode = interface_status.statusCode + + +def get_selected_dancer() -> List[str]: + rpi_status_list: List[CommandCenterRPiStatusType] = getattr( + bpy.context.window_manager, "ld_ui_rpi_status" + ) + return [item.name for item in rpi_status_list if item.selected] + + +def set_countdown(delay: int): + async def countdown(delay: int): + command_status: CommandCenterStatusType = getattr( + bpy.context.window_manager, "ld_ui_command_center" + ) + for t in range(delay + 1): + seconds = delay - t + m, s = divmod(seconds, 60) + countdown = f"{m:02d}:{s:02d}" + command_status.countdown = countdown + print(countdown) + if seconds > 0: + await asyncio.sleep(1) + else: + bpy.ops.screen.animation_play() + + countdown_task.task = AsyncTask(countdown, delay=delay).exec() diff --git a/editor-blender/core/actions/state/auth.py b/editor-blender/core/actions/state/auth.py index 6f62f10be..51900a9c6 100644 --- a/editor-blender/core/actions/state/auth.py +++ b/editor-blender/core/actions/state/auth.py @@ -59,8 +59,13 @@ async def logout() -> None: state.init_editor_task.cancel() state.init_editor_task = None + if state.command_task is not None: + state.command_task.cancel() + state.command_task = None + await client.close_graphql() await client.restart_http() + await client.close_command() unmount_handlers() diff --git a/editor-blender/core/actions/state/command.py b/editor-blender/core/actions/state/command.py new file mode 100644 index 000000000..b503d064a --- /dev/null +++ b/editor-blender/core/actions/state/command.py @@ -0,0 +1,80 @@ +from ....graphqls.command import ( + FromControllerServerBoardInfoPayload, + FromControllerServerCommandResponse, +) +from ...models import InterfaceStatus, RPiStatusItem, ShellTransaction +from ...states import state +from ..property.command import set_RPi_props_from_state + + +def read_board_info_payload(payload: FromControllerServerBoardInfoPayload): + for item in payload.values(): + rpi_status = state.rpi_status + if item.dancer not in rpi_status: + rpi_status[item.dancer] = RPiStatusItem( + ethernet=InterfaceStatus( + name=item.dancer, + IP=item.IP, + MAC=item.MAC, + connected=False, + statusCode=0, + message="", + ), + wifi=InterfaceStatus( + name=item.dancer, + IP=item.IP, + MAC=item.MAC, + connected=False, + statusCode=0, + message="", + ), + ) + match item.interface: + case "ethernet": + rpi_status[item.dancer].ethernet = InterfaceStatus( + name=item.dancer, + IP=item.IP, + MAC=item.MAC, + connected=item.connected, + statusCode=0, + message="", + ) + case "wifi": + rpi_status[item.dancer].wifi = InterfaceStatus( + name=item.dancer, + IP=item.IP, + MAC=item.MAC, + connected=item.connected, + statusCode=0, + message="", + ) + set_RPi_props_from_state() + + +def read_command_response(data: FromControllerServerCommandResponse): + payload = data.payload + dancer = payload.dancer + command = payload.command + message = payload.message + rpi_status = state.rpi_status + if dancer not in state.rpi_status: + rpi_status[dancer] = RPiStatusItem( + ethernet=InterfaceStatus( + name=dancer, IP="", MAC="", connected=False, statusCode=0, message="" + ), + wifi=InterfaceStatus( + name=dancer, IP="", MAC="", connected=False, statusCode=0, message="" + ), + ) + rpi_status_item = rpi_status[dancer] + if rpi_status_item.ethernet.connected: + rpi_status_item.ethernet.message = f"[{command}] {message}" + rpi_status_item.ethernet.statusCode = data.statusCode + if rpi_status_item.wifi.connected: + rpi_status_item.wifi.message = f"[{command}] {message}" + rpi_status_item.wifi.statusCode = data.statusCode + shell_history = state.shell_history + if dancer not in shell_history: + shell_history[dancer] = [] + shell_history[dancer].append(ShellTransaction(command=command, output=message)) + set_RPi_props_from_state() diff --git a/editor-blender/core/actions/state/initialize.py b/editor-blender/core/actions/state/initialize.py index b3854eea2..7ed2dea82 100644 --- a/editor-blender/core/actions/state/initialize.py +++ b/editor-blender/core/actions/state/initialize.py @@ -12,7 +12,7 @@ from ....client import client # from ....client.cache import FieldPolicy, InMemoryCache, TypePolicy -from ....client.subscription import subscribe +from ....client.subscription import subscribe, subscribe_command from ....core.actions.state.app_state import ( set_logged_in, set_ready, diff --git a/editor-blender/core/config/__init__.py b/editor-blender/core/config/__init__.py index 1e797e1bf..61e9ab46c 100644 --- a/editor-blender/core/config/__init__.py +++ b/editor-blender/core/config/__init__.py @@ -50,6 +50,11 @@ def initialize(self): raise Exception("FILE_SERVER_URL is not defined") self.FILE_SERVER_URL = remove_wrapped_slash(FILE_SERVER_URL) + CONTROLLER_WS_URL = os.getenv("CONTROLLER_WS_URL") + if CONTROLLER_WS_URL is None: + raise Exception("CONTROLLER_WS_URL is not defined") + self.CONTROLLER_WS_URL = remove_wrapped_slash(CONTROLLER_WS_URL) + """ Assets """ diff --git a/editor-blender/core/models/__init__.py b/editor-blender/core/models/__init__.py index 8881bd85c..1732bd63d 100644 --- a/editor-blender/core/models/__init__.py +++ b/editor-blender/core/models/__init__.py @@ -287,6 +287,34 @@ class LEDMapPending: update: bool +@dataclass +class InterfaceStatus: + name: str + IP: str + MAC: str + connected: bool + message: str + statusCode: int + + +@dataclass +class RPiStatusItem: + ethernet: InterfaceStatus + wifi: InterfaceStatus + + +RPiStatus = Dict[str, RPiStatusItem] + + +@dataclass +class ShellTransaction: + command: str + output: str + + +ShellHistory = Dict[str, List[ShellTransaction]] + + @dataclass class State: running: bool @@ -298,6 +326,7 @@ class State: subscription_task: Optional[Task[None]] init_editor_task: Optional[Task[None]] + command_task: Optional[Task[None]] assets_path: str @@ -371,9 +400,8 @@ class State: color_map: ColorMap # effect_list: EffectListType - # TODO: Add these - # rpi_status: RPiStatus - # shell_history: ShellHistory + rpi_status: RPiStatus + shell_history: ShellHistory color_map_updates: ColorMapUpdates color_map_pending: ColorMapPending diff --git a/editor-blender/core/states/__init__.py b/editor-blender/core/states/__init__.py index 852c2f5d2..bafd420e1 100644 --- a/editor-blender/core/states/__init__.py +++ b/editor-blender/core/states/__init__.py @@ -22,6 +22,7 @@ requesting=False, subscription_task=None, init_editor_task=None, + command_task=None, assets_path="", token="", username="", @@ -74,8 +75,8 @@ led_part_length_map={}, color_map={}, # effect_list - # rpi_status - # shell_history + rpi_status={}, + shell_history={}, color_map_updates=ColorMapUpdates(added=[], updated=[], deleted=[]), color_map_pending=ColorMapPending(add_or_delete=False, update=False), led_map_updates=LEDMapUpdates(added=[], updated=[], deleted=[]), diff --git a/editor-blender/core/utils/convert.py b/editor-blender/core/utils/convert.py index a160194b8..6bfed9236 100644 --- a/editor-blender/core/utils/convert.py +++ b/editor-blender/core/utils/convert.py @@ -322,6 +322,17 @@ def rgba_to_float(rgb: Union[Tuple[int, ...], List[int]], a: int) -> Tuple[float ) +def is_color_code(color_code: str) -> bool: + if len(color_code) != 7: + return False + if color_code[0] != "#": + return False + for char in color_code[1:8]: + if char not in "1234567890abcdef": + return False + return True + + def frame_to_time(frame: int) -> str: milliseconds = frame seconds = milliseconds // 1000 diff --git a/editor-blender/graphqls/command.py b/editor-blender/graphqls/command.py new file mode 100644 index 000000000..4ca5d8dcc --- /dev/null +++ b/editor-blender/graphqls/command.py @@ -0,0 +1,283 @@ +from dataclasses import dataclass +from typing import Dict, List, Literal, Union + +from dataclass_wizard import JSONWizard + +""" +To controller server +""" + + +@dataclass +class ToControllerServerBasePayload(JSONWizard): + dancers: List[str] + + +@dataclass +class ToControllerServerPlayPayload(JSONWizard): + dancers: List[str] + start: int + timestamp: int + + +@dataclass +class ToControllerServerColorPayload(JSONWizard): + dancers: List[str] + colorCode: str + + +@dataclass +class ToControllerServerWebShellPayload(JSONWizard): + dancers: List[str] + command: str + + +@dataclass +class ToControllerServerBase(JSONWizard): + class _(JSONWizard.Meta): + json_key_to_field = {"__all__": "True", "from": "from_"} + + from_: Literal["controlPanel"] + statusCode: int + + +@dataclass +class ToControllerServerBoardInfoPartial(JSONWizard): + topic: Literal["boardInfo"] + + +@dataclass +class ToControllerServerBoardInfo( + ToControllerServerBase, ToControllerServerBoardInfoPartial +): + pass + + +@dataclass +class ToControllerServerSyncPartial(JSONWizard): + topic: Literal["sync"] + payload: ToControllerServerBasePayload + + +@dataclass +class ToControllerServerSync(ToControllerServerBase, ToControllerServerSyncPartial): + pass + + +@dataclass +class ToControllerServerPlayPartial(JSONWizard): + topic: Literal["play"] + payload: ToControllerServerPlayPayload + + +@dataclass +class ToControllerServerPlay(ToControllerServerBase, ToControllerServerPlayPartial): + pass + + +@dataclass +class ToControllerServerPausePartial(JSONWizard): + topic: Literal["pause"] + payload: ToControllerServerBasePayload + + +@dataclass +class ToControllerServerPause(ToControllerServerBase, ToControllerServerPausePartial): + pass + + +@dataclass +class ToControllerServerStopPartial(JSONWizard): + topic: Literal["stop"] + payload: ToControllerServerBasePayload + + +@dataclass +class ToControllerServerStop(ToControllerServerBase, ToControllerServerStopPartial): + pass + + +@dataclass +class ToControllerServerLoadPartial(JSONWizard): + topic: Literal["load"] + payload: ToControllerServerBasePayload + + +@dataclass +class ToControllerServerLoad(ToControllerServerBase, ToControllerServerLoadPartial): + pass + + +@dataclass +class ToControllerServerUploadPartial(JSONWizard): + topic: Literal["upload"] + payload: ToControllerServerBasePayload + + +@dataclass +class ToControllerServerUpload(ToControllerServerBase, ToControllerServerUploadPartial): + pass + + +@dataclass +class ToControllerServerRebootPartial(JSONWizard): + topic: Literal["reboot"] + payload: ToControllerServerBasePayload + + +@dataclass +class ToControllerServerReboot(ToControllerServerBase, ToControllerServerRebootPartial): + pass + + +@dataclass +class ToControllerServerTestPartial(JSONWizard): + topic: Literal["test"] + payload: ToControllerServerColorPayload + + +@dataclass +class ToControllerServerTest(ToControllerServerBase, ToControllerServerTestPartial): + pass + + +@dataclass +class ToControllerServerColorPartial(JSONWizard): + topic: Literal["red", "green", "blue", "yellow", "magenta", "cyan"] + payload: ToControllerServerBasePayload + + +@dataclass +class ToControllerServerColor(ToControllerServerBase, ToControllerServerColorPartial): + pass + + +@dataclass +class ToControllerServerDarkAllPartial(JSONWizard): + topic: Literal["darkAll"] + + +@dataclass +class ToControllerServerDarkAll( + ToControllerServerBase, ToControllerServerDarkAllPartial +): + pass + + +@dataclass +class ToControllerServerCloseGPIOPartial(JSONWizard): + topic: Literal["close"] + payload: ToControllerServerBasePayload + + +@dataclass +class ToControllerServerCloseGPIO( + ToControllerServerBase, ToControllerServerCloseGPIOPartial +): + pass + + +@dataclass +class ToControllerServerWebShellPartial(JSONWizard): + topic: Literal["webShell"] + payload: ToControllerServerWebShellPayload + + +@dataclass +class ToControllerServerWebShell( + ToControllerServerBase, ToControllerServerWebShellPartial +): + pass + + +""" +From controller server +""" + + +@dataclass +class DancerDataItem(JSONWizard): + class _(JSONWizard.Meta): + json_key_to_field = {"__all__": "True", "IP": "IP", "MAC": "MAC"} + + IP: str + MAC: str + dancer: str + hostname: str + connected: bool + interface: Literal["wifi", "ethernet"] + + +@dataclass +class FromControllerServerBase(JSONWizard): + from_: Literal["server"] + topic: str + statusCode: int + + +FromControllerServerBoardInfoPayload = Dict[str, DancerDataItem] + + +@dataclass +class FromControllerServerBoardInfo(FromControllerServerBase): + class _(JSONWizard.Meta): + json_key_to_field = {"__all__": "True", "from": "from_"} + + topic: Literal["boardInfo"] + payload: FromControllerServerBoardInfoPayload + + +@dataclass +class FromControllerServerCommandResponsePayload(JSONWizard): + command: str + message: str + dancer: str + + +@dataclass +class FromControllerServerCommandResponse(FromControllerServerBase): + class _(JSONWizard.Meta): + json_key_to_field = {"__all__": "True", "from": "from_"} + + topic: Literal["command"] + payload: FromControllerServerCommandResponsePayload + + +ToControllerServer = Union[ + ToControllerServerBoardInfo, + ToControllerServerCloseGPIO, + ToControllerServerColor, + ToControllerServerDarkAll, + ToControllerServerLoad, + ToControllerServerPause, + ToControllerServerPlay, + ToControllerServerReboot, + ToControllerServerStop, + ToControllerServerSync, + ToControllerServerTest, + ToControllerServerUpload, + ToControllerServerWebShell, +] + +ToControllerServerPartial = Union[ + ToControllerServerBoardInfoPartial, + ToControllerServerCloseGPIOPartial, + ToControllerServerColorPartial, + ToControllerServerDarkAllPartial, + ToControllerServerLoadPartial, + ToControllerServerPausePartial, + ToControllerServerPlayPartial, + ToControllerServerRebootPartial, + ToControllerServerStopPartial, + ToControllerServerSyncPartial, + ToControllerServerTestPartial, + ToControllerServerUploadPartial, + ToControllerServerWebShellPartial, +] + +FromControllerServer = Union[ + FromControllerServerBoardInfo, FromControllerServerCommandResponse +] + +# a = FromControllerServerBoardInfo.from_json("""{"from": "server", "topic": "boardInfo", "statusCode": 0, "payload": {}}""") +# print(a.to_dict()) diff --git a/editor-blender/operators/__init__.py b/editor-blender/operators/__init__.py index 9f773d630..a7ea93b19 100644 --- a/editor-blender/operators/__init__.py +++ b/editor-blender/operators/__init__.py @@ -5,6 +5,7 @@ auth, clipboard, color_palette, + command_center, control_editor, editor, led_editor, @@ -32,6 +33,7 @@ def register(): notification.register() timeline.register() control_editor.register() + command_center.register() led_editor.register() shift.register() clipboard.register() @@ -52,6 +54,7 @@ def unregister(): notification.unregister() timeline.unregister() control_editor.unregister() + command_center.register() led_editor.unregister() shift.unregister() clipboard.unregister() diff --git a/editor-blender/operators/command_center/__init__.py b/editor-blender/operators/command_center/__init__.py new file mode 100644 index 000000000..ae3bc737b --- /dev/null +++ b/editor-blender/operators/command_center/__init__.py @@ -0,0 +1,433 @@ +import time + +import bpy + +from ...api.command_agent import command_agent +from ...client import client +from ...client.subscription import subscribe_command +from ...core.actions.property.command import ( + countdown_task, + get_selected_dancer, + set_command_status, + set_countdown, +) +from ...core.actions.state.app_state import set_requesting +from ...core.asyncio import AsyncTask +from ...core.states import state +from ...core.utils.convert import is_color_code +from ...core.utils.notification import notify +from ...graphqls.command import ( + ToControllerServerBoardInfoPartial, + ToControllerServerCloseGPIOPartial, + ToControllerServerColorPartial, + ToControllerServerDarkAllPartial, + ToControllerServerLoadPartial, + ToControllerServerPausePartial, + ToControllerServerPlayPartial, + ToControllerServerRebootPartial, + ToControllerServerStopPartial, + ToControllerServerSyncPartial, + ToControllerServerTestPartial, + ToControllerServerUploadPartial, + ToControllerServerWebShellPartial, +) +from ...properties.ui.types import CommandCenterStatusType +from ..async_core import AsyncOperator + + +class CommandCenterStartOperator(AsyncOperator): + bl_idname = "lightdance.command_center_start" + bl_label = "" + bl_description = "Connect to controller server." + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + await client.open_command() + if state.command_task is not None: + state.command_task.cancel() + state.command_task = AsyncTask(subscribe_command).exec() + + info_payload = ToControllerServerBoardInfoPartial.from_dict( + {"topic": "boardInfo"} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(info_payload) + set_command_status(True) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't connect to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterRefreshOperator(AsyncOperator): + bl_idname = "lightdance.command_center_refresh" + bl_label = "" + bl_description = "Reconnect to controller server" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + await client.restart_command() + set_command_status(True) + info_payload = ToControllerServerBoardInfoPartial.from_dict( + {"topic": "boardInfo"} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(info_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterSyncOperator(AsyncOperator): + bl_idname = "lightdance.command_center_sync" + bl_label = "" + bl_description = "Sync RPi status from controller server" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + sync_payload = ToControllerServerSyncPartial.from_dict( + {"topic": "sync", "payload": {"dancers": get_selected_dancer()}} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(sync_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterPlayOperator(AsyncOperator): + bl_idname = "lightdance.command_center_play" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + command_status: CommandCenterStatusType = getattr( + bpy.context.window_manager, "ld_ui_command_center" + ) + set_countdown(command_status.delay) + try: + play_payload = ToControllerServerPlayPartial.from_dict( + { + "topic": "play", + "payload": { + "dancers": get_selected_dancer(), + "start": bpy.context.scene.frame_current, + "timestamp": int(time.time() * 1000) + + command_status.delay * 1000, + }, + } + ) + # set_requesting(True) + await command_agent.send_to_controller_server(play_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterPauseOperator(AsyncOperator): + bl_idname = "lightdance.command_center_pause" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + bpy.ops.screen.animation_cancel(restore_frame=False) + try: + pause_payload = ToControllerServerPausePartial.from_dict( + {"topic": "pause", "payload": {"dancers": get_selected_dancer()}} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(pause_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterStopOperator(AsyncOperator): + bl_idname = "lightdance.command_center_stop" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + bpy.ops.screen.animation_cancel(restore_frame=True) + if countdown_task.task: + countdown_task.task.cancel() + countdown_task.task = None + command_status: CommandCenterStatusType = getattr( + bpy.context.window_manager, "ld_ui_command_center" + ) + command_status.countdown = "00:00" + try: + stop_payload = ToControllerServerStopPartial.from_dict( + {"topic": "stop", "payload": {"dancers": get_selected_dancer()}} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(stop_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterLoadOperator(AsyncOperator): + bl_idname = "lightdance.command_center_load" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + load_payload = ToControllerServerLoadPartial.from_dict( + {"topic": "load", "payload": {"dancers": get_selected_dancer()}} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(load_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterUploadOperator(AsyncOperator): + bl_idname = "lightdance.command_center_upload" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + upload_payload = ToControllerServerUploadPartial.from_dict( + {"topic": "upload", "payload": {"dancers": get_selected_dancer()}} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(upload_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterRebootOperator(AsyncOperator): + bl_idname = "lightdance.command_center_reboot" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + reboot_payload = ToControllerServerRebootPartial.from_dict( + {"topic": "reboot", "payload": {"dancers": get_selected_dancer()}} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(reboot_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterTestOperator(AsyncOperator): + bl_idname = "lightdance.command_center_test" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + command_status: CommandCenterStatusType = getattr( + bpy.context.window_manager, "ld_ui_command_center" + ) + color_code = command_status.color_code + if not is_color_code(color_code): + notify("WARNING", "Invalid color code!") + return {"CANCELLED"} + sync_payload = ToControllerServerTestPartial.from_dict( + { + "topic": "test", + "payload": { + "dancers": get_selected_dancer(), + "colorCode": f"{color_code}", + }, + } + ) + # set_requesting(True) + await command_agent.send_to_controller_server(sync_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterColorOperator(AsyncOperator): + bl_idname = "lightdance.command_center_color" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + command_status: CommandCenterStatusType = getattr( + bpy.context.window_manager, "ld_ui_command_center" + ) + color = command_status.color + try: + color_payload = ToControllerServerColorPartial.from_dict( + { + "topic": f"{color}", + "payload": {"dancers": get_selected_dancer()}, + } + ) + # set_requesting(True) + await command_agent.send_to_controller_server(color_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterDarkAllOperator(AsyncOperator): + bl_idname = "lightdance.command_center_dark_all" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + dark_all_payload = ToControllerServerDarkAllPartial.from_dict( + {"topic": "darkAll", "payload": {"dancers": get_selected_dancer()}} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(dark_all_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterCloseGPIOOperator(AsyncOperator): + bl_idname = "lightdance.command_center_close_gpio" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + try: + sync_payload = ToControllerServerCloseGPIOPartial.from_dict( + {"topic": "close", "payload": {"dancers": get_selected_dancer()}} + ) + # set_requesting(True) + await command_agent.send_to_controller_server(sync_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +class CommandCenterWebShellOperator(AsyncOperator): + bl_idname = "lightdance.command_center_web_shell" + bl_label = "" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + async def async_execute(self, context: bpy.types.Context): + command_status: CommandCenterStatusType = getattr( + bpy.context.window_manager, "ld_ui_command_center" + ) + command: str = command_status.command + try: + sync_payload = ToControllerServerWebShellPartial.from_dict( + { + "topic": "webShell", + "payload": { + "dancers": get_selected_dancer(), + "command": f"{command}", + }, + } + ) + # set_requesting(True) + await command_agent.send_to_controller_server(sync_payload) + + except Exception as e: + # set_requesting(False) + raise Exception(f"Can't send message to controller server: {e}") + return {"FINISHED"} + + +ops_list = [ + CommandCenterRefreshOperator, + CommandCenterCloseGPIOOperator, + CommandCenterColorOperator, + CommandCenterDarkAllOperator, + CommandCenterLoadOperator, + CommandCenterPauseOperator, + CommandCenterPlayOperator, + CommandCenterRebootOperator, + CommandCenterStopOperator, + CommandCenterStartOperator, + CommandCenterSyncOperator, + CommandCenterTestOperator, + CommandCenterUploadOperator, + CommandCenterWebShellOperator, +] + + +def register(): + for op in ops_list: + bpy.utils.register_class(op) + + +def unregister(): + for op in ops_list: + bpy.utils.unregister_class(op) diff --git a/editor-blender/pack/.env.production b/editor-blender/pack/.env.production index a398e1691..f1272db1b 100644 --- a/editor-blender/pack/.env.production +++ b/editor-blender/pack/.env.production @@ -3,3 +3,5 @@ HTTP_PATH="/api/editor-server" GRAPHQL_PATH="/graphql-backend" GRAPHQL_WS_PATH="/graphql-backend-websocket" FILE_SERVER_URL="https://lightdance-editor.ntuee.org" +# CONTROLLER_WS_URL="wss://lightdance-editor.ntuee.org/controller-server-websocket" +CONTROLLER_WS_URL="ws://localhost:8082" \ No newline at end of file diff --git a/editor-blender/panels/__init__.py b/editor-blender/panels/__init__.py index d5e2bb642..ca9f1691c 100644 --- a/editor-blender/panels/__init__.py +++ b/editor-blender/panels/__init__.py @@ -1,6 +1,7 @@ from . import ( auth, color_palette, + command_center, control_editor, editor, led_editor, @@ -22,6 +23,7 @@ def register(): color_palette.register() timeline.register() + command_center.register() def unregister(): @@ -32,6 +34,7 @@ def unregister(): pos_editor.unregister() color_palette.unregister() timeline.unregister() + command_center.unregister() control_editor.unregister() led_editor.unregister() diff --git a/editor-blender/panels/command_center/__init__.py b/editor-blender/panels/command_center/__init__.py new file mode 100644 index 000000000..355d0ba93 --- /dev/null +++ b/editor-blender/panels/command_center/__init__.py @@ -0,0 +1,171 @@ +from typing import Any + +import bpy +from bpy.types import Context, UILayout + +from ...core.states import state +from ...properties.ui.types import CommandCenterRPiStatusType, CommandCenterStatusType + + +class LD_UL_DancerList(bpy.types.UIList): + def draw_item( + self, + context: Context | None, + layout: UILayout, + data: Any | None, + item: CommandCenterRPiStatusType, + icon: int | None, + active_data: Any, + active_property: str, + index: Any | None = 0, + flt_flag: Any | None = 0, + ): + connection_icon = ( + "SEQUENCE_COLOR_04" if item.connected else "SEQUENCE_COLOR_01" + ) # green and red square + interface_icon = "URL" if item.interface_type == "wifi" else "PLUGIN" + if self.layout_type in {"DEFAULT", "COMPACT"}: + column_main = layout.column() + row = column_main.row() + split = row.split(factor=0.3) + column = split.column() + row = column.row(align=True) + row.prop( + item, "selected", text="", emboss=True + ) # TODO: emboss=item.connected + row.label(text=item.name, icon=connection_icon) + column = split.column() + row = column.row() + split = row.split(factor=0.5) + column = split.column() + column.label(text=f"{item.IP}", icon=interface_icon) + column = split.column() + column.label(text=f"{item.MAC}") + row = column_main.row() + row.label(text=f"Message: {item.message}", icon="INFO") + elif self.layout_type in {"GRID"}: + pass # NOTE: Not sure when this case happens + + +class ControlPanel(bpy.types.Panel): + bl_label = "Control Panel" + bl_idname = "VIEW_PT_LightDance_ControlPanel" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Command center" + + @classmethod + def poll(cls, context: bpy.types.Context): + return state.ready + + def draw(self, context: bpy.types.Context): + command_center_status: CommandCenterStatusType = getattr( + bpy.context.window_manager, "ld_ui_command_center" + ) + connected = command_center_status.connected + layout = self.layout + layout.enabled = not state.requesting + if not connected: + row = layout.row() + row.operator( + "lightdance.command_center_start", text="Start connection", icon="PLAY" + ) + else: + row = layout.row() + row.operator( + "lightdance.command_center_refresh", + text="Reconnect", + icon="FILE_REFRESH", + ) + row.operator( + "lightdance.command_center_sync", text="Sync", icon="UV_SYNC_SELECT" + ) + row = layout.row() + row.operator( + "lightdance.command_center_load", text="Load", icon="FILE_CACHE" + ) + row.operator( + "lightdance.command_center_upload", text="Upload", icon="EXPORT" + ) + row = layout.row() + row.operator( + "lightdance.command_center_close_gpio", text="Close", icon="QUIT" + ) + row.operator( + "lightdance.command_center_reboot", text="Reboot", icon="RECOVER_LAST" + ) + row = layout.row() + split = row.split(factor=0.4) + column = split.column() + column.operator( + "lightdance.command_center_dark_all", text="Dark all", icon="LIGHT" + ) + column = split.column() + row = column.row() + row.operator( + "lightdance.command_center_color", + text="Send color", + icon="OUTLINER_OB_LIGHT", + ) + row.prop(command_center_status, "color") + row = column.row() + row.operator( + "lightdance.command_center_test", + text="Send color", + icon="OUTLINER_OB_LIGHT", + ) + row.prop(command_center_status, "color_code", text="code") + row = layout.row() + split = row.split(factor=0.3) + column = split.column() + row = column.row(align=True) + row.operator("lightdance.command_center_play", text="", icon="PLAY") + row.operator("lightdance.command_center_pause", text="", icon="PAUSE") + row.operator("lightdance.command_center_stop", text="", icon="SNAP_FACE") + op = row.operator("screen.frame_jump", text="", icon="REW") + setattr(op, "end", False) + column = split.column() + row = column.row() + row.prop( + command_center_status, + "countdown", + text="", + emboss=False, + icon=( + "KEYTYPE_JITTER_VEC" + if command_center_status.countdown == "00:00" + else "KEYTYPE_MOVING_HOLD_VEC" + ), + ) + row.prop(command_center_status, "delay", text="Delay") + row = layout.row() + split = row.split(factor=0.3) + column = split.column() + row = column.row(align=True) + row.operator( + "lightdance.command_center_web_shell", + text="Send command", + icon="CONSOLE", + ) + column = split.column() + row = column.row() + row.prop(command_center_status, "command", text="") + row = layout.row() + row.template_list( + "LD_UL_DancerList", + "", + bpy.context.window_manager, + "ld_ui_rpi_status", + bpy.context.window_manager, + "ld_ui_command_center_dancer_index", + ) + + +def register(): + bpy.utils.register_class(LD_UL_DancerList) + bpy.utils.register_class(ControlPanel) + + +def unregister(): + bpy.utils.unregister_class(LD_UL_DancerList) + bpy.utils.unregister_class(ControlPanel) diff --git a/editor-blender/properties/ui/__init__.py b/editor-blender/properties/ui/__init__.py index 551b52713..769dfaee8 100644 --- a/editor-blender/properties/ui/__init__.py +++ b/editor-blender/properties/ui/__init__.py @@ -1,10 +1,19 @@ -from . import color_palette, control_editor, led_editor, login, pos_editor, shift +from . import ( + color_palette, + command_center, + control_editor, + led_editor, + login, + pos_editor, + shift, +) def register(): login.register() pos_editor.register() color_palette.register() + command_center.register() control_editor.register() led_editor.register() shift.register() @@ -14,6 +23,7 @@ def unregister(): login.unregister() pos_editor.unregister() color_palette.unregister() + command_center.unregister() control_editor.unregister() led_editor.unregister() shift.unregister() diff --git a/editor-blender/properties/ui/command_center.py b/editor-blender/properties/ui/command_center.py new file mode 100644 index 000000000..37c22fd96 --- /dev/null +++ b/editor-blender/properties/ui/command_center.py @@ -0,0 +1,62 @@ +import bpy + + +class CommandCenterStatus(bpy.types.PropertyGroup): + """Status of command center""" + + connected: bpy.props.BoolProperty(default=False) # type: ignore + color: bpy.props.EnumProperty( + items=[ # type: ignore + ("red", "red", ""), + ("green", "green", ""), + ("blue", "blue", ""), + ("yellow", "yellow", ""), + ("magenta", "magenta", ""), + ("cyan", "cyan", ""), + ] + ) + color_code: bpy.props.StringProperty(maxlen=7, default="#000000") # type: ignore + command: bpy.props.StringProperty() # type: ignore + delay: bpy.props.IntProperty(min=0, max=3000) # type: ignore + countdown: bpy.props.StringProperty(default="00:00") # type: ignore + + +class CommandCenterRPiStatus(bpy.types.PropertyGroup): + selected: bpy.props.BoolProperty() # type: ignore + name: bpy.props.StringProperty() # type: ignore + IP: bpy.props.StringProperty() # type: ignore + MAC: bpy.props.StringProperty() # type: ignore + connected: bpy.props.BoolProperty() # type: ignore + message: bpy.props.StringProperty() # type: ignore + statusCode: bpy.props.IntProperty() # type: ignore + interface_type: bpy.props.EnumProperty( # type: ignore + items=[("ethernet", "ethernet", ""), ("wifi", "wifi", "")] + ) + + +def register(): + bpy.utils.register_class(CommandCenterStatus) + bpy.utils.register_class(CommandCenterRPiStatus) + setattr( + bpy.types.WindowManager, + "ld_ui_command_center", + bpy.props.PointerProperty(type=CommandCenterStatus), + ) + setattr( + bpy.types.WindowManager, + "ld_ui_rpi_status", + bpy.props.CollectionProperty(type=CommandCenterRPiStatus), + ) + setattr( + bpy.types.WindowManager, + "ld_ui_command_center_dancer_index", + bpy.props.IntProperty(), + ) + + +def unregister(): + bpy.utils.unregister_class(CommandCenterStatus) + bpy.utils.unregister_class(CommandCenterRPiStatus) + delattr(bpy.types.WindowManager, "ld_ui_command_center") + delattr(bpy.types.WindowManager, "ld_ui_rpi_status") + delattr(bpy.types.WindowManager, "ld_ui_command_center_dancer_index") diff --git a/editor-blender/properties/ui/types.py b/editor-blender/properties/ui/types.py index 5cc03faa3..948bfa85c 100644 --- a/editor-blender/properties/ui/types.py +++ b/editor-blender/properties/ui/types.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Tuple +from typing import Literal, Tuple class LoginPanelStatusType: @@ -53,6 +53,26 @@ class LEDEditorStatusType: multi_select_alpha: int +class CommandCenterStatusType: + color: Literal["red", "green", "blue", "yellow", "magenta", "cyan"] + color_code: str + command: str + connected: bool + countdown: str + delay: int + + +class CommandCenterRPiStatusType: + name: str + IP: str + MAC: str + connected: bool + message: str + statusCode: int + interface_type: Literal["ethernet", "wifi"] + selected: bool + + class TimeShiftStatusType: frame_type: str start: int diff --git a/editor-blender/requirements.txt b/editor-blender/requirements.txt index e6f615ff0..2f53e2db6 100644 --- a/editor-blender/requirements.txt +++ b/editor-blender/requirements.txt @@ -4,3 +4,4 @@ dataclass_wizard aiohttp python-dotenv typeguard +websockets