Compare commits

...

84 commits

Author SHA1 Message Date
Neil Dorin
4f1eb979d3
Merge pull request #1436 from PepperDash/claude/deprecate-imobilecontrolmessengerwithsubscriptions 2026-06-26 18:11:17 -06:00
anthropic-code-agent[bot]
8ac4eb7584
refactor: marked mobile control subscription items as obsolete
Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com>
2026-06-26 22:32:56 +00:00
Neil Dorin
0240887d93
Clarify summary for EnableMessengerSubscriptions property
Updated the summary comment for EnableMessengerSubscriptions property to clarify its purpose.
2026-06-26 15:57:52 -06:00
copilot-swe-agent[bot]
640bd7a8a7
Update XML summary for EnableMessengerSubscriptions to reflect obsolete status 2026-06-26 21:55:49 +00:00
Neil Dorin
2fac0ca926
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-26 14:32:22 -06:00
anthropic-code-agent[bot]
af5611e403
Mark IMobileControlMessengerWithSubscriptions and EnableMessengerSubscriptions as obsolete
All messengers are now subscription based in v3.x, making these
constructs no longer necessary.

Closes #1435

Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/bda64c9c-5343-412b-801f-5e60816bc38d

Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com>
2026-06-26 20:11:48 +00:00
anthropic-code-agent[bot]
3286d27898
Initial plan 2026-06-26 20:09:32 +00:00
erikdred
a63da82cc3
Merge pull request #1432 from PepperDash/fix/web-debug-url
Fix/web debug url
2026-06-12 17:15:05 -04:00
Neil Dorin
3974455337 fix: add port forward timeout handling in DebugSessionRequestHandler 2026-06-12 15:08:34 -06:00
Neil Dorin
782bb6c057 fix: improve CS LAN IP handling and update fallback debug session URL in DebugSessionRequestHandler 2026-06-12 14:52:35 -06:00
Neil Dorin
907eb2f397 fix: add csIp handling and update debug session URL in DebugSessionRequestHandler 2026-06-12 14:29:49 -06:00
Neil Dorin
72a4c63a06
Merge pull request #1430 from PepperDash/feature/add-escape-handling-for-devjson
Feature/add escape handling for devjson
2026-06-12 11:31:24 -06:00
Neil Dorin
5f26cb98fd
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-12 11:28:39 -06:00
Neil Dorin
75587c361f
Merge pull request #1428 from PepperDash/tieline-mapping
Fix cache clearing for impossibleRoutes
2026-06-09 09:25:42 -06:00
Andrew Welker
c3511cd1a6 fix: remove impossibleRoutes cache
The cache wasn't being cleared correctly, and was an unnecessary
add.
2026-06-09 10:18:25 -05:00
Neil Dorin
1ce86dab29 fix: add escape handling for port forwarding in DebugSessionRequestHandler 2026-06-02 14:10:33 -06:00
Neil Dorin
77c700c565 fix: add escape handling for byte arrays and strings in DeviceJsonApi 2026-06-02 11:25:48 -06:00
Neil Dorin
aa050121ae
Merge pull request #1424 from PepperDash/fix/videoCodec-routing-issues
fix: update routing logic
2026-05-22 10:18:53 -06:00
Neil Dorin
abef1d095f fix: prevent processing of null or empty midpoint keys in RoutingFeedbackManager 2026-05-22 09:48:12 -06:00
Neil Dorin
f658fdf363 fix: update routing logic and enhance logging in Extensions and MockVC classes
closes #1423
2026-05-21 16:21:03 -06:00
Neil Dorin
469999b6f7
Merge pull request #1422 from PepperDash/fix/fix-eControlMethod-enum-values
Fix/fix e control method enum values
2026-05-20 16:30:22 -06:00
Neil Dorin
08aba35334 fix: addresses #1421 2026-05-20 16:17:21 -06:00
Neil Dorin
68fe205504 fix: assign explicit values to eControlMethod enum members and add UdpClient entry as last value 2026-05-20 15:58:46 -06:00
Neil Dorin
e1a5c32c1f
Merge pull request #1420 from PepperDash/hotfix-udpClient-clean
feat: add support for UdpClient in communication methods
2026-05-18 09:45:34 -06:00
Jonathan Arndt
18f7000d76 fix: add udpClient behavior to throttle receive errors and reset upon valid traffic arrival 2026-05-15 13:45:50 -07:00
Jonathan Arndt
d47cfd5e62
fix: Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 13:13:24 -07:00
copilot-swe-agent[bot]
4f2d2ca746
fix: log udp client send failures when disconnected
Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/761a7a78-c51f-474b-9000-baa9a232c0d0

Co-authored-by: jonnyarndt <21110580+jonnyarndt@users.noreply.github.com>
2026-05-15 20:09:53 +00:00
Jonathan Arndt
e6583f7824
fix: Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 13:06:25 -07:00
Jonathan Arndt
e57bc43a10
fix: Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 13:04:28 -07:00
Jonathan Arndt
b83af26b77
fix: Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 13:01:08 -07:00
Jonathan Arndt
c9f5af184b feat: add support for UdpClient in communication methods and implement GenericUdpClient class 2026-05-14 17:22:38 -07:00
Neil Dorin
beb77ec468
Merge pull request #1418 from PepperDash/fix/devtools-fixex
Fix/devtools fixex
2026-05-11 10:59:58 -06:00
Neil Dorin
b318e7f365 fix: validate certificate for private key presence in DebugWebsocketSink
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 09:55:27 -06:00
Neil Dorin
a9dd57fdaf refactor: remove Password from LoginResponse and related token assignment 2026-05-11 09:51:48 -06:00
Neil Dorin
d879430616 feat: update LoginResponse structure to include Password in the token response 2026-05-11 09:50:39 -06:00
Neil Dorin
1a5840c29a
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 09:48:12 -06:00
Neil Dorin
6fbdaa9ca0 Merge remote-tracking branch 'origin/main' into fix/devtools-fixex 2026-05-10 20:48:10 -06:00
Neil Dorin
e5e1802da9 feat: enhance LoginRequestHandler to include detailed LoginResponse structure
Co-authored-by: Copilot <copilot@github.com>
2026-05-10 20:48:05 -06:00
Neil Dorin
17adac639e
Merge pull request #1417 from PepperDash/maintenance/add-deprecations 2026-05-08 16:50:02 -06:00
Neil Dorin
2607180ab5
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 13:20:03 -06:00
Neil Dorin
f1ef479301
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 13:19:46 -06:00
Neil Dorin
8ba993ed66
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 13:19:04 -06:00
Neil Dorin
9fc866741e feat: mark deprecated classes and methods for future removal 2026-05-08 13:08:58 -06:00
Neil Dorin
f3ea9e1d5a
Merge pull request #1416 from PepperDash/feat/load-webapi-without-config
Feat/load webapi without config
2026-05-07 12:44:30 -06:00
Neil Dorin
404728c708 feat: refactor portal info command to improve configuration handling and response messages 2026-05-07 12:07:29 -06:00
Neil Dorin
c050bb4eb3 feat: disable key persistence for RSA in DebugWebsocketSink and simplify config loading warning message 2026-05-07 11:01:10 -06:00
Neil Dorin
2530003a58 refactor: clean up whitespace and improve debug URL logging format 2026-05-07 10:56:56 -06:00
Neil Dorin
c52d585a0c feat: Allow WebAPI to load if no config file is present 2026-05-07 10:44:54 -06:00
Neil Dorin
89c23f5432 feat: enhance certificate handling with BouncyCastle and remove firmware version checks
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 09:01:27 -06:00
Neil Dorin
841279eb0c
Merge pull request #1415 from PepperDash/feat/use-bouncycastle
Feat/use bouncycastle
2026-05-05 14:30:47 -06:00
Neil Dorin
1b32761e8e fix: invert condition for system_url validation in MobileControlSystemController
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 13:45:17 -06:00
Neil Dorin
507130c1ae feat: refactor certificate generation to use BouncyCastle for improved security and flexibility
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 13:07:52 -06:00
Neil Dorin
1ecfca7e5a
Merge pull request #1414 from PepperDash/fix/debug-on-cslan-and-fw-dependency-check
fix: clean up unused using directives and add firmware version check …
2026-05-05 10:58:32 -06:00
Neil Dorin
dd32f44e3d fix: clean up unused using directives and add firmware version check in ControlSystem
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 10:55:33 -06:00
Andrew Welker
ba48f23a71
Merge pull request #1410 from PepperDash/tieline-visualisation
Tieline visualisation
2026-05-04 11:10:06 -05:00
copilot-swe-agent[bot]
447af3883c
fix: align GetHtmlDebugPath with AssetLoader two-hop path resolution
Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/9d7d71b4-b083-412b-b7b2-3167561eeed3

Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com>
2026-05-04 15:48:12 +00:00
Neil Dorin
3b57860123 fix: correct response status description in LoginRequestHandler and improve error logging in ServeDebugAppRequestHandler 2026-05-04 09:43:06 -06:00
Neil Dorin
b1a575e4d2 feat: enhance URL generation in DebugWebsocketSink to support dual-stack environments 2026-05-01 14:43:48 -06:00
Neil Dorin
2fdd73498a Merge remote-tracking branch 'origin/main' into tieline-visualisation 2026-04-30 14:24:14 -06:00
Neil Dorin
ee240ca378 feat: add ServeDebugAppRequestHandler for serving debug app assets and improve error handling in LoginRequestHandler
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 14:22:53 -06:00
Neil Dorin
f31f0611f1
Merge pull request #1374 from PepperDash/tieline-visualisation
tieline visualisation
2026-04-30 10:59:50 -06:00
Andrew Welker
0a991cffb0 docs: remove commented out code and associated XML comments 2026-04-17 10:57:36 -05:00
Andrew Welker
18088d37a1 feat: handle timers correctly and migrate to native Timer instead of CTimer 2026-04-17 10:56:51 -05:00
Andrew Welker
4f86009209 chore: remove unused using statements 2026-04-17 10:56:14 -05:00
Andrew Welker
2197dc489d fix: pre-built routes now respect source ports for finding routes 2026-04-17 10:30:11 -05:00
Andrew Welker
db14a614bc fix: move to concurrent dictionary instead of hash set for impossible routes 2026-04-17 10:30:11 -05:00
Andrew Welker
4a59cf9f81 chore: remove deprecated Debug.Console methods 2026-04-17 10:30:11 -05:00
Neil Dorin
9ea5ec5d1a feat: implement login functionality with LoginRequestHandler and integrate asset loading 2026-04-16 21:31:30 -06:00
Neil Dorin
6e9480f503 feat: add DeleteAllUiClientsHandler and route for deleting all clients. Resolve issues with client closing websocket connection to DebugWebsocketSink 2026-04-14 22:12:39 -06:00
Neil Dorin
a610e127de fix: update version to 2.29.0-local in Directory.Build.props 2026-04-14 16:40:37 -06:00
Neil Dorin
c820052b41
Merge pull request #1409 from PepperDash/feature/update-codec-webview-state
Refs/heads/feature/update codec webview state
2026-04-14 09:53:32 -06:00
Erik Meyer
bf517ad8b8 fix: add url property to WebViewClear 2026-04-14 10:09:52 -04:00
Erik Meyer
e55ac38719 feat: update IHasWebView with webview state info 2026-04-13 16:08:01 -04:00
Neil Dorin
e800e0912c Merge remote-tracking branch 'origin/main' into tieline-visualisation 2026-04-10 09:50:58 -06:00
Neil Dorin
d2684f364d
Merge pull request #1407 from PepperDash/update-routing-documentation
Update routing docs & tieline creation
2026-04-03 11:21:32 -06:00
Andrew Welker
29d5804cb0
docs: fix spelling error
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-02 17:31:02 -05:00
Andrew Welker
c20d49f430 fix: restrict tieline creation when signal types are invalid 2026-04-02 15:22:17 -05:00
Andrew Welker
a82cf4f449 docs: udpate routing documentation to match the algorithm 2026-04-02 15:21:59 -05:00
Evan DiLallo
305a466b1b
Merge pull request #1395 from PepperDash/fix/implement-IKeyed-on-BridgeBase
feat: implement constructor for BridgeApi with key and name parameters
2026-03-12 16:30:51 -04:00
Neil Dorin
5a07e837ee feat: implement constructor for BridgeApi with key and name parameters 2026-03-12 14:27:21 -06:00
Andrew Welker
9c9a643b6a feat: add CWS endpoint to get routing devices & tielines together 2026-01-14 10:22:35 -06:00
Andrew Welker
fb8216beed feat: map routes/tielines at startup and new console commands
* visualizeroutes allows visualizing configured routes based on tielines and signal type
  * can be filtered by source key, destination key, and type, along with partial matches for source & destination keys
* visualizecurrentroutes visualizes what Essentials says is currently routed by type
  * uses same filtering as visualizeroutes
* improvements to how the routing algorithm works
2026-01-14 10:00:48 -06:00
Andrew Welker
d05ebecd7d fix: getroutingports command now prints port types 2026-01-14 09:55:32 -06:00
Andrew Welker
d0fe225bbc feat: improve routing feedback manager
* Performance improvment by mapping out midpoint to sinks on startup
* Use existing routing methods
* Debounce event handling
* Check all signal types for route updates
2026-01-14 09:54:44 -06:00
46 changed files with 3787 additions and 1046 deletions

View file

@ -20,3 +20,4 @@ jobs:
tag: ${{ needs.getVersion.outputs.tag }} tag: ${{ needs.getVersion.outputs.tag }}
channel: ${{ needs.getVersion.outputs.channel }} channel: ${{ needs.getVersion.outputs.channel }}
bypassPackageCheck: true bypassPackageCheck: true
devToolsVersion: ${{ vars.ESSENTIALSDEVTOOLSVERSION }}

View file

@ -2,13 +2,35 @@
## TL;DR ## TL;DR
Routing is defined by a connection graph or a wiring diagram. Routeable devices are sources, midpoints, or destinations. Devices are connected by tie lines. Tie lines represent the cables connecting devices, and are of type audio, video or both. Routes are made by telling a destination to get an audio/video/combined route from a source. Routing is defined by a connection graph or a wiring diagram. Routable devices are sources, midpoints, or destinations. Devices are connected by tie lines. Tie lines represent the cables connecting devices, and have specific signal types (audio, video, audioVideo, secondaryAudio, usbInput, usbOutput). Routes are made by telling a destination to get a route from a source for a specific signal type. Combined signal types (e.g., audioVideo) are automatically split into separate routing operations.
## Summary ## Summary
Essentials routing is described by defining a graph of connections between devices in a system, typically in configuration. The audio, video and combination connections are like a wiring diagram. This graph is a collection of devices and tie lines, each tie line connecting a source device, source output port, destination device and destination input port. Tie lines are logically represented as a collection. Essentials routing is described by defining a graph of connections between devices in a system, typically in configuration. The audio, video and combination connections are like a wiring diagram. This graph is a collection of devices and tie lines, each tie line connecting a source device, source output port, destination device and destination input port. Tie lines are logically represented as a collection.
When routes are to be executed, Essentials will use this connection graph to decide on routes from source to destination. A method call is made on a destination, which says “destination, find a way for source xyz to get to you.” An algorithm analyzes the tie lines, instantly walking backwards from the destination, down every connection until it finds a complete path from the source. If a connected path is found, the algorithm then walks forward through all midpoints to the destination, executing switches as required until the full route is complete. The developer or configurer only needs to say “destination, get source xyz” and Essentials figures out how, regardless of what devices lie in between. When routes are to be executed, Essentials will use this connection graph to decide on routes from source to destination. A method call is made on a destination, which says "destination, find a way for source xyz to get to you." An algorithm analyzes the tie lines, instantly walking backwards from the destination, down every connection until it finds a complete path from the source. If a connected path is found, the algorithm then walks forward through all midpoints to the destination, executing switches as required until the full route is complete. The developer or configurer only needs to say "destination, get source xyz" and Essentials figures out how, regardless of what devices lie in between.
### Signal Type Handling
When a combined signal type like `audioVideo` is requested, Essentials automatically splits it into two separate routing operations—one for audio and one for video. Each signal type is routed independently through the system, ensuring that:
- Audio-only tie lines can be used for the audio portion
- Video-only tie lines can be used for the video portion
- AudioVideo tie lines can be used for both portions
During path discovery, **only tie lines that support the requested signal type are considered**. For example, if a video route is requested, only tie lines with the video flag will be evaluated. This ensures signal compatibility throughout the entire routing chain.
### Port-Specific Routing
The routing system supports routing to and from specific ports on devices. You can specify:
- A specific input port on the destination device
- A specific output port on the source device
- Both specific ports for precise routing control
When no specific ports are specified, the algorithm will automatically discover the appropriate ports based on available tie lines.
### Request Queuing
All routing requests are processed sequentially through a queue. For devices that implement warming/cooling behavior (e.g., projectors), route requests are automatically held when a device is cooling down and executed once the device is ready. This prevents routing errors and ensures proper device state management.
### Classes Referenced ### Classes Referenced
@ -23,15 +45,15 @@ When routes are to be executed, Essentials will use this connection graph to dec
The diagram below shows the connections in a simple presentation system, with a few variations in connection paths. Example routes will be described following the diagram. The diagram below shows the connections in a simple presentation system, with a few variations in connection paths. Example routes will be described following the diagram.
Each visible line between ports on devices represents a tie line. A tie line connects an output port on one device to an input port on another device, for example: an HDMI port on a document camera to an HDMI input on a matrix switcher. A tie line may be audio, video or both. It is essentially a logical representation of a physical cable in a system. This diagram has 12 tie lines, and those tie lines are defined in the tieLines array in configuration. Each visible line between ports on devices represents a tie line. A tie line connects an output port on one device to an input port on another device, for example: an HDMI port on a document camera to an HDMI input on a matrix switcher. A tie line has a signal type (audio, video, audioVideo, secondaryAudio, usbInput, or usbOutput) that determines what signals can travel through it. It is essentially a logical representation of a physical cable in a system. This diagram has 12 tie lines, and those tie lines are defined in the tieLines array in configuration.
![Routing system diagram](~/docs/images/routing-system-diagram.png) ![Routing system diagram](~/docs/images/routing-system-diagram.png)
Lets go through some examples of routing, using pseudo-code: Lets go through some examples of routing, using pseudo-code:
1. Method call: “Projector 1, show Doc cam.” Routing will walk backwards through DM-RMC-3 and DM-8x8 iterating through all “wired up” ports until it finds a path back to the Doc cam. Routing will then step back through all devices in the discovered chain, switching routes on those that are switchable: Doc cam: no switching; DM 8x8: route input 3 to output 3; DM-RMC-3: no switching; Projector 1: Select input HDMI In. Route is complete. 1. Method call: “Projector 1, show Doc cam.” Routing will walk backwards through DM-RMC-3 and DM-8x8 iterating through all “wired up” ports until it finds a path back to the Doc cam. Routing will then step back through all devices in the discovered chain, switching routes on those that are switchable: Doc cam: no switching; DM 8x8: route input 3 to output 3; DM-RMC-3: no switching; Projector 1: Select input HDMI In. Route is complete.
2. Method call: “Projector 2, show Laptop, video-only.” Routing will walk backwards through DM-RMC-4, DM 8x8, DM-TX-1, iterating through all connected ports until it finds a connection to the laptop. Routing then steps back through all devices, switching video where it can: Laptop: No switching; DM-TX-1: Select HDMI in; DM 8x8: Route input 5 to output 4; DM-RMC-4: No switching; Projector 2: Select HDMI input. Route is complete. 2. Method call: "Projector 2, show Laptop, video-only." Routing will walk backwards through DM-RMC-4, DM 8x8, DM-TX-1, iterating through all connected ports until it finds a connection to the laptop. During this search, only tie lines that support video signals are considered. Routing then steps back through all devices, switching video where it can: Laptop: No switching; DM-TX-1: Select HDMI in; DM 8x8: Route input 5 to output 4; DM-RMC-4: No switching; Projector 2: Select HDMI input. Route is complete.
3. Method call: “Amplifier, connect Laptop audio.” Again walking backwards to Laptop, as in #2 above. Switching will take place on DM-TX-1, DM 8x8, audio-only. 3. Method call: "Amplifier, connect Laptop audio." Again walking backwards to Laptop, as in #2 above, but this time only tie lines supporting audio signals are evaluated. Switching will take place on DM-TX-1, DM 8x8, audio-only.
4. Very simple call: “Lobby display, show signage controller.” Routing will walk back on HDMI input 1 and immediately find the signage controller. It then does a switch to HDMI 1 on the display. 4. Very simple call: “Lobby display, show signage controller.” Routing will walk back on HDMI input 1 and immediately find the signage controller. It then does a switch to HDMI 1 on the display.
All four of the above could be logically combined in a series of calls to define a possible “scene” in a room: Put Document camera on Projector 1, put Laptop on Projector 2 and the audio, put Signage on the Lobby display. They key takeaway is that the developer doesnt need to define what is involved in making a certain route. The person configuring the system defines how its wired up, and the code only needs to tell a given destination to get a source, likely through configuration as well. All four of the above could be logically combined in a series of calls to define a possible “scene” in a room: Put Document camera on Projector 1, put Laptop on Projector 2 and the audio, put Signage on the Lobby display. They key takeaway is that the developer doesnt need to define what is involved in making a certain route. The person configuring the system defines how its wired up, and the code only needs to tell a given destination to get a source, likely through configuration as well.
@ -40,6 +62,37 @@ All of the above routes can be defined in source list routing tables, covered el
--- ---
## Routing Algorithm Details
### Combined Signal Type Splitting
When an `audioVideo` route is requested, the routing system automatically splits it into two independent routing operations:
1. **Audio Route**: Finds the best path for audio signals from source to destination
2. **Video Route**: Finds the best path for video signals from source to destination
Each route can take a different physical path through the system. For example:
- Video might travel: Laptop → DM-TX-1 → DM Matrix → Display
- Audio might travel: Laptop → DM-TX-1 → DM Matrix → Audio Processor → Amplifier
Both routes are discovered, stored, and executed independently. This allows for flexible system designs where audio and video follow different paths.
The same splitting behavior occurs for `Video + SecondaryAudio` requests, where video and secondary audio are routed as separate operations.
### Signal Type Filtering
At each step of the route discovery process, the algorithm filters tie lines based on the requested signal type:
- **Video request**: Only considers tie lines with the `video` flag set
- **Audio request**: Only considers tie lines with the `audio` flag set
- **AudioVideo request**: Routes audio and video separately, each following their respective filtering rules
If no tie line exists with the required signal type at any point in the chain, that path is rejected and the algorithm continues searching for an alternative route. If no valid path is found, the route request fails and no switching occurs.
This filtering ensures that incompatible signal types never interfere with routing decisions. For example, an audio-only cable will never be selected when routing video, preventing misconfiguration errors.
---
### Definitions ### Definitions
#### Ports #### Ports
@ -64,18 +117,101 @@ A sink is a device at the end of a full signal path. For example, a display, amp
#### Tie-line #### Tie-line
A tie-line is a logical representation of a physical cable connection between two devices. It has five properties that define how the tie-line connects two devices. A configuration snippet for a single tie line connecting HDMI output 1 on a Cisco RoomKit to HDMI input 1 on a display, carrying both audio and video, is shown below. A tie-line is a logical representation of a physical cable connection between two devices. It has five properties that define how the tie-line connects two devices.
##### How Tie Line Types Are Determined
The effective type of a tie line is determined by one of two methods:
1. **Automatic (Recommended)**: When no `type` property is specified in configuration, the tie line's type is automatically calculated as the **intersection** of signal types supported by both the source and destination ports. This ensures only compatible signals are considered for routing.
Example: If a source port supports `AudioVideo` and the destination port supports `Audio`, the tie line will have type `Audio` (the only common type).
2. **Manual Override**: When the `type` property is explicitly set, it overrides the automatic calculation. This is useful when the physical cable supports fewer signal types than both ports are capable of.
Example: Both ports support `AudioVideo`, but the cable only carries audio, so you set `"type": "audio"`.
##### Validation
At startup, tie line configurations are validated to ensure:
- Both ports exist on their respective devices
- The source and destination ports have at least one common signal type
- If a `type` override is specified, both ports must support that signal type
Invalid tie lines will fail to build with descriptive error messages, preventing runtime routing issues.
##### Signal Types
Tie lines support the following signal types:
- `audio` - Audio-only signals
- `video` - Video-only signals
- `audioVideo` - Combined audio and video (automatically split during routing)
- `secondaryAudio` - Secondary audio channel (e.g., program audio separate from microphone audio)
- `usbInput` - USB input signals
- `usbOutput` - USB output signals
The `type` property determines which signals can travel through the tie line. During route discovery, only tie lines matching the requested signal type will be considered as valid paths.
**Note**: In most cases, you should omit the `type` property and let the system automatically calculate it from the port capabilities. Only use it when you need to restrict the tie line to fewer signal types than the ports support or when needed for clarity.
##### Configuration Examples
**Example 1: Automatic type calculation (recommended)**
Connecting an HDMI cable between devices that both support audio and video. The `type` property is omitted, so the tie line will automatically support `AudioVideo`:
```json ```json
{ {
"sourceKey": "ciscoSparkPlusCodec-1", "sourceKey": "ciscoSparkPlusCodec-1",
"sourcePort": "HdmiOut1", "sourcePort": "HdmiOut1",
"destinationKey": "display-1", "destinationKey": "display-1",
"destinationPort": "HdmiIn1", "destinationPort": "HdmiIn1"
"type": "audioVideo"
} }
``` ```
**Example 2: Type override for cable limitations**
Both devices support `AudioVideo`, but the physical cable only carries audio. The `type` property restricts routing to audio only:
```json
{
"sourceKey": "dmSwitcher-1",
"sourcePort": "audioVideoOut1",
"destinationKey": "amplifier-1",
"destinationPort": "audioVideoIn1",
"type": "audio"
}
```
**Example 3: Mismatched port types (automatically handled)**
Source only supports audio, destination supports both. No `type` needed—the tie line will automatically be `Audio`:
```json
{
"sourceKey": "audioProcessor-1",
"sourcePort": "audioOut1",
"destinationKey": "dmSwitcher-1",
"destinationPort": "audioVideoIn1"
}
```
**Invalid Example: Incompatible types**
This configuration will **fail validation** at startup because the ports have no common signal types:
```json
{
"sourceKey": "audioProcessor-1",
"sourcePort": "audioOut1",
"destinationKey": "display-1",
"destinationPort": "hdmiIn1",
"type": "video"
}
```
Error: `"Override type 'Video' is not supported by source port 'audioOut1' (type: Audio)"`
### Interfaces ### Interfaces
Todo: Define Interfaces IRouting, IRoutingOutputs, IRoutingInputs Todo: Define Interfaces IRouting, IRoutingOutputs, IRoutingInputs

View file

@ -183,11 +183,12 @@ namespace PepperDash.Core
Cresnet = 8, Cresnet = 8,
Cec = 9, Cec = 9,
Udp = 10, Udp = 10,
UdpClient = 11,
} }
} }
``` ```
These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, and ```Udp```. These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, ```UdpClient```, and ```Udp```.
##### ComParams ##### ComParams
@ -287,7 +288,7 @@ This property maps to the number of the port on the device you have mapped the r
##### TcpSshParams ##### TcpSshParams
A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. A ```Ssh```, ```TcpIp```, ```UdpClient```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket.
```Json ```Json
{ {
@ -304,7 +305,7 @@ A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties```
**```address```** **```address```**
This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. In the case of a UDP device, you can set either a single whitelist address with this data, or an appropriate broadcast address. This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. Use ```UdpClient``` for outbound UDP to a remote endpoint. Use ```Udp``` when you need Essentials to bind a local UDP listener.
**```port```** **```port```**

View file

@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.19.4-local</Version> <Version>2.29.0-local</Version>
<InformationalVersion>$(Version)</InformationalVersion> <InformationalVersion>$(Version)</InformationalVersion>
<Authors>PepperDash Technology</Authors> <Authors>PepperDash Technology</Authors>
<Company>PepperDash Technology</Company> <Company>PepperDash Technology</Company>

View file

@ -0,0 +1,463 @@
using System;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Crestron.SimplSharp;
using Crestron.SimplSharp.CrestronSockets;
using ThreadingTimeout = System.Threading.Timeout;
using NetSocketException = System.Net.Sockets.SocketException;
namespace PepperDash.Core
{
/// <summary>
/// A class to handle basic UDP communications to a remote endpoint
/// </summary>
public class GenericUdpClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect
{
private const string SplusKey = "Uninitialized UdpClient";
private readonly object stateLock = new object();
private readonly Timer reconnectTimer;
private UdpClient client;
private CancellationTokenSource receiveCancellationTokenSource;
private bool connectEnabled;
private bool connectionRefusedLogged;
private SocketStatus clientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT;
/// <summary>
/// Object to enable stream debugging
/// </summary>
public CommunicationStreamDebugging StreamDebugging { get; private set; }
/// <summary>
/// Fires when data is received from the remote endpoint and returns it as a byte array
/// </summary>
public event EventHandler<GenericCommMethodReceiveBytesArgs> BytesReceived;
/// <summary>
/// Fires when data is received from the remote endpoint and returns it as text
/// </summary>
public event EventHandler<GenericCommMethodReceiveTextArgs> TextReceived;
/// <summary>
/// Fires when the socket status changes
/// </summary>
public event EventHandler<GenericSocketStatusChageEventArgs> ConnectionChange;
/// <summary>
/// Address of remote endpoint
/// </summary>
public string Hostname { get; set; }
/// <summary>
/// Port on remote endpoint
/// </summary>
public int Port { get; set; }
/// <summary>
/// Another S+ helper because large port numbers can be treated as signed ints
/// </summary>
public ushort UPort
{
get { return Convert.ToUInt16(Port); }
set { Port = Convert.ToInt32(value); }
}
/// <summary>
/// Defaults to 2000
/// </summary>
public int BufferSize { get; set; }
/// <summary>
/// True when the local socket is created and associated with the configured remote endpoint
/// </summary>
public bool IsConnected
{
get { return ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; }
}
/// <summary>
/// S+ helper for IsConnected
/// </summary>
public ushort UIsConnected
{
get { return (ushort)(IsConnected ? 1 : 0); }
}
/// <summary>
/// The current socket status of the client
/// </summary>
public SocketStatus ClientStatus
{
get
{
lock (stateLock)
{
return clientStatus;
}
}
private set
{
var shouldFireEvent = false;
lock (stateLock)
{
if (clientStatus != value)
{
clientStatus = value;
shouldFireEvent = true;
}
}
if (shouldFireEvent)
ConnectionChange?.Invoke(this, new GenericSocketStatusChageEventArgs(this));
}
}
/// <summary>
/// Ushort representation of client status
/// </summary>
public ushort UStatus
{
get { return (ushort)ClientStatus; }
}
/// <summary>
/// Gets or sets the AutoReconnect
/// </summary>
public bool AutoReconnect { get; set; }
/// <summary>
/// S+ helper for AutoReconnect
/// </summary>
public ushort UAutoReconnect
{
get { return (ushort)(AutoReconnect ? 1 : 0); }
set { AutoReconnect = value == 1; }
}
/// <summary>
/// Milliseconds to wait before attempting to reconnect. Defaults to 5000
/// </summary>
public int AutoReconnectIntervalMs { get; set; }
/// <summary>
/// Constructor
/// </summary>
public GenericUdpClient(string key, string address, int port, int bufferSize)
: base(key)
{
StreamDebugging = new CommunicationStreamDebugging(key);
CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler;
AutoReconnectIntervalMs = 5000;
Hostname = address;
Port = port;
BufferSize = bufferSize;
reconnectTimer = new Timer(o =>
{
if (connectEnabled)
Connect();
}, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite);
}
/// <summary>
/// Constructor for S+
/// </summary>
public GenericUdpClient()
: base(SplusKey)
{
StreamDebugging = new CommunicationStreamDebugging(SplusKey);
CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler;
AutoReconnectIntervalMs = 5000;
BufferSize = 2000;
reconnectTimer = new Timer(o =>
{
if (connectEnabled)
Connect();
}, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite);
}
/// <summary>
/// Initialize method
/// </summary>
public void Initialize(string key)
{
Key = key;
}
private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType)
{
if (programEventType == eProgramStatusEventType.Stopping)
{
Debug.Console(1, this, "Program stopping. Closing connection");
Deactivate();
}
}
/// <summary>
/// Deactivate method
/// </summary>
public override bool Deactivate()
{
Disconnect();
return true;
}
/// <summary>
/// Connect method
/// </summary>
public void Connect()
{
if (string.IsNullOrEmpty(Hostname))
{
Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': No address set", Key);
ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT;
return;
}
if (Port < 1 || Port > 65535)
{
Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Invalid port", Key);
ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT;
return;
}
var hostname = Hostname;
var port = Port;
var bufferSize = BufferSize;
UdpClient newClient = null;
CancellationTokenSource newReceiveCancellationTokenSource = null;
CancellationToken startReceiveToken = default(CancellationToken);
var shouldStartReceive = false;
lock (stateLock)
{
connectEnabled = true;
if (client != null)
return;
}
try
{
newReceiveCancellationTokenSource = new CancellationTokenSource();
newClient = new UdpClient();
newClient.Client.ReceiveBufferSize = bufferSize;
newClient.Client.SendBufferSize = bufferSize;
newClient.Connect(hostname, port);
lock (stateLock)
{
if (!connectEnabled || client != null)
{
newClient.Close();
newReceiveCancellationTokenSource.Cancel();
newReceiveCancellationTokenSource.Dispose();
return;
}
receiveCancellationTokenSource = newReceiveCancellationTokenSource;
client = newClient;
ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED;
reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite);
startReceiveToken = receiveCancellationTokenSource.Token;
shouldStartReceive = true;
}
if (shouldStartReceive)
StartReceive(startReceiveToken);
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key);
if (newClient != null)
newClient.Close();
if (newReceiveCancellationTokenSource != null)
{
newReceiveCancellationTokenSource.Cancel();
newReceiveCancellationTokenSource.Dispose();
}
lock (stateLock)
{
if (connectEnabled && client == null)
{
ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT;
StartReconnectTimer();
}
}
}
}
/// <summary>
/// Disconnect method
/// </summary>
public void Disconnect()
{
lock (stateLock)
{
connectEnabled = false;
reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite);
CleanupClient();
ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT;
}
}
/// <summary>
/// SendText method
/// </summary>
public void SendText(string text)
{
this.PrintSentText(text);
var bytes = Encoding.GetEncoding(28591).GetBytes(text);
SendBytes(bytes);
}
/// <summary>
/// SendBytes method
/// </summary>
public void SendBytes(byte[] bytes)
{
if (bytes == null)
return;
try
{
this.PrintSentBytes(bytes);
if (!IsConnected || client == null)
Connect();
var udpClient = client;
if (!IsConnected || udpClient == null)
{
Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Cannot send bytes because the client is not connected", Key);
return;
}
udpClient.Send(bytes, bytes.Length);
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Error sending UDP bytes for {0}", this, Key);
HandleDisconnected();
}
}
private void StartReceive(CancellationToken token)
{
Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
try
{
var udpClient = client;
if (udpClient == null)
return;
var result = await udpClient.ReceiveAsync().ConfigureAwait(false);
var bytes = result.Buffer;
if (bytes == null || bytes.Length == 0)
continue;
connectionRefusedLogged = false;
var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length);
this.PrintReceivedBytes(bytes);
this.PrintReceivedText(text);
BytesReceived?.Invoke(this, new GenericCommMethodReceiveBytesArgs(bytes));
TextReceived?.Invoke(this, new GenericCommMethodReceiveTextArgs(text));
}
catch (ObjectDisposedException)
{
return;
}
catch (InvalidOperationException)
{
return;
}
catch (NetSocketException ex)
{
if (ex.SocketErrorCode == SocketError.ConnectionRefused)
{
if (!connectionRefusedLogged)
{
Debug.Console(1, Debug.ErrorLogLevel.Warning,
"GenericUdpClient '{0}': Remote endpoint refused UDP traffic or is no longer listening",
Key);
connectionRefusedLogged = true;
}
HandleDisconnected();
return;
}
Debug.LogMessage(ex, "UDP receive error for {0}", this, Key);
if (AutoReconnect)
{
HandleDisconnected();
return;
}
continue;
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Unexpected UDP receive error for {0}", this, Key);
if (AutoReconnect)
{
HandleDisconnected();
return;
}
continue;
}
}
}, token);
}
private void HandleDisconnected()
{
lock (stateLock)
{
CleanupClient();
ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT;
StartReconnectTimer();
}
}
private void StartReconnectTimer()
{
if (AutoReconnect && connectEnabled)
reconnectTimer.Change(AutoReconnectIntervalMs, ThreadingTimeout.Infinite);
}
private void CleanupClient()
{
if (receiveCancellationTokenSource != null)
{
receiveCancellationTokenSource.Cancel();
receiveCancellationTokenSource.Dispose();
receiveCancellationTokenSource = null;
}
if (client != null)
{
client.Close();
client = null;
}
}
}
}

View file

@ -18,70 +18,75 @@ namespace PepperDash.Core
/// <summary> /// <summary>
/// RS232/422/485 /// RS232/422/485
/// </summary> /// </summary>
Com, Com = 1,
/// <summary> /// <summary>
/// Crestron IpId (most Crestron ethernet devices) /// Crestron IpId (most Crestron ethernet devices)
/// </summary> /// </summary>
IpId, IpId = 2,
/// <summary> /// <summary>
/// Crestron IpIdTcp (HD-MD series, etc.) /// Crestron IpIdTcp (HD-MD series, etc.)
/// </summary> /// </summary>
IpidTcp, IpidTcp = 3,
/// <summary> /// <summary>
/// Crestron IR control /// Crestron IR control
/// </summary> /// </summary>
IR, IR = 4,
/// <summary> /// <summary>
/// SSH client /// SSH client
/// </summary> /// </summary>
Ssh, Ssh = 5,
/// <summary> /// <summary>
/// TCP/IP client /// TCP/IP client
/// </summary> /// </summary>
Tcpip, Tcpip = 6,
/// <summary> /// <summary>
/// Telnet /// Telnet
/// </summary> /// </summary>
Telnet, Telnet = 7,
/// <summary> /// <summary>
/// Crestnet device /// Crestnet device
/// </summary> /// </summary>
Cresnet, Cresnet = 8,
/// <summary> /// <summary>
/// CEC Control, via a DM HDMI port /// CEC Control, via a DM HDMI port
/// </summary> /// </summary>
Cec, Cec = 9,
/// <summary> /// <summary>
/// UDP Server /// UDP Server
/// </summary> /// </summary>
Udp, Udp = 10,
/// <summary> /// <summary>
/// HTTP client /// HTTP client
/// </summary> /// </summary>
Http, Http = 11,
/// <summary> /// <summary>
/// HTTPS client /// HTTPS client
/// </summary> /// </summary>
Https, Https = 12,
/// <summary> /// <summary>
/// Websocket client /// Websocket client
/// </summary> /// </summary>
Ws, Ws = 13,
/// <summary> /// <summary>
/// Secure Websocket client /// Secure Websocket client
/// </summary> /// </summary>
Wss, Wss = 14,
/// <summary> /// <summary>
/// Secure TCP/IP /// Secure TCP/IP
/// </summary> /// </summary>
SecureTcpIp, SecureTcpIp = 15,
/// <summary> /// <summary>
/// Used when comms needs to be handled in SIMPL and bridged opposite the normal direction /// Used when comms needs to be handled in SIMPL and bridged opposite the normal direction
/// </summary> /// </summary>
ComBridge, ComBridge = 16,
/// <summary> /// <summary>
/// InfinetEX control /// InfinetEX control
/// </summary> /// </summary>
InfinetEx InfinetEx = 17,
/// <summary>
/// UDP client
/// </summary>
UdpClient = 18,
} }
} }

View file

@ -24,16 +24,6 @@ namespace PepperDash.Core
/// </summary> /// </summary>
public bool Enabled { get; protected set; } public bool Enabled { get; protected set; }
/// <summary>
/// A place to store reference to the original config object, if any. These values should
/// NOT be used as properties on the device as they are all publicly-settable values.
/// </summary>
//public DeviceConfig Config { get; private set; }
/// <summary>
/// Helper method to check if Config exists
/// </summary>
//public bool HasConfig { get { return Config != null; } }
List<Action> _PreActivationActions; List<Action> _PreActivationActions;
List<Action> _PostActivationActions; List<Action> _PostActivationActions;

View file

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Crestron.SimplSharp; using Crestron.SimplSharp;

View file

@ -11,6 +11,7 @@ namespace PepperDash.Core
/// <summary> /// <summary>
/// Represents a debugging context /// Represents a debugging context
/// </summary> /// </summary>
[Obsolete("DebugContext is no longer supported and will be removed in a future release.")]
public class DebugContext public class DebugContext
{ {
/// <summary> /// <summary>

View file

@ -1,8 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Serilog; using Serilog;
using Serilog.Core; using Serilog.Core;
using Serilog.Events; using Serilog.Events;
@ -12,11 +8,18 @@ using Crestron.SimplSharp;
using WebSocketSharp; using WebSocketSharp;
using System.Security.Authentication; using System.Security.Authentication;
using WebSocketSharp.Net; using WebSocketSharp.Net;
using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; using System.Security.Cryptography.X509Certificates;
using System.IO; using System.IO;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Operators;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.X509;
using Serilog.Formatting; using Serilog.Formatting;
using Newtonsoft.Json.Linq;
using Serilog.Formatting.Json; using Serilog.Formatting.Json;
namespace PepperDash.Core namespace PepperDash.Core
@ -32,8 +35,13 @@ namespace PepperDash.Core
private const string _certificateName = "selfCres"; private const string _certificateName = "selfCres";
private const string _certificatePassword = "cres12345"; private const string _certificatePassword = "cres12345";
private static string CertPath =>
$"{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}{_certificateName}.pfx";
public int Port public int Port
{ get {
get
{ {
if (_httpsServer == null) return 0; if (_httpsServer == null) return 0;
@ -45,8 +53,17 @@ namespace PepperDash.Core
{ {
get get
{ {
if (_httpsServer == null) return ""; if (_httpsServer == null || !_httpsServer.IsListening) return "";
return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{_httpsServer.WebSocketServices[_path].Path}"; 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.
var cslanIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1);
if (!string.IsNullOrEmpty(cslanIp) && cslanIp != "Invalid Value")
return $"wss://{cslanIp}:{_httpsServer.Port}{service.Path}";
else
return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{service.Path}";
} }
} }
@ -55,59 +72,137 @@ namespace PepperDash.Core
/// </summary> /// </summary>
public bool IsRunning { get => _httpsServer?.IsListening ?? false; } public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
/// <summary>
/// Gets a value indicating whether there are active WebSocket connections.
/// </summary>
public bool HasActiveConnections
{
get
{
if (_httpsServer == null || !_httpsServer.IsListening) return false;
var service = _httpsServer.WebSocketServices[_path];
if (service == null) return false;
return service.Sessions.Count > 0;
}
}
private readonly ITextFormatter _textFormatter; private readonly ITextFormatter _textFormatter;
/// <summary>
/// Initializes a new instance of the <see cref="DebugWebsocketSink"/> class with the specified text formatter.
/// </summary>
/// <remarks>This constructor initializes the WebSocket sink and ensures that a certificate is
/// available for secure communication. If the required certificate does not exist, it will be created
/// automatically. Additionally, the sink is configured to stop the server when the program is
/// stopping.</remarks>
/// <param name="formatProvider">The text formatter used to format log messages. If null, a default JSON formatter is used.</param>
public DebugWebsocketSink(ITextFormatter formatProvider) public DebugWebsocketSink(ITextFormatter formatProvider)
{ {
_textFormatter = formatProvider ?? new JsonFormatter(); _textFormatter = formatProvider ?? new JsonFormatter();
if (!File.Exists($"\\user\\{_certificateName}.pfx")) if (!File.Exists(CertPath))
CreateCert(null); CreateCert();
try
{
CrestronEnvironment.ProgramStatusEventHandler += type => CrestronEnvironment.ProgramStatusEventHandler += type =>
{ {
if (type == eProgramStatusEventType.Stopping) if (type == eProgramStatusEventType.Stopping)
{
StopServer(); StopServer();
}
}; };
} }
catch
private void CreateCert(string[] args)
{ {
// CrestronEnvironment is not available in test / dev environments — safe to skip.
}
}
private static void CreateCert()
{
// NOTE: This method is called from the constructor, which is itself called during Debug's static
// constructor before _logger is assigned. Do NOT call any Debug.Log* methods here — use
// CrestronConsole.PrintLine only, to avoid a NullReferenceException that would poison the Debug type.
try try
{ {
//Debug.Console(0,"CreateCert Creating Utility");
CrestronConsole.PrintLine("CreateCert Creating Utility");
//var utility = new CertificateUtility();
var utility = new BouncyCertificate();
//Debug.Console(0, "CreateCert Calling CreateCert");
CrestronConsole.PrintLine("CreateCert Calling CreateCert");
//utility.CreateCert();
var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); var hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0);
var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0); var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0);
//Debug.Console(0, "DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress); CrestronConsole.PrintLine(string.Format("CreateCert: DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress));
CrestronConsole.PrintLine(string.Format("DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress));
var certificate = utility.CreateSelfSignedCertificate(string.Format("CN={0}.{1}", hostName, domainName), new[] { string.Format("{0}.{1}", hostName, domainName), ipAddress }, new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth }); var subjectName = string.Format("CN={0}.{1}", hostName, domainName);
//Crestron fails to let us do this...perhaps it should be done through their Dll's but haven't tested var fqdn = string.Format("{0}.{1}", hostName, domainName);
//Debug.Print($"CreateCert Storing Certificate To My.LocalMachine");
//utility.AddCertToStore(certificate, StoreName.My, StoreLocation.LocalMachine); var random = new SecureRandom();
//Debug.Console(0, "CreateCert Saving Cert to \\user\\");
CrestronConsole.PrintLine("CreateCert Saving Cert to \\user\\"); // Generate RSA 2048 key pair
utility.CertificatePassword = _certificatePassword; var keyPairGenerator = new RsaKeyPairGenerator();
utility.WriteCertificate(certificate, @"\user\", _certificateName); keyPairGenerator.Init(new KeyGenerationParameters(random, 2048));
//Debug.Console(0, "CreateCert Ending CreateCert"); var keyPair = keyPairGenerator.GenerateKeyPair();
CrestronConsole.PrintLine("CreateCert Ending CreateCert");
// Build certificate
var certGenerator = new X509V3CertificateGenerator();
certGenerator.SetSerialNumber(BigInteger.ValueOf(Math.Abs(DateTime.UtcNow.Ticks)));
certGenerator.SetIssuerDN(new X509Name(subjectName));
certGenerator.SetSubjectDN(new X509Name(subjectName));
certGenerator.SetNotBefore(DateTime.UtcNow);
certGenerator.SetNotAfter(DateTime.UtcNow.AddYears(2));
certGenerator.SetPublicKey(keyPair.Public);
// Extended Key Usage: server + client auth
certGenerator.AddExtension(X509Extensions.ExtendedKeyUsage, false,
new ExtendedKeyUsage(new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth }));
// Subject Alternative Names: DNS + IP
System.Net.IPAddress parsedIp;
if (System.Net.IPAddress.TryParse(ipAddress, out parsedIp))
{
certGenerator.AddExtension(X509Extensions.SubjectAlternativeName, false,
new GeneralNames(new GeneralName[] {
new GeneralName(GeneralName.DnsName, fqdn),
new GeneralName(GeneralName.IPAddress, ipAddress)
}));
}
else
{
certGenerator.AddExtension(X509Extensions.SubjectAlternativeName, false,
new GeneralNames(new GeneralName(GeneralName.DnsName, fqdn)));
}
// Sign with SHA256withRSA
var signatureFactory = new Asn1SignatureFactory("SHA256WITHRSA", keyPair.Private, random);
var certificate = certGenerator.Generate(signatureFactory);
// Export as PKCS12/PFX
var pkcs12Store = new Pkcs12StoreBuilder().Build();
var certEntry = new X509CertificateEntry(certificate);
pkcs12Store.SetCertificateEntry(_certificateName, certEntry);
pkcs12Store.SetKeyEntry(_certificateName, new AsymmetricKeyEntry(keyPair.Private), new[] { certEntry });
var separator = Path.DirectorySeparatorChar;
var outputPath = string.Format("{0}user{1}{2}.pfx", separator, separator, _certificateName);
using (var ms = new MemoryStream())
{
var passwordChars = _certificatePassword.ToCharArray();
try
{
pkcs12Store.Save(ms, passwordChars, random);
}
finally
{
Array.Clear(passwordChars, 0, passwordChars.Length);
}
File.WriteAllBytes(outputPath, ms.ToArray());
}
CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath));
} }
catch (Exception ex) catch (Exception ex)
{ {
//Debug.Console(0, "WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace); CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed: {0}\r\n{1}", ex.Message, ex.StackTrace));
CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace));
} }
} }
@ -126,14 +221,77 @@ namespace PepperDash.Core
} }
/// <summary> /// <summary>
/// StartServerAndSetPort method /// Starts the WebSocket server on the specified port and configures it with the appropriate certificate.
/// </summary> /// </summary>
/// <remarks>This method initializes the WebSocket server and binds it to the specified port. It
/// also applies the server's certificate for secure communication. Ensure that the port is not already in use
/// and that the certificate file is accessible.</remarks>
/// <param name="port">The port number on which the WebSocket server will listen. Must be a valid, non-negative port number.</param>
public void StartServerAndSetPort(int port) public void StartServerAndSetPort(int port)
{ {
Debug.Console(0, "Starting Websocket Server on port: {0}", port); Debug.LogInformation("Starting Websocket Server on port: {0}", port);
Start(port, $"\\user\\{_certificateName}.pfx", _certificatePassword);
Start(port, CertPath, _certificatePassword);
}
private static X509Certificate2 LoadOrRecreateCert(string certPath, string certPassword)
{
if (!File.Exists(certPath))
CreateCert();
try
{
return LoadCertFromBouncyCastle(certPath, certPassword);
}
catch (Exception ex)
{
// Cert is corrupt or was written by an incompatible tool — delete and regenerate once.
CrestronConsole.PrintLine(string.Format("SSL cert load failed ({0}); regenerating...", ex.Message));
try { File.Delete(certPath); } catch { }
CreateCert();
return LoadCertFromBouncyCastle(certPath, certPassword);
}
}
/// <summary>
/// Loads a PKCS#12 file written by BouncyCastle and returns an <see cref="X509Certificate2"/> with
/// private key attached.
/// The PFX is parsed and re-encoded by BouncyCastle (ensuring format compatibility), then passed as
/// raw bytes to <see cref="X509Certificate2"/> so neither <c>RSACryptoServiceProvider</c> nor the
/// <c>EphemeralKeySet</c> flag (unsupported on the Crestron/Mono runtime) is needed.
/// </summary>
private static X509Certificate2 LoadCertFromBouncyCastle(string certPath, string certPassword)
{
var passwordChars = certPassword.ToCharArray();
try
{
using (var stream = File.OpenRead(certPath))
{
var store = new Pkcs12StoreBuilder().Build();
store.Load(stream, passwordChars);
// Re-encode through BouncyCastle to guarantee PKCS#12 format compatibility,
// then hand raw bytes to X509Certificate2 — no RSACryptoServiceProvider needed.
using (var ms = new MemoryStream())
{
store.Save(ms, passwordChars, new SecureRandom());
var cert = new X509Certificate2(ms.ToArray(), certPassword);
if (!cert.HasPrivateKey)
throw new InvalidOperationException(
string.Format("Certificate loaded from '{0}' does not contain a private key and cannot be used as a server certificate.", certPath));
return cert;
}
}
}
finally
{
Array.Clear(passwordChars, 0, passwordChars.Length);
}
} }
private void Start(int port, string certPath = "", string certPassword = "") private void Start(int port, string certPath = "", string certPassword = "")
@ -142,66 +300,37 @@ namespace PepperDash.Core
{ {
_httpsServer = new HttpServer(port, true); _httpsServer = new HttpServer(port, true);
if (!string.IsNullOrWhiteSpace(certPath)) if (!string.IsNullOrWhiteSpace(certPath))
{ {
Debug.Console(0, "Assigning SSL Configuration"); Debug.LogInformation("Assigning SSL Configuration");
_httpsServer.SslConfiguration = new ServerSslConfiguration(new X509Certificate2(certPath, certPassword))
{ _httpsServer.SslConfiguration.ServerCertificate = LoadOrRecreateCert(certPath, certPassword);
ClientCertificateRequired = false, _httpsServer.SslConfiguration.ClientCertificateRequired = false;
CheckCertificateRevocation = false, _httpsServer.SslConfiguration.CheckCertificateRevocation = false;
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls, _httpsServer.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12;
//this is just to test, you might want to actually validate //this is just to test, you might want to actually validate
ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => _httpsServer.SslConfiguration.ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
{ {
Debug.Console(0, "HTTPS ClientCerticateValidation Callback triggered"); Debug.LogInformation("HTTPS ClientCerticateValidation Callback triggered");
return true; return true;
}
}; };
} }
Debug.Console(0, "Adding Debug Client Service"); Debug.LogInformation("Adding Debug Client Service");
_httpsServer.AddWebSocketService<DebugClient>(_path); _httpsServer.AddWebSocketService<DebugClient>(_path);
Debug.Console(0, "Assigning Log Info"); Debug.LogInformation("Assigning Log Info");
_httpsServer.Log.Level = LogLevel.Trace; _httpsServer.Log.Level = LogLevel.Trace;
_httpsServer.Log.Output = (d, s) => _httpsServer.Log.Output = WriteWebSocketInternalLog;
{ Debug.LogInformation("Starting");
uint level;
switch(d.Level)
{
case WebSocketSharp.LogLevel.Fatal:
level = 3;
break;
case WebSocketSharp.LogLevel.Error:
level = 2;
break;
case WebSocketSharp.LogLevel.Warn:
level = 1;
break;
case WebSocketSharp.LogLevel.Info:
level = 0;
break;
case WebSocketSharp.LogLevel.Debug:
level = 4;
break;
case WebSocketSharp.LogLevel.Trace:
level = 5;
break;
default:
level = 4;
break;
}
Debug.Console(level, "{1} {0}\rCaller:{2}\rMessage:{3}\rs:{4}", d.Level.ToString(), d.Date.ToString(), d.Caller.ToString(), d.Message, s);
};
Debug.Console(0, "Starting");
_httpsServer.Start(); _httpsServer.Start();
Debug.Console(0, "Ready"); Debug.LogInformation("Ready");
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.Console(0, "WebSocket Failed to start {0}", ex.Message); Debug.LogError(ex, "WebSocket Failed to start {0}", ex.Message);
Debug.LogVerbose("Stack Trace:\r{0}", ex.StackTrace);
// Null out the server so callers can detect failure via IsRunning / Url null guards.
_httpsServer = null;
} }
} }
@ -210,10 +339,68 @@ namespace PepperDash.Core
/// </summary> /// </summary>
public void StopServer() public void StopServer()
{ {
Debug.Console(0, "Stopping Websocket Server"); Debug.LogInformation("Stopping Websocket Server");
_httpsServer?.Stop();
try
{
if (_httpsServer == null || !_httpsServer.IsListening)
{
return;
}
// Prevent close-sequence internal websocket logs from re-entering the logging pipeline.
_httpsServer.Log.Output = (d, s) => { };
var serviceHost = _httpsServer.WebSocketServices[_path];
if (serviceHost == null)
{
_httpsServer.Stop();
_httpsServer = null;
return;
}
serviceHost.Sessions.Broadcast("Server is stopping");
foreach (var session in serviceHost.Sessions.Sessions)
{
if (session?.Context?.WebSocket != null && session.Context.WebSocket.IsAlive)
{
session.Context.WebSocket.Close(1001, "Server is stopping");
}
}
_httpsServer.Stop();
_httpsServer = null; _httpsServer = null;
}
catch (Exception ex)
{
Debug.LogError(ex, "WebSocket Failed to stop gracefully {0}", ex.Message);
Debug.LogVerbose("Stack Trace\r\n{0}", ex.StackTrace);
}
}
private static void WriteWebSocketInternalLog(LogData data, string supplemental)
{
try
{
if (data == null)
{
return;
}
var message = string.IsNullOrWhiteSpace(data.Message) ? "<none>" : data.Message;
var details = string.IsNullOrWhiteSpace(supplemental) ? string.Empty : string.Format(" | details: {0}", supplemental);
// Use direct console output to avoid recursive log sink calls.
CrestronConsole.PrintLine(string.Format("WS[{0}] {1} | message: {2}{3}", data.Level, data.Date, message, details));
}
catch
{
// Never throw from websocket log callback.
}
} }
} }

View file

@ -5,6 +5,7 @@ using Crestron.SimplSharp;
using Crestron.SimplSharp.WebScripting; using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using PepperDash.Core.Logging;
using PepperDash.Core.Web.RequestHandlers; using PepperDash.Core.Web.RequestHandlers;
namespace PepperDash.Core.Web namespace PepperDash.Core.Web
@ -45,9 +46,6 @@ namespace PepperDash.Core.Web
/// </summary> /// </summary>
public bool IsRegistered { get; private set; } public bool IsRegistered { get; private set; }
/// <summary>
/// Http request handler
/// </summary>
//public IHttpCwsHandler HttpRequestHandler //public IHttpCwsHandler HttpRequestHandler
//{ //{
// get { return _server.HttpRequestHandler; } // get { return _server.HttpRequestHandler; }
@ -58,9 +56,6 @@ namespace PepperDash.Core.Web
// } // }
//} //}
/// <summary>
/// Received request event handler
/// </summary>
//public event EventHandler<HttpCwsRequestEventArgs> ReceivedRequestEvent //public event EventHandler<HttpCwsRequestEventArgs> ReceivedRequestEvent
//{ //{
// add { _server.ReceivedRequestEvent += new HttpCwsRequestEventHandler(value); } // add { _server.ReceivedRequestEvent += new HttpCwsRequestEventHandler(value); }
@ -99,6 +94,8 @@ namespace PepperDash.Core.Web
if (_server == null) _server = new HttpCwsServer(BasePath); if (_server == null) _server = new HttpCwsServer(BasePath);
_server.AuthenticateAllRoutes = false;
_server.setProcessName(Key); _server.setProcessName(Key);
_server.HttpRequestHandler = new DefaultRequestHandler(); _server.HttpRequestHandler = new DefaultRequestHandler();
@ -114,7 +111,7 @@ namespace PepperDash.Core.Web
{ {
if (programEventType != eProgramStatusEventType.Stopping) return; if (programEventType != eProgramStatusEventType.Stopping) return;
Debug.Console(DebugInfo, this, "Program stopping. stopping server"); this.LogInformation("Program stopping. stopping server");
Stop(); Stop();
} }
@ -128,11 +125,11 @@ namespace PepperDash.Core.Web
// Re-enable the server if the link comes back up and the status should be connected // Re-enable the server if the link comes back up and the status should be connected
if (ethernetEventArgs.EthernetEventType == eEthernetEventType.LinkUp && IsRegistered) if (ethernetEventArgs.EthernetEventType == eEthernetEventType.LinkUp && IsRegistered)
{ {
Debug.Console(DebugInfo, this, "Ethernet link up. Server is alreedy registered."); this.LogInformation("Ethernet link up. Server is alreedy registered.");
return; return;
} }
Debug.Console(DebugInfo, this, "Ethernet link up. Starting server"); this.LogInformation("Ethernet link up. Starting server");
Start(); Start();
} }
@ -153,7 +150,7 @@ namespace PepperDash.Core.Web
{ {
if (route == null) if (route == null)
{ {
Debug.Console(DebugInfo, this, "Failed to add route, route parameter is null"); this.LogWarning("Failed to add route, route parameter is null");
return; return;
} }
@ -165,20 +162,33 @@ namespace PepperDash.Core.Web
/// Removes a route from CWS /// Removes a route from CWS
/// </summary> /// </summary>
/// <param name="route"></param> /// <param name="route"></param>
/// <summary>
/// RemoveRoute method
/// </summary>
public void RemoveRoute(HttpCwsRoute route) public void RemoveRoute(HttpCwsRoute route)
{ {
if (route == null) if (route == null)
{ {
Debug.Console(DebugInfo, this, "Failed to remote route, orute parameter is null"); this.LogWarning("Failed to remove route, route parameter is null");
return; return;
} }
_server.Routes.Remove(route); _server.Routes.Remove(route);
} }
/// <summary>
/// Sets the fallback request handler that is invoked when no registered route
/// matches an incoming request. Must be called before <see cref="Start"/>.
/// </summary>
/// <param name="handler">The handler to use as the server-level fallback.</param>
public void SetFallbackHandler(IHttpCwsHandler handler)
{
if (handler == null)
{
this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring");
return;
}
_server.HttpRequestHandler = handler;
}
/// <summary> /// <summary>
/// GetRouteCollection method /// GetRouteCollection method
/// </summary> /// </summary>
@ -198,26 +208,24 @@ namespace PepperDash.Core.Web
if (_server == null) if (_server == null)
{ {
Debug.Console(DebugInfo, this, "Server is null, unable to start"); this.LogWarning("Server is null, unable to start");
return; return;
} }
if (IsRegistered) if (IsRegistered)
{ {
Debug.Console(DebugInfo, this, "Server has already been started"); this.LogWarning("Server has already been started");
return; return;
} }
IsRegistered = _server.Register(); IsRegistered = _server.Register();
Debug.Console(DebugInfo, this, "Starting server, registration {0}", IsRegistered ? "was successful" : "failed"); this.LogInformation("Starting server, registration {registrationResult}", IsRegistered ? "was successful" : "failed");
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.Console(DebugInfo, this, "Start Exception Message: {0}", ex.Message); this.LogError("Start Exception Message: {message}", ex.Message);
Debug.Console(DebugVerbose, this, "Start Exception StackTrace: {0}", ex.StackTrace); this.LogDebug(ex, "Start Exception StackTrace");
if (ex.InnerException != null)
Debug.Console(DebugVerbose, this, "Start Exception InnerException: {0}", ex.InnerException);
} }
finally finally
{ {
@ -236,23 +244,21 @@ namespace PepperDash.Core.Web
if (_server == null) if (_server == null)
{ {
Debug.Console(DebugInfo, this, "Server is null or has already been stopped"); this.LogWarning("Server is null or has already been stopped");
return; return;
} }
IsRegistered = _server.Unregister() == false; IsRegistered = _server.Unregister() == false;
Debug.Console(DebugInfo, this, "Stopping server, unregistration {0}", IsRegistered ? "failed" : "was successful"); this.LogInformation("Stopping server, unregistration {unregistrationResult}", IsRegistered ? "failed" : "was successful");
_server.Dispose(); _server.Dispose();
_server = null; _server = null;
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.Console(DebugInfo, this, "Server Stop Exception Message: {0}", ex.Message); this.LogError("Server Stop Exception Message: {message}", ex.Message);
Debug.Console(DebugVerbose, this, "Server Stop Exception StackTrace: {0}", ex.StackTrace); this.LogDebug(ex, "Server Stop Exception StackTrace");
if (ex.InnerException != null)
Debug.Console(DebugVerbose, this, "Server Stop Exception InnerException: {0}", ex.InnerException);
} }
finally finally
{ {
@ -273,14 +279,12 @@ namespace PepperDash.Core.Web
try try
{ {
var j = JsonConvert.SerializeObject(args.Context, Formatting.Indented); var j = JsonConvert.SerializeObject(args.Context, Formatting.Indented);
Debug.Console(DebugVerbose, this, "RecieveRequestEventHandler Context:\x0d\x0a{0}", j); this.LogVerbose("RecieveRequestEventHandler Context:\x0d\x0a{0}", j);
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.Console(DebugInfo, this, "ReceivedRequestEventHandler Exception Message: {0}", ex.Message); this.LogError("ReceivedRequestEventHandler Exception Message: {message}", ex.Message);
Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception StackTrace: {0}", ex.StackTrace); this.LogDebug(ex, "ReceivedRequestEventHandler Exception StackTrace: {stackTrace}", ex.StackTrace);
if (ex.InnerException != null)
Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception InnerException: {0}", ex.InnerException);
} }
} }
} }

View file

@ -13,6 +13,7 @@ namespace PepperDash.Core.WebApi.Presets
/// <summary> /// <summary>
/// Passcode client for the WebApi /// Passcode client for the WebApi
/// </summary> /// </summary>
[Obsolete("WebApiPasscodeClient is no longer supported and will be removed in a future release.")]
public class WebApiPasscodeClient : IKeyed public class WebApiPasscodeClient : IKeyed
{ {
/// <summary> /// <summary>
@ -106,11 +107,11 @@ namespace PepperDash.Core.WebApi.Presets
var user = JsonConvert.DeserializeObject<User>(resp.ContentString); var user = JsonConvert.DeserializeObject<User>(resp.ContentString);
CurrentUser = user; CurrentUser = user;
if (handler != null) if (handler != null)
UserReceived(this, new UserReceivedEventArgs(user, true)); handler(this, new UserReceivedEventArgs(user, true));
} }
else else
if (handler != null) if (handler != null)
UserReceived(this, new UserReceivedEventArgs(null, false)); handler(this, new UserReceivedEventArgs(null, false));
} }
/// <summary> /// <summary>
@ -170,14 +171,14 @@ namespace PepperDash.Core.WebApi.Presets
J2SMaster.LoadWithJson(preset.Data); J2SMaster.LoadWithJson(preset.Data);
if (handler != null) if (handler != null)
PresetReceived(this, new PresetReceivedEventArgs(preset, true)); handler(this, new PresetReceivedEventArgs(preset, true));
} }
else // no existing preset else // no existing preset
{ {
CurrentPreset = new Preset(); CurrentPreset = new Preset();
LoadDefaultPresetData(); LoadDefaultPresetData();
if (handler != null) if (handler != null)
PresetReceived(this, new PresetReceivedEventArgs(null, false)); handler(this, new PresetReceivedEventArgs(null, false));
} }
} }
catch (HttpException e) catch (HttpException e)

View file

@ -30,6 +30,17 @@ namespace PepperDash.Essentials.Core.Bridges
{ {
} }
/// <summary>
/// Constructor
/// </summary>
/// <param name="key"></param>
/// <param name="name"></param>
protected BridgeApi(string key, string name) :
base(key, name)
{
}
} }
/// <summary> /// <summary>
@ -58,7 +69,7 @@ namespace PepperDash.Essentials.Core.Bridges
/// <param name="dc">Device configuration</param> /// <param name="dc">Device configuration</param>
/// <param name="eisc">EISC instance</param> /// <param name="eisc">EISC instance</param>
public EiscApiAdvanced(DeviceConfig dc, BasicTriList eisc) : public EiscApiAdvanced(DeviceConfig dc, BasicTriList eisc) :
base(dc.Key) base(dc.Key, dc.Name)
{ {
JoinMaps = new Dictionary<string, JoinMapBaseAdvanced>(); JoinMaps = new Dictionary<string, JoinMapBaseAdvanced>();

View file

@ -96,6 +96,17 @@ namespace PepperDash.Essentials.Core
comm = udp; comm = udp;
break; break;
} }
case eControlMethod.UdpClient:
{
var udpClient = new GenericUdpClient(deviceConfig.Key + "-udpClient", c.Address, c.Port, c.BufferSize)
{
AutoReconnect = c.AutoReconnect
};
if (udpClient.AutoReconnect)
udpClient.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs;
comm = udpClient;
break;
}
case eControlMethod.Telnet: case eControlMethod.Telnet:
break; break;
case eControlMethod.SecureTcpIp: case eControlMethod.SecureTcpIp:

View file

@ -19,6 +19,7 @@ namespace PepperDash.Essentials.Core.Config
/// <summary> /// <summary>
/// ConfigUpdater class /// ConfigUpdater class
/// </summary> /// </summary>
[Obsolete("ConfigUpdater is no longer supported and will be removed in a future release.")]
public static class ConfigUpdater public static class ConfigUpdater
{ {
/// <summary> /// <summary>
@ -149,7 +150,7 @@ namespace PepperDash.Essentials.Core.Config
// Directory exists, first clear any contents // Directory exists, first clear any contents
var archivedConfigFiles = ConfigReader.GetConfigFiles(archiveDirectoryPath + Global.DirectorySeparator + Global.ConfigFileName + ".bak"); var archivedConfigFiles = ConfigReader.GetConfigFiles(archiveDirectoryPath + Global.DirectorySeparator + Global.ConfigFileName + ".bak");
if(archivedConfigFiles != null || archivedConfigFiles.Length > 0) if (archivedConfigFiles != null && archivedConfigFiles.Length > 0)
{ {
Debug.LogMessage(LogEventLevel.Information, "{0} Existing files found in archive folder. Deleting.", archivedConfigFiles.Length); Debug.LogMessage(LogEventLevel.Information, "{0} Existing files found in archive folder. Deleting.", archivedConfigFiles.Length);

View file

@ -3,9 +3,291 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{ {
/// <summary>
/// Defines the display mode for a webview event, with expected values of "Fullscreen", "Modal", or "Unknown".
/// </summary>
public enum eWebViewEventMode
{
/// <summary>
/// The display mode for the webview event is unknown or not specified. This value can be used as a default or fallback when the display mode is not provided or cannot be parsed into a known value.
/// </summary>
Unknown,
/// <summary>
/// The webview event should be displayed in fullscreen mode, covering the entire screen and typically used for immersive experiences or when maximum screen real estate is needed. When a webview event with this display mode is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen", and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
Fullscreen,
/// <summary>
/// The webview event should be displayed in modal mode, which typically means it will be shown as a dialog or overlay on top of the existing content, allowing the user to interact with it while still being able to see the underlying content. This display mode is often used for alerts, confirmations, or when the webview content is related to the current context but does not require full immersion. When a webview event with this display mode is shown, it will typically trigger the WebViewStatusChanged event with a status of "Modal", and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
Modal,
}
/// <summary>
/// Defines the target for a webview event, with expected values of "OSD", "Controller", "PersistentWebApp", or "RoomScheduler".
/// </summary>
public enum eWebViewTarget
{
/// <summary>
/// The target for the webview event is unknown or not specified. This value can be used as a default or fallback when the target is not provided or cannot be parsed into a known value.
/// </summary>
Unknown,
/// <summary>
/// The webview event should be displayed on the On-Screen Display (OSD).
/// </summary>
OSD,
/// <summary>
/// The webview event should be displayed on the controller.
/// </summary>
Controller,
/// <summary>
/// The webview event should be displayed on the persistent web application.
/// </summary>
PersistentWebApp,
/// <summary>
/// The webview event should be displayed on the room scheduler.
/// </summary>
RoomScheduler
}
/// <summary>
/// Represents the reason for an error in a webview event, which can provide additional information about what went wrong. This class is typically only used in the Status property of a WebViewEvent when the status indicates an error, and may be null otherwise.
/// </summary>
public class Reason
{
/// <summary>
/// The reason for an error in a webview event as a string, which can provide additional information about what went wrong. This property is typically only populated in case of an error, and may be null otherwise.
/// </summary>
public string Value { get; set; }
}
/// <summary>
/// Represents the XPath of a webview event, which can provide information about where an error occurred in the webview. This class is typically only used in the Status property of a WebViewEvent when the status indicates an error, and may be null otherwise.
/// </summary>
public class XPath
{
/// <summary>
/// The XPath of a webview event as a string, which can provide information about where an error occurred in the webview. This property is typically only populated in case of an error, and may be null otherwise.
/// </summary>
public string Value { get; set; }
}
/// <summary>
/// Represents a base class for properties that have a string value and trigger an action when the value changes. This class can be used as a base for properties like DisplayMode and Target in the WebViewEvent, which have string values that can be set directly or parsed into enums for easier handling of expected values. The ValueChangedAction can be set to trigger any desired behavior when the value changes, such as updating the UI or triggering other events.
/// </summary>
public abstract class ValueProperty
{
/// <summary>
/// Triggered when Value is set
/// </summary>
public Action ValueChangedAction { get; set; }
/// <summary>
/// Triggers the ValueChangedAction if it is set. This method should be called whenever the Value property is set to ensure that any desired behavior associated with a change in value is executed.
/// </summary>
protected void OnValueChanged()
{
var a = ValueChangedAction;
if (a != null)
a();
}
}
/// <summary>
/// Represents a webview event, which can include information about the status of the webview, the display parameters for the webview, and any error information if applicable. This class can be used to represent both show and clear events for a webview, with the Status property indicating the current status of the webview (e.g., "Fullscreen", "Modal", "Cleared", "Error", or "Unknown"), the Display property providing details about how the webview is being displayed (e.g., mode, URL, target, title), and the Cleared property providing details about a cleared/closed webview event (e.g., target and ID). The Id property can be used to correlate show and clear events for the same webview instance.
/// </summary>
public class WebViewEvent
{
/// <summary>
/// The unique identifier for the webview event, which can be used to correlate show and clear events for the same webview instance. This property is typically included in both show and clear events for a webview, allowing you to track the lifecycle of a specific webview instance from when it is shown to when it is cleared/closed. The Id can be any string value, but it should be unique for each webview instance to ensure proper correlation between show and clear events.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>
/// The status of the webview event, which can indicate the current state of the webview (e.g., "Fullscreen", "Modal", "Cleared", "Error", or "Unknown") as well as any error information if applicable (XPath and Reason). The Value property can be used to get or set the current status of the webview, while the XPath and Reason properties can provide additional information in case of an error. The StatusString property can be used to get or set the raw status string from the event, but it is recommended to use the Value property for easier handling of expected values. Setting the Value property will trigger the ValueChangedAction if it is set, allowing you to respond to changes in the webview status as needed.
/// </summary>
[JsonProperty("status")]
public Status Status { get; set; } // /Event/UserInterface/WebView/Status
/// <summary>
/// The display parameters for the webview event, which can include the display mode (e.g., "Fullscreen", "Modal", or "Unknown"), the URL to display in the webview, the target for the webview (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler"), and the title to display on the webview. This property is typically included in show events for a webview, providing details about how the webview is being displayed. When a webview event with these display parameters is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen" or "Modal" depending on the specified display mode, and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
[JsonProperty("display")]
public WebViewDisplay Display { get; set; } // /Event/UserInterface/WebView/Display
/// <summary>
/// The details for a cleared/closed webview event, which can include the target for the webview that was cleared (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler") and the unique identifier for the webview event that was cleared. This property is typically included in clear events for a webview, providing details about which webview instance was cleared/closed. When a webview event with this property is cleared/closed, it will typically trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
[JsonProperty("cleared")]
public WebViewClear Cleared { get; set; } // /Event/UserInterface/WebView/Cleared
}
/// <summary>
/// Represents the display parameters for a webview event, which can include the display mode (e.g., "Fullscreen", "Modal", or "Unknown"), the URL to display in the webview, the target for the webview (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler"), and the title to display on the webview. This class is typically used in the Display property of a WebViewEvent to provide details about how the webview is being displayed when a show event occurs. When a webview event with these display parameters is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen" or "Modal" depending on the specified display mode, and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
public class WebViewDisplay
{
/// <summary>
/// The display mode for the webview event. Expected values are "Fullscreen", "Modal", or "Unknown".
/// </summary>
[JsonProperty("mode")]
public DisplayMode Mode { get; set; }
/// <summary>
/// The URL to display in the webview.
/// </summary>
[JsonProperty("url")]
public string Url { get; set; }
/// <summary>
/// The target for the webview. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler".
/// </summary>
[JsonProperty("target")]
public Target Target { get; set; }
/// <summary>
/// The title to display on the webview.
/// </summary>
[JsonProperty("title")]
public string Title { get; set; }
/// <summary>
/// The unique identifier for the webview event, used to correlate show and clear events for the same webview instance.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
}
/// <summary>
/// Represents the data for a webview cleared event, which indicates that a webview with the specified ID and target has been cleared/closed.
/// </summary>
public class WebViewClear
{
/// <summary>
/// The target for the webview that was cleared. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler".
/// </summary>
[JsonProperty("target")]
public Target Target { get; set; }
/// <summary>
/// The unique identifier for the webview event that was cleared, used to correlate show and clear events for the same webview instance.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>
/// The URL that was displayed in the webview that was cleared.
/// </summary>
[JsonProperty("url")]
public string Url { get; set; }
}
/// <summary>
/// Represents the display mode for a webview event, with a string value and a corresponding enum property for easier handling of expected values.
/// </summary>
public class DisplayMode : ValueProperty
{
private string _value;
/// <summary>
/// The id of the webview event.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>
/// The string value for the display mode, which can be set directly or parsed into the WebViewEventMode enum using the WebViewEventMode property. Setting this property will also trigger the ValueChangedAction if it is set.
/// </summary>
public string Value { get { return _value; } set { _value = value; OnValueChanged(); } }
/// <summary>
/// The display mode for the webview event as an enum, which can be used for easier handling of expected values. Expected values are Fullscreen, Modal, or Unknown.
/// </summary>
public eWebViewEventMode WebViewEventMode
{
get
{
eWebViewEventMode mode;
System.Enum.TryParse(Value, true, out mode);
return mode;
}
}
}
/// <summary>
/// Represents the target for a webview event, with a string value and a corresponding enum property for easier handling of expected values. Setting the Value property will also trigger the ValueChangedAction if it is set.
/// </summary>
public class Target : ValueProperty
{
private string _value;
/// <summary>
/// The id of the webview event.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>
/// The string value for the target, which can be set directly or parsed into the eWebViewTarget enum using the WebViewTarget property. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler". Setting this property will also trigger the ValueChangedAction if it is set.
/// </summary>
public string Value { get { return _value; } set { _value = value; OnValueChanged(); } }
/// <summary>
/// The target for the webview event as an enum, which can be used for easier handling of expected values. Expected values are OSD, Controller, PersistentWebApp, or RoomScheduler.
/// </summary>
public eWebViewTarget WebViewTarget
{
get
{
eWebViewTarget target;
System.Enum.TryParse(Value, true, out target);
return target;
}
}
}
/// <summary>
/// Represents the status of a webview event, which can include error information (XPath and Reason) as well as the current status of the webview. The Value property can be used to get or set the current status of the webview, while the XPath and Reason properties can provide additional information in case of an error. The StatusString property can be used to get or set the raw status string from the event.
/// </summary>
public class Status
{
/// <summary>
/// The XPath of the webview event, which can provide information about where an error occurred in the webview. This property is typically only populated in case of an error, and may be null otherwise.
/// </summary>
[JsonProperty("XPath", NullValueHandling = NullValueHandling.Ignore)]
public XPath XPath { get; set; }
/// <summary>
/// The reason for an error in the webview event, which can provide additional information about what went wrong. This property is typically only populated in case of an error, and may be null otherwise.
/// </summary>
[JsonProperty("Reason", NullValueHandling = NullValueHandling.Ignore)]
public Reason Reason { get; set; }
/// <summary>
/// The raw status string from the webview event, which can provide information about the current status of the webview. This property can be used to get or set the status directly, but it is recommended to use the Value property for easier handling of expected values. Setting this property will not trigger any actions, while setting the Value property will trigger the ValueChangedAction if it is set.
/// </summary>
[JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)]
public string StatusString { get; set; }
/// <summary>
/// The current status of the webview as a string, which can be set directly or parsed into a WebViewEventMode enum using the WebViewEventMode property. Expected values are "Fullscreen", "Modal", "Cleared", "Error", or "Unknown". Setting this property will trigger the ValueChangedAction if it is set.
/// </summary>
[JsonProperty("Value", NullValueHandling = NullValueHandling.Ignore)]
public string Value { get; set; }
}
/// <summary> /// <summary>
/// Defines the contract for IHasWebView /// Defines the contract for IHasWebView
/// </summary> /// </summary>
@ -34,6 +316,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
/// Event raised when the webview status changes /// Event raised when the webview status changes
/// </summary> /// </summary>
event EventHandler<WebViewStatusChangedEventArgs> WebViewStatusChanged; event EventHandler<WebViewStatusChangedEventArgs> WebViewStatusChanged;
} }
@ -75,6 +358,11 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
/// </summary> /// </summary>
public string Status { get; } public string Status { get; }
/// <summary>
/// Gets or sets the WebViewEvent associated with the status change, which can provide additional information about the webview event that triggered the status change, such as display parameters or error information. This property allows you to include the full WebViewEvent in the event args, giving you access to all relevant details about the webview event when handling the WebViewStatusChanged event.
/// </summary>
public WebViewEvent WebView { get; }
/// <summary> /// <summary>
/// Constructor for WebViewStatusChangedEventArgs /// Constructor for WebViewStatusChangedEventArgs
/// </summary> /// </summary>
@ -83,5 +371,16 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{ {
Status = status; Status = status;
} }
/// <summary>
/// Constructor for WebViewStatusChangedEventArgs with WebViewEvent parameter, which can provide additional information about the webview event that triggered the status change, such as display parameters or error information. This constructor allows you to include the full WebViewEvent in the event args, giving you access to all relevant details about the webview event when handling the WebViewStatusChanged event.
/// </summary>
/// <param name="status">the new status of the webview</param>
/// <param name="webview">the WebViewEvent associated with the status change</param>
public WebViewStatusChangedEventArgs(string status, WebViewEvent webview)
{
Status = status;
WebView = webview;
}
} }
} }

View file

@ -1,10 +1,12 @@
using System;
using PepperDash.Core; using PepperDash.Core;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{ {
/// <summary> /// <summary>
/// Defines the contract for IMobileControlMessenger /// Obsolete: messengers are subscription based by default; use IMobileControlMessenger instead.
/// </summary> /// </summary>
[Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")]
public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger
{ {
/// <summary> /// <summary>

View file

@ -7,6 +7,7 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace PepperDash.Essentials.Core namespace PepperDash.Essentials.Core
@ -176,7 +177,20 @@ namespace PepperDash.Essentials.Core
{ {
if (!conversionType.IsEnum) if (!conversionType.IsEnum)
{ {
return Convert.ChangeType(value, conversionType, System.Globalization.CultureInfo.InvariantCulture); if (conversionType == typeof(byte[]) && value is string byteString)
{
var unescaped = UnescapeString(byteString);
return System.Text.Encoding.GetEncoding(28591).GetBytes(unescaped);
}
var converted = Convert.ChangeType(value, conversionType, System.Globalization.CultureInfo.InvariantCulture);
if (conversionType == typeof(string) && converted is string s)
{
return UnescapeString(s);
}
return converted;
} }
var stringValue = Convert.ToString(value); var stringValue = Convert.ToString(value);
@ -189,6 +203,32 @@ namespace PepperDash.Essentials.Core
return Enum.Parse(conversionType, stringValue, true); return Enum.Parse(conversionType, stringValue, true);
} }
/// <summary>
/// Processes escape sequences in a string, converting sequences like \r, \n, \t, \xHH
/// to their corresponding non-printable ASCII characters.
/// </summary>
private static string UnescapeString(string input)
{
if (string.IsNullOrEmpty(input))
return input;
return Regex.Replace(input, @"\\(r|n|t|\\|x[0-9A-Fa-f]{2})", match =>
{
var seq = match.Groups[1].Value;
switch (seq)
{
case "r": return "\r";
case "n": return "\n";
case "t": return "\t";
case "\\": return "\\";
default:
// \xHH hex escape
var hex = seq.Substring(1);
return ((char)Convert.ToInt32(hex, 16)).ToString();
}
});
}
/// <summary> /// <summary>
/// Gets the properties on a device /// Gets the properties on a device
/// </summary> /// </summary>

View file

@ -436,14 +436,14 @@ namespace PepperDash.Essentials.Core
CrestronConsole.ConsoleCommandResponse("Device {0} has {1} Input Ports:{2}", s, inputPorts.Count, CrestronEnvironment.NewLine); CrestronConsole.ConsoleCommandResponse("Device {0} has {1} Input Ports:{2}", s, inputPorts.Count, CrestronEnvironment.NewLine);
foreach (var routingInputPort in inputPorts) foreach (var routingInputPort in inputPorts)
{ {
CrestronConsole.ConsoleCommandResponse("{0}{1}", routingInputPort.Key, CrestronEnvironment.NewLine); CrestronConsole.ConsoleCommandResponse("key: {0} signalType: {1}{2}", routingInputPort.Key, routingInputPort.Type, CrestronEnvironment.NewLine);
} }
} }
if (outputPorts == null) return; if (outputPorts == null) return;
CrestronConsole.ConsoleCommandResponse("Device {0} has {1} Output Ports:{2}", s, outputPorts.Count, CrestronEnvironment.NewLine); CrestronConsole.ConsoleCommandResponse("Device {0} has {1} Output Ports:{2}", s, outputPorts.Count, CrestronEnvironment.NewLine);
foreach (var routingOutputPort in outputPorts) foreach (var routingOutputPort in outputPorts)
{ {
CrestronConsole.ConsoleCommandResponse("{0}{1}", routingOutputPort.Key, CrestronEnvironment.NewLine); CrestronConsole.ConsoleCommandResponse("key: {0} signalType: {1}{2}", routingOutputPort.Key, routingOutputPort.Type, CrestronEnvironment.NewLine);
} }
} }

View file

@ -10,6 +10,7 @@ namespace PepperDash.Essentials.Core.Interfaces
/// <summary> /// <summary>
/// Defines the contract for ILogStrings /// Defines the contract for ILogStrings
/// </summary> /// </summary>
[Obsolete("ILogStrings is no longer supported and will be removed in a future release.")]
public interface ILogStrings : IKeyed public interface ILogStrings : IKeyed
{ {
/// <summary> /// <summary>

View file

@ -10,6 +10,7 @@ namespace PepperDash.Essentials.Core.Interfaces
/// <summary> /// <summary>
/// Defines the contract for ILogStringsWithLevel /// Defines the contract for ILogStringsWithLevel
/// </summary> /// </summary>
[Obsolete("ILogStringsWithLevel is no longer supported and will be removed in a future release.")]
public interface ILogStringsWithLevel : IKeyed public interface ILogStringsWithLevel : IKeyed
{ {
/// <summary> /// <summary>

View file

@ -2,10 +2,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using Crestron.SimplSharpPro.Keypads;
using PepperDash.Essentials.Core.Queues; using PepperDash.Essentials.Core.Queues;
using PepperDash.Essentials.Core.Routing; using PepperDash.Essentials.Core.Routing;
using Serilog.Events;
using Debug = PepperDash.Core.Debug; using Debug = PepperDash.Core.Debug;
@ -18,6 +16,20 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public static class Extensions public static class Extensions
{ {
/// <summary>
/// A collection of RouteDescriptors for each signal type.
/// </summary>
public static readonly Dictionary<eRoutingSignalType, RouteDescriptorCollection> RouteDescriptors = new Dictionary<eRoutingSignalType, RouteDescriptorCollection>()
{
{ eRoutingSignalType.Audio, new RouteDescriptorCollection() },
{ eRoutingSignalType.Video, new RouteDescriptorCollection() },
{ eRoutingSignalType.SecondaryAudio, new RouteDescriptorCollection() },
{ eRoutingSignalType.AudioVideo, new RouteDescriptorCollection() },
{ eRoutingSignalType.UsbInput, new RouteDescriptorCollection() },
{ eRoutingSignalType.UsbOutput, new RouteDescriptorCollection() }
};
/// <summary> /// <summary>
/// Stores pending route requests, keyed by the destination device key. /// Stores pending route requests, keyed by the destination device key.
/// Used primarily to handle routing requests while a device is cooling down. /// Used primarily to handle routing requests while a device is cooling down.
@ -29,6 +41,78 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
private static readonly GenericQueue routeRequestQueue = new GenericQueue("routingQueue"); private static readonly GenericQueue routeRequestQueue = new GenericQueue("routingQueue");
/// <summary>
/// Indexed lookup of TieLines by destination device key for faster queries.
/// </summary>
private static Dictionary<string, List<TieLine>> _tieLinesByDestination;
/// <summary>
/// Indexed lookup of TieLines by source device key for faster queries.
/// </summary>
private static Dictionary<string, List<TieLine>> _tieLinesBySource;
/// <summary>
/// Indexes all TieLines by source and destination device keys for faster lookups.
/// Should be called once at system startup after all TieLines are created.
/// </summary>
public static void IndexTieLines()
{
try
{
Debug.LogInformation("Indexing TieLines for faster route discovery");
_tieLinesByDestination = TieLineCollection.Default
.GroupBy(t => t.DestinationPort.ParentDevice.Key)
.ToDictionary(g => g.Key, g => g.ToList());
_tieLinesBySource = TieLineCollection.Default
.GroupBy(t => t.SourcePort.ParentDevice.Key)
.ToDictionary(g => g.Key, g => g.ToList());
Debug.LogInformation("TieLine indexing complete. {0} destination keys, {1} source keys",
_tieLinesByDestination.Count, _tieLinesBySource.Count);
}
catch (Exception ex)
{
Debug.LogError("Exception indexing TieLines: {exception}", ex.Message);
Debug.LogDebug(ex, "Stack Trace: ");
}
}
/// <summary>
/// Gets TieLines connected to a destination device.
/// Uses indexed lookup if available, otherwise falls back to LINQ query.
/// </summary>
/// <param name="destinationKey">The destination device key</param>
/// <returns>List of TieLines connected to the destination</returns>
private static IEnumerable<TieLine> GetTieLinesForDestination(string destinationKey)
{
if (_tieLinesByDestination != null && _tieLinesByDestination.TryGetValue(destinationKey, out List<TieLine> tieLines))
{
return tieLines;
}
// Fallback to LINQ if index not available
return TieLineCollection.Default.Where(t => t.DestinationPort.ParentDevice.Key == destinationKey);
}
/// <summary>
/// Gets TieLines connected to a source device.
/// Uses indexed lookup if available, otherwise falls back to LINQ query.
/// </summary>
/// <param name="sourceKey">The source device key</param>
/// <returns>List of TieLines connected to the source</returns>
private static IEnumerable<TieLine> GetTieLinesForSource(string sourceKey)
{
if (_tieLinesBySource != null && _tieLinesBySource.TryGetValue(sourceKey, out List<TieLine> tieLines))
{
return tieLines;
}
// Fallback to LINQ if index not available
return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey);
}
/// <summary> /// <summary>
/// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute /// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute
/// and then attempts a new Route and if sucessful, stores that RouteDescriptor /// and then attempts a new Route and if sucessful, stores that RouteDescriptor
@ -38,7 +122,7 @@ namespace PepperDash.Essentials.Core
{ {
// Remove this line before committing!!!!! // Remove this line before committing!!!!!
var frame = new StackFrame(1, true); var frame = new StackFrame(1, true);
Debug.LogMessage(LogEventLevel.Information, "ReleaseAndMakeRoute Called from {method} with params {destinationKey}:{sourceKey}:{signalType}:{destinationPortKey}:{sourcePortKey}", frame.GetMethod().Name, destination.Key, source.Key, signalType.ToString(), destinationPortKey, sourcePortKey); Debug.LogInformation("ReleaseAndMakeRoute Called from {method} with params {destinationKey}:{sourceKey}:{signalType}:{destinationPortKey}:{sourcePortKey}", frame.GetMethod().Name, destination.Key, source.Key, signalType.ToString(), destinationPortKey, sourcePortKey);
var inputPort = string.IsNullOrEmpty(destinationPortKey) ? null : destination.InputPorts.FirstOrDefault(p => p.Key == destinationPortKey); var inputPort = string.IsNullOrEmpty(destinationPortKey) ? null : destination.InputPorts.FirstOrDefault(p => p.Key == destinationPortKey);
var outputPort = string.IsNullOrEmpty(sourcePortKey) ? null : source.OutputPorts.FirstOrDefault(p => p.Key == sourcePortKey); var outputPort = string.IsNullOrEmpty(sourcePortKey) ? null : source.OutputPorts.FirstOrDefault(p => p.Key == sourcePortKey);
@ -96,13 +180,13 @@ namespace PepperDash.Essentials.Core
/// <param name="destinationKey">destination device key</param> /// <param name="destinationKey">destination device key</param>
public static void RemoveRouteRequestForDestination(string destinationKey) public static void RemoveRouteRequestForDestination(string destinationKey)
{ {
Debug.LogMessage(LogEventLevel.Information, "Removing route request for {destination}", null, destinationKey); Debug.LogInformation("Removing route request for {destination}", destinationKey);
var result = RouteRequests.Remove(destinationKey); var result = RouteRequests.Remove(destinationKey);
var messageTemplate = result ? "Route Request for {destination} removed" : "Route Request for {destination} not found"; var messageTemplate = result ? "Route Request for {destination} removed" : "Route Request for {destination} not found";
Debug.LogMessage(LogEventLevel.Information, messageTemplate, null, destinationKey); Debug.LogInformation(messageTemplate, destinationKey);
} }
/// <summary> /// <summary>
@ -118,8 +202,8 @@ namespace PepperDash.Essentials.Core
if (!signalType.HasFlag(eRoutingSignalType.AudioVideo) && if (!signalType.HasFlag(eRoutingSignalType.AudioVideo) &&
!(signalType.HasFlag(eRoutingSignalType.Video) && signalType.HasFlag(eRoutingSignalType.SecondaryAudio))) !(signalType.HasFlag(eRoutingSignalType.Video) && signalType.HasFlag(eRoutingSignalType.SecondaryAudio)))
{ {
var singleTypeRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, signalType); var singleTypeRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, signalType);
Debug.LogMessage(LogEventLevel.Debug, "Attempting to build source route from {sourceKey} of type {type}", destination, source.Key, signalType); Debug.LogDebug(destination, "Attempting to build source route from {sourceKey} of type {type}", source.Key, signalType);
if (!destination.GetRouteToSource(source, null, null, signalType, 0, singleTypeRouteDescriptor, destinationPort, sourcePort)) if (!destination.GetRouteToSource(source, null, null, signalType, 0, singleTypeRouteDescriptor, destinationPort, sourcePort))
singleTypeRouteDescriptor = null; singleTypeRouteDescriptor = null;
@ -127,54 +211,55 @@ namespace PepperDash.Essentials.Core
var routes = singleTypeRouteDescriptor?.Routes ?? new List<RouteSwitchDescriptor>(); var routes = singleTypeRouteDescriptor?.Routes ?? new List<RouteSwitchDescriptor>();
foreach (var route in routes) foreach (var route in routes)
{ {
Debug.LogMessage(LogEventLevel.Verbose, "Route for device: {route}", destination, route.ToString()); Debug.LogVerbose(destination, "Route for device: {route}", route.ToString());
} }
return (singleTypeRouteDescriptor, null); return (singleTypeRouteDescriptor, null);
} }
// otherwise, audioVideo needs to be handled as two steps. // otherwise, audioVideo needs to be handled as two steps.
Debug.LogMessage(LogEventLevel.Debug, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination, source.Key, signalType); Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination.Key, source.Key, signalType);
RouteDescriptor audioRouteDescriptor; RouteDescriptor audioRouteDescriptor;
if (signalType.HasFlag(eRoutingSignalType.SecondaryAudio)) if (signalType.HasFlag(eRoutingSignalType.SecondaryAudio))
{ {
audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.SecondaryAudio); audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.SecondaryAudio);
} }
else else
{ {
audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.Audio); audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.Audio);
} }
var audioSuccess = destination.GetRouteToSource(source, null, null, signalType.HasFlag(eRoutingSignalType.SecondaryAudio) ? eRoutingSignalType.SecondaryAudio : eRoutingSignalType.Audio, 0, audioRouteDescriptor, destinationPort, sourcePort); var audioSuccess = destination.GetRouteToSource(source, null, null, signalType.HasFlag(eRoutingSignalType.SecondaryAudio) ? eRoutingSignalType.SecondaryAudio : eRoutingSignalType.Audio, 0, audioRouteDescriptor, destinationPort, sourcePort);
if (!audioSuccess) if (!audioSuccess)
Debug.LogMessage(LogEventLevel.Debug, "Cannot find audio route to {0}", destination, source.Key); Debug.LogDebug(destination, "Cannot find audio route to {0}", source.Key);
var videoRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.Video); var videoRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.Video);
var videoSuccess = destination.GetRouteToSource(source, null, null, eRoutingSignalType.Video, 0, videoRouteDescriptor, destinationPort, sourcePort); var videoSuccess = destination.GetRouteToSource(source, null, null, eRoutingSignalType.Video, 0, videoRouteDescriptor, destinationPort, sourcePort);
if (!videoSuccess) if (!videoSuccess)
Debug.LogMessage(LogEventLevel.Debug, "Cannot find video route to {0}", destination, source.Key); Debug.LogDebug(destination, "Cannot find video route to {0}", source.Key);
foreach (var route in audioRouteDescriptor.Routes) foreach (var route in audioRouteDescriptor.Routes)
{ {
Debug.LogMessage(LogEventLevel.Verbose, "Audio route for device: {route}", destination, route.ToString()); Debug.LogVerbose(destination, "Audio route for device: {route}", route.ToString());
} }
foreach (var route in videoRouteDescriptor.Routes) foreach (var route in videoRouteDescriptor.Routes)
{ {
Debug.LogMessage(LogEventLevel.Verbose, "Video route for device: {route}", destination, route.ToString()); Debug.LogVerbose(destination, "Video route for device: {route}", route.ToString());
} }
if (!audioSuccess && !videoSuccess) if (!audioSuccess && !videoSuccess)
return (null, null); return (null, null);
// Return null for descriptors that have no routes
return (audioRouteDescriptor, videoRouteDescriptor); return (audioSuccess && audioRouteDescriptor.Routes.Count > 0 ? audioRouteDescriptor : null,
videoSuccess && videoRouteDescriptor.Routes.Count > 0 ? videoRouteDescriptor : null);
} }
/// <summary> /// <summary>
@ -190,8 +275,8 @@ namespace PepperDash.Essentials.Core
{ {
if (destination == null) throw new ArgumentNullException(nameof(destination)); if (destination == null) throw new ArgumentNullException(nameof(destination));
if (source == null) throw new ArgumentNullException(nameof(source)); if (source == null) throw new ArgumentNullException(nameof(source));
if (destinationPort == null) Debug.LogMessage(LogEventLevel.Information, "Destination port is null"); if (destinationPort == null) Debug.LogDebug("Destination port is null");
if (sourcePort == null) Debug.LogMessage(LogEventLevel.Information, "Source port is null"); if (sourcePort == null) Debug.LogDebug("Source port is null");
var routeRequest = new RouteRequest var routeRequest = new RouteRequest
{ {
@ -213,7 +298,7 @@ namespace PepperDash.Essentials.Core
RouteRequests[destination.Key] = routeRequest; RouteRequests[destination.Key] = routeRequest;
Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is cooling down and already has a routing request stored. Storing new route request to route to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); Debug.LogInformation("Device: {destination} is cooling down and already has a routing request stored. Storing new route request to route to source key: {sourceKey}", destination.Key, routeRequest.Source.Key);
return; return;
} }
@ -225,7 +310,7 @@ namespace PepperDash.Essentials.Core
RouteRequests.Add(destination.Key, routeRequest); RouteRequests.Add(destination.Key, routeRequest);
Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is cooling down. Storing route request to route to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); Debug.LogInformation("Device: {destination} is cooling down. Storing route request to route to source key: {sourceKey}", destination.Key, routeRequest.Source.Key);
return; return;
} }
@ -237,7 +322,7 @@ namespace PepperDash.Essentials.Core
RouteRequests.Remove(destination.Key); RouteRequests.Remove(destination.Key);
Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is NOT cooling down. Removing stored route request and routing to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); Debug.LogInformation("Device: {destination} is NOT cooling down. Removing stored route request and routing to source key: {sourceKey}", destination.Key, routeRequest.Source.Key);
} }
routeRequestQueue.Enqueue(new ReleaseRouteQueueItem(ReleaseRouteInternal, destination, destinationPort?.Key ?? string.Empty, false)); routeRequestQueue.Enqueue(new ReleaseRouteQueueItem(ReleaseRouteInternal, destination, destinationPort?.Key ?? string.Empty, false));
@ -245,6 +330,94 @@ namespace PepperDash.Essentials.Core
routeRequestQueue.Enqueue(new RouteRequestQueueItem(RunRouteRequest, routeRequest)); routeRequestQueue.Enqueue(new RouteRequestQueueItem(RunRouteRequest, routeRequest));
} }
/// <summary>
/// Maps destination input ports to source output ports for all routing devices.
/// </summary>
public static void MapDestinationsToSources()
{
try
{
// Index TieLines before mapping if not already done
if (_tieLinesByDestination == null || _tieLinesBySource == null)
{
IndexTieLines();
}
var sinks = DeviceManager.AllDevices.OfType<IRoutingInputs>();
var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>();
foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs)))
{
foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs)))
{
foreach (var inputPort in sink.InputPorts)
{
foreach (var outputPort in source.OutputPorts)
{
var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, outputPort.Type, inputPort, outputPort);
if (audioOrSingleRoute == null && videoRoute == null)
{
continue;
}
Debug.LogVerbose("AudioOrSingleRoute Found: {audioRoute}", audioOrSingleRoute);
Debug.LogVerbose("VideoRoute Found: {videoRoute}", videoRoute);
if (audioOrSingleRoute != null)
{
// Only add routes that have actual switching steps
if (audioOrSingleRoute.Routes == null || audioOrSingleRoute.Routes.Count == 0)
{
continue;
}
// Add to the appropriate collection(s) based on signal type
// Note: A single route descriptor with combined flags (e.g., AudioVideo) will be added once per matching signal type
if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Audio))
{
RouteDescriptors[eRoutingSignalType.Audio].AddRouteDescriptor(audioOrSingleRoute);
}
if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Video))
{
RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(audioOrSingleRoute);
}
if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.SecondaryAudio))
{
RouteDescriptors[eRoutingSignalType.SecondaryAudio].AddRouteDescriptor(audioOrSingleRoute);
}
if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.UsbInput))
{
RouteDescriptors[eRoutingSignalType.UsbInput].AddRouteDescriptor(audioOrSingleRoute);
}
if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.UsbOutput))
{
RouteDescriptors[eRoutingSignalType.UsbOutput].AddRouteDescriptor(audioOrSingleRoute);
}
}
if (videoRoute != null)
{
// Only add routes that have actual switching steps
if (videoRoute.Routes == null || videoRoute.Routes.Count == 0)
{
continue;
}
RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute);
}
}
}
}
}
}
catch (Exception ex)
{
Debug.LogError("Exception mapping routes: {exception}", ex.Message);
Debug.LogDebug(ex, "Stack Trace: ");
}
}
/// <summary> /// <summary>
/// Executes the actual routing based on a <see cref="RouteRequest"/>. /// Executes the actual routing based on a <see cref="RouteRequest"/>.
/// Finds the route path, adds it to the collection, and executes the switches. /// Finds the route path, adds it to the collection, and executes the switches.
@ -257,7 +430,54 @@ namespace PepperDash.Essentials.Core
if (request.Source == null) if (request.Source == null)
return; return;
var (audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort); RouteDescriptor audioOrSingleRoute = null;
RouteDescriptor videoRoute = null;
// Try to use pre-loaded route descriptors first
if (request.SignalType.HasFlag(eRoutingSignalType.AudioVideo))
{
// For AudioVideo routes, check both Audio and Video collections
if (RouteDescriptors.TryGetValue(eRoutingSignalType.Audio, out RouteDescriptorCollection audioCollection))
{
audioOrSingleRoute = audioCollection.Descriptors.FirstOrDefault(d =>
d.Source.Key == request.Source.Key &&
d.Destination.Key == request.Destination.Key &&
(request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) &&
(request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key));
}
if (RouteDescriptors.TryGetValue(eRoutingSignalType.Video, out RouteDescriptorCollection videoCollection))
{
videoRoute = videoCollection.Descriptors.FirstOrDefault(d =>
d.Source.Key == request.Source.Key &&
d.Destination.Key == request.Destination.Key &&
(request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) &&
(request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key));
}
}
else
{
// For single signal type routes
var signalTypeToCheck = request.SignalType.HasFlag(eRoutingSignalType.SecondaryAudio)
? eRoutingSignalType.SecondaryAudio
: request.SignalType;
if (RouteDescriptors.TryGetValue(signalTypeToCheck, out RouteDescriptorCollection collection))
{
audioOrSingleRoute = collection.Descriptors.FirstOrDefault(d =>
d.Source.Key == request.Source.Key &&
d.Destination.Key == request.Destination.Key &&
(request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) &&
(request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key));
}
}
// If no pre-loaded route found, build it dynamically
if (audioOrSingleRoute == null && videoRoute == null)
{
Debug.LogDebug(request.Destination, "No pre-loaded route found, building dynamically");
(audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort);
}
if (audioOrSingleRoute == null && videoRoute == null) if (audioOrSingleRoute == null && videoRoute == null)
return; return;
@ -269,14 +489,15 @@ namespace PepperDash.Essentials.Core
RouteDescriptorCollection.DefaultCollection.AddRouteDescriptor(videoRoute); RouteDescriptorCollection.DefaultCollection.AddRouteDescriptor(videoRoute);
} }
Debug.LogMessage(LogEventLevel.Verbose, "Executing full route", request.Destination); Debug.LogVerbose(request.Destination, "Executing full route");
audioOrSingleRoute.ExecuteRoutes(); audioOrSingleRoute.ExecuteRoutes();
videoRoute?.ExecuteRoutes(); videoRoute?.ExecuteRoutes();
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogMessage(ex, "Exception Running Route Request {request}", null, request); Debug.LogError("Exception Running Route Request {request}: {exception}", request, ex.Message);
Debug.LogDebug(ex, "Stack Trace: ");
} }
} }
@ -290,7 +511,7 @@ namespace PepperDash.Essentials.Core
{ {
try try
{ {
Debug.LogMessage(LogEventLevel.Information, "Release route for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); Debug.LogInformation(destination, "Release route for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey);
if (RouteRequests.TryGetValue(destination.Key, out RouteRequest existingRequest) && destination is IWarmingCooling) if (RouteRequests.TryGetValue(destination.Key, out RouteRequest existingRequest) && destination is IWarmingCooling)
{ {
@ -304,13 +525,14 @@ namespace PepperDash.Essentials.Core
var current = RouteDescriptorCollection.DefaultCollection.RemoveRouteDescriptor(destination, inputPortKey); var current = RouteDescriptorCollection.DefaultCollection.RemoveRouteDescriptor(destination, inputPortKey);
if (current != null) if (current != null)
{ {
Debug.LogMessage(LogEventLevel.Information, "Releasing current route: {0}", destination, current.Source.Key); Debug.LogInformation(destination, "Releasing current route: {0}", current.Source.Key);
current.ReleaseRoutes(clearRoute); current.ReleaseRoutes(clearRoute);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogMessage(ex, "Exception releasing route for '{destination}':'{inputPortKey}'", null, destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); Debug.LogError("Exception releasing route for '{destination}':'{inputPortKey}': {exception}", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey, ex.Message);
Debug.LogDebug(ex, "Stack Trace: ");
} }
} }
@ -321,13 +543,13 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
/// <param name="destination"></param> /// <param name="destination"></param>
/// <param name="source"></param> /// <param name="source"></param>
/// <param name="destinationPort">The RoutingOutputPort whose link is being checked for a route</param> /// <param name="outputPortToUse">The RoutingOutputPort whose link is being checked for a route</param>
/// <param name="alreadyCheckedDevices">Prevents Devices from being twice-checked</param> /// <param name="alreadyCheckedDevices">Prevents Devices from being twice-checked</param>
/// <param name="signalType">This recursive function should not be called with AudioVideo</param> /// <param name="signalType">This recursive function should not be called with AudioVideo</param>
/// <param name="cycle">Just an informational counter</param> /// <param name="cycle">Just an informational counter</param>
/// <param name="routeTable">The RouteDescriptor being populated as the route is discovered</param> /// <param name="routeTable">The RouteDescriptor being populated as the route is discovered</param>
/// <param name="outputPortToUse">The RoutingOutputPort to use for the route</param> /// <param name="destinationPort">The RoutingOutputPort whose link is being checked for a route</param>
/// <param name="sourcePort">The specific source output port to use (optional)</param> /// <param name="sourcePort">The source output port (optional)</param>
/// <returns>true if source is hit</returns> /// <returns>true if source is hit</returns>
private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOutputs source, private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOutputs source,
RoutingOutputPort outputPortToUse, List<IRoutingInputsOutputs> alreadyCheckedDevices, RoutingOutputPort outputPortToUse, List<IRoutingInputsOutputs> alreadyCheckedDevices,
@ -335,42 +557,46 @@ namespace PepperDash.Essentials.Core
{ {
cycle++; cycle++;
Debug.LogMessage(LogEventLevel.Verbose, "GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); Debug.LogVerbose("GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString());
RoutingInputPort goodInputPort = null; RoutingInputPort goodInputPort = null;
// Use indexed lookup instead of LINQ query
var allDestinationTieLines = GetTieLinesForDestination(destination.Key);
IEnumerable<TieLine> destinationTieLines; IEnumerable<TieLine> destinationTieLines;
TieLine directTie = null; TieLine directTie = null;
if (destinationPort == null) if (destinationPort == null)
{ {
destinationTieLines = TieLineCollection.Default.Where(t => destinationTieLines = allDestinationTieLines.Where(t =>
t.DestinationPort.ParentDevice.Key == destination.Key && (t.Type.HasFlag(signalType) || signalType == eRoutingSignalType.AudioVideo)); t.Type.HasFlag(signalType) || signalType == eRoutingSignalType.AudioVideo);
} }
else else
{ {
destinationTieLines = TieLineCollection.Default.Where(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && (t.Type.HasFlag(signalType))); destinationTieLines = allDestinationTieLines.Where(t =>
t.DestinationPort.Key == destinationPort.Key && t.Type.HasFlag(signalType));
} }
// find the TieLine without a port // find the TieLine without a port
if (destinationPort == null && sourcePort == null) if (destinationPort == null && sourcePort == null)
{ {
directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.SourcePort.ParentDevice.Key == source.Key); directTie = destinationTieLines.FirstOrDefault(t => t.SourcePort.ParentDevice.Key == source.Key);
} }
// find a tieLine to a specific destination port without a specific source port // find a tieLine to a specific destination port without a specific source port
else if (destinationPort != null && sourcePort == null) else if (destinationPort != null && sourcePort == null)
{ {
directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key); directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key);
} }
// find a tieline to a specific source port without a specific destination port // find a tieline to a specific source port without a specific destination port
else if (destinationPort == null & sourcePort != null) else if (destinationPort == null & sourcePort != null)
{ {
directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); directTie = destinationTieLines.FirstOrDefault(t => t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key);
} }
// find a tieline to a specific source port and destination port // find a tieline to a specific source port and destination port
else if (destinationPort != null && sourcePort != null) else if (destinationPort != null && sourcePort != null)
{ {
directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key);
} }
if (directTie != null) // Found a tie directly to the source if (directTie != null) // Found a tie directly to the source
@ -379,12 +605,14 @@ namespace PepperDash.Essentials.Core
} }
else // no direct-connect. Walk back devices. else // no direct-connect. Walk back devices.
{ {
Debug.LogMessage(LogEventLevel.Verbose, "is not directly connected to {sourceKey}. Walking down tie lines", destination, source.Key); Debug.LogVerbose(destination, "is not directly connected to {sourceKey}. Walking down tie lines", source.Key);
// No direct tie? Run back out on the inputs' attached devices... // No direct tie? Run back out on the inputs' attached devices...
// Only the ones that are routing devices // Only the ones that are routing devices
var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs); var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs);
Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count(), destination.Key);
//Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration //Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration
if (alreadyCheckedDevices == null) if (alreadyCheckedDevices == null)
alreadyCheckedDevices = new List<IRoutingInputsOutputs>(); alreadyCheckedDevices = new List<IRoutingInputsOutputs>();
@ -397,13 +625,13 @@ namespace PepperDash.Essentials.Core
// Check if this previous device has already been walked // Check if this previous device has already been walked
if (alreadyCheckedDevices.Contains(midpointDevice)) if (alreadyCheckedDevices.Contains(midpointDevice))
{ {
Debug.LogMessage(LogEventLevel.Verbose, "Skipping input {midpointDeviceKey} on {destinationKey}, this was already checked", destination, midpointDevice.Key, destination.Key); Debug.LogVerbose(destination, "Skipping input {midpointDeviceKey} on {destinationKey}, this was already checked", midpointDevice.Key, destination.Key);
continue; continue;
} }
var midpointOutputPort = tieLine.SourcePort; var midpointOutputPort = tieLine.SourcePort;
Debug.LogMessage(LogEventLevel.Verbose, "Trying to find route on {midpointDeviceKey}", destination, midpointDevice.Key); Debug.LogVerbose(destination, "Trying to find route on {midpointDeviceKey}", midpointDevice.Key);
// haven't seen this device yet. Do it. Pass the output port to the next // haven't seen this device yet. Do it. Pass the output port to the next
// level to enable switching on success // level to enable switching on success
@ -412,9 +640,9 @@ namespace PepperDash.Essentials.Core
if (upstreamRoutingSuccess) if (upstreamRoutingSuccess)
{ {
Debug.LogMessage(LogEventLevel.Verbose, "Upstream device route found", destination); Debug.LogVerbose(destination, "Upstream device route found");
Debug.LogMessage(LogEventLevel.Verbose, "Route found on {midpointDeviceKey}", destination, midpointDevice.Key); Debug.LogVerbose(destination, "Route found on {midpointDeviceKey}", midpointDevice.Key);
Debug.LogMessage(LogEventLevel.Verbose, "TieLine: SourcePort: {SourcePort} DestinationPort: {DestinationPort}", destination, tieLine.SourcePort, tieLine.DestinationPort); Debug.LogVerbose(destination, "TieLine: SourcePort: {SourcePort} DestinationPort: {DestinationPort}", tieLine.SourcePort, tieLine.DestinationPort);
goodInputPort = tieLine.DestinationPort; goodInputPort = tieLine.DestinationPort;
break; // Stop looping the inputs in this cycle break; // Stop looping the inputs in this cycle
} }
@ -424,7 +652,8 @@ namespace PepperDash.Essentials.Core
if (goodInputPort == null) if (goodInputPort == null)
{ {
Debug.LogMessage(LogEventLevel.Verbose, "No route found to {0}", destination, source.Key); Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType);
return false; return false;
} }
@ -440,7 +669,7 @@ namespace PepperDash.Essentials.Core
routeTable.Routes.Add(new RouteSwitchDescriptor(outputPortToUse, goodInputPort)); routeTable.Routes.Add(new RouteSwitchDescriptor(outputPortToUse, goodInputPort));
} }
else // device is merely IRoutingInputOutputs else // device is merely IRoutingInputOutputs
Debug.LogMessage(LogEventLevel.Verbose, "No routing. Passthrough device", destination); Debug.LogVerbose(destination, "No routing. Passthrough device");
return true; return true;
} }

View file

@ -20,22 +20,27 @@ namespace PepperDash.Essentials.Core
public IRoutingInputs Destination { get; private set; } public IRoutingInputs Destination { get; private set; }
/// <summary> /// <summary>
/// Gets or sets the InputPort /// The InputPort on the destination device for this route, if applicable. May be null if the route is not for a specific input port.
/// </summary> /// </summary>
public RoutingInputPort InputPort { get; private set; } public RoutingInputPort InputPort { get; private set; }
/// <summary> /// <summary>
/// Gets or sets the Source /// Gets the source device (sink or midpoint) for the route.
/// </summary> /// </summary>
public IRoutingOutputs Source { get; private set; } public IRoutingOutputs Source { get; private set; }
/// <summary> /// <summary>
/// Gets or sets the SignalType /// Gets the OutputPort on the source device for this route, if applicable. May be null if the route is not for a specific output port.
/// </summary>
public RoutingOutputPort OutputPort { get; private set; }
/// <summary>
/// Gets the signal type for this route.
/// </summary> /// </summary>
public eRoutingSignalType SignalType { get; private set; } public eRoutingSignalType SignalType { get; private set; }
/// <summary> /// <summary>
/// Gets or sets the Routes /// Gets the collection of route switch descriptors for this route.
/// </summary> /// </summary>
public List<RouteSwitchDescriptor> Routes { get; private set; } public List<RouteSwitchDescriptor> Routes { get; private set; }
@ -56,11 +61,24 @@ namespace PepperDash.Essentials.Core
/// <param name="destination">The destination device.</param> /// <param name="destination">The destination device.</param>
/// <param name="inputPort">The destination input port (optional).</param> /// <param name="inputPort">The destination input port (optional).</param>
/// <param name="signalType">The signal type for this route.</param> /// <param name="signalType">The signal type for this route.</param>
public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, eRoutingSignalType signalType) public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, eRoutingSignalType signalType) : this(source, destination, inputPort, null, signalType)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RouteDescriptor"/> class for a route with specific destination input and source output ports.
/// </summary>
/// <param name="source"></param>
/// <param name="destination"></param>
/// <param name="inputPort"></param>
/// <param name="outputPort"></param>
/// <param name="signalType"></param>
public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, RoutingOutputPort outputPort, eRoutingSignalType signalType)
{ {
Destination = destination; Destination = destination;
InputPort = inputPort; InputPort = inputPort;
Source = source; Source = source;
OutputPort = outputPort;
SignalType = signalType; SignalType = signalType;
Routes = new List<RouteSwitchDescriptor>(); Routes = new List<RouteSwitchDescriptor>();
} }
@ -72,7 +90,7 @@ namespace PepperDash.Essentials.Core
{ {
foreach (var route in Routes) foreach (var route in Routes)
{ {
Debug.LogMessage(LogEventLevel.Verbose, "ExecuteRoutes: {0}", null, route.ToString()); Debug.LogVerbose("ExecuteRoutes: {0}", route.ToString());
if (route.SwitchingDevice is IRoutingSinkWithSwitching sink) if (route.SwitchingDevice is IRoutingSinkWithSwitching sink)
{ {
@ -86,7 +104,7 @@ namespace PepperDash.Essentials.Core
route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType); route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType);
Debug.LogMessage(LogEventLevel.Verbose, "Output port {0} routing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); Debug.LogVerbose("Output port {0} routing. Count={1}", route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue);
} }
} }
} }
@ -112,6 +130,7 @@ namespace PepperDash.Essentials.Core
catch (Exception e) catch (Exception e)
{ {
Debug.LogError("Error executing switch: {exception}", e.Message); Debug.LogError("Error executing switch: {exception}", e.Message);
Debug.LogDebug(e, "Stack Trace: ");
} }
} }
@ -123,11 +142,11 @@ namespace PepperDash.Essentials.Core
if (route.OutputPort.InUseTracker != null) if (route.OutputPort.InUseTracker != null)
{ {
route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType); route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType);
Debug.LogMessage(LogEventLevel.Verbose, "Port {0} releasing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); Debug.LogVerbose("Port {0} releasing. Count={1}", route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue);
} }
else else
{ {
Debug.LogMessage(LogEventLevel.Error, "InUseTracker is null for OutputPort {0}", null, route.OutputPort.Key); Debug.LogVerbose("InUseTracker is null for OutputPort {0}", route.OutputPort.Key);
} }
} }
} }
@ -137,98 +156,11 @@ namespace PepperDash.Essentials.Core
/// Returns a string representation of the route descriptor, including source, destination, and individual route steps. /// Returns a string representation of the route descriptor, including source, destination, and individual route steps.
/// </summary> /// </summary>
/// <returns>A string describing the route.</returns> /// <returns>A string describing the route.</returns>
public override string ToString() public override string ToString()
{ {
var routesText = Routes.Select(r => r.ToString()).ToArray(); var routesText = Routes.Select(r => r.ToString()).ToArray();
return string.Format("Route table from {0} to {1}:\r{2}", Source.Key, Destination.Key, string.Join("\r", routesText)); return $"Route table from {Source.Key} to {Destination.Key} for {SignalType}:\r\n {string.Join("\r\n ", routesText)}";
} }
} }
/*/// <summary>
/// Represents an collection of individual route steps between Source and Destination
/// </summary>
/// <summary>
/// Represents a RouteDescriptor
/// </summary>
public class RouteDescriptor<TInputSelector, TOutputSelector>
{
/// <summary>
/// Gets or sets the Destination
/// </summary>
public IRoutingInputs<TInputSelector> Destination { get; private set; }
/// <summary>
/// Gets or sets the Source
/// </summary>
public IRoutingOutputs<TOutputSelector> Source { get; private set; }
/// <summary>
/// Gets or sets the SignalType
/// </summary>
public eRoutingSignalType SignalType { get; private set; }
public List<RouteSwitchDescriptor<TInputSelector, TOutputSelector>> Routes { get; private set; }
public RouteDescriptor(IRoutingOutputs<TOutputSelector> source, IRoutingInputs<TInputSelector> destination, eRoutingSignalType signalType)
{
Destination = destination;
Source = source;
SignalType = signalType;
Routes = new List<RouteSwitchDescriptor<TInputSelector, TOutputSelector>>();
}
/// <summary>
/// ExecuteRoutes method
/// </summary>
public void ExecuteRoutes()
{
foreach (var route in Routes)
{
Debug.LogMessage(LogEventLevel.Verbose, "ExecuteRoutes: {0}", null, route.ToString());
if (route.SwitchingDevice is IRoutingSinkWithSwitching<TInputSelector> sink)
{
sink.ExecuteSwitch(route.InputPort.Selector);
continue;
}
if (route.SwitchingDevice is IRouting switchingDevice)
{
switchingDevice.ExecuteSwitch(route.InputPort.Selector, route.OutputPort.Selector, SignalType);
route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType);
Debug.LogMessage(LogEventLevel.Verbose, "Output port {0} routing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue);
}
}
}
/// <summary>
/// ReleaseRoutes method
/// </summary>
public void ReleaseRoutes()
{
foreach (var route in Routes)
{
if (route.SwitchingDevice is IRouting<TInputSelector, TOutputSelector>)
{
// Pull the route from the port. Whatever is watching the output's in use tracker is
// responsible for responding appropriately.
route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType);
Debug.LogMessage(LogEventLevel.Verbose, "Port {0} releasing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue);
}
}
}
/// <summary>
/// ToString method
/// </summary>
/// <inheritdoc />
public override string ToString()
{
var routesText = Routes.Select(r => r.ToString()).ToArray();
return string.Format("Route table from {0} to {1}:\r{2}", Source.Key, Destination.Key, string.Join("\r", routesText));
}
}*/
} }

View file

@ -1,7 +1,7 @@
using PepperDash.Core; using System.Collections.Generic;
using Serilog.Events;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using PepperDash.Core;
using Serilog.Events;
namespace PepperDash.Essentials.Core namespace PepperDash.Essentials.Core
@ -12,7 +12,7 @@ namespace PepperDash.Essentials.Core
public class RouteDescriptorCollection public class RouteDescriptorCollection
{ {
/// <summary> /// <summary>
/// DefaultCollection static property /// Gets the default collection of RouteDescriptors.
/// </summary> /// </summary>
public static RouteDescriptorCollection DefaultCollection public static RouteDescriptorCollection DefaultCollection
{ {
@ -27,6 +27,11 @@ namespace PepperDash.Essentials.Core
private readonly List<RouteDescriptor> RouteDescriptors = new List<RouteDescriptor>(); private readonly List<RouteDescriptor> RouteDescriptors = new List<RouteDescriptor>();
/// <summary>
/// Gets an enumerable collection of all RouteDescriptors in this collection.
/// </summary>
public IEnumerable<RouteDescriptor> Descriptors => RouteDescriptors.AsReadOnly();
/// <summary> /// <summary>
/// Adds a RouteDescriptor to the list. If an existing RouteDescriptor for the /// Adds a RouteDescriptor to the list. If an existing RouteDescriptor for the
/// destination exists already, it will not be added - in order to preserve /// destination exists already, it will not be added - in order to preserve
@ -40,13 +45,34 @@ namespace PepperDash.Essentials.Core
return; return;
} }
if (RouteDescriptors.Any(t => t.Destination == descriptor.Destination) // Check if a route already exists with the same source, destination, input port, AND signal type
&& RouteDescriptors.Any(t => t.Destination == descriptor.Destination && t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key)) var existingRoute = RouteDescriptors.FirstOrDefault(t =>
t.Source == descriptor.Source &&
t.Destination == descriptor.Destination &&
t.SignalType == descriptor.SignalType &&
((t.InputPort == null && descriptor.InputPort == null) ||
(t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key)) &&
((t.OutputPort == null && descriptor.OutputPort == null) ||
(t.OutputPort != null && descriptor.OutputPort != null && t.OutputPort.Key == descriptor.OutputPort.Key)));
if (existingRoute != null)
{ {
Debug.LogMessage(LogEventLevel.Debug, descriptor.Destination, Debug.LogInformation(descriptor.Destination,
"Route to [{0}] already exists in global routes table", descriptor?.Source?.Key); "Route from {source}:{outputPort} to {destination}:{inputPort} ({signalType}) already exists in this collection",
descriptor?.Source?.Key,
descriptor?.OutputPort?.Key ?? "auto",
descriptor?.Destination?.Key,
descriptor?.InputPort?.Key ?? "auto",
descriptor?.SignalType
);
return; return;
} }
Debug.LogVerbose("Adding route descriptor: {source}:{outputPort} -> {destination}:{inputPort} ({signalType})",
descriptor?.Source?.Key,
descriptor?.OutputPort?.Key ?? "auto",
descriptor?.Destination?.Key,
descriptor?.InputPort?.Key ?? "auto",
descriptor?.SignalType);
RouteDescriptors.Add(descriptor); RouteDescriptors.Add(descriptor);
} }
@ -61,11 +87,11 @@ namespace PepperDash.Essentials.Core
} }
/// <summary> /// <summary>
/// Gets the RouteDescriptor for a destination and input port key. Returns null if no matching RouteDescriptor exists. /// Gets the route descriptor for a specific destination and input port
/// </summary> /// </summary>
/// <param name="destination"></param> /// <param name="destination">The destination device</param>
/// <param name="inputPortKey"></param> /// <param name="inputPortKey">The input port key</param>
/// <returns></returns> /// <returns>The matching RouteDescriptor or null if not found</returns>
public RouteDescriptor GetRouteDescriptorForDestinationAndInputPort(IRoutingInputs destination, string inputPortKey) public RouteDescriptor GetRouteDescriptorForDestinationAndInputPort(IRoutingInputs destination, string inputPortKey)
{ {
Debug.LogMessage(LogEventLevel.Information, "Getting route descriptor for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); Debug.LogMessage(LogEventLevel.Information, "Getting route descriptor for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey);
@ -93,70 +119,4 @@ namespace PepperDash.Essentials.Core
return descr; return descr;
} }
} }
/*/// <summary>
/// A collection of RouteDescriptors - typically the static DefaultCollection is used
/// </summary>
/// <summary>
/// Represents a RouteDescriptorCollection
/// </summary>
public class RouteDescriptorCollection<TInputSelector, TOutputSelector>
{
public static RouteDescriptorCollection<TInputSelector, TOutputSelector> DefaultCollection
{
get
{
if (_DefaultCollection == null)
_DefaultCollection = new RouteDescriptorCollection<TInputSelector, TOutputSelector>();
return _DefaultCollection;
}
}
private static RouteDescriptorCollection<TInputSelector, TOutputSelector> _DefaultCollection;
private readonly List<RouteDescriptor> RouteDescriptors = new List<RouteDescriptor>();
/// <summary>
/// Adds a RouteDescriptor to the list. If an existing RouteDescriptor for the
/// destination exists already, it will not be added - in order to preserve
/// proper route releasing.
/// </summary>
/// <param name="descriptor"></param>
/// <summary>
/// AddRouteDescriptor method
/// </summary>
public void AddRouteDescriptor(RouteDescriptor descriptor)
{
if (RouteDescriptors.Any(t => t.Destination == descriptor.Destination))
{
Debug.LogMessage(LogEventLevel.Debug, descriptor.Destination,
"Route to [{0}] already exists in global routes table", descriptor.Source.Key);
return;
}
RouteDescriptors.Add(descriptor);
}
/// <summary>
/// Gets the RouteDescriptor for a destination
/// </summary>
/// <returns>null if no RouteDescriptor for a destination exists</returns>
/// <summary>
/// GetRouteDescriptorForDestination method
/// </summary>
public RouteDescriptor GetRouteDescriptorForDestination(IRoutingInputs<TInputSelector> destination)
{
return RouteDescriptors.FirstOrDefault(rd => rd.Destination == destination);
}
/// <summary>
/// Returns the RouteDescriptor for a given destination AND removes it from collection.
/// Returns null if no route with the provided destination exists.
/// </summary>
public RouteDescriptor RemoveRouteDescriptor(IRoutingInputs<TInputSelector> destination)
{
var descr = GetRouteDescriptorForDestination(destination);
if (descr != null)
RouteDescriptors.Remove(descr);
return descr;
}
}*/
} }

View file

@ -51,49 +51,4 @@
return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches to input {(InputPort != null ? InputPort.Key : "No input port")}"; return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches to input {(InputPort != null ? InputPort.Key : "No input port")}";
} }
} }
/*/// <summary>
/// Represents an individual link for a route
/// </summary>
/// <summary>
/// Represents a RouteSwitchDescriptor
/// </summary>
public class RouteSwitchDescriptor<TInputSelector, TOutputSelector>
{
/// <summary>
/// Gets or sets the SwitchingDevice
/// </summary>
public IRoutingInputs<TInputSelector> SwitchingDevice { get { return InputPort.ParentDevice; } }
/// <summary>
/// Gets or sets the OutputPort
/// </summary>
public RoutingOutputPort<TOutputSelector> OutputPort { get; set; }
/// <summary>
/// Gets or sets the InputPort
/// </summary>
public RoutingInputPort<TInputSelector> InputPort { get; set; }
public RouteSwitchDescriptor(RoutingInputPort<TInputSelector> inputPort)
{
InputPort = inputPort;
}
public RouteSwitchDescriptor(RoutingOutputPort<TOutputSelector> outputPort, RoutingInputPort<TInputSelector> inputPort)
{
InputPort = inputPort;
OutputPort = outputPort;
}
/// <summary>
/// ToString method
/// </summary>
/// <inheritdoc />
public override string ToString()
{
if (SwitchingDevice is IRouting)
return string.Format("{0} switches output '{1}' to input '{2}'", SwitchingDevice.Key, OutputPort.Selector, InputPort.Selector);
else
return string.Format("{0} switches to input '{1}'", SwitchingDevice.Key, InputPort.Selector);
}
}*/
} }

View file

@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Timers;
using PepperDash.Core; using PepperDash.Core;
using PepperDash.Essentials.Core.Config; using PepperDash.Essentials.Core.Config;
@ -11,6 +13,26 @@ namespace PepperDash.Essentials.Core.Routing
/// </summary> /// </summary>
public class RoutingFeedbackManager : EssentialsDevice public class RoutingFeedbackManager : EssentialsDevice
{ {
/// <summary>
/// Maps midpoint device keys to the set of sink device keys that are downstream
/// </summary>
private Dictionary<string, HashSet<string>> midpointToSinksMap;
/// <summary>
/// Debounce timers for each sink device to prevent rapid successive updates
/// </summary>
private readonly Dictionary<string, Timer> updateTimers = new Dictionary<string, Timer>();
/// <summary>
/// Lock object protecting all access to <see cref="updateTimers"/>.
/// </summary>
private readonly object _timerLock = new object();
/// <summary>
/// Debounce delay in milliseconds
/// </summary>
private const long DEBOUNCE_MS = 500;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="RoutingFeedbackManager"/> class. /// Initializes a new instance of the <see cref="RoutingFeedbackManager"/> class.
/// </summary> /// </summary>
@ -19,10 +41,103 @@ namespace PepperDash.Essentials.Core.Routing
public RoutingFeedbackManager(string key, string name) public RoutingFeedbackManager(string key, string name)
: base(key, name) : base(key, name)
{ {
AddPreActivationAction(BuildMidpointSinkMap);
AddPreActivationAction(SubscribeForMidpointFeedback); AddPreActivationAction(SubscribeForMidpointFeedback);
AddPreActivationAction(SubscribeForSinkFeedback); AddPreActivationAction(SubscribeForSinkFeedback);
} }
/// <summary>
/// Builds a map of which sink devices are downstream of each midpoint device
/// for performance optimization in HandleMidpointUpdate
/// </summary>
private void BuildMidpointSinkMap()
{
midpointToSinksMap = new Dictionary<string, HashSet<string>>();
var sinks = DeviceManager.AllDevices.OfType<IRoutingSinkWithSwitchingWithInputPort>();
foreach (var sink in sinks)
{
if (sink.CurrentInputPort == null)
continue;
// Find all upstream midpoints for this sink
var upstreamMidpoints = GetUpstreamMidpoints(sink);
foreach (var midpointKey in upstreamMidpoints)
{
if (string.IsNullOrEmpty(midpointKey))
continue;
if (!midpointToSinksMap.ContainsKey(midpointKey))
midpointToSinksMap[midpointKey] = new HashSet<string>();
midpointToSinksMap[midpointKey].Add(sink.Key);
}
}
Debug.LogMessage(
Serilog.Events.LogEventLevel.Information,
"Built midpoint-to-sink map with {count} midpoints",
this,
midpointToSinksMap.Count
);
}
/// <summary>
/// Gets all upstream midpoint device keys for a given sink
/// </summary>
private HashSet<string> GetUpstreamMidpoints(IRoutingSinkWithSwitchingWithInputPort sink)
{
var result = new HashSet<string>();
var visited = new HashSet<string>();
if (sink.CurrentInputPort == null)
return result;
var tieLine = TieLineCollection.Default.FirstOrDefault(tl =>
tl.DestinationPort.Key == sink.CurrentInputPort.Key &&
tl.DestinationPort.ParentDevice.Key == sink.CurrentInputPort.ParentDevice.Key);
if (tieLine == null)
return result;
TraceUpstreamMidpoints(tieLine, result, visited);
return result;
}
/// <summary>
/// Recursively traces upstream to find all midpoint devices
/// </summary>
private void TraceUpstreamMidpoints(TieLine tieLine, HashSet<string> midpoints, HashSet<string> visited)
{
if (tieLine == null || visited.Contains(tieLine.SourcePort.ParentDevice.Key))
return;
visited.Add(tieLine.SourcePort.ParentDevice.Key);
if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint)
{
if (!string.IsNullOrEmpty(midpoint.Key))
midpoints.Add(midpoint.Key);
// Find upstream TieLines connected to this midpoint's inputs
var midpointInputs = (midpoint as IRoutingInputs)?.InputPorts;
if (midpointInputs != null)
{
foreach (var inputPort in midpointInputs)
{
var upstreamTieLine = TieLineCollection.Default.FirstOrDefault(tl =>
tl.DestinationPort.Key == inputPort.Key &&
tl.DestinationPort.ParentDevice.Key == inputPort.ParentDevice.Key);
if (upstreamTieLine != null)
TraceUpstreamMidpoints(upstreamTieLine, midpoints, visited);
}
}
}
}
/// <summary> /// <summary>
/// Subscribes to the RouteChanged event on all devices implementing <see cref="IRoutingWithFeedback"/>. /// Subscribes to the RouteChanged event on all devices implementing <see cref="IRoutingWithFeedback"/>.
/// </summary> /// </summary>
@ -52,7 +167,7 @@ namespace PepperDash.Essentials.Core.Routing
/// <summary> /// <summary>
/// Handles the RouteChanged event from a midpoint device. /// Handles the RouteChanged event from a midpoint device.
/// Triggers an update for all sink devices. /// Only triggers updates for sink devices that are downstream of this midpoint.
/// </summary> /// </summary>
/// <param name="midpoint">The midpoint device that reported a route change.</param> /// <param name="midpoint">The midpoint device that reported a route change.</param>
/// <param name="newRoute">The descriptor of the new route.</param> /// <param name="newRoute">The descriptor of the new route.</param>
@ -63,12 +178,33 @@ namespace PepperDash.Essentials.Core.Routing
{ {
try try
{ {
var devices = // Only update affected sinks (performance optimization)
DeviceManager.AllDevices.OfType<IRoutingSinkWithSwitchingWithInputPort>(); if (midpointToSinksMap != null && midpointToSinksMap.TryGetValue(midpoint.Key, out var affectedSinkKeys))
foreach (var device in devices)
{ {
UpdateDestination(device, device.CurrentInputPort); Debug.LogMessage(
Serilog.Events.LogEventLevel.Debug,
"Midpoint {midpoint} changed, updating {count} downstream sinks",
this,
midpoint.Key,
affectedSinkKeys.Count
);
foreach (var sinkKey in affectedSinkKeys)
{
if (DeviceManager.GetDeviceForKey(sinkKey) is IRoutingSinkWithSwitchingWithInputPort sink)
{
UpdateDestination(sink, sink.CurrentInputPort);
}
}
}
else
{
Debug.LogMessage(
Serilog.Events.LogEventLevel.Debug,
"Midpoint {midpoint} changed but has no downstream sinks in map",
this,
midpoint.Key
);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -83,9 +219,49 @@ namespace PepperDash.Essentials.Core.Routing
} }
} }
/// <summary>
/// Removes a sink from every midpoint set in the map and re-adds it based on its
/// current input port. Call this whenever a sink's selected input changes so that
/// HandleMidpointUpdate always sees an up-to-date downstream set.
/// </summary>
private void RebuildMapForSink(IRoutingSinkWithSwitchingWithInputPort sink)
{
if (midpointToSinksMap == null)
return;
// Remove this sink from all existing midpoint sets
foreach (var set in midpointToSinksMap.Values)
set.Remove(sink.Key);
// Drop any midpoint entries that are now empty
var emptyKeys = midpointToSinksMap
.Where(kvp => kvp.Value.Count == 0)
.Select(kvp => kvp.Key)
.ToList();
foreach (var k in emptyKeys)
midpointToSinksMap.Remove(k);
// Re-add the sink under every midpoint that is upstream of its new input
if (sink.CurrentInputPort == null)
return;
var upstreamMidpoints = GetUpstreamMidpoints(sink);
foreach (var midpointKey in upstreamMidpoints)
{
if (string.IsNullOrEmpty(midpointKey))
continue;
if (!midpointToSinksMap.ContainsKey(midpointKey))
midpointToSinksMap[midpointKey] = new HashSet<string>();
midpointToSinksMap[midpointKey].Add(sink.Key);
}
}
/// <summary> /// <summary>
/// Handles the InputChanged event from a sink device. /// Handles the InputChanged event from a sink device.
/// Triggers an update for the specific sink device. /// Updates the midpoint-to-sink map for the new input path, then triggers
/// a source-info update for the sink.
/// </summary> /// </summary>
/// <param name="sender">The sink device that reported an input change.</param> /// <param name="sender">The sink device that reported an input change.</param>
/// <param name="currentInputPort">The new input port selected on the sink device.</param> /// <param name="currentInputPort">The new input port selected on the sink device.</param>
@ -96,6 +272,10 @@ namespace PepperDash.Essentials.Core.Routing
{ {
try try
{ {
// Keep the map current so HandleMidpointUpdate can find this sink
if (sender is IRoutingSinkWithSwitchingWithInputPort sinkWithInputPort)
RebuildMapForSink(sinkWithInputPort);
UpdateDestination(sender, currentInputPort); UpdateDestination(sender, currentInputPort);
} }
catch (Exception ex) catch (Exception ex)
@ -113,6 +293,7 @@ namespace PepperDash.Essentials.Core.Routing
/// <summary> /// <summary>
/// Updates the CurrentSourceInfo and CurrentSourceInfoKey properties on a destination (sink) device /// Updates the CurrentSourceInfo and CurrentSourceInfoKey properties on a destination (sink) device
/// based on its currently selected input port by tracing the route back through tie lines. /// based on its currently selected input port by tracing the route back through tie lines.
/// Uses debouncing to prevent rapid successive updates.
/// </summary> /// </summary>
/// <param name="destination">The destination sink device to update.</param> /// <param name="destination">The destination sink device to update.</param>
/// <param name="inputPort">The currently selected input port on the destination device.</param> /// <param name="inputPort">The currently selected input port on the destination device.</param>
@ -120,6 +301,76 @@ namespace PepperDash.Essentials.Core.Routing
IRoutingSinkWithSwitching destination, IRoutingSinkWithSwitching destination,
RoutingInputPort inputPort RoutingInputPort inputPort
) )
{
if (destination == null)
return;
var key = destination.Key;
// Cancel and replace any existing timer under the lock so no callback
// can race with us while we swap the entry.
Timer timerToDispose = null;
Timer newTimer = null;
newTimer = new Timer(DEBOUNCE_MS) { AutoReset = false };
newTimer.Elapsed += (s, e) =>
{
try
{
UpdateDestinationImmediate(destination, inputPort);
}
catch (Exception ex)
{
Debug.LogMessage(
ex,
"Error in debounced update for destination {destinationKey}: {message}",
this,
destination.Key,
ex.Message
);
}
finally
{
// Remove the entry first so a concurrent UpdateDestination call
// cannot re-dispose whatever timer we're about to dispose.
Timer selfTimer = null;
lock (_timerLock)
{
if (updateTimers.TryGetValue(key, out var current) && ReferenceEquals(current, newTimer))
{
selfTimer = current;
updateTimers.Remove(key);
}
}
selfTimer?.Dispose();
}
};
lock (_timerLock)
{
if (updateTimers.TryGetValue(key, out var existingTimer))
timerToDispose = existingTimer;
updateTimers[key] = newTimer;
}
// Dispose the old timer outside the lock to avoid holding the lock during disposal.
// Dispose implicitly stops the timer, preventing its Elapsed event from firing.
timerToDispose?.Dispose();
// Start after the lock is released so the Elapsed callback cannot deadlock
// trying to acquire _timerLock while we still hold it.
newTimer.Start();
}
/// <summary>
/// Immediately updates the CurrentSourceInfo for a destination device.
/// Called after debounce delay.
/// </summary>
private void UpdateDestinationImmediate(
IRoutingSinkWithSwitching destination,
RoutingInputPort inputPort
)
{ {
Debug.LogMessage( Debug.LogMessage(
Serilog.Events.LogEventLevel.Debug, Serilog.Events.LogEventLevel.Debug,
@ -206,7 +457,8 @@ namespace PepperDash.Essentials.Core.Routing
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogMessage(ex, "Error getting sourceTieLine: {Exception}", this, ex); Debug.LogError(this, "Error getting sourceTieLine: {message}", ex.Message);
Debug.LogDebug(ex, "StackTrace: ");
return; return;
} }
@ -230,6 +482,11 @@ namespace PepperDash.Essentials.Core.Routing
return roomDefaultDisplay.DefaultDisplay.Key == destination.Key; return roomDefaultDisplay.DefaultDisplay.Key == destination.Key;
} }
if (ConfigReader.ConfigObject.GetDestinationListForKey(r.DestinationListKey)?.FirstOrDefault(d => d.Value.SinkKey == destination.Key) != null)
{
return true;
}
return false; return false;
} }
); );
@ -251,10 +508,8 @@ namespace PepperDash.Essentials.Core.Routing
if (sourceList == null) if (sourceList == null)
{ {
Debug.LogMessage( Debug.LogDebug(this,
Serilog.Events.LogEventLevel.Debug,
"No source list found for source list key {key}. Unable to find source for tieLine {sourceTieLine}", "No source list found for source list key {key}. Unable to find source for tieLine {sourceTieLine}",
this,
room.SourceListKey, room.SourceListKey,
sourceTieLine sourceTieLine
); );
@ -283,10 +538,8 @@ namespace PepperDash.Essentials.Core.Routing
if (source == null) if (source == null)
{ {
Debug.LogMessage( Debug.LogDebug(this,
Serilog.Events.LogEventLevel.Debug,
"No source found for device {key}. Creating transient source for {destination}", "No source found for device {key}. Creating transient source for {destination}",
this,
sourceTieLine.SourcePort.ParentDevice.Key, sourceTieLine.SourcePort.ParentDevice.Key,
destination destination
); );
@ -309,105 +562,92 @@ namespace PepperDash.Essentials.Core.Routing
} }
/// <summary> /// <summary>
/// Recursively traces a route back from a given tie line to find the root source tie line. /// Traces a route back from a given tie line to find the root source tie line.
/// It navigates through midpoint devices (<see cref="IRoutingWithFeedback"/>) by checking their current routes. /// Leverages the existing Extensions.GetRouteToSource method with loop protection.
/// </summary> /// </summary>
/// <param name="tieLine">The starting tie line (typically connected to a sink or midpoint).</param> /// <param name="tieLine">The starting tie line (typically connected to a sink or midpoint).</param>
/// <returns>The <see cref="TieLine"/> connected to the original source device, or null if the source cannot be determined.</returns> /// <returns>The <see cref="TieLine"/> connected to the original source device, or null if the source cannot be determined.</returns>
private TieLine GetRootTieLine(TieLine tieLine) private TieLine GetRootTieLine(TieLine tieLine)
{ {
TieLine nextTieLine = null;
try try
{ {
//Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "**Following tieLine {tieLine}**", this, tieLine); if (!(tieLine.DestinationPort.ParentDevice is IRoutingInputs sink))
if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint)
{ {
// Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLine Source device {sourceDevice} is midpoint", this, midpoint); Debug.LogDebug(this,
"TieLine destination {device} is not IRoutingInputs",
if (midpoint.CurrentRoutes == null || midpoint.CurrentRoutes.Count == 0) tieLine.DestinationPort.ParentDevice.Key
{
Debug.LogMessage(
Serilog.Events.LogEventLevel.Debug,
"Midpoint {midpointKey} has no routes",
this,
midpoint.Key
); );
return null; return null;
} }
var currentRoute = midpoint.CurrentRoutes.FirstOrDefault(route => // Get all potential sources (devices that only have outputs, not inputs+outputs)
{ var sources = DeviceManager.AllDevices
//Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Checking {route} against {tieLine}", this, route, tieLine); .OfType<IRoutingOutputs>()
.Where(s => !(s is IRoutingInputsOutputs));
return route.OutputPort != null // Try each signal type that this TieLine supports
&& route.InputPort != null var signalTypes = new[]
&& route.OutputPort?.Key == tieLine.SourcePort.Key
&& route.OutputPort?.ParentDevice.Key
== tieLine.SourcePort.ParentDevice.Key;
});
if (currentRoute == null)
{ {
Debug.LogMessage( eRoutingSignalType.Audio,
Serilog.Events.LogEventLevel.Debug, eRoutingSignalType.Video,
"No route through midpoint {midpoint} for outputPort {outputPort}", eRoutingSignalType.AudioVideo,
this, eRoutingSignalType.SecondaryAudio,
midpoint.Key, eRoutingSignalType.UsbInput,
tieLine.SourcePort eRoutingSignalType.UsbOutput
};
foreach (var signalType in signalTypes)
{
if (!tieLine.Type.HasFlag(signalType))
continue;
foreach (var source in sources)
{
// Use the optimized route discovery with loop protection
var (route, _) = sink.GetRouteToSource(
source,
signalType,
tieLine.DestinationPort,
null
); );
if (route != null && route.Routes != null && route.Routes.Count > 0)
{
// Routes[0] is the hop nearest the source: its InputPort is the
// port on the first switching device that receives the signal from
// the source side. The TieLine whose DestinationPort matches that
// port is the exact tie that was traversed, giving us the precise
// source output port via SourcePort — regardless of how many output
// ports the source device has.
var firstHop = route.Routes[0];
var sourceTieLine = TieLineCollection.Default.FirstOrDefault(tl =>
tl.DestinationPort.Key == firstHop.InputPort.Key &&
tl.DestinationPort.ParentDevice.Key == firstHop.InputPort.ParentDevice.Key);
if (sourceTieLine != null)
{
Debug.LogDebug(this,
"Found route from {source} to {sink} with {count} hops",
source.Key,
sink.Key,
route.Routes.Count
);
return sourceTieLine;
}
}
}
}
Debug.LogDebug(this, "No route found to any source from {sink}", sink.Key);
return null; return null;
} }
//Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found currentRoute {currentRoute} through {midpoint}", this, currentRoute, midpoint);
nextTieLine = TieLineCollection.Default.FirstOrDefault(tl =>
{
//Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Checking {route} against {tieLine}", tl.DestinationPort.Key, currentRoute.InputPort.Key);
return tl.DestinationPort.Key == currentRoute.InputPort.Key
&& tl.DestinationPort.ParentDevice.Key
== currentRoute.InputPort.ParentDevice.Key;
});
if (nextTieLine != null)
{
//Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found next tieLine {tieLine}. Walking the chain", this, nextTieLine);
return GetRootTieLine(nextTieLine);
}
//Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found root tieLine {tieLine}", this,nextTieLine);
return nextTieLine;
}
//Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLIne Source Device {sourceDeviceKey} is IRoutingSource: {isIRoutingSource}", this, tieLine.SourcePort.ParentDevice.Key, tieLine.SourcePort.ParentDevice is IRoutingSource);
//Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLine Source Device interfaces: {typeFullName}:{interfaces}", this, tieLine.SourcePort.ParentDevice.GetType().FullName, tieLine.SourcePort.ParentDevice.GetType().GetInterfaces().Select(i => i.Name));
if (
tieLine.SourcePort.ParentDevice is IRoutingSource
|| tieLine.SourcePort.ParentDevice is IRoutingOutputs
) //end of the chain
{
// Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found root: {tieLine}", this, tieLine);
return tieLine;
}
nextTieLine = TieLineCollection.Default.FirstOrDefault(tl =>
tl.DestinationPort.Key == tieLine.SourcePort.Key
&& tl.DestinationPort.ParentDevice.Key == tieLine.SourcePort.ParentDevice.Key
);
if (nextTieLine != null)
{
return GetRootTieLine(nextTieLine);
}
}
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogMessage(ex, "Error walking tieLines: {Exception}", this, ex); Debug.LogError(this, "Error getting root tieLine: {message}", ex.Message);
return null; Debug.LogDebug(ex, "StackTrace: ");
}
return null; return null;
} }
} }
} }
}

View file

@ -1,6 +1,6 @@
using Newtonsoft.Json; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json;
namespace PepperDash.Essentials.Core namespace PepperDash.Essentials.Core
{ {
@ -21,24 +21,24 @@ namespace PepperDash.Essentials.Core
//public int InUseCount { get { return DestinationUsingThis.Count; } } //public int InUseCount { get { return DestinationUsingThis.Count; } }
/// <summary> /// <summary>
/// Gets the type of this tie line. Will either be the type of the destination port /// Gets the type of this tie line. Returns the intersection of signal types supported by both
/// or the type of OverrideType when it is set. /// the source and destination ports (what signals can actually travel through this tie line),
/// or the OverrideType when it is set.
/// </summary> /// </summary>
public eRoutingSignalType Type public eRoutingSignalType Type
{ {
get get
{ {
if (OverrideType.HasValue) return OverrideType.Value; if (OverrideType.HasValue) return OverrideType.Value;
return DestinationPort.Type; return SourcePort.Type & DestinationPort.Type;
} }
} }
/// <summary> /// <summary>
/// Use this to override the Type property for the destination port. For example, /// Use this to override the Type property. For example, when both ports support AudioVideo
/// when the tie line is type AudioVideo, and the signal flow should be limited to /// but the physical cable only carries Audio or Video, setting this will limit the signal
/// Audio-only or Video only, changing this type will alter the signal paths /// paths available to the routing algorithm without affecting the actual port types.
/// available to the routing algorithm without affecting the actual Type /// When set, this value is used instead of the calculated intersection of source and destination types.
/// of the destination port.
/// </summary> /// </summary>
public eRoutingSignalType? OverrideType { get; set; } public eRoutingSignalType? OverrideType { get; set; }
@ -79,7 +79,7 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
/// <param name="sourcePort">The source output port.</param> /// <param name="sourcePort">The source output port.</param>
/// <param name="destinationPort">The destination input port.</param> /// <param name="destinationPort">The destination input port.</param>
/// <param name="overrideType">The signal type to limit the link to. Overrides DestinationPort.Type for routing calculations.</param> /// <param name="overrideType">The signal type to limit the link to. Overrides the calculated intersection of port types for routing calculations.</param>
public TieLine(RoutingOutputPort sourcePort, RoutingInputPort destinationPort, eRoutingSignalType? overrideType) : public TieLine(RoutingOutputPort sourcePort, RoutingInputPort destinationPort, eRoutingSignalType? overrideType) :
this(sourcePort, destinationPort) this(sourcePort, destinationPort)
{ {
@ -91,7 +91,7 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
/// <param name="sourcePort">The source output port.</param> /// <param name="sourcePort">The source output port.</param>
/// <param name="destinationPort">The destination input port.</param> /// <param name="destinationPort">The destination input port.</param>
/// <param name="overrideType">The signal type to limit the link to. Overrides DestinationPort.Type for routing calculations.</param> /// <param name="overrideType">The signal type to limit the link to. Overrides the calculated intersection of port types for routing calculations.</param>
public TieLine(RoutingOutputPort sourcePort, RoutingInputPort destinationPort, eRoutingSignalType overrideType) : public TieLine(RoutingOutputPort sourcePort, RoutingInputPort destinationPort, eRoutingSignalType overrideType) :
this(sourcePort, destinationPort) this(sourcePort, destinationPort)
{ {

View file

@ -49,7 +49,9 @@ namespace PepperDash.Essentials.Core.Config
public string DestinationPort { get; set; } public string DestinationPort { get; set; }
/// <summary> /// <summary>
/// Optional override for the signal type of the tie line. If set, this overrides the destination port's type for routing calculations. /// Optional override for the signal type of the tie line. If set, this overrides the calculated
/// intersection of source and destination port types for routing calculations. Useful when the
/// physical cable supports fewer signal types than both ports are capable of.
/// </summary> /// </summary>
[JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)]
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
@ -96,6 +98,32 @@ namespace PepperDash.Essentials.Core.Config
return null; return null;
} }
// Validate signal type compatibility
if (OverrideType.HasValue)
{
// When override type is specified, both ports must support it
if (!sourceOutputPort.Type.HasFlag(OverrideType.Value))
{
LogError($"Override type '{OverrideType.Value}' is not supported by source port '{SourcePort}' (type: {sourceOutputPort.Type})");
return null;
}
if (!destinationInputPort.Type.HasFlag(OverrideType.Value))
{
LogError($"Override type '{OverrideType.Value}' is not supported by destination port '{DestinationPort}' (type: {destinationInputPort.Type})");
return null;
}
}
else
{
// Without override type, ports must have at least one common signal type flag
if ((sourceOutputPort.Type & destinationInputPort.Type) == 0)
{
LogError($"Incompatible signal types: source port '{SourcePort}' (type: {sourceOutputPort.Type}) has no common signal types with destination port '{DestinationPort}' (type: {destinationInputPort.Type})");
return null;
}
}
return new TieLine(sourceOutputPort, destinationInputPort, OverrideType); return new TieLine(sourceOutputPort, destinationInputPort, OverrideType);
} }

View file

@ -27,16 +27,19 @@ namespace PepperDash.Essentials.Core
/// <summary> /// <summary>
/// Control signal type /// Control signal type
/// </summary> /// </summary>
[Obsolete("UsbOutput is no longer supported and will be removed in a future release.")]
UsbOutput = 8, UsbOutput = 8,
/// <summary> /// <summary>
/// Control signal type /// Control signal type
/// </summary> /// </summary>
[Obsolete("UsbInput is no longer supported and will be removed in a future release.")]
UsbInput = 16, UsbInput = 16,
/// <summary> /// <summary>
/// Secondary audio signal type /// Secondary audio signal type
/// </summary> /// </summary>
[Obsolete("SecondaryAudio is no longer supported and will be removed in a future release.")]
SecondaryAudio = 32 SecondaryAudio = 32
} }
} }

View file

@ -17,6 +17,11 @@ namespace PepperDash.Essentials.Core.Web
{ {
private readonly WebApiServer _server; private readonly WebApiServer _server;
private readonly WebApiServer _debugServer;
///<example> ///<example>
/// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/cws/{basePath}
/// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath}
@ -70,6 +75,9 @@ namespace PepperDash.Essentials.Core.Web
_server = new WebApiServer(Key, Name, BasePath); _server = new WebApiServer(Key, Name, BasePath);
_debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug");
_debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler());
SetupRoutes(); SetupRoutes();
} }
@ -77,6 +85,11 @@ namespace PepperDash.Essentials.Core.Web
{ {
var routes = new List<HttpCwsRoute> var routes = new List<HttpCwsRoute>
{ {
new HttpCwsRoute("login")
{
Name = "Root",
RouteHandler = new LoginRequestHandler()
},
new HttpCwsRoute("versions") new HttpCwsRoute("versions")
{ {
Name = "ReportVersions", Name = "ReportVersions",
@ -177,6 +190,11 @@ namespace PepperDash.Essentials.Core.Web
Name = "Get Routing Ports for a device", Name = "Get Routing Ports for a device",
RouteHandler = new GetRoutingPortsHandler() RouteHandler = new GetRoutingPortsHandler()
}, },
new HttpCwsRoute("routingDevicesAndTieLines")
{
Name = "Get Routing Devices and TieLines",
RouteHandler = new GetRoutingDevicesAndTieLinesHandler()
},
}; };
AddRoute(routes); AddRoute(routes);
@ -212,7 +230,8 @@ namespace PepperDash.Essentials.Core.Web
/// <inheritdoc /> /// <inheritdoc />
public override void Initialize() public override void Initialize()
{ {
AddRoute(new HttpCwsRoute("apiPaths") { AddRoute(new HttpCwsRoute("apiPaths")
{
Name = "GetPaths", Name = "GetPaths",
RouteHandler = new GetRoutesHandler(_server.GetRouteCollection(), BasePath) RouteHandler = new GetRoutesHandler(_server.GetRouteCollection(), BasePath)
}); });
@ -231,6 +250,7 @@ namespace PepperDash.Essentials.Core.Web
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series");
_server.Start(); _server.Start();
_debugServer.Start();
GetPaths(); GetPaths();
@ -241,12 +261,13 @@ namespace PepperDash.Essentials.Core.Web
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server");
_server.Start(); _server.Start();
_debugServer.Start();
GetPaths(); GetPaths();
} }
/// <summary> /// <summary>
/// Print the available pahts /// Print the available paths
/// </summary> /// </summary>
/// <example> /// <example>
/// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/cws/{basePath}
@ -282,7 +303,15 @@ namespace PepperDash.Essentials.Core.Web
{ {
Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url);
} }
Debug.LogInformation(this, "Web API initialized and ready to accept requests");
Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50));
var debugAppUrl = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server
? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws/debug"
: $"https://{currentIp}/cws/debug";
Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: {debugAppUrl:l}", debugAppUrl);
} }
} }
} }

View file

@ -36,17 +36,25 @@ namespace PepperDash.Essentials.Core.Web
}; };
} }
/// <summary> /// <summary>
/// MapToDeviceListObject method /// MapToDeviceListObject method
/// </summary> /// </summary>
public static object MapToDeviceListObject(IKeyed device) public static object MapToDeviceListObject(IKeyed device)
{ {
var interfaces = device.GetType()
.GetInterfaces()
.Select(i => i.Name)
.ToList();
return new return new
{ {
device.Key, device.Key,
Name = (device is IKeyName) Name = (device is IKeyName)
? (device as IKeyName).Name ? (device as IKeyName).Name
: "---" : "---",
Interfaces = interfaces
}; };
} }
@ -110,5 +118,6 @@ namespace PepperDash.Essentials.Core.Web
CType = device.Value.Type == null ? "---": device.Value.Type.ToString() CType = device.Value.Type == null ? "---": device.Value.Type.ToString()
}; };
} }
} }
} }

View file

@ -18,6 +18,9 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// </summary> /// </summary>
public class DebugSessionRequestHandler : WebApiBaseRequestHandler public class DebugSessionRequestHandler : WebApiBaseRequestHandler
{ {
private CTimer _portForwardTimeoutTimer;
private readonly object _timerLock = new object();
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
@ -48,6 +51,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var port = 0; var port = 0;
string csIp = null;
if (!Debug.WebsocketSink.IsRunning) if (!Debug.WebsocketSink.IsRunning)
{ {
@ -59,14 +63,50 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose); Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose);
} }
// Attempt to get the CS LAN IP and forward the port
try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
if (port > 0)
{
var result = CrestronEthernetHelper.AddPortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error adding port forwarding for debug websocket: {0}", result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port);
StartPortForwardTimeout(port, csIp);
}
}
}
catch (ArgumentException)
{
Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding");
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message);
}
var url = Debug.WebsocketSink.Url; var url = Debug.WebsocketSink.Url;
object data = new var data = new
{ {
url = Debug.WebsocketSink.Url url = Debug.WebsocketSink.Url,
fallbackUrl = csIp != null ? url.Replace(csIp, ip) : null
}; };
Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url); Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url);
Debug.LogMessage(LogEventLevel.Information, "Fallback Debug Session URL: {0}", data.fallbackUrl);
// Return the port number with the full url of the WS Server // Return the port number with the full url of the WS Server
var res = JsonConvert.SerializeObject(data); var res = JsonConvert.SerializeObject(data);
@ -90,8 +130,49 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// <param name="context"></param> /// <param name="context"></param>
protected override void HandlePost(HttpCwsContext context) protected override void HandlePost(HttpCwsContext context)
{ {
CancelPortForwardTimeout();
var port = Debug.WebsocketSink.Port;
Debug.WebsocketSink.StopServer(); Debug.WebsocketSink.StopServer();
// Remove the port forwarding entry
try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
var csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
if (port <= 0)
{
Debug.LogMessage(LogEventLevel.Debug, "Debug websocket port is not set; skipping port forwarding removal");
}
else
{
var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for debug websocket: {0}", result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port);
}
}
}
catch (ArgumentException)
{
Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding removal");
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing debug websocket port forwarding: {0}", ex.Message);
}
context.Response.StatusCode = 200; context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK"; context.Response.StatusDescription = "OK";
context.Response.End(); context.Response.End();
@ -99,5 +180,55 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped"); Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped");
} }
private void StartPortForwardTimeout(int port, string csIp)
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = new CTimer(_ =>
{
if (Debug.WebsocketSink.HasActiveConnections)
{
Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward");
return;
}
Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port);
try
{
var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed due to timeout", port);
}
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", ex.Message);
}
}, 30000);
}
}
/// <summary>
/// Cancels the port forward timeout timer if a session is being explicitly stopped.
/// </summary>
private void CancelPortForwardTimeout()
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = null;
}
}
} }
} }

View file

@ -0,0 +1,178 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core;
using PepperDash.Core.Web.RequestHandlers;
namespace PepperDash.Essentials.Core.Web.RequestHandlers
{
/// <summary>
/// Handles HTTP requests to retrieve routing devices and tielines information
/// </summary>
public class GetRoutingDevicesAndTieLinesHandler : WebApiBaseRequestHandler
{
public GetRoutingDevicesAndTieLinesHandler() : base(true) { }
protected override void HandleGet(HttpCwsContext context)
{
var devices = new List<RoutingDeviceInfo>();
// Get all devices from DeviceManager
foreach (var device in DeviceManager.AllDevices)
{
var deviceInfo = new RoutingDeviceInfo
{
Key = device.Key,
Name = (device as IKeyName)?.Name ?? device.Key
};
// Check if device implements IRoutingInputs
if (device is IRoutingInputs inputDevice)
{
deviceInfo.HasInputs = true;
deviceInfo.InputPorts = inputDevice.InputPorts.Select(p => new PortInfo
{
Key = p.Key,
SignalType = p.Type.ToString(),
ConnectionType = p.ConnectionType.ToString(),
IsInternal = p.IsInternal
}).ToList();
}
// Check if device implements IRoutingOutputs
if (device is IRoutingOutputs outputDevice)
{
deviceInfo.HasOutputs = true;
deviceInfo.OutputPorts = outputDevice.OutputPorts.Select(p => new PortInfo
{
Key = p.Key,
SignalType = p.Type.ToString(),
ConnectionType = p.ConnectionType.ToString(),
IsInternal = p.IsInternal
}).ToList();
}
// Check if device implements IRoutingInputsOutputs
if (device is IRoutingInputsOutputs)
{
deviceInfo.HasInputsAndOutputs = true;
}
// Only include devices that have routing capabilities
if (deviceInfo.HasInputs || deviceInfo.HasOutputs)
{
devices.Add(deviceInfo);
}
}
// Get all tielines
var tielines = TieLineCollection.Default.Select(tl => new TieLineInfo
{
SourceDeviceKey = tl.SourcePort.ParentDevice.Key,
SourcePortKey = tl.SourcePort.Key,
DestinationDeviceKey = tl.DestinationPort.ParentDevice.Key,
DestinationPortKey = tl.DestinationPort.Key,
SignalType = tl.Type.ToString(),
IsInternal = tl.IsInternal
}).ToList();
var response = new RoutingSystemInfo
{
Devices = devices,
TieLines = tielines
};
var jsonResponse = JsonConvert.SerializeObject(response, Formatting.Indented);
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.ContentType = "application/json";
context.Response.ContentEncoding = Encoding.UTF8;
context.Response.Write(jsonResponse, false);
context.Response.End();
}
}
/// <summary>
/// Represents the complete routing system information including devices and tielines
/// </summary>
public class RoutingSystemInfo
{
[JsonProperty("devices")]
public List<RoutingDeviceInfo> Devices { get; set; }
[JsonProperty("tieLines")]
public List<TieLineInfo> TieLines { get; set; }
}
/// <summary>
/// Represents a routing device with its ports information
/// </summary>
public class RoutingDeviceInfo
{
[JsonProperty("key")]
public string Key { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("hasInputs")]
public bool HasInputs { get; set; }
[JsonProperty("hasOutputs")]
public bool HasOutputs { get; set; }
[JsonProperty("hasInputsAndOutputs")]
public bool HasInputsAndOutputs { get; set; }
[JsonProperty("inputPorts", NullValueHandling = NullValueHandling.Ignore)]
public List<PortInfo> InputPorts { get; set; }
[JsonProperty("outputPorts", NullValueHandling = NullValueHandling.Ignore)]
public List<PortInfo> OutputPorts { get; set; }
}
/// <summary>
/// Represents a routing port with its properties
/// </summary>
public class PortInfo
{
[JsonProperty("key")]
public string Key { get; set; }
[JsonProperty("signalType")]
public string SignalType { get; set; }
[JsonProperty("connectionType")]
public string ConnectionType { get; set; }
[JsonProperty("isInternal")]
public bool IsInternal { get; set; }
}
/// <summary>
/// Represents a tieline connection between two ports
/// </summary>
public class TieLineInfo
{
[JsonProperty("sourceDeviceKey")]
public string SourceDeviceKey { get; set; }
[JsonProperty("sourcePortKey")]
public string SourcePortKey { get; set; }
[JsonProperty("destinationDeviceKey")]
public string DestinationDeviceKey { get; set; }
[JsonProperty("destinationPortKey")]
public string DestinationPortKey { get; set; }
[JsonProperty("signalType")]
public string SignalType { get; set; }
[JsonProperty("isInternal")]
public bool IsInternal { get; set; }
}
}

View file

@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using Crestron.SimplSharp.CrestronAuthentication;
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core.Web.RequestHandlers;
namespace PepperDash.Essentials.Core.Web.RequestHandlers
{
/// <summary>
/// Represents a LoginRequestHandler
/// </summary>
public class LoginRequestHandler : WebApiBaseRequestHandler
{
/// <summary>
/// Constructor
/// </summary>
/// <remarks>
/// base(true) enables CORS support by default
/// </remarks>
public LoginRequestHandler()
: base(true)
{
}
/// <summary>
/// Handles POST method requests for user login and token generation
/// </summary>
/// <param name="context">The HTTP context for the request.</param>
protected override void HandlePost(HttpCwsContext context)
{
try
{
if (context.Request.ContentLength < 0)
{
context.Response.StatusCode = 400;
context.Response.StatusDescription = "Bad Request";
context.Response.End();
return;
}
var data = context.Request.GetRequestBody();
if (string.IsNullOrEmpty(data))
{
context.Response.StatusCode = 400;
context.Response.StatusDescription = "Bad Request";
context.Response.End();
return;
}
var loginRequest = JsonConvert.DeserializeObject<LoginRequest>(data);
if (loginRequest == null || string.IsNullOrEmpty(loginRequest.Username) || string.IsNullOrEmpty(loginRequest.Password))
{
context.Response.StatusCode = 400;
context.Response.StatusDescription = "Bad Request";
context.Response.End();
return;
}
Authentication.UserToken token;
try
{
token = Authentication.GetAuthenticationToken(loginRequest.Username, loginRequest.Password);
}
catch (ArgumentException)
{
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;
}
if (!token.Valid)
{
context.Response.StatusCode = 401;
context.Response.StatusDescription = "Unauthorized";
context.Response.End();
return;
}
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.ContentType = "application/json";
context.Response.ContentEncoding = System.Text.Encoding.UTF8;
context.Response.Write(JsonConvert.SerializeObject(
new
{
Token = new LoginResponse
{
UserName = token.UserName,
Access = token.Access,
State = token.State,
Groups = token.Groups,
ADConnect = token.ADConnect,
Valid = token.Valid
}
}, Formatting.Indented), false);
context.Response.End();
}
catch (System.Exception ex)
{
context.Response.StatusCode = 500;
context.Response.StatusDescription = "Internal Server Error";
context.Response.ContentType = "application/json";
context.Response.ContentEncoding = System.Text.Encoding.UTF8;
context.Response.Write(JsonConvert.SerializeObject(new { Error = ex.Message }, Formatting.Indented), false);
context.Response.End();
}
}
}
/// <summary>
/// Represents a LoginRequest
/// </summary>
public class LoginRequest
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; }
}
/// <summary>
/// Represents a LoginResponse
/// </summary>
internal class LoginResponse
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string UserName { get; set; }
/// <summary>
/// Gets or sets the access level.
/// </summary>
public Authentication.UserAuthenticationLevelEnum Access { get; set; }
/// <summary>
/// Gets or sets the token authenticated state.
/// </summary>
public Authentication.eTokenAuthenticatedState State { get; set; }
/// <summary>
/// Gets or sets the list of groups.
/// </summary>
public List<string> Groups { get; set; }
/// <summary>
/// Gets or sets the active directory connection flag.
/// </summary>
public int ADConnect { get; set; }
/// <summary>
/// Gets or sets the valid flag indicating whether the token is valid.
/// </summary>
public bool Valid { get; set; }
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler
{
private static readonly Dictionary<string, string> MimeTypes = new Dictionary<string, string>(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<string> TextExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml"
};
/// <summary>
/// Constructor. CORS is enabled so browser dev-tools requests succeed.
/// </summary>
public ServeDebugAppRequestHandler() : base(true) { }
/// <summary>
/// Handles GET requests for the debug app and its static assets.
/// </summary>
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 */ }
}
}
/// <summary>
/// Resolves the absolute path of the /HTML/debug folder on the processor.
/// </summary>
/// <remarks>
/// <c>Global.FilePathPrefix</c> is always <c>{root}/user/programX/</c> (or
/// equivalent), so walking up two parents gives the processor root that
/// contains the <c>html</c> folder. This mirrors the two-hop strategy used
/// by <c>AssetLoader.ExtractDevToolsZip</c> so that serving and extraction
/// always resolve to the same directory.
/// </remarks>
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;
}
}
/// <summary>
/// Extracts the file sub-path from the request by parsing <c>RawUrl</c>.
/// Returns an empty string when the URL ends at <c>/debug</c> (root hit).
/// </summary>
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();
}
}
}

View file

@ -17,6 +17,8 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
/// <summary> /// <summary>
/// Represents a CameraVisca /// Represents a CameraVisca
/// </summary> /// </summary>
[Obsolete("CameraVisca is no longer supported and will be removed in a future release. Use the CameraVisca plugin instead.")]
public class CameraVisca : CameraBase, IHasCameraPtzControl, ICommunicationMonitor, IHasCameraPresets, IHasPowerControlWithFeedback, IBridgeAdvanced, IHasCameraFocusControl, IHasAutoFocusMode public class CameraVisca : CameraBase, IHasCameraPtzControl, ICommunicationMonitor, IHasCameraPresets, IHasPowerControlWithFeedback, IBridgeAdvanced, IHasCameraFocusControl, IHasAutoFocusMode
{ {
private readonly CameraViscaPropertiesConfig PropertiesConfig; private readonly CameraViscaPropertiesConfig PropertiesConfig;

View file

@ -21,7 +21,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec
/// <summary> /// <summary>
/// Represents a MockVC /// Represents a MockVC
/// </summary> /// </summary>
public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets, IRoutingInputs
{ {
/// <summary> /// <summary>
/// Gets or sets the PropertiesConfig /// Gets or sets the PropertiesConfig

View file

@ -26,7 +26,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec
/// <summary> /// <summary>
/// Base class for video codec devices /// Base class for video codec devices
/// </summary> /// </summary>
public abstract class VideoCodecBase : ReconfigurableDevice, IRoutingInputsOutputs, public abstract class VideoCodecBase : ReconfigurableDevice,
IUsageTracking, IHasDialer, IHasContentSharing, ICodecAudio, iVideoCodecInfo, IBridgeAdvanced, IHasStandbyMode IUsageTracking, IHasDialer, IHasContentSharing, ICodecAudio, iVideoCodecInfo, IBridgeAdvanced, IHasStandbyMode
{ {
private const int XSigEncoding = 28591; private const int XSigEncoding = 28591;

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -18,6 +19,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// Sets the interfaces implemented by the device sending the message /// Sets the interfaces implemented by the device sending the message
/// </summary> /// </summary>
/// <param name="interfaces"></param> /// <param name="interfaces"></param>
[Obsolete("SetInterfaces is no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")]
public void SetInterfaces(List<string> interfaces) public void SetInterfaces(List<string> interfaces)
{ {
Interfaces = interfaces; Interfaces = interfaces;

View file

@ -264,6 +264,9 @@ namespace PepperDash.Essentials.AppServer.Messengers
message.Name = _device.Name; message.Name = _device.Name;
message.MessageBasePath = MessagePath;
var token = JToken.FromObject(message); var token = JToken.FromObject(message);
PostStatusMessage(token, MessagePath, clientId); PostStatusMessage(token, MessagePath, clientId);

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
@ -40,9 +41,10 @@ namespace PepperDash.Essentials
public bool EnableApiServer { get; set; } = true; public bool EnableApiServer { get; set; } = true;
/// <summary> /// <summary>
/// Enable subscriptions for Messengers /// Enables subscriptions for messengers
/// </summary> /// </summary>
[JsonProperty("enableMessengerSubscriptions")] [JsonProperty("enableMessengerSubscriptions")]
[Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")]
public bool EnableMessengerSubscriptions { get; set; } public bool EnableMessengerSubscriptions { get; set; }
} }

View file

@ -137,7 +137,7 @@ namespace PepperDash.Essentials
"No system_url value defined in config. Checking for value from SIMPL Bridge." "No system_url value defined in config. Checking for value from SIMPL Bridge."
); );
if (!string.IsNullOrEmpty(SystemUrl)) if (string.IsNullOrEmpty(SystemUrl))
{ {
this.LogError( this.LogError(
"No system_url value defined in config or SIMPL Bridge. Unable to connect to Mobile Control." "No system_url value defined in config or SIMPL Bridge. Unable to connect to Mobile Control."

View file

@ -0,0 +1,41 @@
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core.Web.RequestHandlers;
using PepperDash.Essentials.WebSocketServer;
namespace PepperDash.Essentials.WebApiHandlers
{
/// <summary>
/// Represents a DeleteAllUiClientsHandler
/// </summary>
public class DeleteAllUiClientsHandler : WebApiBaseRequestHandler
{
private readonly MobileControlWebsocketServer server;
/// <summary>
/// Essentials CWS API handler for the MC Direct Server
/// </summary>
/// <param name="directServer">Direct Server instance</param>
public DeleteAllUiClientsHandler(MobileControlWebsocketServer directServer) : base(true)
{
server = directServer;
}
/// <summary>
/// Deletes all clients from the Direct Server
/// </summary>
/// <param name="context">HTTP Context for this request</param>
protected override void HandleDelete(HttpCwsContext context)
{
server.RemoveAllTokens("confirm");
var res = context.Response;
res.StatusCode = 200;
res.ContentType = "application/json";
res.Headers.Add("Content-Type", "application/json");
res.Write(JsonConvert.SerializeObject(new { success = true }), false);
res.End();
}
}
}

View file

@ -238,11 +238,17 @@ namespace PepperDash.Essentials.WebSocketServer
var routes = new List<HttpCwsRoute> var routes = new List<HttpCwsRoute>
{ {
new HttpCwsRoute($"devices/{Key}/client") new HttpCwsRoute($"device/{Key}/client")
{ {
Name = "ClientHandler", Name = "ClientHandler",
RouteHandler = new UiClientHandler(this) RouteHandler = new UiClientHandler(this)
}, },
new HttpCwsRoute($"device/{Key}/deleteAllUiClients")
{
Name = "DeleteAllClientsHandler",
RouteHandler = new DeleteAllUiClientsHandler(this)
},
}; };
apiServer.AddRoute(routes); apiServer.AddRoute(routes);
@ -908,7 +914,7 @@ namespace PepperDash.Essentials.WebSocketServer
/// <summary> /// <summary>
/// Removes all clients from the server /// Removes all clients from the server
/// </summary> /// </summary>
private void RemoveAllTokens(string s) public void RemoveAllTokens(string s)
{ {
if (s == "?" || string.IsNullOrEmpty(s)) if (s == "?" || string.IsNullOrEmpty(s))
{ {

View file

@ -0,0 +1,257 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using PepperDash.Core;
using Serilog.Events;
namespace PepperDash.Essentials
{
/// <summary>
/// Handles extracting embedded asset bundles and moving configuration files from the
/// application directory to the program file-path prefix at startup.
/// Implemented using <c>System.IO</c> types so it can run (and be tested) outside
/// of a Crestron runtime environment.
/// </summary>
internal static class AssetLoader
{
/// <summary>
/// Scans <paramref name="applicationDirectoryPath"/> for well-known zip bundles and
/// JSON configuration files and deploys them to <paramref name="filePathPrefix"/>.
/// </summary>
/// <param name="applicationDirectoryPath">
/// The directory to scan (typically the Crestron application root).
/// </param>
/// <param name="filePathPrefix">
/// The program's runtime working directory (e.g. <c>/nvram/program1/</c>).
/// </param>
internal static void Load(string applicationDirectoryPath, string filePathPrefix)
{
var applicationDirectory = new DirectoryInfo(applicationDirectoryPath);
Debug.LogMessage(LogEventLevel.Information,
"Searching: {applicationDirectory:l} for embedded assets - {Destination}",
applicationDirectory.FullName, filePathPrefix);
ExtractAssetsZip(applicationDirectory, filePathPrefix);
ExtractHtmlAssetsZip(applicationDirectory, filePathPrefix);
ExtractDevToolsZip(applicationDirectory, filePathPrefix);
MoveConfigurationFile(applicationDirectory, filePathPrefix);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static void ExtractAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
{
var zipFiles = applicationDirectory.GetFiles("assets*.zip");
if (zipFiles.Length > 1)
throw new Exception("Multiple assets zip files found. Cannot continue.");
if (zipFiles.Length == 1)
{
var zipFile = zipFiles[0];
var assetsRoot = Path.GetFullPath(filePathPrefix);
if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
assetsRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information,
"Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName);
using (var archive = ZipFile.OpenRead(zipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(filePathPrefix, entry.FullName);
var fullDest = Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
if (Directory.Exists(destinationPath))
Directory.Delete(destinationPath, recursive: true);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
entry.ExtractToFile(destinationPath, overwrite: true);
Debug.LogMessage(LogEventLevel.Information,
"Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
foreach (var file in zipFiles)
File.Delete(file.FullName);
}
private static void ExtractHtmlAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
{
var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip");
if (htmlZipFiles.Length > 1)
throw new Exception(
"Multiple htmlassets zip files found in application directory. " +
"Please ensure only one htmlassets*.zip file is present and retry.");
if (htmlZipFiles.Length == 1)
{
var htmlZipFile = htmlZipFiles[0];
var programDir = new DirectoryInfo(
filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var userOrNvramDir = programDir.Parent;
var rootDir = userOrNvramDir?.Parent;
if (rootDir == null)
throw new Exception(
$"Unable to determine root directory for html extraction. Current path: {filePathPrefix}");
var htmlDir = Path.Combine(rootDir.FullName, "html");
var htmlRoot = Path.GetFullPath(htmlDir);
if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
htmlRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information,
"Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName);
using (var archive = ZipFile.OpenRead(htmlZipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(htmlDir, entry.FullName);
var fullDest = Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
if (File.Exists(destinationPath))
File.Delete(destinationPath);
var parentDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(parentDir))
Directory.CreateDirectory(parentDir);
entry.ExtractToFile(destinationPath, overwrite: true);
Debug.LogMessage(LogEventLevel.Information,
"Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
foreach (var file in htmlZipFiles)
File.Delete(file.FullName);
}
private static void ExtractDevToolsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
{
var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip");
if (devToolsZipFiles.Length > 1)
throw new Exception(
"Multiple essentials-devtools zip files found in application directory. " +
"Please ensure only one essentials-devtools*.zip file is present and retry.");
if (devToolsZipFiles.Length == 1)
{
var devToolsZipFile = devToolsZipFiles[0];
var programDir = new DirectoryInfo(
filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var userOrNvramDir = programDir.Parent;
var rootDir = userOrNvramDir?.Parent;
if (rootDir == null)
throw new Exception(
$"Unable to determine root directory for debug html extraction. Current path: {filePathPrefix}");
var debugDir = Path.Combine(rootDir.FullName, "html", "debug");
var debugRoot = Path.GetFullPath(debugDir);
if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
debugRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information,
"Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...",
devToolsZipFile.FullName, debugDir);
using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(debugDir, entry.FullName);
var fullDest = Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
if (File.Exists(destinationPath))
File.Delete(destinationPath);
var parentDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(parentDir))
Directory.CreateDirectory(parentDir);
entry.ExtractToFile(destinationPath, overwrite: true);
Debug.LogMessage(LogEventLevel.Information,
"Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
foreach (var file in devToolsZipFiles)
File.Delete(file.FullName);
}
private static void MoveConfigurationFile(DirectoryInfo applicationDirectory, string filePathPrefix)
{
var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json");
if (jsonFiles.Length > 1)
{
Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}",
jsonFiles.Select(f => f.FullName).ToArray());
throw new Exception("Multiple configuration files found. Cannot continue.");
}
if (jsonFiles.Length == 1)
{
var jsonFile = jsonFiles[0];
var finalPath = Path.Combine(filePathPrefix, jsonFile.Name);
Debug.LogMessage(LogEventLevel.Information,
"Found configuration file: {jsonFile:l}... Moving to: {Destination}",
jsonFile.FullName, finalPath);
if (File.Exists(finalPath))
{
Debug.LogMessage(LogEventLevel.Information,
"Removing existing configuration file: {Destination}", finalPath);
File.Delete(finalPath);
}
jsonFile.MoveTo(finalPath);
}
}
}
}

View file

@ -1,5 +1,4 @@
using System; using System;
using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using Crestron.SimplSharp; using Crestron.SimplSharp;
@ -28,6 +27,8 @@ namespace PepperDash.Essentials
private CEvent _initializeEvent; private CEvent _initializeEvent;
private const long StartupTime = 500; private const long StartupTime = 500;
// private const string minimumFirmwareVersion = "2.8006.00110";
/// <summary> /// <summary>
/// Initializes a new instance of the ControlSystem class /// Initializes a new instance of the ControlSystem class
/// </summary> /// </summary>
@ -47,6 +48,24 @@ namespace PepperDash.Essentials
/// <inheritdoc /> /// <inheritdoc />
public override void InitializeSystem() public override void InitializeSystem()
{ {
// Get FW version and stop if it's too low to run this version of Essentials. Must be greater than v2.8006.00110
// var fwVersion = InitialParametersClass.FirmwareVersion;
// Debug.LogInformation("Control System Hardware Version: {fwVersion}", fwVersion);
// // split the version into parts and compare against minimumFirmwareVersion
// var versionParts = fwVersion.Split('.').Select(int.Parse).ToArray();
// var minParts = minimumFirmwareVersion.Split('.').Select(int.Parse).ToArray();
// if (versionParts.Length < minParts.Length
// || versionParts[0] < minParts[0]
// || (versionParts[0] == minParts[0] && versionParts[1] < minParts[1])
// || (versionParts[0] == minParts[0] && versionParts[1] == minParts[1] && versionParts[2] <= minParts[2]))
// {
// Debug.LogFatal("Firmware version {fwVersion} is too low to run this version of Essentials. Please upgrade to greater than v{minimumFirmwareVersion}.", fwVersion, minimumFirmwareVersion);
// return;
// }
// If the control system is a DMPS type, we need to wait to exit this method until all devices have had time to activate // If the control system is a DMPS type, we need to wait to exit this method until all devices have had time to activate
// to allow any HD-BaseT DM endpoints to register first. // to allow any HD-BaseT DM endpoints to register first.
bool preventInitializationComplete = Global.ControlSystemIsDmpsType; bool preventInitializationComplete = Global.ControlSystemIsDmpsType;
@ -92,12 +111,16 @@ namespace PepperDash.Essentials
CrestronConsole.AddNewConsoleCommand(s => Debug.LogMessage(LogEventLevel.Information, "CONSOLE MESSAGE: {0}", s), "appdebugmessage", "Writes message to log", ConsoleAccessLevelEnum.AccessOperator); CrestronConsole.AddNewConsoleCommand(s => Debug.LogMessage(LogEventLevel.Information, "CONSOLE MESSAGE: {0}", s), "appdebugmessage", "Writes message to log", ConsoleAccessLevelEnum.AccessOperator);
CrestronConsole.AddNewConsoleCommand(s => CrestronConsole.AddNewConsoleCommand(ListTieLines,
{ "listtielines", "Prints out all tie lines. Usage: listtielines [signaltype]", ConsoleAccessLevelEnum.AccessOperator);
foreach (var tl in TieLineCollection.Default)
CrestronConsole.ConsoleCommandResponse(" {0}{1}", tl, CrestronEnvironment.NewLine); CrestronConsole.AddNewConsoleCommand(VisualizeRoutes, "visualizeroutes",
}, "Visualizes routes by signal type",
"listtielines", "Prints out all tie lines", ConsoleAccessLevelEnum.AccessOperator); ConsoleAccessLevelEnum.AccessOperator);
CrestronConsole.AddNewConsoleCommand(VisualizeCurrentRoutes, "visualizecurrentroutes",
"Visualizes current active routes from DefaultCollection",
ConsoleAccessLevelEnum.AccessOperator);
CrestronConsole.AddNewConsoleCommand(s => CrestronConsole.AddNewConsoleCommand(s =>
{ {
@ -107,14 +130,8 @@ namespace PepperDash.Essentials
(ConfigReader.ConfigObject, Newtonsoft.Json.Formatting.Indented).Replace(Environment.NewLine, "\r\n")); (ConfigReader.ConfigObject, Newtonsoft.Json.Formatting.Indented).Replace(Environment.NewLine, "\r\n"));
}, "showconfig", "Shows the current running merged config", ConsoleAccessLevelEnum.AccessOperator); }, "showconfig", "Shows the current running merged config", ConsoleAccessLevelEnum.AccessOperator);
CrestronConsole.AddNewConsoleCommand(s => CrestronConsole.AddNewConsoleCommand(
CrestronConsole.ConsoleCommandResponse( PrintPortalInfo,
"This system can be found at the following URLs:{2}" +
"System URL: {0}{2}" +
"Template URL: {1}{2}",
ConfigReader.ConfigObject.SystemUrl,
ConfigReader.ConfigObject.TemplateUrl,
CrestronEnvironment.NewLine),
"portalinfo", "portalinfo",
"Shows portal URLS from configuration", "Shows portal URLS from configuration",
ConsoleAccessLevelEnum.AccessOperator); ConsoleAccessLevelEnum.AccessOperator);
@ -137,6 +154,29 @@ namespace PepperDash.Essentials
} }
} }
private void PrintPortalInfo(string args)
{
if(ConfigReader.ConfigObject == null)
{
CrestronConsole.ConsoleCommandResponse("No configuration loaded. Cannot show portal URLs.");
return;
}
if (string.IsNullOrEmpty(ConfigReader.ConfigObject.SystemUrl) && string.IsNullOrEmpty(ConfigReader.ConfigObject.TemplateUrl))
{
CrestronConsole.ConsoleCommandResponse("No portal URLs defined in config.");
return;
}
CrestronConsole.ConsoleCommandResponse(
"This system can be found at the following URLs:{2}" +
"System URL: {0}{2}" +
"Template URL: {1}{2}",
ConfigReader.ConfigObject?.SystemUrl,
ConfigReader.ConfigObject?.TemplateUrl,
CrestronEnvironment.NewLine);
}
/// <summary> /// <summary>
/// DeterminePlatform method /// DeterminePlatform method
/// </summary> /// </summary>
@ -234,13 +274,8 @@ namespace PepperDash.Essentials
PluginLoader.AddProgramAssemblies(); PluginLoader.AddProgramAssemblies();
_ = new Core.DeviceFactory(); _ = new Core.DeviceFactory();
// _ = new Devices.Common.DeviceFactory();
// _ = new DeviceFactory();
// _ = new ProcessorExtensionDeviceFactory(); LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix);
// _ = new MobileControlFactory();
LoadAssets();
Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration"); Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration");
@ -251,10 +286,9 @@ namespace PepperDash.Essentials
PluginLoader.LoadPlugins(); PluginLoader.LoadPlugins();
Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config...");
if (!ConfigReader.LoadConfig2()) if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null)
{ {
Debug.LogMessage(LogEventLevel.Information, "Essentials Load complete with errors"); Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file.");
return;
} }
Load(); Load();
@ -376,6 +410,12 @@ namespace PepperDash.Essentials
new Core.Monitoring.SystemMonitorController("systemMonitor")); new Core.Monitoring.SystemMonitorController("systemMonitor"));
} }
if (ConfigReader.ConfigObject is null)
{
Debug.LogMessage(LogEventLevel.Warning, "LoadDevices: ConfigObject is null. Cannot load devices.");
return;
}
foreach (var devConf in ConfigReader.ConfigObject.Devices) foreach (var devConf in ConfigReader.ConfigObject.Devices)
{ {
IKeyed newDev = null; IKeyed newDev = null;
@ -429,7 +469,7 @@ namespace PepperDash.Essentials
var tlc = TieLineCollection.Default; var tlc = TieLineCollection.Default;
if (ConfigReader.ConfigObject.TieLines == null) if (ConfigReader.ConfigObject?.TieLines == null)
{ {
return; return;
} }
@ -443,6 +483,282 @@ namespace PepperDash.Essentials
Debug.LogMessage(LogEventLevel.Information, "All Tie Lines Loaded."); Debug.LogMessage(LogEventLevel.Information, "All Tie Lines Loaded.");
Extensions.MapDestinationsToSources();
Debug.LogMessage(LogEventLevel.Information, "All Routes Mapped.");
}
/// <summary>
/// Visualizes routes in a tree format for better understanding of signal paths
/// </summary>
private void ListTieLines(string args)
{
try
{
if (!string.IsNullOrEmpty(args) && args.Contains("?"))
{
CrestronConsole.ConsoleCommandResponse("Usage: listtielines [signaltype]\r\n");
CrestronConsole.ConsoleCommandResponse("Signal types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n");
return;
}
eRoutingSignalType? signalTypeFilter = null;
if (!string.IsNullOrEmpty(args))
{
eRoutingSignalType parsedType;
if (Enum.TryParse(args.Trim(), true, out parsedType))
{
signalTypeFilter = parsedType;
}
else
{
CrestronConsole.ConsoleCommandResponse("Invalid signal type: {0}\r\n", args.Trim());
CrestronConsole.ConsoleCommandResponse("Valid types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n");
return;
}
}
var tielines = signalTypeFilter.HasValue
? TieLineCollection.Default.Where(tl => tl.Type.HasFlag(signalTypeFilter.Value))
: TieLineCollection.Default;
var count = 0;
foreach (var tl in tielines)
{
CrestronConsole.ConsoleCommandResponse(" {0}{1}", tl, CrestronEnvironment.NewLine);
count++;
}
CrestronConsole.ConsoleCommandResponse("\r\nTotal: {0} tieline{1}{2}", count, count == 1 ? "" : "s", CrestronEnvironment.NewLine);
}
catch (Exception ex)
{
CrestronConsole.ConsoleCommandResponse("Error listing tielines: {0}\r\n", ex.Message);
}
}
private void VisualizeRoutes(string args)
{
try
{
if (!string.IsNullOrEmpty(args) && args.Contains("?"))
{
CrestronConsole.ConsoleCommandResponse("Usage: visualizeroutes [signaltype] [-s source] [-d destination]\r\n");
CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n");
CrestronConsole.ConsoleCommandResponse(" -s: Filter by source key (partial match)\r\n");
CrestronConsole.ConsoleCommandResponse(" -d: Filter by destination key (partial match)\r\n");
return;
}
ParseRouteFilters(args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter);
CrestronConsole.ConsoleCommandResponse("\r\n+===========================================================================+\r\n");
CrestronConsole.ConsoleCommandResponse("| ROUTE VISUALIZATION |\r\n");
CrestronConsole.ConsoleCommandResponse("+===========================================================================+\r\n\r\n");
foreach (var descriptorCollection in Extensions.RouteDescriptors.Where(kv => kv.Value.Descriptors.Count() > 0))
{
// Filter by signal type if specified
if (signalTypeFilter.HasValue && descriptorCollection.Key != signalTypeFilter.Value)
continue;
CrestronConsole.ConsoleCommandResponse("\r\n+--- Signal Type: {0} ({1} routes) ---\r\n",
descriptorCollection.Key,
descriptorCollection.Value.Descriptors.Count());
foreach (var descriptor in descriptorCollection.Value.Descriptors)
{
// Filter by source/dest if specified
if (sourceFilter != null && !descriptor.Source.Key.ToLower().Contains(sourceFilter))
continue;
if (destFilter != null && !descriptor.Destination.Key.ToLower().Contains(destFilter))
continue;
VisualizeRouteDescriptor(descriptor);
}
}
CrestronConsole.ConsoleCommandResponse("\r\n");
}
catch (Exception ex)
{
CrestronConsole.ConsoleCommandResponse("Error visualizing routes: {0}\r\n", ex.Message);
}
}
private void VisualizeCurrentRoutes(string args)
{
try
{
if (!string.IsNullOrEmpty(args) && args.Contains("?"))
{
CrestronConsole.ConsoleCommandResponse("Usage: visualizecurrentroutes [signaltype] [-s source] [-d destination]\r\n");
CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n");
CrestronConsole.ConsoleCommandResponse(" -s: Filter by source key (partial match)\r\n");
CrestronConsole.ConsoleCommandResponse(" -d: Filter by destination key (partial match)\r\n");
return;
}
ParseRouteFilters(args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter);
CrestronConsole.ConsoleCommandResponse("\r\n+===========================================================================+\r\n");
CrestronConsole.ConsoleCommandResponse("| CURRENT ROUTES VISUALIZATION |\r\n");
CrestronConsole.ConsoleCommandResponse("+===========================================================================+\r\n\r\n");
var hasRoutes = false;
// Get all descriptors from DefaultCollection
var allDescriptors = RouteDescriptorCollection.DefaultCollection.Descriptors;
// Group by signal type
var groupedByType = allDescriptors.GroupBy(d => d.SignalType);
foreach (var group in groupedByType)
{
var signalType = group.Key;
// Filter by signal type if specified
if (signalTypeFilter.HasValue && signalType != signalTypeFilter.Value)
continue;
var filteredDescriptors = group.Where(d =>
{
if (sourceFilter != null && !d.Source.Key.ToLower().Contains(sourceFilter))
return false;
if (destFilter != null && !d.Destination.Key.ToLower().Contains(destFilter))
return false;
return true;
}).ToList();
if (filteredDescriptors.Count == 0)
continue;
hasRoutes = true;
CrestronConsole.ConsoleCommandResponse("\r\n+--- Signal Type: {0} ({1} routes) ---\r\n",
signalType,
filteredDescriptors.Count);
foreach (var descriptor in filteredDescriptors)
{
VisualizeRouteDescriptor(descriptor);
}
}
if (!hasRoutes)
{
CrestronConsole.ConsoleCommandResponse("\r\nNo active routes found in current state.\r\n");
}
CrestronConsole.ConsoleCommandResponse("\r\n");
}
catch (Exception ex)
{
CrestronConsole.ConsoleCommandResponse("Error visualizing current state: {0}\r\n", ex.Message);
}
}
/// <summary>
/// Parses route filter arguments from command line
/// </summary>
/// <param name="args">Command line arguments</param>
/// <param name="signalTypeFilter">Parsed signal type filter (if any)</param>
/// <param name="sourceFilter">Parsed source filter (if any)</param>
/// <param name="destFilter">Parsed destination filter (if any)</param>
private void ParseRouteFilters(string args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter)
{
signalTypeFilter = null;
sourceFilter = null;
destFilter = null;
if (string.IsNullOrEmpty(args))
return;
var parts = args.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < parts.Length; i++)
{
var part = parts[i];
// Check for flags
if (part == "-s" && i + 1 < parts.Length)
{
sourceFilter = parts[++i].ToLower();
}
else if (part == "-d" && i + 1 < parts.Length)
{
destFilter = parts[++i].ToLower();
}
// Try to parse as signal type if not a flag and no signal type set yet
else if (!part.StartsWith("-") && !signalTypeFilter.HasValue)
{
if (Enum.TryParse(part, true, out eRoutingSignalType parsedType))
{
signalTypeFilter = parsedType;
}
}
}
}
/// <summary>
/// Visualizes a single route descriptor in a tree format
/// </summary>
private void VisualizeRouteDescriptor(RouteDescriptor descriptor)
{
CrestronConsole.ConsoleCommandResponse("|\r\n");
CrestronConsole.ConsoleCommandResponse("|-- {0} --> {1}\r\n",
descriptor.Source.Key,
descriptor.Destination.Key);
if (descriptor.Routes == null || descriptor.Routes.Count == 0)
{
CrestronConsole.ConsoleCommandResponse("| +-- (No switching steps)\r\n");
return;
}
for (int i = 0; i < descriptor.Routes.Count; i++)
{
var route = descriptor.Routes[i];
var isLast = i == descriptor.Routes.Count - 1;
var prefix = isLast ? "+" : "|";
var continuation = isLast ? " " : "|";
if (route.SwitchingDevice != null)
{
CrestronConsole.ConsoleCommandResponse("| {0}-- [{1}] {2}\r\n",
prefix,
route.SwitchingDevice.Key,
GetSwitchDescription(route));
// Add visual connection line for non-last items
if (!isLast)
CrestronConsole.ConsoleCommandResponse("| {0} |\r\n", continuation);
}
else
{
CrestronConsole.ConsoleCommandResponse("| {0}-- {1}\r\n", prefix, route.ToString());
}
}
}
/// <summary>
/// Gets a readable description of the switching operation
/// </summary>
private string GetSwitchDescription(RouteSwitchDescriptor route)
{
if (route.OutputPort != null && route.InputPort != null)
{
return string.Format("{0} -> {1}", route.OutputPort.Key, route.InputPort.Key);
}
else if (route.InputPort != null)
{
return string.Format("-> {0}", route.InputPort.Key);
}
else
{
return "(passthrough)";
}
} }
/// <summary> /// <summary>
@ -450,7 +766,7 @@ namespace PepperDash.Essentials
/// </summary> /// </summary>
public void LoadRooms() public void LoadRooms()
{ {
if (ConfigReader.ConfigObject.Rooms == null) if (ConfigReader.ConfigObject?.Rooms == null)
{ {
Debug.LogMessage(LogEventLevel.Information, "Notice: Configuration contains no rooms - Is this intentional? This may be a valid configuration."); Debug.LogMessage(LogEventLevel.Information, "Notice: Configuration contains no rooms - Is this intentional? This may be a valid configuration.");
return; return;
@ -485,15 +801,16 @@ namespace PepperDash.Essentials
/// <summary> /// <summary>
/// Fires up a logo server if not already running /// Fires up a logo server if not already running
/// </summary> /// </summary>
[Obsolete("Logo server is no longer supported and will be removed in a future release.")]
void LoadLogoServer() void LoadLogoServer()
{ {
if (ConfigReader.ConfigObject.Rooms == null) if (ConfigReader.ConfigObject?.Rooms == null)
{ {
Debug.LogMessage(LogEventLevel.Information, "No rooms configured. Bypassing Logo server startup."); Debug.LogMessage(LogEventLevel.Information, "No rooms configured. Bypassing Logo server startup.");
return; return;
} }
if ( if (ConfigReader.ConfigObject?.Rooms == null ||
!ConfigReader.ConfigObject.Rooms.Any( !ConfigReader.ConfigObject.Rooms.Any(
CheckRoomConfig)) CheckRoomConfig))
{ {
@ -544,142 +861,8 @@ namespace PepperDash.Essentials
} }
} }
private static void LoadAssets() internal static void LoadAssets(string applicationDirectoryPath, string filePathPrefix) =>
{ AssetLoader.Load(applicationDirectoryPath, filePathPrefix);
var applicationDirectory = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix);
Debug.LogMessage(LogEventLevel.Information, "Searching: {applicationDirectory:l} for embedded assets - {Destination}", applicationDirectory.FullName, Global.FilePathPrefix);
var zipFiles = applicationDirectory.GetFiles("assets*.zip");
if (zipFiles.Length > 1)
{
throw new Exception("Multiple assets zip files found. Cannot continue.");
}
if (zipFiles.Length == 1)
{
var zipFile = zipFiles[0];
var assetsRoot = System.IO.Path.GetFullPath(Global.FilePathPrefix);
if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
assetsRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information, "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName);
using (var archive = ZipFile.OpenRead(zipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(Global.FilePathPrefix, entry.FullName);
var fullDest = System.IO.Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
// If a directory exists where a file should go, delete it
if (Directory.Exists(destinationPath))
Directory.Delete(destinationPath, true);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
entry.ExtractToFile(destinationPath, true);
Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
// cleaning up zip files
foreach (var file in zipFiles)
{
File.Delete(file.FullName);
}
var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip");
if (htmlZipFiles.Length > 1)
{
throw new Exception("Multiple htmlassets zip files found in application directory. Please ensure only one htmlassets*.zip file is present and retry.");
}
if (htmlZipFiles.Length == 1)
{
var htmlZipFile = htmlZipFiles[0];
var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var userOrNvramDir = programDir.Parent;
var rootDir = userOrNvramDir?.Parent;
if (rootDir == null)
{
throw new Exception($"Unable to determine root directory for html extraction. Current path: {Global.FilePathPrefix}");
}
var htmlDir = Path.Combine(rootDir.FullName, "html");
var htmlRoot = System.IO.Path.GetFullPath(htmlDir);
if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
htmlRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information, "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName);
using (var archive = ZipFile.OpenRead(htmlZipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(htmlDir, entry.FullName);
var fullDest = System.IO.Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
// Only delete the file if it exists and is a file, not a directory
if (File.Exists(destinationPath))
File.Delete(destinationPath);
var parentDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(parentDir))
Directory.CreateDirectory(parentDir);
entry.ExtractToFile(destinationPath, true);
Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
// cleaning up html zip files
foreach (var file in htmlZipFiles)
{
File.Delete(file.FullName);
}
var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json");
if (jsonFiles.Length > 1)
{
Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", jsonFiles.Select(f => f.FullName).ToArray());
throw new Exception("Multiple configuration files found. Cannot continue.");
}
if (jsonFiles.Length == 1)
{
var jsonFile = jsonFiles[0];
var finalPath = Path.Combine(Global.FilePathPrefix, jsonFile.Name);
Debug.LogMessage(LogEventLevel.Information, "Found configuration file: {jsonFile:l}... Moving to: {Destination}", jsonFile.FullName, finalPath);
if (File.Exists(finalPath))
{
Debug.LogMessage(LogEventLevel.Information, "Removing existing configuration file: {Destination}", finalPath);
File.Delete(finalPath);
}
jsonFile.MoveTo(finalPath);
}
}
} }
} }