From cd05b0e6a43bb606fb6a9bf67631f408bdb4ca52 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 26 Sep 2026 12:48:21 -0700 Subject: [PATCH] feat(multisync): add JBoards controller support JBoards controllers play FSEQ files locally and follow MultiSync as remotes. This adds them as a known controller type. - Allocate 0xC5 in the other systems block. ControlProtocol.txt also gets the Baldrick (0xC4) and Twinkly (0xFA) rows it was missing. - DetectJBoardsController reads the board's /api/system/summary for its hostname, model, firmware version, UUID, channel ranges and sync role. - A JBoards board in remote mode is a unicast sync target. - The MultiSync page polls JBoards status, uptime and version, and the platform filter has a JBoards entry. --- docs/ControlProtocol.txt | 3 ++ src/MultiSync.cpp | 5 +- src/MultiSync.h | 1 + src/NetworkController.cpp | 61 +++++++++++++++++++++++ src/NetworkController.h | 1 + www/api/controllers/system.php | 2 + www/multisync.php | 90 +++++++++++++++++++++++++++++++++- 7 files changed, 160 insertions(+), 3 deletions(-) diff --git a/docs/ControlProtocol.txt b/docs/ControlProtocol.txt index 0272c9d41..74ea5b0f2 100644 --- a/docs/ControlProtocol.txt +++ b/docs/ControlProtocol.txt @@ -141,6 +141,9 @@ buf[9] = App/Hardware Type - 0xC1 - xSchedule - 0xC2 - ESPixelStick - ESP8266 - 0xC3 - ESPixelStick - ESP32 + - 0xC4 - Baldrick + - 0xC5 - JBoards + - 0xFA - Twinkly - 0xFB - WLED - 0xFC - DIYLEDExpress - 0xFD - HinksPix diff --git a/src/MultiSync.cpp b/src/MultiSync.cpp index 719c6fe0a..bb08cdb5f 100644 --- a/src/MultiSync.cpp +++ b/src/MultiSync.cpp @@ -208,7 +208,8 @@ void MultiSyncSystem::update(MultiSyncSystemType type, // learned via the UDP MultiSync protocol so that FPP remotes discovered // via HTTP (e.g. on a network segment where multicast is blocked - the // very case this mode exists for) are still targeted. - this->supportsUnicast = (this->type < kSysTypeFalconController) && + this->supportsUnicast = ((this->type < kSysTypeFalconController) || + (this->type == kSysTypeJBoards)) && (this->fppMode == REMOTE_MODE); } @@ -933,6 +934,8 @@ std::string MultiSync::GetTypeString(MultiSyncSystemType type, bool local) { return "ESPixelStick-ESP32"; case kSysTypeBaldrick: return "Baldrick"; + case kSysTypeJBoards: + return "JBoards"; case kSysTypeMacOS: return "MacOS"; case kSysTypeFPPArmbian: diff --git a/src/MultiSync.h b/src/MultiSync.h index 86c2e886d..b05204fc2 100644 --- a/src/MultiSync.h +++ b/src/MultiSync.h @@ -118,6 +118,7 @@ typedef enum systemType { kSysTypeESPixelStick = 0xC2, kSysTypeESPixelStickESP32 = 0xC3, kSysTypeBaldrick = 0xC4, + kSysTypeJBoards = 0xC5, kSysTypeNonMultiSyncCapable = 0xF0, kSysTypeTwinkly = 0xFA, kSysTypeWLED = 0xFB, diff --git a/src/NetworkController.cpp b/src/NetworkController.cpp index cb4e005b5..02f470e0f 100644 --- a/src/NetworkController.cpp +++ b/src/NetworkController.cpp @@ -85,6 +85,7 @@ class NetworkController::Detection { &NetworkController::DetectSanDevicesController, &NetworkController::DetectESPixelStickController, &NetworkController::DetectBaldrickController, + &NetworkController::DetectJBoardsController, &NetworkController::DetectAlphaPixController, &NetworkController::DetectHinksPixController, &NetworkController::DetectDIYLEDExpressController, @@ -564,6 +565,66 @@ void NetworkController::DetectBaldrickController(Detection* st) { }); } +void NetworkController::DetectJBoardsController(Detection* st) { + LogExcess(VB_SYNC, "Checking if %s is a JBoards controller\n", st->ip.c_str()); + + if (!contains(st->html, "JBoards")) { + st->noMatch(); + return; + } + + LogExcess(VB_SYNC, "%s is potentially a JBoards controller, checking further\n", st->ip.c_str()); + + st->fetch(buildHttpURL(st->ip, "/api/system/summary"), [this, st](bool ok, const std::string& resp) { + if (!ok) { + st->noMatch(); + return; + } + Json::Value v; + LoadJsonFromString(resp, v, JsonRoot::Object); + if (v.get("product", "").asString() != "JBoards") { + st->noMatch(); + return; + } + + vendor = "JBoards"; + vendorURL = "https://pixelpropshop.com"; + typeId = kSysTypeJBoards; + + typeStr = v.get("model", "").asString(); + if (typeStr.empty()) { + typeStr = "JBoards"; + } + hostname = v.get("hostname", "").asString(); + if (hostname.empty()) { + hostname = ip; + } + + std::string id = v.get("uuid", "").asString(); + if (!id.empty()) { + uuid = id; + } + std::string r = v.get("channelRanges", "").asString(); + if (!r.empty()) { + ranges = r; + } + + systemMode = (v.get("syncRole", "").asString() == "remote") ? REMOTE_MODE : PLAYER_MODE; + sendingMultiSync = v.get("sendingSync", false).asBool(); + + version = v.get("firmwareVersion", "").asString(); + std::size_t verDot = version.find("."); + if (verDot != std::string::npos) { + majorVersion = atoi(version.substr(0, verDot).c_str()); + std::size_t verDot2 = version.find(".", verDot + 1); + minorVersion = atoi(version.substr(verDot + 1, verDot2 - (verDot + 1)).c_str()); + } + + DumpControllerInfo(); + st->matched(); + }); +} + void NetworkController::DetectAlphaPixController(Detection* st) { LogExcess(VB_SYNC, "Checking if %s is a AlphaPix controller\n", st->ip.c_str()); diff --git a/src/NetworkController.h b/src/NetworkController.h index 022e98380..9148fc23b 100644 --- a/src/NetworkController.h +++ b/src/NetworkController.h @@ -87,6 +87,7 @@ class NetworkController { void DetectDIYLEDExpressController(Detection* st); void DetectWLEDController(Detection* st); void DetectTwinklyController(Detection* st); + void DetectJBoardsController(Detection* st); void DetectExperienceController(Detection* st); void DetectFPP(Detection* st); diff --git a/www/api/controllers/system.php b/www/api/controllers/system.php index 843d03566..6fb0979b5 100644 --- a/www/api/controllers/system.php +++ b/www/api/controllers/system.php @@ -799,6 +799,8 @@ function SystemGetStatus() $curl = curl_init("http://" . $urlHost . "/json/info"); } else if ($type == "Baldrick") { $curl = curl_init("http://" . $urlHost . "/system_state"); + } else if ($type == "JBoards") { + $curl = curl_init("http://" . $urlHost . "/api/system/summary"); } else if ($type == "FV3") { $curl = curl_init("http://" . $urlHost . "/status.xml"); } else if ($type == "FV4") { diff --git a/www/multisync.php b/www/multisync.php index 1dfc713ff..0d6f72d68 100644 --- a/www/multisync.php +++ b/www/multisync.php @@ -243,6 +243,7 @@ function isUserInteracting() { const wledPoller = new AutoRefreshController(); const geniusPoller = new AutoRefreshController(); const baldrickPoller = new AutoRefreshController(); + const jboardsPoller = new AutoRefreshController(); var unavailables = []; var reorderModeActive = false; @@ -318,6 +319,8 @@ function isUserInteracting() { WLED: new Set([0xFB]), // 0xC4 = 196 BALDRICK: new Set([0xC4]), + // 0xC5 = 197 + JBOARDS: new Set([0xC5]), }; function isDeviceType(typeId, category) { @@ -338,6 +341,7 @@ function isGenius(typeId) { return isDeviceType(typeId, 'GENIUS'); } function isWLED(typeId) { return isDeviceType(typeId, 'WLED'); } function isTwinkly(typeId) { return isDeviceType(typeId, 'TWINKLY'); } function isBaldrick(typeId) { return isDeviceType(typeId, 'BALDRICK'); } + function isJBoards(typeId) { return isDeviceType(typeId, 'JBOARDS'); } // ============================================================ // SECTION: Utilities @@ -1800,6 +1804,7 @@ function parseFPPSystems(data) { var wledIpAddresses = []; var geniusIpAddresses = []; var baldrickIpAddresses = []; + var jboardsIpAddresses = []; var espIpAddresses = []; var falconV4Addresses = []; var falconV3Addresses = []; @@ -1938,7 +1943,7 @@ function parseFPPSystems(data) { // poll slot (and its action buttons, via _dataIp) onto it. if (ipLocalityScore(ip, localIps) > ipLocalityScore(mergeItem._dataIp, localIps)) { swapPollAddress([fppIpAddresses, wledIpAddresses, geniusIpAddresses, - baldrickIpAddresses, espIpAddresses, + baldrickIpAddresses, jboardsIpAddresses, espIpAddresses, falconV4Addresses, falconV3Addresses], mergeItem._dataIp, ip); mergeItem._dataIp = ip; @@ -2134,12 +2139,15 @@ function parseFPPSystems(data) { geniusIpAddresses.push(ip); } else if (isBaldrick(data[i].typeId)) { baldrickIpAddresses.push(ip); + } else if (isJBoards(data[i].typeId)) { + jboardsIpAddresses.push(ip); } } getFPPSystemStatus(fppIpAddresses, false); getWLEDControllerStatus(wledIpAddresses, false); getGeniusControllerStatus(geniusIpAddresses, false); getBaldrickControllerStatus(baldrickIpAddresses, false); + getJBoardsControllerStatus(jboardsIpAddresses, false); for (var ei = 0; ei < espIpAddresses.length; ei++) { getESPixelStickBridgeStatus(espIpAddresses[ei]); } @@ -2825,6 +2833,78 @@ function getESPixelStickBridgeStatus(ip) { } } + // ============================================================ + // SECTION: JBoards polling + // ============================================================ + + async function getJBoardsControllerStatus(ipAddresses, refreshing = false) { + ips = ""; + if (Array.isArray(ipAddresses)) { + jboardsPoller.cancel(); + ipAddresses.forEach(function (entry) { + ips += "&ip[]=" + entry; + }); + } else { + ips = "&ip[]=" + ipAddresses; + } + if (ips == "") { + return; + } + try { + const r = await fetch("api/system/status?type=JBoards" + ips); + const alldata = await r.json(); + var $tbl = $('#fppSystemsTable'); + Object.entries(alldata).forEach(function (entry) { + var ip = entry[0], data = entry[1]; + if (data == null || data == "" || data == "null" || !data.state) { + return; + } + var uf = formatUptime(data.uptimeSeconds); + + var u = ""; + u += ''; + u += "
UP:' + uf.short + '
"; + + var rowId = hostRows[ip.replace(/\./g, '_')]; + var item = $tbl.bootstrapTable('getRowByUniqueId', rowId); + if (!item) return; + + item.utilization = u; + if (data.model && item.hostname.indexOf("class='hostDescriptionSM'>") >= 0) { + item.hostname = item.hostname.replace( + "class='hostDescriptionSM'>", + "class='hostDescriptionSM'>" + msEscape(data.model) + "" + ); + } + + var status = data.state.charAt(0).toUpperCase() + data.state.slice(1); + var elapsed = ""; + if (data.state == 'playing' || data.state == 'paused') { + var files = []; + if (data.playlist) files.push(msEscape(data.playlist)); + if (data.sequence) files.push(msEscape(data.sequence)); + if (files.length) { + status += ":
" + files.join("
"); + } + if (data.durationSeconds > 0) { + elapsed = SecondsToHuman(data.elapsedSeconds); + } + } + item.status = status; + item.elapsed = elapsed; + + if (data.firmwareVersion) { + item.version = msEscape(data.firmwareVersion); + } + }); + safeInitBody($tbl); + } finally { + if (Array.isArray(ipAddresses) && $('#MultiSyncRefreshStatus').is(":checked") && !isUserInteracting()) { + jboardsPoller.schedule(() => getJBoardsControllerStatus(ipAddresses, true)); + } + } + } + // ============================================================ // SECTION: Refresh orchestration // ============================================================ @@ -2835,11 +2915,12 @@ function clearRefreshTimers() { wledPoller.cancel(); geniusPoller.cancel(); baldrickPoller.cancel(); + jboardsPoller.cancel(); } function RefreshStats() { var $tbl = $('#fppSystemsTable'); - var ips = [], gips = [], wips = [], bips = [], fv3ips = [], fv4ips = []; + var ips = [], gips = [], wips = [], bips = [], jips = [], fv3ips = [], fv4ips = []; var seenRows = {}; Object.keys(hostRows).forEach(function (key) { @@ -2872,6 +2953,8 @@ function RefreshStats() { gips.push(ip); } else if (isBaldrick(typeId)) { bips.push(ip); + } else if (isJBoards(typeId)) { + jips.push(ip); } }); @@ -2879,6 +2962,7 @@ function RefreshStats() { getGeniusControllerStatus(gips, true); getWLEDControllerStatus(wips, true); getBaldrickControllerStatus(bips, true); + getJBoardsControllerStatus(jips, true); getFalconControllerStatus(fv3ips, fv4ips, true); } @@ -4390,6 +4474,7 @@ function key(raw, row) { 'SanDevices': 'SanDevices', 'WLED': 'WLED', 'Twinkly': 'Twinkly', + 'JBoards': 'JBoards', 'Unknown': 'Unknown' }; window.platformFilterSearch = function (text, value, field, data) { @@ -4418,6 +4503,7 @@ function key(raw, row) { case 'sandevices': return isSanDevices(typeId); case 'wled': return isWLED(typeId); case 'twinkly': return isTwinkly(typeId); + case 'jboards': return isJBoards(typeId); case 'unknown': return isUnknownController(typeId); default: return true; }