diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
index 12b3d90a..b94d9b12 100644
--- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
+++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
@@ -46,8 +46,15 @@ namespace PepperDash.Core
{
get
{
- if (_httpsServer == null) return "";
- return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{_httpsServer.WebSocketServices[_path].Path}";
+ if (_httpsServer == null || !_httpsServer.IsListening) return "";
+ var service = _httpsServer.WebSocketServices[_path];
+ if (service == null) return "";
+
+ // Use CSLAN IP if available, otherwise fallback to primary IP. This ensures we provide a reachable URL in dual-stack environments.
+ if (!string.IsNullOrEmpty(CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1)))
+ return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1)}:{_httpsServer.Port}{service.Path}";
+ else
+ return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{service.Path}";
}
}
diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs
index 62e577ca..f07aab42 100644
--- a/src/PepperDash.Core/Web/WebApiServer.cs
+++ b/src/PepperDash.Core/Web/WebApiServer.cs
@@ -173,6 +173,22 @@ namespace PepperDash.Core.Web
_server.Routes.Remove(route);
}
+ ///
+ /// Sets the fallback request handler that is invoked when no registered route
+ /// matches an incoming request. Must be called before .
+ ///
+ /// The handler to use as the server-level fallback.
+ public void SetFallbackHandler(IHttpCwsHandler handler)
+ {
+ if (handler == null)
+ {
+ this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring");
+ return;
+ }
+
+ _server.HttpRequestHandler = handler;
+ }
+
///
/// GetRouteCollection method
///
diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
index 2a7a48c5..9e781883 100644
--- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
+++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
@@ -17,6 +17,9 @@ namespace PepperDash.Essentials.Core.Web
{
private readonly WebApiServer _server;
+ private readonly WebApiServer _debugServer;
+
+
///
/// http(s)://{ipaddress}/cws/{basePath}
/// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath}
@@ -70,6 +73,9 @@ namespace PepperDash.Essentials.Core.Web
_server = new WebApiServer(Key, Name, BasePath);
+ _debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug");
+ _debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler());
+
SetupRoutes();
}
@@ -242,6 +248,7 @@ namespace PepperDash.Essentials.Core.Web
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series");
_server.Start();
+ _debugServer.Start();
GetPaths();
@@ -252,7 +259,8 @@ namespace PepperDash.Essentials.Core.Web
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server");
_server.Start();
-
+ _debugServer.Start();
+
GetPaths();
}
diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs
index 397278f5..c966ea59 100644
--- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs
+++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs
@@ -36,7 +36,7 @@ namespace PepperDash.Essentials.Core.Web
};
}
-
+
///
/// MapToDeviceListObject method
@@ -119,9 +119,5 @@ namespace PepperDash.Essentials.Core.Web
};
}
- internal static bool IsAuthenticated(HttpCwsContext context)
- {
- throw new NotImplementedException();
- }
}
}
\ No newline at end of file
diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs
index 67aded59..5a4b7df7 100644
--- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs
+++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs
@@ -71,8 +71,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
{
context.Response.StatusCode = 401;
context.Response.StatusDescription = "Bad Request";
+ context.Response.ContentType = "application/json";
+ context.Response.ContentEncoding = System.Text.Encoding.UTF8;
+ context.Response.Write(JsonConvert.SerializeObject(new { Error = "Unauthorized" }, Formatting.Indented), false);
context.Response.End();
-
return;
}
diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs
new file mode 100644
index 00000000..d8a296e5
--- /dev/null
+++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs
@@ -0,0 +1,224 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Crestron.SimplSharp;
+using Crestron.SimplSharp.CrestronIO;
+using Crestron.SimplSharp.WebScripting;
+using PepperDash.Core;
+using PepperDash.Core.Web.RequestHandlers;
+using Serilog.Events;
+
+namespace PepperDash.Essentials.Core.Web.RequestHandlers
+{
+ ///
+ /// Serves the React debug app from the processor's /HTML/debug folder.
+ /// The root route (debug) and all sub-paths (debug/{*filePath}) are handled here.
+ /// Text assets are sent as UTF-8 strings; binary assets are written to the response
+ /// OutputStream. Any sub-path that does not match a real file falls back to
+ /// index.html so that client-side (React Router) routing continues to work.
+ ///
+ public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler
+ {
+ private static readonly Dictionary MimeTypes = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { ".html", "text/html; charset=utf-8" },
+ { ".htm", "text/html; charset=utf-8" },
+ { ".js", "application/javascript" },
+ { ".mjs", "application/javascript" },
+ { ".jsx", "application/javascript" },
+ { ".css", "text/css" },
+ { ".json", "application/json" },
+ { ".map", "application/json" },
+ { ".svg", "image/svg+xml" },
+ { ".ico", "image/x-icon" },
+ { ".png", "image/png" },
+ { ".jpg", "image/jpeg" },
+ { ".jpeg", "image/jpeg" },
+ { ".gif", "image/gif" },
+ { ".woff", "font/woff" },
+ { ".woff2","font/woff2" },
+ { ".ttf", "font/ttf" },
+ { ".eot", "application/vnd.ms-fontobject" },
+ };
+
+ private static readonly HashSet TextExtensions = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ ".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml"
+ };
+
+ ///
+ /// Constructor. CORS is enabled so browser dev-tools requests succeed.
+ ///
+ public ServeDebugAppRequestHandler() : base(true) { }
+
+ ///
+ /// Handles GET requests for the debug app and its static assets.
+ ///
+ protected override void HandleGet(HttpCwsContext context)
+ {
+ // When acting as the server-level fallback handler, only handle
+ // requests that are actually for the /debug path; defer everything
+ // else to the base class (which returns 501 Not Implemented).
+ var rawUrl = context.Request.RawUrl ?? string.Empty;
+ if (rawUrl.IndexOf("/debug", StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ base.HandleGet(context);
+ return;
+ }
+
+ try
+ {
+ var htmlDebugPath = GetHtmlDebugPath();
+ if (htmlDebugPath == null)
+ {
+ SendResponse(context, 500, "Internal Server Error");
+ return;
+ }
+
+ var requestedPath = GetRequestedFilePath(context);
+
+ // Paths with no file extension are SPA client-side routes — serve index.html
+ string candidate;
+ if (string.IsNullOrEmpty(requestedPath) || !System.IO.Path.HasExtension(requestedPath))
+ {
+ candidate = System.IO.Path.Combine(htmlDebugPath, "index.html");
+ }
+ else
+ {
+ var relativePart = requestedPath.Replace('/', System.IO.Path.DirectorySeparatorChar);
+ candidate = System.IO.Path.Combine(htmlDebugPath, relativePart);
+ }
+
+ // Resolve to an absolute path and guard against path-traversal attacks
+ var resolvedCandidate = System.IO.Path.GetFullPath(candidate);
+ var resolvedBase = System.IO.Path.GetFullPath(htmlDebugPath)
+ + System.IO.Path.DirectorySeparatorChar;
+
+ if (!resolvedCandidate.StartsWith(resolvedBase, StringComparison.OrdinalIgnoreCase))
+ {
+ SendResponse(context, 403, "Forbidden");
+ return;
+ }
+
+ // Missing static asset → fall back to index.html (SPA deep-link support)
+ if (!File.Exists(resolvedCandidate) && System.IO.Path.HasExtension(requestedPath ?? string.Empty))
+ {
+ resolvedCandidate = System.IO.Path.Combine(htmlDebugPath, "index.html");
+ Debug.LogMessage(LogEventLevel.Debug,
+ "ServeDebugAppRequestHandler: '{requestedPath:l}' not found, falling back to index.html",
+ requestedPath);
+ }
+
+ if (!File.Exists(resolvedCandidate))
+ {
+ SendResponse(context, 404, "Not Found");
+ return;
+ }
+
+ var ext = System.IO.Path.GetExtension(resolvedCandidate);
+ var contentType = MimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream";
+
+ context.Response.StatusCode = 200;
+ context.Response.StatusDescription = "OK";
+ context.Response.ContentType = contentType;
+
+ if (TextExtensions.Contains(ext))
+ {
+ string content;
+ using (var reader = new StreamReader(resolvedCandidate, Encoding.UTF8))
+ content = reader.ReadToEnd();
+
+ context.Response.ContentEncoding = Encoding.UTF8;
+ context.Response.Write(content, false);
+ }
+ else
+ {
+ var bytes = System.IO.File.ReadAllBytes(resolvedCandidate);
+ context.Response.OutputStream.Write(bytes, 0, bytes.Length);
+ }
+
+ context.Response.End();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogMessage(LogEventLevel.Error, ex,
+ "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}'",
+ context.Request.RawUrl);
+ try { SendResponse(context, 500, "Internal Server Error"); }
+ catch { /* best-effort */ }
+ }
+ }
+
+ ///
+ /// Resolves the absolute path of the /HTML/debug folder on the processor.
+ ///
+ ///
+ /// Global.FilePathPrefix is always {root}/user/programX/ (or
+ /// equivalent), so walking up two parents gives the processor root that
+ /// contains the html folder. This mirrors the two-hop strategy used
+ /// by AssetLoader.ExtractDevToolsZip so that serving and extraction
+ /// always resolve to the same directory.
+ ///
+ private static string GetHtmlDebugPath()
+ {
+ try
+ {
+ var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar };
+ var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators));
+
+ // Walk up two levels: {root}/user/programX/ → {root}/user/ → {root}
+ // This matches the path calculation used in AssetLoader.ExtractDevToolsZip.
+ var userOrNvramDir = programDir.Parent;
+ var rootDir = userOrNvramDir?.Parent;
+
+ if (rootDir == null)
+ {
+ Debug.LogMessage(LogEventLevel.Error,
+ "ServeDebugAppRequestHandler: Cannot resolve HTML root from FilePathPrefix '{prefix:l}'",
+ Global.FilePathPrefix);
+ return null;
+ }
+
+ return System.IO.Path.Combine(rootDir.FullName, "html", "debug");
+ }
+ catch (Exception ex)
+ {
+ Debug.LogMessage(LogEventLevel.Error, ex,
+ "ServeDebugAppRequestHandler: Error resolving HTML debug path");
+ return null;
+ }
+ }
+
+ ///
+ /// Extracts the file sub-path from the request by parsing RawUrl.
+ /// Returns an empty string when the URL ends at /debug (root hit).
+ ///
+ private static string GetRequestedFilePath(HttpCwsContext context)
+ {
+ var rawUrl = context.Request.RawUrl ?? string.Empty;
+
+ // Locate the /debug segment in the URL
+ const string debugToken = "/debug";
+ var idx = rawUrl.IndexOf(debugToken, StringComparison.OrdinalIgnoreCase);
+ if (idx < 0)
+ return string.Empty;
+
+ var afterDebug = rawUrl.Substring(idx + debugToken.Length);
+
+ // Strip query string
+ var qIdx = afterDebug.IndexOf('?');
+ if (qIdx >= 0)
+ afterDebug = afterDebug.Substring(0, qIdx);
+
+ // Strip leading slash to get a relative file path
+ return afterDebug.TrimStart('/');
+ }
+
+ private static void SendResponse(HttpCwsContext context, int statusCode, string statusDescription)
+ {
+ context.Response.StatusCode = statusCode;
+ context.Response.StatusDescription = statusDescription;
+ context.Response.End();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs
index 03c01a0c..be020e2e 100644
--- a/src/PepperDash.Essentials/ControlSystem.cs
+++ b/src/PepperDash.Essentials/ControlSystem.cs
@@ -1,5 +1,4 @@
using System;
-using System.IO.Compression;
using System.Linq;
using System.Reflection;
using Crestron.SimplSharp;