Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ variants:
This fork additionally supports:

* Mirabox Stream Dock 293S
* Soomfon Stream Controller (XF-CN001)

## Package Installation:

Expand Down
10 changes: 10 additions & 0 deletions doc/source/examples/soomfon.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
***********************************************
Example Script: Soomfon Stream Controller
***********************************************

The following is an example script to connect to the Soomfon Stream Controller,
print whenever its buttons are pushed and released, and use the three tiles of
its non-touch side display as a clock.

.. literalinclude:: ../../../src/example_soomfon.py
:language: python
1 change: 1 addition & 0 deletions doc/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Index
examples/plus.rst
examples/neo.rst
examples/studio.rst
examples/soomfon.rst
examples/tiled.rst
examples/animated.rst

Expand Down
9 changes: 9 additions & 0 deletions doc/source/modules/devices.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ Mirabox Stream Dock 293S
:show-inheritance:


=========================
Soomfon Stream Controller
=========================

.. automodule:: StreamDeck.Devices.SoomfonStreamController
:members:
:show-inheritance:


============
Rotated Deck
============
Expand Down
2 changes: 2 additions & 0 deletions src/StreamDeck/DeviceManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .Devices.StreamDeckStudio import StreamDeckStudio
from .Devices.StreamDeckXL import StreamDeckXL
from .Devices.UlanziD200 import UlanziD200
from .Devices.SoomfonStreamController import SoomfonStreamController
from .ProductIDs import USBProductIDs, USBVendorIDs
from .Transport import Transport
from .Transport.Dummy import Dummy
Expand Down Expand Up @@ -159,6 +160,7 @@ def _default_factory(self) -> list[StreamDeck]:
(USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS_XL, StreamDeckPlusXL),
(USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_MIRABOX_STREAMDOCK_293S, Mirabox293S),
(USBVendorIDs.USB_VID_ULANZI, USBProductIDs.USB_PID_ULANZI_D200, UlanziD200, 0),
(USBVendorIDs.USB_VID_SOOMFON, USBProductIDs.USB_PID_SOOMFON_STREAM_CONTROLLER, SoomfonStreamController, 0),
]

streamdecks = list()
Expand Down
139 changes: 139 additions & 0 deletions src/StreamDeck/Devices/SoomfonStreamController.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Python Stream Deck Library
# Released under the MIT license
#
# dean [at] fourwalledcubicle [dot] com
# www.fourwalledcubicle.com
#

import io
import threading

from .Mirabox293S import Mirabox293S
from .StreamDeck import ControlType
from ..Transport.Transport import TransportError


class SoomfonStreamController(Mirabox293S):
"""
Represents a physically attached Soomfon Stream Controller (model
XF-CN001, USB 1500:3003), a rebranded Mirabox 293S variant that speaks a
newer revision of the Mirabox protocol ("v3" in the mirajazz project).

Differences from :class:`Mirabox293S`, verified on hardware:

- output reports are 1024 bytes (plus report ID) instead of 512;
- key images are 95x95 JPEG; the three side-display tiles (keys 5, 11 and
17) are 82x82 and are resized internally, so callers can keep using a
single key image format;
- the device reports distinct press and release events (byte 9 = key ID,
byte 10 = state), so no press/release emulation is needed;
- a ``CONNECT`` heartbeat is sent periodically while the deck is open.

The deck exposes two HID interfaces under the same VID/PID; only
interface 0 speaks the deck protocol (interface 1 is a keyboard), see
the interface filter in :class:`DeviceManager`.
"""

DECK_TYPE = "Soomfon Stream Controller"

PACKET_LENGHT = 1024
INPUT_REPORT_LENGTH = 512

KEY_PIXEL_WIDTH = 95
KEY_PIXEL_HEIGHT = 95

SIDE_KEYS = (5, 11, 17)
SIDE_KEY_PIXEL_SIZE = 82

HEARTBEAT_INTERVAL = 8.0

_CMD_PREFIX = [0x43, 0x52, 0x54, 0x00, 0x00] # "CRT\0\0"
_CMD_CONNECT = _CMD_PREFIX + [0x43, 0x4F, 0x4E, 0x4E, 0x45, 0x43, 0x54] # "CONNECT"
_CMD_STP = _CMD_PREFIX + [0x53, 0x54, 0x50] # "STP"

def __init__(self, device):
super().__init__(device)
self._heartbeat_stop = threading.Event()
self._heartbeat_thread = None

def _make_payload_for_report_id(self, report_id, payload_data):
payload = bytearray(self.PACKET_LENGHT + 1)
payload[0] = report_id
payload[1:1 + len(payload_data)] = payload_data
return payload

def _send_command(self, command):
self.device.write(self._make_payload_for_report_id(0x00, command))

def _read_control_states(self):
data = self.device.read(self.INPUT_REPORT_LENGTH)
if not data or len(data) < 11 or bytes(data[:3]) != b"ACK":
return None

key_id, state = data[9], data[10]
if key_id not in self.KEY_DEVICE_KEY_ID_TO_NUM:
return None

states = list(self.last_key_states)
states[self._convert_device_key_id_to_key_num(key_id)] = bool(state)
return {ControlType.KEY: states}

def reset(self):
with self.update_lock:
super().reset()
self._send_command(self._CMD_STP)

def _blank_image(self, size):
from PIL import Image

with io.BytesIO() as buffer:
Image.new("RGB", (size, size), "black").save(buffer, "JPEG", quality=90)
return buffer.getvalue()

def _fit_image(self, image, size):
from PIL import Image

decoded = Image.open(io.BytesIO(image))
if decoded.size == (size, size):
return image

with io.BytesIO() as buffer:
decoded.convert("RGB").resize((size, size), Image.LANCZOS).save(buffer, "JPEG", quality=90)
return buffer.getvalue()

def set_key_image(self, key, image):
if min(max(key, 0), self.KEY_COUNT - 1) != key:
raise IndexError("Invalid key index {}.".format(key))

size = self.SIDE_KEY_PIXEL_SIZE if key in self.SIDE_KEYS else self.KEY_PIXEL_WIDTH
image = self._fit_image(bytes(image), size) if image else self._blank_image(size)

# Keep the BAT/data/STP sequence atomic with respect to the heartbeat.
with self.update_lock:
super().set_key_image(key, image)

def _heartbeat_worker(self):
while not self._heartbeat_stop.wait(self.HEARTBEAT_INTERVAL):
try:
with self.update_lock:
self._send_command(self._CMD_CONNECT)
except TransportError:
# Device temporarily unavailable (e.g. host suspend); the read
# thread handles reconnection, keep trying until close().
pass

def open(self, resume_from_suspend: bool = True) -> None:
super().open(resume_from_suspend)
self._heartbeat_stop.clear()
if self._heartbeat_thread is None or not self._heartbeat_thread.is_alive():
self._heartbeat_thread = threading.Thread(
target=self._heartbeat_worker, name="SoomfonHeartbeat", daemon=True
)
self._heartbeat_thread.start()

def close(self) -> None:
self._heartbeat_stop.set()
if self._heartbeat_thread is not None and self._heartbeat_thread is not threading.current_thread():
self._heartbeat_thread.join(timeout=self.HEARTBEAT_INTERVAL)
self._heartbeat_thread = None
super().close()
2 changes: 2 additions & 0 deletions src/StreamDeck/ProductIDs.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class USBVendorIDs:
USB_VID_ELGATO = 0x0fd9
USB_VID_MIRABOX = 0x5548
USB_VID_ULANZI = 0x2207
USB_VID_SOOMFON = 0x1500


class USBProductIDs:
Expand Down Expand Up @@ -42,3 +43,4 @@ class USBProductIDs:

USB_PID_MIRABOX_STREAMDOCK_293S = 0x6670
USB_PID_ULANZI_D200 = 0x0019
USB_PID_SOOMFON_STREAM_CONTROLLER = 0x3003
Loading