Skip to content

Commit 774d1cd

Browse files
authored
Merge pull request #12 from strohganoff/feature/event-listeners-manager
Feature/event listeners manager
2 parents 25a9b62 + aa2096d commit 774d1cd

28 files changed

+1241
-470
lines changed

.github/workflows/publish.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ jobs:
2525
python -m pip install build
2626
python -m build
2727
- name: Publish release distributions to PyPI
28-
uses: pypa/gh-action-pypi-publish@v1.11.0
28+
uses: pypa/gh-action-pypi-publish@v1.12.4

README.md

+63
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,69 @@ And a command like the following is called by the Stream Deck software:
284284
streamdeck -port 28196 -pluginUUID 63831042F4048F072B096732E0385245 -registerEvent registerPlugin -info '{"application": {...}, "plugin": {"uuid": "my-plugin-name", "version": "1.1.3"}, ...}'
285285
```
286286
287+
## Custom Event Listeners
288+
289+
The SDK allows you to create custom event listeners and events by extending the `EventListener` and `EventBase` classes. This is useful when you need to monitor data from external applications and perform specific actions in response to changes or alerts.
290+
291+
### Creating a Custom Event Listener
292+
293+
To create a custom event listener:
294+
295+
1. Create new event model that inherits from `EventBase`.
296+
2. Create a new class that inherits from `EventListener`.
297+
a. Implement the required `listen` and `stop` methods. The `listen` method should yield results as a json string that matches the new event model.
298+
b. List the new event classes in the `event_models` class variable of the new `EventListener` class.
299+
3. Configure your plugin in its `pyproject.toml` file to use your custom listener.
300+
301+
```python
302+
# custom_listener.py
303+
from collections.abc import Generator
304+
from typing import ClassVar, Literal
305+
306+
from streamdeck.event_listener import EventListener
307+
from streamdeck.models.events import EventBase
308+
309+
310+
class MyCustomEvent(EventBase):
311+
event: Literal["somethingHappened"]
312+
... # Define additional data attributes here
313+
314+
class MyCustomEventListener(EventListener):
315+
def listen(self) -> Generator[str | bytes, None, None]:
316+
...
317+
# Listen/poll for something here in a loop, and yield the result.
318+
# This will be ran in a background thread.
319+
# Ex:
320+
# while self._running is True:
321+
# result = module.check_status()
322+
# if result is not None:
323+
# yield json.dumps({"event": "somethingHappend", "result": result})
324+
# time.sleep(1)
325+
326+
def stop(self) -> None:
327+
...
328+
# Stop the loop or blocking call in the listen method.
329+
# Ex:
330+
# self._running = False
331+
```
332+
333+
### Configuring Your Custom Listener
334+
335+
To use your custom event listener, add it to your `pyproject.toml` file:
336+
337+
```toml
338+
[tools.streamdeck]
339+
action_scripts = [
340+
"main.py",
341+
]
342+
event_listener_modules = [
343+
"myplugin.custom_listener",
344+
]
345+
```
346+
347+
The `event_listeners` list should contain strings in module format for each module you want to use.
348+
349+
287350
## Creating and Packaging Plugins
288351
289352
To create a new plugin with all of the necessary files to start from and package it for use on your Stream Deck, use [the Python SDK CLI tool](https://github.com/strohganoff/python-streamdeck-plugin-sdk-cli).

streamdeck/__main__.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,18 @@ def main(
7272
info=info_data,
7373
)
7474

75+
# Event listeners and their Event models are registered before actions in order to validate the actions' registered events' names.
76+
for event_listener in pyproject.event_listeners:
77+
manager.register_event_listener(event_listener())
78+
7579
for action in actions:
7680
manager.register_action(action)
7781

78-
manager.run()
82+
try:
83+
manager.run()
84+
except Exception as e:
85+
logger.exception("Error in plugin manager")
86+
raise
7987

8088

8189
# Also run the plugin if this script is ran as a console script.

streamdeck/actions.py

+17-10
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from logging import getLogger
77
from typing import TYPE_CHECKING, cast
88

9-
from streamdeck.types import BaseEventHandlerFunc, available_event_names
9+
from streamdeck.types import BaseEventHandlerFunc
1010

1111

1212
if TYPE_CHECKING:
@@ -22,7 +22,7 @@
2222
class ActionBase(ABC):
2323
"""Base class for all actions."""
2424

25-
def __init__(self):
25+
def __init__(self) -> None:
2626
"""Initialize an Action instance.
2727
2828
Args:
@@ -42,13 +42,13 @@ def on(self, event_name: EventNameStr, /) -> Callable[[EventHandlerFunc[TEvent_c
4242
Raises:
4343
KeyError: If the provided event name is not available.
4444
"""
45-
if event_name not in available_event_names:
46-
msg = f"Provided event name for action handler does not exist: {event_name}"
47-
raise KeyError(msg)
45+
# if event_name not in DEFAULT_EVENT_NAMES:
46+
# msg = f"Provided event name for action handler does not exist: {event_name}"
47+
# raise KeyError(msg)
4848

4949
def _wrapper(func: EventHandlerFunc[TEvent_contra]) -> EventHandlerFunc[TEvent_contra]:
5050
# Cast to BaseEventHandlerFunc so that the storage type is consistent.
51-
self._events[event_name].add(cast(BaseEventHandlerFunc, func))
51+
self._events[event_name].add(cast("BaseEventHandlerFunc", func))
5252

5353
return func
5454

@@ -66,15 +66,22 @@ def get_event_handlers(self, event_name: EventNameStr, /) -> Generator[EventHand
6666
Raises:
6767
KeyError: If the provided event name is not available.
6868
"""
69-
if event_name not in available_event_names:
70-
msg = f"Provided event name for pulling handlers from action does not exist: {event_name}"
71-
raise KeyError(msg)
69+
# if event_name not in DEFAULT_EVENT_NAMES:
70+
# msg = f"Provided event name for pulling handlers from action does not exist: {event_name}"
71+
# raise KeyError(msg)
7272

7373
if event_name not in self._events:
7474
return
7575

7676
yield from self._events[event_name]
7777

78+
def get_registered_event_names(self) -> list[str]:
79+
"""Get all event names for which event handlers are registered.
80+
81+
Returns:
82+
list[str]: The list of event names for which event handlers are registered.
83+
"""
84+
return list(self._events.keys())
7885

7986
class GlobalAction(ActionBase):
8087
"""Represents an action that is performed at the plugin level, meaning it isn't associated with a specific device or action."""
@@ -83,7 +90,7 @@ class GlobalAction(ActionBase):
8390
class Action(ActionBase):
8491
"""Represents an action that can be performed for a specific action, with event handlers for specific event types."""
8592

86-
def __init__(self, uuid: str):
93+
def __init__(self, uuid: str) -> None:
8794
"""Initialize an Action instance.
8895
8996
Args:

streamdeck/command_sender.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,6 @@ def send_to_plugin(
257257
def send_action_registration(
258258
self,
259259
register_event: str,
260-
plugin_registration_uuid: str,
261260
) -> None:
262261
"""Registers a plugin with the Stream Deck software very shortly after the plugin is started.
263262
@@ -270,10 +269,8 @@ def send_action_registration(
270269
Args:
271270
register_event (str): The registration event type, passed in by the Stream Deck software as -registerEvent option.
272271
It's value will almost definitely will be "registerPlugin".
273-
plugin_registration_uuid (str): Randomly-generated unique ID passed in by StreamDeck as -pluginUUID option,
274-
used to send back in the registerPlugin event. Note that this is NOT the manifest.json -configured plugin UUID value.
275272
"""
276273
self._send_event(
277274
event=register_event,
278-
uuid=plugin_registration_uuid,
275+
uuid=self._plugin_registration_uuid,
279276
)

streamdeck/event_listener.py

+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from __future__ import annotations
2+
3+
import threading
4+
from abc import ABC, abstractmethod
5+
from logging import getLogger
6+
from queue import Queue
7+
from typing import TYPE_CHECKING
8+
9+
10+
if TYPE_CHECKING:
11+
from collections.abc import Generator
12+
from typing import Any, ClassVar
13+
14+
from typing_extensions import TypeIs
15+
16+
from streamdeck.models.events import EventBase
17+
18+
19+
20+
logger = getLogger("streamdeck.event_listener")
21+
22+
23+
class _SENTINAL:
24+
"""A sentinel object used to signal the end of the event stream.
25+
26+
Not meant to be instantiated, but rather used as a singleton (e.g. `_SENTINAL`).
27+
"""
28+
@classmethod
29+
def is_sentinal(cls, event: str | bytes | type[_SENTINAL]) -> TypeIs[type[_SENTINAL]]:
30+
"""Check if an event is the sentinal object. Provided to enable better type-checking."""
31+
return event is cls
32+
33+
34+
class StopStreaming(Exception): # noqa: N818
35+
"""Raised by an EventListener implementation to signal that the entire EventManagerListener should stop streaming events."""
36+
37+
38+
class EventListenerManager:
39+
"""Manages event listeners and provides a shared event queue for them to push events into.
40+
41+
With this class, a single event stream can be created from multiple listeners.
42+
This allows for us to listen for not only Stream Deck events, but also other events plugin-developer -defined events.
43+
"""
44+
def __init__(self) -> None:
45+
self.event_queue: Queue[str | bytes | type[_SENTINAL]] = Queue()
46+
self.listeners_lookup_by_thread: dict[threading.Thread, EventListener] = {}
47+
self._running = False
48+
49+
def add_listener(self, listener: EventListener) -> None:
50+
"""Registers a listener function that yields events.
51+
52+
Args:
53+
listener: A function that yields events.
54+
"""
55+
# Create a thread for the listener
56+
thread = threading.Thread(
57+
target=self._listener_wrapper,
58+
args=(listener,),
59+
daemon=True,
60+
)
61+
self.listeners_lookup_by_thread[thread] = listener
62+
63+
def _listener_wrapper(self, listener: EventListener) -> None:
64+
"""Wraps the listener function: for each event yielded, push it into the shared queue."""
65+
try:
66+
for event in listener.listen():
67+
self.event_queue.put(event)
68+
69+
if not self.running:
70+
break
71+
72+
except StopStreaming:
73+
logger.debug("Event listener requested to stop streaming.")
74+
self.event_queue.put(_SENTINAL)
75+
76+
except Exception:
77+
logger.exception("Unexpected error in wrapped listener %s. Stopping just this listener.", listener)
78+
79+
def stop(self) -> None:
80+
"""Stops the event generation loop and waits for all threads to finish.
81+
82+
Listeners will check the running flag if implemented to stop listening.
83+
"""
84+
# Set the running flag to False to stop the listeners running in separate threads.
85+
self.running = False
86+
# Push the sentinel to immediately unblock the queue.get() in event_stream.
87+
self.event_queue.put(_SENTINAL)
88+
89+
for thread in self.listeners_lookup_by_thread:
90+
logger.debug("Stopping listener %s.")
91+
self.listeners_lookup_by_thread[thread].stop()
92+
if thread.is_alive():
93+
thread.join()
94+
95+
logger.info("All listeners have been stopped.")
96+
97+
def event_stream(self) -> Generator[str | bytes, None, None]:
98+
"""Starts all registered listeners, sets the running flag to True, and yields events from the shared queue."""
99+
logger.info("Starting event stream.")
100+
# Set the running flag to True and start the listeners in their separate threads.
101+
self.running = True
102+
for thread in self.listeners_lookup_by_thread:
103+
thread.start()
104+
105+
try:
106+
while True:
107+
event = self.event_queue.get()
108+
if _SENTINAL.is_sentinal(event):
109+
logger.debug("Sentinal received, stopping event stream.")
110+
break # Exit loop immediately if the sentinal is received
111+
yield event
112+
finally:
113+
self.stop()
114+
115+
116+
class EventListener(ABC):
117+
"""Base class for event listeners.
118+
119+
Event listeners are classes that listen for events and simply yield them as they come.
120+
The EventListenerManager will handle the threading and pushing the events yielded into a shared queue.
121+
"""
122+
event_models: ClassVar[list[type[EventBase]]]
123+
"""A list of event models that the listener can yield. Read in by the PluginManager to model the incoming event data off of.
124+
125+
The plugin-developer must define this list in their subclass.
126+
"""
127+
128+
@abstractmethod
129+
def listen(self) -> Generator[str | bytes, Any, None]:
130+
"""Start listening for events and yield them as they come.
131+
132+
This is the method that run in a separate thread.
133+
"""
134+
135+
@abstractmethod
136+
def stop(self) -> None:
137+
"""Stop the listener. This could set an internal flag, close a connection, etc."""

0 commit comments

Comments
 (0)