mirror of
https://github.com/PepperDash/Essentials.git
synced 2026-07-02 10:38:16 +00:00
Compare commits
84 commits
v3.0.0-dev
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f1eb979d3 | ||
|
|
8ac4eb7584 | ||
|
|
0240887d93 | ||
|
|
640bd7a8a7 | ||
|
|
2fac0ca926 | ||
|
|
af5611e403 | ||
|
|
3286d27898 | ||
|
|
a63da82cc3 | ||
|
|
3974455337 | ||
|
|
782bb6c057 | ||
|
|
907eb2f397 | ||
|
|
72a4c63a06 | ||
|
|
5f26cb98fd | ||
|
|
75587c361f | ||
|
|
c3511cd1a6 | ||
|
|
1ce86dab29 | ||
|
|
77c700c565 | ||
|
|
aa050121ae | ||
|
|
abef1d095f | ||
|
|
f658fdf363 | ||
|
|
469999b6f7 | ||
|
|
08aba35334 | ||
|
|
68fe205504 | ||
|
|
e1a5c32c1f | ||
|
|
18f7000d76 | ||
|
|
d47cfd5e62 | ||
|
|
4f2d2ca746 | ||
|
|
e6583f7824 | ||
|
|
e57bc43a10 | ||
|
|
b83af26b77 | ||
|
|
c9f5af184b | ||
|
|
beb77ec468 | ||
|
|
b318e7f365 | ||
|
|
a9dd57fdaf | ||
|
|
d879430616 | ||
|
|
1a5840c29a | ||
|
|
6fbdaa9ca0 | ||
|
|
e5e1802da9 | ||
|
|
17adac639e | ||
|
|
2607180ab5 | ||
|
|
f1ef479301 | ||
|
|
8ba993ed66 | ||
|
|
9fc866741e | ||
|
|
f3ea9e1d5a | ||
|
|
404728c708 | ||
|
|
c050bb4eb3 | ||
|
|
2530003a58 | ||
|
|
c52d585a0c | ||
|
|
89c23f5432 | ||
|
|
841279eb0c | ||
|
|
1b32761e8e | ||
|
|
507130c1ae | ||
|
|
1ecfca7e5a | ||
|
|
dd32f44e3d | ||
|
|
ba48f23a71 | ||
|
|
447af3883c | ||
|
|
3b57860123 | ||
|
|
b1a575e4d2 | ||
|
|
2fdd73498a | ||
|
|
ee240ca378 | ||
|
|
f31f0611f1 | ||
|
|
0a991cffb0 | ||
|
|
18088d37a1 | ||
|
|
4f86009209 | ||
|
|
2197dc489d | ||
|
|
db14a614bc | ||
|
|
4a59cf9f81 | ||
|
|
9ea5ec5d1a | ||
|
|
6e9480f503 | ||
|
|
a610e127de | ||
|
|
c820052b41 | ||
|
|
bf517ad8b8 | ||
|
|
e55ac38719 | ||
|
|
e800e0912c | ||
|
|
d2684f364d | ||
|
|
29d5804cb0 | ||
|
|
c20d49f430 | ||
|
|
a82cf4f449 | ||
|
|
305a466b1b | ||
|
|
5a07e837ee | ||
|
|
9c9a643b6a | ||
|
|
fb8216beed | ||
|
|
d05ebecd7d | ||
|
|
d0fe225bbc |
46 changed files with 3787 additions and 1046 deletions
|
|
@ -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 }}
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Let’s go through some examples of routing, using pseudo-code:
|
Let’s 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 doesn’t need to define what is involved in making a certain route. The person configuring the system defines how it’s 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 doesn’t need to define what is involved in making a certain route. The person configuring the system defines how it’s 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
|
||||||
|
|
|
||||||
|
|
@ -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```**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
463
src/PepperDash.Core/Comm/GenericUdpClient.cs
Normal file
463
src/PepperDash.Core/Comm/GenericUdpClient.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
257
src/PepperDash.Essentials/AssetLoader.cs
Normal file
257
src/PepperDash.Essentials/AssetLoader.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue