Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1aad686
Add ShowXpress lighting control
Aug 7, 2026
a1f737e
Improve lighting connection errors
Aug 7, 2026
594bf96
Use ShowXpress client identifier
Aug 7, 2026
326c400
Document ShowXpress setup steps
Aug 7, 2026
cbc0528
Improve lighting widget controls
Aug 7, 2026
778ede1
Enable lighting widget triggers
Aug 7, 2026
c19ba58
Mirror lighting controller page layout
Aug 7, 2026
23e753c
Keep lighting triggers in one session
Aug 7, 2026
91ab172
Send lighting press and release commands
Aug 7, 2026
d238f9c
Keep lighting controls stable during refresh
Aug 7, 2026
adb4d04
Toggle standard lighting scenes
Aug 7, 2026
35b6c6b
Align zero-based TLC button positions
Aug 7, 2026
661263b
Prevent TLC scene button overlap
Aug 7, 2026
38dd175
Always press selected lighting scene
Aug 7, 2026
8b84bda
Offset numbered lighting scene commands
Aug 7, 2026
7a5b64d
Send zero-based TLC scene commands
Aug 7, 2026
2ca96e7
Preserve lighting widget scroll position
Aug 7, 2026
02c6e18
Highlight active lighting scene
Aug 7, 2026
8de608c
Customize active lighting border
Aug 7, 2026
9635a94
Merge pull request #10 from WorshipWarehouse/agent/add-showxpress-lig…
WorshipWarehouse Aug 7, 2026
b578c12
Revert "Move ProPresenter controls to live widget and fix thumbnails"
Aug 7, 2026
48a77d8
Merge pull request #11 from WorshipWarehouse/agent/revert-live-prop-c…
WorshipWarehouse Aug 7, 2026
8b6f711
Group editor widgets by integration
Aug 7, 2026
c4a07eb
Organize widget palette by integration
Aug 7, 2026
f3603bd
Isolate macOS packaging workspace
Aug 7, 2026
57c8240
Merge pull request #12 from WorshipWarehouse/agent/group-widgets-by-i…
WorshipWarehouse Aug 8, 2026
fc3b8d4
Merge wtapper89 main and retain Wtapper ProPresenter controls
Aug 11, 2026
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ See ChurchBoard in action and follow the setup walkthrough in the **[ChurchBoard
- Direct Open Sound Meter monitoring with selectable weighting/response and downloadable service graphs and per-item averages
- Restream broadcast, viewer, and destination-status monitoring through OAuth
- OBS Studio streaming/recording state, connection health, output statistics, dropped-frame warnings, and an optional preview image
- Native lighting control for Chauvet ShowXpress, Showtec QuickDMX, Sweetlight Controller, and other TheLightingController-based apps, with their exposed Live buttons shown on a dashboard
- Planning Center Services LIVE control buttons
- A WYSIWYG dashboard editor with an always-visible **Edit** button, right-click settings, independent layouts, and color-matched liquid-glass widgets for each destination
- Exportable/importable dashboard layout files and a categorized widget palette with modal settings and direct edge/corner resizing
Expand Down Expand Up @@ -98,7 +99,7 @@ Open `http://127.0.0.1:8040/admin`, turn off demonstration data, and configure P

![ChurchBoard integrations setup](docs/screenshots/setup.jpg)

Start with [Configuration](docs/CONFIGURATION.md), then follow the detailed [Planning Center setup](docs/PLANNING_CENTER.md), [ProPresenter setup](docs/PROPRESENTER.md), [Open Sound Meter setup](docs/OPEN_SOUND_METER.md), and [Restream setup](docs/RESTREAM.md) guides. They cover secure credentials, permissions, photos, leaders, linked service playlists, the Network API, Services LIVE automation, dashboards, microphone mapping, level reporting, livestream monitoring, and troubleshooting.
Start with [Configuration](docs/CONFIGURATION.md), then follow the detailed [Planning Center setup](docs/PLANNING_CENTER.md), [ProPresenter setup](docs/PROPRESENTER.md), [Open Sound Meter setup](docs/OPEN_SOUND_METER.md), and [Restream setup](docs/RESTREAM.md) guides. To control ShowXpress-compatible lighting software, enable its External App setting, **restart the lighting application**, enable External Control with a password, then configure the matching address and port (normally 7348) in Setup. Only pages marked visible to external applications appear in ChurchBoard.

## Dashboard editing

Expand Down
41 changes: 41 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from app.services.planning_center import PlanningCenterClient
from app.services.propresenter import ProPresenterClient
from app.services.restream import RestreamClient
from app.services.thelightingcontroller import TheLightingControllerClient
from app.store import ConfigStore
from app.update import download_update, update_status
from app.version import __version__
Expand Down Expand Up @@ -71,6 +72,13 @@ class ProPresenterNavigationRequest(BaseModel):
widget_id: str | None = None


class LightingButtonTrigger(BaseModel):
name: str
mode: str = "toggle"
dashboard_slug: str | None = None
widget_id: str | None = None


class MediaTagRulesRequest(BaseModel):
items: list[dict] = Field(default_factory=list)

Expand Down Expand Up @@ -315,12 +323,45 @@ async def update_settings(payload: SettingsUpdate, request: Request) -> dict:
settings.setdefault("restream", {})[secret_name] = existing_restream.get(secret_name, "")
if not settings.get("obs", {}).get("password"):
settings.setdefault("obs", {})["password"] = data["settings"].get("obs", {}).get("password", "")
if not settings.get("lighting", {}).get("password"):
settings.setdefault("lighting", {})["password"] = data["settings"].get("lighting", {}).get("password", "")
data["settings"] = settings
store.save(data)
await request.app.state.runtime.refresh(force=True)
return store.public_settings()


@app.get("/api/integrations/lighting/buttons")
async def lighting_buttons(request: Request) -> dict:
client = TheLightingControllerClient(store_from(request).load()["settings"].get("lighting", {}))
if not client.configured:
raise HTTPException(400, "Enable lighting control and save its computer address first")
try:
return {"items": await client.buttons()}
except Exception as exc:
raise HTTPException(502, f"Could not read lighting controls: {exc}") from exc


def require_lighting_widget_control(request: Request, dashboard_slug: str | None, widget_id: str | None) -> dict:
dashboard = dashboard_or_404(store_from(request), dashboard_slug or "")
widget = next((item for item in dashboard.get("widgets", []) if item.get("id") == widget_id), None)
if not widget or widget.get("type") != "lighting":
raise HTTPException(403, "Lighting controls must be triggered from a Lighting controls widget")
return store_from(request).load()["settings"].get("lighting", {})


@app.post("/api/integrations/lighting/button")
async def lighting_trigger_button(payload: LightingButtonTrigger, request: Request) -> dict:
client = TheLightingControllerClient(require_lighting_widget_control(request, payload.dashboard_slug, payload.widget_id))
if not client.configured:
raise HTTPException(400, "Lighting control is not connected")
try:
await client.trigger_button(payload.name, payload.mode)
return {"ok": True}
except Exception as exc:
raise HTTPException(502, f"Could not trigger lighting button: {exc}") from exc


@app.get("/api/auth/status")
async def auth_status(request: Request) -> dict:
auth: AuthManager = request.app.state.auth
Expand Down
3 changes: 2 additions & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

class Widget(BaseModel):
id: str = Field(min_length=1, max_length=80)
type: Literal["clock", "service", "timing", "assignments", "mics", "slides", "playlist", "pp_controls", "notes", "sermon_notes", "order", "person", "people", "spl", "controls", "text", "restream", "livestreams", "obs", "propresenter_timers"]
type: Literal["clock", "service", "timing", "assignments", "mics", "slides", "playlist", "pp_controls", "notes", "sermon_notes", "order", "person", "people", "spl", "controls", "lighting", "text", "restream", "livestreams", "obs", "propresenter_timers"]
x: int = Field(ge=0, le=23)
y: int = Field(ge=0, le=100)
w: int = Field(ge=1, le=24)
Expand Down Expand Up @@ -54,6 +54,7 @@ class SettingsUpdate(BaseModel):
open_sound_meter: dict[str, Any] = Field(default_factory=dict)
restream: dict[str, Any] = Field(default_factory=dict)
obs: dict[str, Any] = Field(default_factory=dict)
lighting: dict[str, Any] = Field(default_factory=dict)
server: dict[str, Any] = Field(default_factory=dict)
position_mic_map: dict[str, str] = Field(default_factory=dict)
manual_plan: dict[str, str] | None = None
148 changes: 148 additions & 0 deletions app/services/thelightingcontroller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
from __future__ import annotations

import asyncio
from typing import Any
from xml.etree import ElementTree


class TheLightingControllerClient:
"""Client for TLC's External Application protocol (also used by ShowXpress)."""

# TLC/ShowXpress recognises this client identifier from its official Live
# Mobile/Companion-compatible External App protocol implementation.
APP_NAME = "thelightingcontrollerclient"

def __init__(self, settings: dict[str, Any]):
self.settings = settings

@property
def configured(self) -> bool:
return bool(self.settings.get("enabled") and str(self.settings.get("host") or "").strip())

async def buttons(self) -> list[dict[str, Any]]:
reader, writer = await self._connect()
try:
return await self._button_list(reader, writer)
finally:
writer.close()
await writer.wait_closed()

async def trigger_button(self, name: str, mode: str = "toggle") -> None:
if not name or any(character in name for character in "|\r\n"):
raise ValueError("Invalid lighting button name")
if mode not in {"press", "release", "toggle"}:
raise ValueError("Lighting button mode must be press, release, or toggle")
reader, writer = await self._connect()
try:
if mode == "press":
# A cue click is an unambiguous press. Do not require the
# command name to appear in BUTTON_LIST: some TLC versions
# expose one-based captions while accepting zero-based cue
# identifiers (for example, displayed "1" accepts "0").
await self._send(writer, "BUTTON_PRESS", name)
return
# Keep discovery and the command in the same authenticated session.
# TLC installations commonly accept only one External App client.
buttons = await self._button_list(reader, writer)
button = next((item for item in buttons if item["name"] == name), None)
if button is None:
raise ValueError("That lighting button is no longer exposed by the controller")
if mode == "toggle" and button["flash"]:
# Flash buttons are momentary scenes; a click must not leave
# one held down after ChurchBoard's request finishes.
await self._send(writer, "BUTTON_PRESS", name)
await self._send(writer, "BUTTON_RELEASE", name)
else:
command = "BUTTON_PRESS" if mode == "press" or (mode == "toggle" and not button["pressed"]) else "BUTTON_RELEASE"
await self._send(writer, command, name)
finally:
writer.close()
await writer.wait_closed()

async def _button_list(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> list[dict[str, Any]]:
await self._send(writer, "BUTTON_LIST")
while True:
line = await self._read_line(reader, "the exposed button list")
if not line:
raise ConnectionError("The lighting controller closed the connection")
text = line.decode("utf-8", "replace").rstrip("\r\n")
if text.startswith("ERROR|"):
raise ValueError(text.split("|", 1)[1] or "The lighting controller rejected the request")
if text.startswith("BUTTON_LIST|"):
return self._parse_buttons(text.split("|", 1)[1])

async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
host = str(self.settings.get("host") or "").strip()
try:
port = int(self.settings.get("port") or 7348)
except (TypeError, ValueError) as exc:
raise ValueError("Enter a valid External App port number") from exc
if not 1 <= port <= 65535:
raise ValueError("External App port must be between 1 and 65535")
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=3)
except asyncio.TimeoutError as exc:
raise ConnectionError(f"Timed out connecting to {host}:{port}. Verify the computer address, port, and firewall.") from exc
except OSError as exc:
detail = exc.strerror or str(exc) or exc.__class__.__name__
raise ConnectionError(f"Could not connect to {host}:{port}: {detail}") from exc
await self._send(writer, "HELLO", self.APP_NAME, str(self.settings.get("password") or ""))
while True:
line = await self._read_line(reader, "the ShowXpress/TLC sign-in reply")
if not line:
writer.close()
await writer.wait_closed()
raise ConnectionError("The lighting controller closed the connection during sign-in")
text = line.decode("utf-8", "replace").rstrip("\r\n")
if text == "HELLO":
return reader, writer
if text.startswith("ERROR|"):
writer.close()
await writer.wait_closed()
raise ValueError(text.split("|", 1)[1] or "The lighting controller rejected the password")

@staticmethod
async def _read_line(reader: asyncio.StreamReader, waiting_for: str) -> bytes:
try:
return await asyncio.wait_for(reader.readline(), timeout=3)
except asyncio.TimeoutError as exc:
raise ConnectionError(f"Timed out waiting for {waiting_for}. Check that External App and External Control are enabled.") from exc

@staticmethod
async def _send(writer: asyncio.StreamWriter, *parts: str) -> None:
writer.write(("|".join(parts) + "\r\n").encode("ascii"))
await writer.drain()

@staticmethod
def _parse_buttons(payload: str) -> list[dict[str, Any]]:
try:
root = ElementTree.fromstring(payload)
except ElementTree.ParseError as exc:
raise ValueError(f"The lighting controller returned invalid button data: {exc}") from exc
buttons: list[dict[str, Any]] = []
for page in root.findall("page"):
page_name = page.get("name") or "Lighting"
page_columns = int(page.get("columns") or 0)
page_buttons = list(page.findall("button"))
# TLC's external-app XML uses zero-based positions on some
# releases, whereas CSS grid lines are one-based. Treat a zero
# in either coordinate as an unambiguous zero-based page so two
# adjacent TLC buttons cannot be rendered on top of each other.
zero_based = any(
element.get(axis) == "0"
for element in page_buttons
for axis in ("column", "line")
)
coordinate_offset = 1 if zero_based else 0
for element in page_buttons:
name = (element.text or "").strip()
if not name:
continue
buttons.append({
"name": name, "page": page_name, "page_columns": page_columns,
"column": int(element.get("column") or 0) + coordinate_offset,
"line": int(element.get("line") or 0) + coordinate_offset,
"color": element.get("color") or "#4c6b8a",
"pressed": element.get("pressed") == "1", "flash": element.get("flash") == "1",
})
return sorted(buttons, key=lambda item: (item["page"].casefold(), item["line"], item["column"], item["name"].casefold()))
Loading