Compare commits

...

25 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
1676ba7649 Implement basic unit test infrastructure with abstraction patterns
Co-authored-by: ngenovese11 <23391587+ngenovese11@users.noreply.github.com>
2025-07-25 01:23:02 +00:00
copilot-swe-agent[bot]
046b6fdb3b Initial plan 2025-07-25 01:09:21 +00:00
Andrew Welker
592607f3c8 Merge pull request #1296 from PepperDash/feature/add-IHasCamerasMessenger 2025-07-24 18:53:05 -05:00
Neil Dorin
ea0a779f8b Merge branch 'feature/add-IHasCamerasMessenger' of https://github.com/PepperDash/Essentials into feature/add-IHasCamerasMessenger 2025-07-24 16:40:06 -06:00
Neil Dorin
86e4d2f7fb feat: Update SendFullStatus to target specific clients
Modified the `SendFullStatus` method to accept a `string clientId` parameter, allowing it to send status messages to specific clients. Updated the action for `"/fullStatus"` to pass the client ID and adjusted the `PostStatusMessage` call accordingly.
2025-07-24 16:39:28 -06:00
Neil Dorin
0069233e13 Update src/PepperDash.Essentials.Devices.Common/Cameras/CameraControl.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-24 16:16:05 -06:00
Neil Dorin
4048efb07e Merge branch 'main' into feature/add-IHasCamerasMessenger 2025-07-24 16:03:41 -06:00
Andrew Welker
1dbac7d1c8 Merge pull request #1292 from PepperDash/portkey-add
feat: add destination and source port key properties for advanced routing
2025-07-22 15:26:44 -05:00
Neil Dorin
799d4c127c Update src/PepperDash.Essentials.Core/Devices/DestinationListItem.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-22 14:02:01 -06:00
Andrew Welker
a6cd9a0571 feat: add destination and source port key properties for advanced routing 2025-07-22 14:56:28 -05:00
Andrew Welker
da30424657 Merge pull request #1289 from PepperDash/meter-feedback-interface
meter feedback interface
2025-07-21 15:20:29 -05:00
Andrew Welker
311452beac fix: use correct namespaces
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-21 13:30:11 -05:00
Andrew Welker
789113008e docs: update comments
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-21 13:29:11 -05:00
Andrew Welker
660836bd5a docs: remove spaces
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-21 13:28:59 -05:00
Andrew Welker
97b2ffed9c docs: fix comment
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-21 13:28:37 -05:00
Andrew Welker
2bbefa062d docs: fix comments
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-21 13:28:10 -05:00
Andrew Welker
3421b2f28c Merge branch 'main' into meter-feedback-interface 2025-07-17 12:34:16 -05:00
Andrew Welker
1dcd4e328c fix: Destination support for USB 2025-07-17 12:32:26 -05:00
Andrew Welker
e76369726d docs: XML comments for DestinationListItem 2025-07-17 12:25:52 -05:00
Neil Dorin
f8455d4110 feat: Refactor IHasCamerasMessenger constructor parameters
Updated the constructor of `IHasCamerasMessenger` to reorder parameters, placing `IHasCameras cameraController` last. Added logic in `MobileControlSystemController.cs` to instantiate and add `IHasCamerasMessenger` for devices implementing the `IHasCameras` interface.
2025-07-17 11:14:32 -06:00
Andrew Welker
9813673b66 feat: ICurrentSources interface to allow for tracking breakaway routing 2025-07-17 09:15:25 -05:00
Neil Dorin
f006ed0076 feat: Add camera control interfaces and messenger classes
This commit introduces new interfaces in `CameraControl.cs` for various camera functionalities, including muting, panning, tilting, zooming, and auto modes. The `IHasCameras` interface is expanded to manage camera lists and selections, with the addition of `CameraSelectedEventArgs` for event handling.

In `CameraBaseMessenger.cs`, a new `IHasCamerasMessenger` class is created to facilitate communication for devices implementing the `IHasCameras` interface, along with the `IHasCamerasStateMessage` class to represent the state of camera devices. These changes enhance the overall camera control capabilities and improve interaction management within the application.
2025-07-16 16:54:57 -06:00
Andrew Welker
ddbcc13c50 fix: add property for sync device association 2025-07-16 10:41:46 -05:00
Andrew Welker
2a70fc678e fix: add IStateFeedback interface 2025-07-11 13:13:52 -05:00
Andrew Welker
056614cba1 fix: add IMeterFeedback interface 2025-07-09 14:32:01 -05:00
26 changed files with 1646 additions and 123 deletions

36
.github/workflows/unit-tests.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Unit Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj
- name: Build tests
run: dotnet build tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj --no-restore
- name: Run tests
run: dotnet test tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage"
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
with:
files: ./tests/PepperDash.Essentials.Core.Tests/TestResults/*/coverage.cobertura.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false

9
.gitignore vendored
View File

@@ -60,6 +60,15 @@ bld/
[Ll]og/
[Ll]ogs/
# Test results and coverage
TestResults/
coverage/
*.trx
*.coverage
*.coveragexml
coverage.cobertura.xml
coverage-report/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot

9
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"ms-dotnettools.vscode-dotnet-runtime",
"ms-dotnettools.csharp",
"ms-dotnettools.csdevkit",
"vivaxy.vscode-conventional-commits",
"mhutchie.git-graph"
]
}

View File

@@ -36,6 +36,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{02EA681E-C
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Core", "src\PepperDash.Core\PepperDash.Core.csproj", "{E5336563-1194-501E-BC4A-79AD9283EF90}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{C8EAB8E7-4F14-4E5C-8D23-1A3956D46DE8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PepperDash.Essentials.Core.Tests", "tests\PepperDash.Essentials.Core.Tests\PepperDash.Essentials.Core.Tests.csproj", "{A1234567-8901-2345-6789-ABCDEF012345}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug 4.7.2|Any CPU = Debug 4.7.2|Any CPU
@@ -79,6 +83,12 @@ Global
{E5336563-1194-501E-BC4A-79AD9283EF90}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5336563-1194-501E-BC4A-79AD9283EF90}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5336563-1194-501E-BC4A-79AD9283EF90}.Release|Any CPU.Build.0 = Release|Any CPU
{A1234567-8901-2345-6789-ABCDEF012345}.Debug 4.7.2|Any CPU.ActiveCfg = Debug|Any CPU
{A1234567-8901-2345-6789-ABCDEF012345}.Debug 4.7.2|Any CPU.Build.0 = Debug|Any CPU
{A1234567-8901-2345-6789-ABCDEF012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1234567-8901-2345-6789-ABCDEF012345}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1234567-8901-2345-6789-ABCDEF012345}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1234567-8901-2345-6789-ABCDEF012345}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -90,6 +100,7 @@ Global
{F6D362DE-2256-44B1-927A-8CE4705D839A} = {B24989D7-32B5-48D5-9AE1-5F3B17D25206}
{B438694F-8FF7-464A-9EC8-10427374471F} = {B24989D7-32B5-48D5-9AE1-5F3B17D25206}
{E5336563-1194-501E-BC4A-79AD9283EF90} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{A1234567-8901-2345-6789-ABCDEF012345} = {C8EAB8E7-4F14-4E5C-8D23-1A3956D46DE8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6907A4BF-7201-47CF-AAB1-3597F3B8E1C3}

188
docs/testing/QuickStart.md Normal file
View File

@@ -0,0 +1,188 @@
# Quick Start Guide: Unit Testing
This guide helps you get started with writing unit tests for PepperDash Essentials components.
## Prerequisites
- .NET 8 SDK
- Visual Studio 2022 or VS Code with C# extension
## Running Existing Tests
```bash
# Navigate to the test project
cd tests/PepperDash.Essentials.Core.Tests
# Run all tests
dotnet test
# Run with coverage
dotnet test --collect:"XPlat Code Coverage"
```
## Creating Your First Test
### 1. Identify the Component
Choose a component that has business logic you want to test. Look for classes that:
- Have clear inputs and outputs
- Contain conditional logic or algorithms
- Are used by multiple parts of the system
### 2. Create Test File
Create a new test file in `tests/PepperDash.Essentials.Core.Tests/Unit/`:
```csharp
using PepperDash.Essentials.Core.Tests.Abstractions;
namespace PepperDash.Essentials.Core.Tests.Unit
{
public class YourComponentTests
{
[Fact]
public void YourMethod_WithValidInput_ReturnsExpectedResult()
{
// Arrange
var component = new YourComponent();
var input = "test data";
// Act
var result = component.YourMethod(input);
// Assert
Assert.Equal("expected result", result);
}
}
}
```
### 3. Abstract Dependencies
If your component uses Crestron SDK or other external dependencies:
```csharp
// Create interface in Abstractions/
public interface IYourDependency
{
string DoSomething(string input);
}
// Modify your component to use the interface
public class YourComponent
{
private readonly IYourDependency _dependency;
public YourComponent(IYourDependency dependency)
{
_dependency = dependency;
}
public string YourMethod(string input)
{
return _dependency.DoSomething(input);
}
}
// Test with mocks
[Fact]
public void YourMethod_CallsDependency_ReturnsResult()
{
// Arrange
var mockDependency = new Mock<IYourDependency>();
mockDependency.Setup(x => x.DoSomething("input")).Returns("output");
var component = new YourComponent(mockDependency.Object);
// Act
var result = component.YourMethod("input");
// Assert
Assert.Equal("output", result);
mockDependency.Verify(x => x.DoSomething("input"), Times.Once);
}
```
## Testing Patterns
### Testing Exceptions
```csharp
[Fact]
public void Constructor_WithNullDependency_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new YourComponent(null));
}
```
### Testing Async Methods
```csharp
[Fact]
public async Task YourAsyncMethod_WithValidInput_ReturnsExpectedResult()
{
// Arrange
var component = new YourComponent();
// Act
var result = await component.YourAsyncMethod();
// Assert
Assert.True(result);
}
```
### Testing Collections
```csharp
[Fact]
public void GetItems_ReturnsExpectedCount()
{
// Arrange
var component = new YourComponent();
// Act
var items = component.GetItems();
// Assert
Assert.Equal(3, items.Count());
Assert.Contains(items, x => x.Name == "Expected Item");
}
```
## Common Mistakes to Avoid
1. **Testing Implementation Details** - Test behavior, not internal implementation
2. **Overly Complex Tests** - Keep tests simple and focused on one thing
3. **Not Testing Edge Cases** - Include null values, empty collections, boundary conditions
4. **Ignoring Test Performance** - Tests should run quickly to enable fast feedback
## Debugging Tests
### In Visual Studio
- Set breakpoints in test methods
- Use Test Explorer to run individual tests
- Check test output window for detailed information
### From Command Line
```bash
# Run specific test
dotnet test --filter "YourComponentTests.YourMethod_WithValidInput_ReturnsExpectedResult"
# Run with detailed output
dotnet test --verbosity diagnostic
```
## Next Steps
1. **Review Examples** - Look at `TestableActionSequenceTests.cs` for comprehensive examples
2. **Start Small** - Begin with simple components and build up complexity
3. **Read Documentation** - Check `docs/testing/README.md` for detailed patterns
4. **Get Feedback** - Have your tests reviewed by team members
## Need Help?
- Check existing test examples in the codebase
- Review the full testing documentation
- Ask team members for guidance on complex scenarios
- Consider pair programming for your first few tests

255
docs/testing/README.md Normal file
View File

@@ -0,0 +1,255 @@
# Unit Testing Infrastructure
This document outlines the unit testing infrastructure and patterns for PepperDash Essentials.
## Overview
The testing infrastructure is designed to enable comprehensive testing of business logic while abstracting away Crestron SDK dependencies. This allows for:
- Fast, reliable unit tests that don't require hardware
- Isolated testing of business logic
- Better code quality through testability
- Easier debugging and maintenance
## Project Structure
```
tests/
├── PepperDash.Essentials.Core.Tests/
│ ├── Abstractions/ # Interface abstractions for SDK dependencies
│ ├── TestableComponents/ # Demonstration components showing abstraction patterns
│ ├── Unit/ # Unit test files
│ └── GlobalUsings.cs # Global using statements for tests
```
## Test Project Configuration
The test projects use:
- **.NET 8** - Modern testing framework with better performance
- **xUnit** - Primary testing framework
- **Moq** - Mocking framework for dependencies
- **Coverlet** - Code coverage analysis
## Abstraction Patterns
### Interface Segregation
Create focused interfaces that abstract SDK functionality:
```csharp
public interface IThreadService
{
void Sleep(int milliseconds);
object CreateAndStartThread(Func<object, object> threadFunction, object parameter);
void AbortThread(object thread);
bool IsThreadRunning(object thread);
}
public interface ILogger
{
void LogDebug(object source, string message, params object[] args);
void LogVerbose(object source, string message, params object[] args);
}
```
### Dependency Injection
Components should accept dependencies via constructor injection:
```csharp
public class TestableActionSequence
{
private readonly IQueue<TestableSequencedAction> _actionQueue;
private readonly IThreadService _threadService;
private readonly ILogger _logger;
public TestableActionSequence(
IQueue<TestableSequencedAction> actionQueue,
IThreadService threadService,
ILogger logger,
List<TestableSequencedAction> actions)
{
_actionQueue = actionQueue ?? throw new ArgumentNullException(nameof(actionQueue));
_threadService = threadService ?? throw new ArgumentNullException(nameof(threadService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// ...
}
}
```
## Writing Tests
### Test Organization
- **One test class per component**
- **Descriptive test method names** that describe the scenario and expected outcome
- **Arrange-Act-Assert pattern** for clear test structure
### Example Test Structure
```csharp
public class TestableActionSequenceTests
{
private readonly Mock<IQueue<TestableSequencedAction>> _mockQueue;
private readonly Mock<IThreadService> _mockThreadService;
private readonly Mock<ILogger> _mockLogger;
private readonly TestableActionSequence _actionSequence;
public TestableActionSequenceTests()
{
// Arrange - Set up mocks and dependencies
_mockQueue = new Mock<IQueue<TestableSequencedAction>>();
_mockThreadService = new Mock<IThreadService>();
_mockLogger = new Mock<ILogger>();
_actionSequence = new TestableActionSequence(
_mockQueue.Object,
_mockThreadService.Object,
_mockLogger.Object,
new List<TestableSequencedAction>());
}
[Fact]
public void StartSequence_WhenNoThreadRunning_StartsNewThread()
{
// Arrange
_mockThreadService.Setup(x => x.IsThreadRunning(It.IsAny<object>())).Returns(false);
// Act
_actionSequence.StartSequence();
// Assert
_mockLogger.Verify(x => x.LogDebug(_actionSequence, "Starting Action Sequence"), Times.Once);
_mockThreadService.Verify(x => x.CreateAndStartThread(It.IsAny<Func<object, object>>(), null), Times.Once);
}
}
```
### Test Naming Convention
Use descriptive names that follow the pattern:
`MethodName_Scenario_ExpectedBehavior`
Examples:
- `StartSequence_WhenNoThreadRunning_StartsNewThread`
- `Constructor_WithNullQueue_ThrowsArgumentNullException`
- `StopSequence_SetsAllowActionsToFalseAndAbortsThread`
## Running Tests
### From Command Line
```bash
# Run all tests
dotnet test
# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage"
# Run specific test class
dotnet test --filter "TestableActionSequenceTests"
# Run with verbose output
dotnet test --verbosity normal
```
### From Visual Studio
- Use Test Explorer to run and debug tests
- Right-click on test methods to run individually
- Use "Run Tests in Parallel" for faster execution
## Code Coverage
The test projects include Coverlet for code coverage analysis. After running tests with coverage:
```bash
# Generate coverage report
dotnet tool install -g reportgenerator
reportgenerator -reports:"coverage.cobertura.xml" -targetdir:"coverage-report" -reporttypes:Html
```
## Best Practices
### 1. Test Independence
- Each test should be independent and able to run in isolation
- Use fresh mock objects for each test
- Don't rely on test execution order
### 2. Mocking Guidelines
- Mock external dependencies (SDK calls, file system, network)
- Don't mock the component under test
- Use strict mocks when behavior verification is important
### 3. Assertions
- Make assertions specific and meaningful
- Test both positive and negative scenarios
- Verify state changes and behavior calls
### 4. Test Data
- Use meaningful test data that represents real scenarios
- Consider edge cases and boundary conditions
- Use constants or factory methods for complex test data
## Migrating Existing Code
To make existing components testable:
1. **Identify SDK Dependencies** - Look for direct Crestron SDK usage
2. **Extract Interfaces** - Create abstractions for SDK functionality
3. **Inject Dependencies** - Modify constructors to accept abstractions
4. **Create Tests** - Write comprehensive unit tests for business logic
5. **Implement Wrappers** - Create concrete implementations for production use
## Example Implementation Wrapper
When implementing the abstractions for production use:
```csharp
public class CrestronThreadService : IThreadService
{
public void Sleep(int milliseconds)
{
Thread.Sleep(milliseconds);
}
public object CreateAndStartThread(Func<object, object> threadFunction, object parameter)
{
var thread = new Thread(threadFunction, parameter, Thread.eThreadStartOptions.Running);
return thread;
}
public void AbortThread(object thread)
{
if (thread is Thread crestronThread)
{
crestronThread.Abort();
}
}
public bool IsThreadRunning(object thread)
{
return thread is Thread crestronThread &&
crestronThread.ThreadState == Thread.eThreadStates.ThreadRunning;
}
}
```
## Future Enhancements
The testing infrastructure can be extended with:
- **Integration test support** for end-to-end scenarios
- **Performance testing** utilities
- **Test data builders** for complex object creation
- **Custom assertions** for domain-specific validations
- **Automated test generation** for common patterns
## Getting Help
For questions about testing patterns or infrastructure:
1. Review existing test examples in the `tests/` directory
2. Check this documentation for guidance
3. Consult the team for complex abstraction scenarios
4. Consider the impact on existing plugin interfaces before major changes

View File

@@ -2,7 +2,14 @@
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
public interface IDisplay: IHasFeedback, IRoutingSinkWithSwitching, IHasPowerControl, IWarmingCooling, IUsageTracking, IKeyName
/// <summary>
/// Interface for display devices that can be controlled and monitored.
/// This interface combines functionality for feedback, routing, power control,
/// warming/cooling, usage tracking, and key name management.
/// It is designed to be implemented by devices that require these capabilities,
/// such as projectors, displays, and other visual output devices.
/// </summary>
public interface IDisplay : IHasFeedback, IRoutingSinkWithSwitching, IHasPowerControl, IWarmingCooling, IUsageTracking, IKeyName
{
}
}

View File

@@ -0,0 +1,18 @@
using System;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// Interface for devices that provide audio meter feedback.
/// This interface is used to standardize access to meter feedback across different devices.
/// </summary>
public interface IMeterFeedback
{
/// <summary>
/// Gets the meter feedback for the device.
/// This property provides an IntFeedback that represents the current audio level or meter value.
/// </summary>
IntFeedback MeterFeedback { get; }
}
}

View File

@@ -0,0 +1,18 @@
using System;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// Interface for devices that provide state feedback.
/// This interface is used to standardize access to state feedback across different devices.
/// </summary>
public interface IStateFeedback
{
/// <summary>
/// Gets the state feedback for the device.
/// This property provides a BoolFeedback that represents the current state (on/off) of the device.
/// </summary>
BoolFeedback StateFeedback { get; }
}
}

View File

@@ -5,19 +5,34 @@ using PepperDash.Essentials.Core;
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Represents a destination item in a routing system that can receive audio/video signals.
/// Contains information about the destination device, its properties, and location settings.
/// </summary>
public class DestinationListItem
{
/// <summary>
/// Gets or sets the key identifier for the sink device that this destination represents.
/// </summary>
[JsonProperty("sinkKey")]
public string SinkKey { get; set; }
private EssentialsDevice _sinkDevice;
/// <summary>
/// Gets the actual device instance for this destination.
/// Lazily loads the device from the DeviceManager using the SinkKey.
/// </summary>
[JsonIgnore]
public EssentialsDevice SinkDevice
{
get { return _sinkDevice ?? (_sinkDevice = DeviceManager.GetDeviceForKey(SinkKey) as EssentialsDevice); }
}
/// <summary>
/// Gets the preferred display name for this destination.
/// Returns the custom Name if set, otherwise returns the SinkDevice name, or "---" if no device is found.
/// </summary>
[JsonProperty("preferredName")]
public string PreferredName
{
@@ -32,31 +47,78 @@ namespace PepperDash.Essentials.Core
}
}
/// <summary>
/// Gets or sets the custom name for this destination.
/// If set, this name will be used as the PreferredName instead of the device name.
/// </summary>
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this destination should be included in destination lists.
/// </summary>
[JsonProperty("includeInDestinationList")]
public bool IncludeInDestinationList { get; set; }
/// <summary>
/// Gets or sets the display order for this destination in lists.
/// Lower values appear first in sorted lists.
/// </summary>
[JsonProperty("order")]
public int Order { get; set; }
/// <summary>
/// Gets or sets the surface location identifier for this destination.
/// Used to specify which surface or screen this destination is located on.
/// </summary>
[JsonProperty("surfaceLocation")]
public int SurfaceLocation { get; set; }
/// <summary>
/// Gets or sets the vertical location position for this destination.
/// Used for spatial positioning in multi-display configurations.
/// </summary>
[JsonProperty("verticalLocation")]
public int VerticalLocation { get; set; }
/// <summary>
/// Gets or sets the horizontal location position for this destination.
/// Used for spatial positioning in multi-display configurations.
/// </summary>
[JsonProperty("horizontalLocation")]
public int HorizontalLocation { get; set; }
/// <summary>
/// Gets or sets the signal type that this destination can receive (Audio, Video, AudioVideo, etc.).
/// </summary>
[JsonProperty("sinkType")]
public eRoutingSignalType SinkType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this destination is used for codec content sharing.
/// </summary>
[JsonProperty("isCodecContentDestination")]
public bool isCodecContentDestination { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this destination is used for program audio output.
/// </summary>
[JsonProperty("isProgramAudioDestination")]
public bool isProgramAudioDestination { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this destination supports USB connections.
/// Indicates if the destination can handle USB functionality, such as USB signal routing or device connections.
/// This property is used to determine compatibility with USB-based devices or systems.
/// </summary>
[JsonProperty("supportsUsb")]
public bool SupportsUsb { get; set; }
/// <summary>
/// The key of the destination port associated with this destination item
/// This is used to identify the specific port on the destination device that this item refers to for advanced routing
/// </summary>
[JsonProperty("destinationPortKey")]
public string DestinationPortKey { get; set; }
}
}

View File

@@ -1,12 +1,14 @@
using Newtonsoft.Json;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using PepperDash.Core;
using System.Collections.Generic;
namespace PepperDash.Essentials.Core
{
/// <summary>
///
/// Defines the type of source list item, which can be a route, off, or other.
/// This is used to categorize the source list items in a room.
/// The type is serialized to JSON and can be used to determine how the item should be displayed or handled in the UI.
/// </summary>
public enum eSourceListItemType
{
@@ -166,6 +168,26 @@ namespace PepperDash.Essentials.Core
[JsonProperty("disableSimpleRouting")]
public bool DisableSimpleRouting { get; set; }
/// <summary>
/// The key of the device that provides video sync for this source item
/// </summary>
[JsonProperty("syncProviderDeviceKey")]
public string SyncProviderDeviceKey { get; set; }
/// <summary>
/// Indicates if the source supports USB connections
/// </summary>
[JsonProperty("supportsUsb")]
public bool SupportsUsb { get; set; }
/// <summary>
/// The key of the source port associated with this source item
/// This is used to identify the specific port on the source device that this item refers to for advanced routing
/// </summary>
[JsonProperty("sourcePortKey")]
public string SourcePortKey { get; set; }
/// <summary>
/// Default constructor for SourceListItem, initializes the Icon to "Blank"
/// </summary>
@@ -177,7 +199,7 @@ namespace PepperDash.Essentials.Core
/// <summary>
/// Returns a string representation of the SourceListItem, including the SourceKey and Name
/// </summary>
/// <returns></returns>
/// <returns> A string representation of the SourceListItem</returns>
public override string ToString()
{
return $"{SourceKey}:{Name}";

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
using PepperDash.Essentials.Core;
namespace PepperDash.Essentials.Core.Routing
{
/// <summary>
/// The current sources for the room, keyed by eRoutingSignalType.
/// This allows for multiple sources to be tracked, such as audio and video.
/// </summary>
/// <remarks>
/// This interface is used to provide access to the current sources in a room,
/// allowing for more complex routing scenarios where multiple signal types are involved.
/// </remarks>
public interface ICurrentSources
{
/// <summary>
/// Gets the current sources for the room, keyed by eRoutingSignalType.
/// This dictionary contains the current source for each signal type, such as audio, video, and control signals.
/// </summary>
Dictionary<eRoutingSignalType, SourceListItem> CurrentSources { get; }
/// <summary>
/// Gets the current source keys for the room, keyed by eRoutingSignalType.
/// This dictionary contains the keys for the current source for each signal type, such as audio, video, and control signals.
/// </summary>
Dictionary<eRoutingSignalType, string> CurrentSourceKeys { get; }
}
}

View File

@@ -9,6 +9,8 @@ using PepperDash.Essentials.Core.Routing;
using PepperDash.Essentials.Core.Routing;
using PepperDash.Essentials.Core.Routing.Interfaces
*/
using System;
namespace PepperDash.Essentials.Core
{
/// <summary>
@@ -21,10 +23,24 @@ namespace PepperDash.Essentials.Core
/// <summary>
/// For rooms with a single presentation source, change event
/// </summary>
[Obsolete("Use ICurrentSources instead")]
public interface IHasCurrentSourceInfoChange
{
/// <summary>
/// The key for the current source info, used to look up the source in the SourceList
/// </summary>
string CurrentSourceInfoKey { get; set; }
/// <summary>
/// The current source info for the room, used to look up the source in the SourceList
/// </summary>
SourceListItem CurrentSourceInfo { get; set; }
/// <summary>
/// Event that is raised when the current source info changes.
/// This is used to notify the system of changes to the current source info.
/// The event handler receives the new source info and the type of change that occurred.
/// </summary>
event SourceInfoChangeHandler CurrentSourceChange;
}
}

View File

@@ -1,29 +1,29 @@
namespace PepperDash.Essentials.Core
using PepperDash.Essentials.Core.Routing;
namespace PepperDash.Essentials.Core
{
/// <summary>
/// For fixed-source endpoint devices
/// </summary>
public interface IRoutingSink : IRoutingInputs, IHasCurrentSourceInfoChange
{
{
}
/// <summary>
/// For fixed-source endpoint devices with an input port
/// </summary>
public interface IRoutingSinkWithInputPort :IRoutingSink
public interface IRoutingSinkWithInputPort : IRoutingSink
{
/// <summary>
/// Gets the current input port for this routing sink.
/// </summary>
RoutingInputPort CurrentInputPort { get; }
}
/*/// <summary>
/// For fixed-source endpoint devices
/// </summary>
public interface IRoutingSink<TSelector> : IRoutingInputs<TSelector>, IHasCurrentSourceInfoChange
{
void UpdateRouteRequest<TOutputSelector>(RouteRequest<TSelector, TOutputSelector> request);
RouteRequest<TSelector, TOutputSelector> GetRouteRequest<TOutputSelector>();
}*/
/// <summary>
/// Interface for routing sinks that have access to the current source information.
/// </summary>
public interface IRoutingSinkWithCurrentSources : IRoutingSink, ICurrentSources
{
}
}

View File

@@ -3,29 +3,60 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using Crestron.SimplSharp;
using PepperDash.Core;
using PepperDash.Essentials.Core;
namespace PepperDash.Essentials.Devices.Common.Cameras
{
/// <summary>
/// Enum for camera control modes
/// </summary>
public enum eCameraControlMode
{
{
/// <summary>
/// Manual control mode, where the camera is controlled directly by the user or system
/// </summary>
Manual = 0,
/// <summary>
/// Off control mode, where the camera is turned off or disabled
/// </summary>
Off,
/// <summary>
/// Auto control mode, where the camera automatically adjusts settings based on the environment or conditions
/// </summary>
Auto
}
public interface IHasCameras
/// <summary>
/// Interface for devices that have cameras
/// </summary>
public interface IHasCameras : IKeyName
{
/// <summary>
/// Event that is raised when a camera is selected
/// </summary>
event EventHandler<CameraSelectedEventArgs> CameraSelected;
/// <summary>
/// List of cameras on the device. This should be a list of CameraBase objects
/// </summary>
List<CameraBase> Cameras { get; }
/// <summary>
/// The currently selected camera. This should be a CameraBase object
/// </summary>
CameraBase SelectedCamera { get; }
/// <summary>
/// Feedback that indicates the currently selected camera
/// </summary>
StringFeedback SelectedCameraFeedback { get; }
/// <summary>
/// Selects a camera from the list of available cameras based on the provided key.
/// </summary>
/// <param name="key">The unique identifier or name of the camera to select.</param>
void SelectCamera(string key);
}
@@ -42,7 +73,14 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
/// </summary>
public interface IHasCameraOff
{
/// <summary>
/// Feedback that indicates whether the camera is off
/// </summary>
BoolFeedback CameraIsOffFeedback { get; }
/// <summary>
/// Turns the camera off, blanking the near end video
/// </summary>
void CameraOff();
}
@@ -51,31 +89,71 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
/// </summary>
public interface IHasCameraMute
{
/// <summary>
/// Feedback that indicates whether the camera is muted
/// </summary>
BoolFeedback CameraIsMutedFeedback { get; }
/// <summary>
/// Mutes the camera video, preventing it from being sent to the far end
/// </summary>
void CameraMuteOn();
/// <summary>
/// Unmutes the camera video, allowing it to be sent to the far end
/// </summary>
void CameraMuteOff();
/// <summary>
/// Toggles the camera mute state. If the camera is muted, it will be unmuted, and vice versa.
/// </summary>
void CameraMuteToggle();
}
/// <summary>
/// Interface for devices that can mute and unmute their camera video, with an event for unmute requests
/// </summary>
public interface IHasCameraMuteWithUnmuteReqeust : IHasCameraMute
{
/// <summary>
/// Event that is raised when a video unmute is requested, typically by the far end
/// </summary>
event EventHandler VideoUnmuteRequested;
}
/// <summary>
/// Event arguments for the CameraSelected event
/// </summary>
public class CameraSelectedEventArgs : EventArgs
{
/// <summary>
/// The selected camera
/// </summary>
public CameraBase SelectedCamera { get; private set; }
/// <summary>
/// Constructor for CameraSelectedEventArgs
/// </summary>
/// <param name="camera"></param>
public CameraSelectedEventArgs(CameraBase camera)
{
SelectedCamera = camera;
}
}
/// <summary>
/// Interface for devices that have a far end camera control
/// </summary>
public interface IHasFarEndCameraControl
{
/// <summary>
/// Gets the far end camera, which is typically a CameraBase object that represents the camera at the far end of a call
/// </summary>
CameraBase FarEndCamera { get; }
/// <summary>
/// Feedback that indicates whether the far end camera is being controlled
/// </summary>
BoolFeedback ControllingFarEndCameraFeedback { get; }
}
@@ -88,6 +166,9 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
}
/// <summary>
/// Interface for devices that have camera controls
/// </summary>
public interface IHasCameraControls
{
}
@@ -108,8 +189,19 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
/// </summary>
public interface IHasCameraPanControl : IHasCameraControls
{
/// <summary>
/// Pans the camera left
/// </summary>
void PanLeft();
/// <summary>
/// Pans the camera right
/// </summary>
void PanRight();
/// <summary>
/// Stops the camera pan movement
/// </summary>
void PanStop();
}
@@ -118,8 +210,19 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
/// </summary>
public interface IHasCameraTiltControl : IHasCameraControls
{
/// <summary>
/// Tilts the camera down
/// </summary>
void TiltDown();
/// <summary>
/// Tilts the camera up
/// </summary>
void TiltUp();
/// <summary>
/// Stops the camera tilt movement
/// </summary>
void TiltStop();
}
@@ -128,8 +231,19 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
/// </summary>
public interface IHasCameraZoomControl : IHasCameraControls
{
/// <summary>
/// Zooms the camera in
/// </summary>
void ZoomIn();
/// <summary>
/// Zooms the camera out
/// </summary>
void ZoomOut();
/// <summary>
/// Stops the camera zoom movement
/// </summary>
void ZoomStop();
}
@@ -138,25 +252,71 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
/// </summary>
public interface IHasCameraFocusControl : IHasCameraControls
{
/// <summary>
/// Focuses the camera near
/// </summary>
void FocusNear();
/// <summary>
/// Focuses the camera far
/// </summary>
void FocusFar();
/// <summary>
/// Stops the camera focus movement
/// </summary>
void FocusStop();
/// <summary>
/// Triggers the camera's auto focus functionality, if available.
/// </summary>
void TriggerAutoFocus();
}
/// <summary>
/// Interface for devices that have auto focus mode control
/// </summary>
public interface IHasAutoFocusMode
{
/// <summary>
/// Sets the focus mode to auto or manual, or toggles between them.
/// </summary>
void SetFocusModeAuto();
/// <summary>
/// Sets the focus mode to manual, allowing for manual focus adjustments.
/// </summary>
void SetFocusModeManual();
/// <summary>
/// Toggles the focus mode between auto and manual.
/// </summary>
void ToggleFocusMode();
}
/// <summary>
/// Interface for devices that have camera auto mode control
/// </summary>
public interface IHasCameraAutoMode : IHasCameraControls
{
/// <summary>
/// Enables or disables the camera's auto mode, which may include automatic adjustments for focus, exposure, and other settings.
/// </summary>
void CameraAutoModeOn();
/// <summary>
/// Disables the camera's auto mode, allowing for manual control of camera settings.
/// </summary>
void CameraAutoModeOff();
/// <summary>
/// Toggles the camera's auto mode state. If the camera is in auto mode, it will switch to manual mode, and vice versa.
/// </summary>
void CameraAutoModeToggle();
/// <summary>
/// Feedback that indicates whether the camera's auto mode is currently enabled.
/// </summary>
BoolFeedback CameraAutoModeIsOnFeedback { get; }
}

View File

@@ -1,110 +1,199 @@
using Crestron.SimplSharp;
using System;
using System.Collections.Generic;
using System.Linq;
using Crestron.SimplSharp;
using Crestron.SimplSharpPro.DeviceSupport;
using Newtonsoft.Json;
using PepperDash.Core;
using PepperDash.Essentials.Core;
using PepperDash.Essentials.Core.Bridges;
using PepperDash.Essentials.Core.DeviceTypeInterfaces;
using PepperDash.Essentials.Core.Routing;
using Serilog.Events;
using System;
using System.Collections.Generic;
using System.Linq;
using Feedback = PepperDash.Essentials.Core.Feedback;
namespace PepperDash.Essentials.Devices.Common.Displays
{
public abstract class DisplayBase : EssentialsDevice, IDisplay
/// <summary>
/// Abstract base class for display devices that provides common display functionality
/// including power control, input switching, and routing capabilities.
/// </summary>
public abstract class DisplayBase : EssentialsDevice, IDisplay, ICurrentSources
{
private RoutingInputPort _currentInputPort;
public RoutingInputPort CurrentInputPort
{
get
{
return _currentInputPort;
}
private RoutingInputPort _currentInputPort;
protected set
{
if (_currentInputPort == value) return;
/// <summary>
/// Gets or sets the current input port that is selected on the display.
/// </summary>
public RoutingInputPort CurrentInputPort
{
get
{
return _currentInputPort;
}
_currentInputPort = value;
protected set
{
if (_currentInputPort == value) return;
InputChanged?.Invoke(this, _currentInputPort);
}
}
_currentInputPort = value;
public event InputChangedEventHandler InputChanged;
InputChanged?.Invoke(this, _currentInputPort);
}
}
public event SourceInfoChangeHandler CurrentSourceChange;
/// <summary>
/// Event that is raised when the input changes on the display.
/// </summary>
public event InputChangedEventHandler InputChanged;
public string CurrentSourceInfoKey { get; set; }
public SourceListItem CurrentSourceInfo
{
get
{
return _CurrentSourceInfo;
}
set
{
if (value == _CurrentSourceInfo) return;
/// <summary>
/// Event that is raised when the current source information changes.
/// </summary>
public event SourceInfoChangeHandler CurrentSourceChange;
var handler = CurrentSourceChange;
/// <summary>
/// Gets or sets the key of the current source information.
/// </summary>
public string CurrentSourceInfoKey { get; set; }
if (handler != null)
handler(_CurrentSourceInfo, ChangeType.WillChange);
/// <summary>
/// Gets or sets the current source information for the display.
/// </summary>
public SourceListItem CurrentSourceInfo
{
get
{
return _CurrentSourceInfo;
}
set
{
if (value == _CurrentSourceInfo) return;
_CurrentSourceInfo = value;
var handler = CurrentSourceChange;
if (handler != null)
handler(_CurrentSourceInfo, ChangeType.DidChange);
}
}
SourceListItem _CurrentSourceInfo;
if (handler != null)
handler(_CurrentSourceInfo, ChangeType.WillChange);
_CurrentSourceInfo = value;
if (handler != null)
handler(_CurrentSourceInfo, ChangeType.DidChange);
}
}
SourceListItem _CurrentSourceInfo;
/// <inheritdoc/>
public Dictionary<eRoutingSignalType, SourceListItem> CurrentSources { get; private set; }
/// <inheritdoc/>
public Dictionary<eRoutingSignalType, string> CurrentSourceKeys { get; private set; }
/// <summary>
/// Gets feedback indicating whether the display is currently cooling down after being powered off.
/// </summary>
public BoolFeedback IsCoolingDownFeedback { get; protected set; }
/// <summary>
/// Gets feedback indicating whether the display is currently warming up after being powered on.
/// </summary>
public BoolFeedback IsWarmingUpFeedback { get; private set; }
public UsageTracking UsageTracker { get; set; }
/// <summary>
/// Gets or sets the usage tracking instance for monitoring display usage statistics.
/// </summary>
public UsageTracking UsageTracker { get; set; }
/// <summary>
/// Gets or sets the warmup time in milliseconds for the display to become ready after power on.
/// </summary>
public uint WarmupTime { get; set; }
/// <summary>
/// Gets or sets the cooldown time in milliseconds for the display to fully power down.
/// </summary>
public uint CooldownTime { get; set; }
/// <summary>
/// Bool Func that will provide a value for the PowerIsOn Output. Must be implemented
/// by concrete sub-classes
/// Abstract function that must be implemented by derived classes to provide the cooling down feedback value.
/// Must be implemented by concrete sub-classes.
/// </summary>
abstract protected Func<bool> IsCoolingDownFeedbackFunc { get; }
abstract protected Func<bool> IsWarmingUpFeedbackFunc { get; }
/// <summary>
/// Abstract function that must be implemented by derived classes to provide the warming up feedback value.
/// Must be implemented by concrete sub-classes.
/// </summary>
abstract protected Func<bool> IsWarmingUpFeedbackFunc { get; }
/// <summary>
/// Timer used for managing display warmup timing.
/// </summary>
protected CTimer WarmupTimer;
/// <summary>
/// Timer used for managing display cooldown timing.
/// </summary>
protected CTimer CooldownTimer;
#region IRoutingInputs Members
/// <summary>
/// Gets the collection of input ports available on this display device.
/// </summary>
public RoutingPortCollection<RoutingInputPort> InputPorts { get; private set; }
#endregion
protected DisplayBase(string key, string name)
: base(key, name)
/// <summary>
/// Initializes a new instance of the DisplayBase class.
/// </summary>
/// <param name="key">The unique key identifier for this display device.</param>
/// <param name="name">The friendly name for this display device.</param>
protected DisplayBase(string key, string name)
: base(key, name)
{
IsCoolingDownFeedback = new BoolFeedback("IsCoolingDown", IsCoolingDownFeedbackFunc);
IsWarmingUpFeedback = new BoolFeedback("IsWarmingUp", IsWarmingUpFeedbackFunc);
InputPorts = new RoutingPortCollection<RoutingInputPort>();
CurrentSources = new Dictionary<eRoutingSignalType, SourceListItem>
{
{ eRoutingSignalType.Audio, null },
{ eRoutingSignalType.Video, null },
};
CurrentSourceKeys = new Dictionary<eRoutingSignalType, string>
{
{ eRoutingSignalType.Audio, string.Empty },
{ eRoutingSignalType.Video, string.Empty },
};
}
/// <summary>
/// Powers on the display device. Must be implemented by derived classes.
/// </summary>
public abstract void PowerOn();
/// <summary>
/// Powers off the display device. Must be implemented by derived classes.
/// </summary>
public abstract void PowerOff();
/// <summary>
/// Toggles the power state of the display device. Must be implemented by derived classes.
/// </summary>
public abstract void PowerToggle();
public virtual FeedbackCollection<Feedback> Feedbacks
/// <summary>
/// Gets the collection of feedback objects for this display device.
/// </summary>
public virtual FeedbackCollection<Feedback> Feedbacks
{
get
{
return new FeedbackCollection<Feedback>
return new FeedbackCollection<Feedback>
{
IsCoolingDownFeedback,
IsWarmingUpFeedback
@@ -112,30 +201,50 @@ namespace PepperDash.Essentials.Devices.Common.Displays
}
}
public abstract void ExecuteSwitch(object selector);
/// <summary>
/// Executes a switch to the specified input on the display device. Must be implemented by derived classes.
/// </summary>
/// <param name="selector">The selector object that identifies which input to switch to.</param>
public abstract void ExecuteSwitch(object selector);
protected void LinkDisplayToApi(DisplayBase displayDevice, BasicTriList trilist, uint joinStart, string joinMapKey,
EiscApiAdvanced bridge)
{
var joinMap = new DisplayControllerJoinMap(joinStart);
/// <summary>
/// Links the display device to an API using a trilist, join start, join map key, and bridge.
/// This overload uses serialized join map configuration.
/// </summary>
/// <param name="displayDevice">The display device to link.</param>
/// <param name="trilist">The BasicTriList for communication.</param>
/// <param name="joinStart">The starting join number for the device.</param>
/// <param name="joinMapKey">The key for the join map configuration.</param>
/// <param name="bridge">The EISC API bridge instance.</param>
protected void LinkDisplayToApi(DisplayBase displayDevice, BasicTriList trilist, uint joinStart, string joinMapKey,
EiscApiAdvanced bridge)
{
var joinMap = new DisplayControllerJoinMap(joinStart);
var joinMapSerialized = JoinMapHelper.GetSerializedJoinMapForDevice(joinMapKey);
var joinMapSerialized = JoinMapHelper.GetSerializedJoinMapForDevice(joinMapKey);
if (!string.IsNullOrEmpty(joinMapSerialized))
joinMap = JsonConvert.DeserializeObject<DisplayControllerJoinMap>(joinMapSerialized);
if (!string.IsNullOrEmpty(joinMapSerialized))
joinMap = JsonConvert.DeserializeObject<DisplayControllerJoinMap>(joinMapSerialized);
if (bridge != null)
{
bridge.AddJoinMap(Key, joinMap);
}
else
{
Debug.LogMessage(LogEventLevel.Information,this,"Please update config to use 'eiscapiadvanced' to get all join map features for this device.");
}
if (bridge != null)
{
bridge.AddJoinMap(Key, joinMap);
}
else
{
Debug.LogMessage(LogEventLevel.Information, this, "Please update config to use 'eiscapiadvanced' to get all join map features for this device.");
}
LinkDisplayToApi(displayDevice, trilist, joinMap);
}
}
/// <summary>
/// Links the display device to an API using a trilist and join map.
/// This overload uses a pre-configured join map instance.
/// </summary>
/// <param name="displayDevice">The display device to link.</param>
/// <param name="trilist">The BasicTriList for communication.</param>
/// <param name="joinMap">The join map configuration for the device.</param>
protected void LinkDisplayToApi(DisplayBase displayDevice, BasicTriList trilist, DisplayControllerJoinMap joinMap)
{
Debug.LogMessage(LogEventLevel.Debug, "Linking to Trilist '{0}'", trilist.ID.ToString("X"));
@@ -268,68 +377,96 @@ namespace PepperDash.Essentials.Devices.Common.Displays
volumeDisplayWithFeedback.MuteFeedback.LinkComplementInputSig(trilist.BooleanInput[joinMap.VolumeMuteOff.JoinNumber]);
}
}
}
public abstract class TwoWayDisplayBase : DisplayBase, IRoutingFeedback, IHasPowerControlWithFeedback
/// <summary>
/// Abstract base class for two-way display devices that provide feedback capabilities.
/// Extends DisplayBase with routing feedback and power control feedback functionality.
/// </summary>
public abstract class TwoWayDisplayBase : DisplayBase, IRoutingFeedback, IHasPowerControlWithFeedback
{
public StringFeedback CurrentInputFeedback { get; private set; }
/// <summary>
/// Gets feedback for the current input selection on the display.
/// </summary>
public StringFeedback CurrentInputFeedback { get; private set; }
abstract protected Func<string> CurrentInputFeedbackFunc { get; }
/// <summary>
/// Abstract function that must be implemented by derived classes to provide the current input feedback value.
/// Must be implemented by concrete sub-classes.
/// </summary>
abstract protected Func<string> CurrentInputFeedbackFunc { get; }
public BoolFeedback PowerIsOnFeedback { get; protected set; }
/// <summary>
/// Gets feedback indicating whether the display is currently powered on.
/// </summary>
public BoolFeedback PowerIsOnFeedback { get; protected set; }
abstract protected Func<bool> PowerIsOnFeedbackFunc { get; }
/// <summary>
/// Abstract function that must be implemented by derived classes to provide the power state feedback value.
/// Must be implemented by concrete sub-classes.
/// </summary>
abstract protected Func<bool> PowerIsOnFeedbackFunc { get; }
public static MockDisplay DefaultDisplay
{
get
/// <summary>
/// Gets the default mock display instance for testing and development purposes.
/// </summary>
public static MockDisplay DefaultDisplay
{
get
{
if (_DefaultDisplay == null)
_DefaultDisplay = new MockDisplay("default", "Default Display");
return _DefaultDisplay;
}
}
}
static MockDisplay _DefaultDisplay;
/// <summary>
/// Initializes a new instance of the TwoWayDisplayBase class.
/// </summary>
/// <param name="key">The unique key identifier for this display device.</param>
/// <param name="name">The friendly name for this display device.</param>
public TwoWayDisplayBase(string key, string name)
: base(key, name)
{
CurrentInputFeedback = new StringFeedback(CurrentInputFeedbackFunc);
CurrentInputFeedback = new StringFeedback(CurrentInputFeedbackFunc);
WarmupTime = 7000;
CooldownTime = 15000;
PowerIsOnFeedback = new BoolFeedback("PowerOnFeedback", PowerIsOnFeedbackFunc);
PowerIsOnFeedback = new BoolFeedback("PowerOnFeedback", PowerIsOnFeedbackFunc);
Feedbacks.Add(CurrentInputFeedback);
Feedbacks.Add(PowerIsOnFeedback);
Feedbacks.Add(CurrentInputFeedback);
Feedbacks.Add(PowerIsOnFeedback);
PowerIsOnFeedback.OutputChange += PowerIsOnFeedback_OutputChange;
PowerIsOnFeedback.OutputChange += PowerIsOnFeedback_OutputChange;
}
void PowerIsOnFeedback_OutputChange(object sender, EventArgs e)
{
if (UsageTracker != null)
{
if (PowerIsOnFeedback.BoolValue)
UsageTracker.StartDeviceUsage();
else
UsageTracker.EndDeviceUsage();
}
}
void PowerIsOnFeedback_OutputChange(object sender, EventArgs e)
{
if (UsageTracker != null)
{
if (PowerIsOnFeedback.BoolValue)
UsageTracker.StartDeviceUsage();
else
UsageTracker.EndDeviceUsage();
}
}
public event EventHandler<RoutingNumericEventArgs> NumericSwitchChange;
/// <summary>
/// Event that is raised when a numeric switch change occurs on the display.
/// </summary>
public event EventHandler<RoutingNumericEventArgs> NumericSwitchChange;
/// <summary>
/// Raise an event when the status of a switch object changes.
/// </summary>
/// <param name="e">Arguments defined as IKeyName sender, output, input, and eRoutingSignalType</param>
protected void OnSwitchChange(RoutingNumericEventArgs e)
{
var newEvent = NumericSwitchChange;
if (newEvent != null) newEvent(this, e);
}
/// <summary>
/// Raise an event when the status of a switch object changes.
/// </summary>
/// <param name="e">Arguments defined as IKeyName sender, output, input, and eRoutingSignalType</param>
protected void OnSwitchChange(RoutingNumericEventArgs e)
{
var newEvent = NumericSwitchChange;
if (newEvent != null) newEvent(this, e);
}
}
}

View File

@@ -6,11 +6,14 @@ using System.Collections.Generic;
namespace PepperDash.Essentials.AppServer.Messengers
{
/// <summary>
/// Messenger for a CameraBase device
/// </summary>
public class CameraBaseMessenger : MessengerBase
{
/// <summary>
/// Device being bridged
/// </summary>
/// </summary>
public CameraBase Camera { get; set; }
/// <summary>
@@ -45,6 +48,9 @@ namespace PepperDash.Essentials.AppServer.Messengers
);
}
/// <summary>
/// Registers the actions for this messenger. This is called by the base class
/// </summary>
protected override void RegisterActions()
{
base.RegisterActions();

View File

@@ -0,0 +1,104 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PepperDash.Essentials.Devices.Common.Cameras;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PepperDash.Essentials.AppServer.Messengers
{
/// <summary>
/// Messenger for devices that implement the IHasCameras interface.
/// </summary>
public class IHasCamerasMessenger : MessengerBase
{
/// <summary>
/// Device being bridged that implements IHasCameras interface.
/// </summary>
public IHasCameras CameraController { get; private set; }
/// <summary>
/// Messenger for devices that implement IHasCameras interface.
/// </summary>
/// <param name="key"></param>
/// <param name="cameraController"></param>
/// <param name="messagePath"></param>
/// <exception cref="ArgumentNullException"></exception>
public IHasCamerasMessenger(string key, string messagePath , IHasCameras cameraController)
: base(key, messagePath, cameraController)
{
CameraController = cameraController ?? throw new ArgumentNullException("cameraController");
CameraController.CameraSelected += CameraController_CameraSelected;
}
private void CameraController_CameraSelected(object sender, CameraSelectedEventArgs e)
{
PostStatusMessage(new IHasCamerasStateMessage
{
SelectedCamera = e.SelectedCamera
});
}
/// <summary>
/// Registers the actions for this messenger.
/// </summary>
/// <exception cref="ArgumentException"></exception>
protected override void RegisterActions()
{
base.RegisterActions();
AddAction("/fullStatus", (id, context) =>
{
SendFullStatus(id);
});
AddAction("/selectCamera", (id, content) =>
{
var cameraKey = content?.ToObject<string>();
if (!string.IsNullOrEmpty(cameraKey))
{
CameraController.SelectCamera(cameraKey);
}
else
{
throw new ArgumentException("Content must be a string representing the camera key");
}
});
}
private void SendFullStatus(string clientId)
{
var state = new IHasCamerasStateMessage
{
CameraList = CameraController.Cameras,
SelectedCamera = CameraController.SelectedCamera
};
PostStatusMessage(state, clientId);
}
}
/// <summary>
/// State message for devices that implement the IHasCameras interface.
/// </summary>
public class IHasCamerasStateMessage : DeviceStateMessageBase
{
/// <summary>
/// List of cameras available in the device.
/// </summary>
[JsonProperty("cameraList", NullValueHandling = NullValueHandling.Ignore)]
public List<CameraBase> CameraList { get; set; }
/// <summary>
/// The currently selected camera on the device.
/// </summary>
[JsonProperty("selectedCamera", NullValueHandling = NullValueHandling.Ignore)]
public CameraBase SelectedCamera { get; set; }
}
}

View File

@@ -907,6 +907,19 @@ namespace PepperDash.Essentials
messengerAdded = true;
}
if (device is IHasCameras cameras)
{
this.LogVerbose("Adding IHasCamerasMessenger for {deviceKey}", device.Key
);
var messenger = new IHasCamerasMessenger(
$"{device.Key}-cameras-{Key}",
$"/device/{device.Key}",
cameras
);
AddDefaultDeviceMessenger(messenger);
messengerAdded = true;
}
this.LogVerbose("Trying to cast to generic device for device: {key}", device.Key);
if (device is EssentialsDevice)

View File

@@ -0,0 +1,26 @@
using System;
namespace PepperDash.Essentials.Core.Tests.Abstractions
{
/// <summary>
/// Abstraction for logging operations to enable testing
/// </summary>
public interface ILogger
{
/// <summary>
/// Logs a debug message
/// </summary>
/// <param name="source">Source of the log message</param>
/// <param name="message">Message to log</param>
/// <param name="args">Format arguments</param>
void LogDebug(object source, string message, params object[] args);
/// <summary>
/// Logs a verbose message
/// </summary>
/// <param name="source">Source of the log message</param>
/// <param name="message">Message to log</param>
/// <param name="args">Format arguments</param>
void LogVerbose(object source, string message, params object[] args);
}
}

View File

@@ -0,0 +1,28 @@
using System;
namespace PepperDash.Essentials.Core.Tests.Abstractions
{
/// <summary>
/// Abstraction for queue operations to enable testing
/// </summary>
/// <typeparam name="T">Type of items in the queue</typeparam>
public interface IQueue<T>
{
/// <summary>
/// Number of items in the queue
/// </summary>
int Count { get; }
/// <summary>
/// Adds an item to the queue
/// </summary>
/// <param name="item">Item to add</param>
void Enqueue(T item);
/// <summary>
/// Removes and returns the next item from the queue
/// </summary>
/// <returns>The next item, or default(T) if queue is empty</returns>
T Dequeue();
}
}

View File

@@ -0,0 +1,37 @@
using System;
namespace PepperDash.Essentials.Core.Tests.Abstractions
{
/// <summary>
/// Abstraction for thread operations to enable testing
/// </summary>
public interface IThreadService
{
/// <summary>
/// Sleeps the current thread for the specified milliseconds
/// </summary>
/// <param name="milliseconds">Time to sleep in milliseconds</param>
void Sleep(int milliseconds);
/// <summary>
/// Creates and starts a new thread
/// </summary>
/// <param name="threadFunction">The function to execute in the thread</param>
/// <param name="parameter">Parameter to pass to the thread function</param>
/// <returns>Thread identifier or handle</returns>
object CreateAndStartThread(Func<object, object> threadFunction, object parameter);
/// <summary>
/// Aborts the specified thread
/// </summary>
/// <param name="thread">The thread to abort</param>
void AbortThread(object thread);
/// <summary>
/// Checks if the thread is currently running
/// </summary>
/// <param name="thread">The thread to check</param>
/// <returns>True if the thread is running, false otherwise</returns>
bool IsThreadRunning(object thread);
}
}

View File

@@ -0,0 +1,5 @@
global using Xunit;
global using Moq;
global using System;
global using System.Collections.Generic;
global using System.Linq;

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.69" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using PepperDash.Essentials.Core.Tests.Abstractions;
namespace PepperDash.Essentials.Core.Tests.TestableComponents
{
/// <summary>
/// A simplified, testable action sequence that demonstrates abstraction patterns
/// This shows how we can separate business logic from SDK dependencies
/// </summary>
public class TestableActionSequence
{
private readonly IQueue<TestableSequencedAction> _actionQueue;
private readonly IThreadService _threadService;
private readonly ILogger _logger;
private readonly List<TestableSequencedAction> _configuredActions;
private object _workerThread;
private bool _allowActionsToExecute;
public TestableActionSequence(
IQueue<TestableSequencedAction> actionQueue,
IThreadService threadService,
ILogger logger,
List<TestableSequencedAction> actions)
{
_actionQueue = actionQueue ?? throw new ArgumentNullException(nameof(actionQueue));
_threadService = threadService ?? throw new ArgumentNullException(nameof(threadService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_configuredActions = actions ?? new List<TestableSequencedAction>();
}
/// <summary>
/// Starts executing the sequenced actions
/// </summary>
public void StartSequence()
{
if (_workerThread != null && _threadService.IsThreadRunning(_workerThread))
{
_logger.LogDebug(this, "Thread already running. Cannot Start Sequence");
return;
}
_logger.LogDebug(this, "Starting Action Sequence");
_allowActionsToExecute = true;
AddActionsToQueue();
_workerThread = _threadService.CreateAndStartThread(ProcessActions, null);
}
/// <summary>
/// Stops executing the sequenced actions
/// </summary>
public void StopSequence()
{
_logger.LogDebug(this, "Stopping Action Sequence");
_allowActionsToExecute = false;
if (_workerThread != null)
{
_threadService.AbortThread(_workerThread);
}
}
/// <summary>
/// Gets the current status of the sequence
/// </summary>
public bool IsRunning => _allowActionsToExecute && _threadService.IsThreadRunning(_workerThread);
/// <summary>
/// Gets the number of pending actions
/// </summary>
public int PendingActionsCount => _actionQueue.Count;
/// <summary>
/// Populates the queue from the configuration information
/// </summary>
private void AddActionsToQueue()
{
_logger.LogDebug(this, "Adding {0} actions to queue", _configuredActions.Count);
foreach (var action in _configuredActions)
{
_actionQueue.Enqueue(action);
}
}
private object ProcessActions(object obj)
{
while (_allowActionsToExecute && _actionQueue.Count > 0)
{
var action = _actionQueue.Dequeue();
if (action == null)
break;
// Delay before executing
if (action.DelayMs > 0)
_threadService.Sleep(action.DelayMs);
ExecuteAction(action);
}
return null;
}
private void ExecuteAction(TestableSequencedAction action)
{
if (action == null)
return;
try
{
_logger.LogDebug(this, "Executing action: {0} with delay: {1}ms", action.Name, action.DelayMs);
action.Execute();
}
catch (Exception e)
{
_logger.LogVerbose(this, "Error Executing Action: {0}", e);
}
}
}
/// <summary>
/// A testable action that can be sequenced
/// </summary>
public class TestableSequencedAction
{
public string Name { get; set; }
public int DelayMs { get; set; }
public Action ActionToExecute { get; set; }
public TestableSequencedAction(string name, int delayMs = 0, Action actionToExecute = null)
{
Name = name;
DelayMs = delayMs;
ActionToExecute = actionToExecute ?? (() => { });
}
public void Execute()
{
ActionToExecute?.Invoke();
}
}
}

View File

@@ -0,0 +1,160 @@
using PepperDash.Essentials.Core.Tests.Abstractions;
using PepperDash.Essentials.Core.Tests.TestableComponents;
namespace PepperDash.Essentials.Core.Tests.Unit
{
public class TestableActionSequenceTests
{
private readonly Mock<IQueue<TestableSequencedAction>> _mockQueue;
private readonly Mock<IThreadService> _mockThreadService;
private readonly Mock<ILogger> _mockLogger;
private readonly TestableActionSequence _actionSequence;
private readonly List<TestableSequencedAction> _testActions;
public TestableActionSequenceTests()
{
_mockQueue = new Mock<IQueue<TestableSequencedAction>>();
_mockThreadService = new Mock<IThreadService>();
_mockLogger = new Mock<ILogger>();
_testActions = new List<TestableSequencedAction>
{
new TestableSequencedAction("Action1", 100),
new TestableSequencedAction("Action2", 200)
};
_actionSequence = new TestableActionSequence(
_mockQueue.Object,
_mockThreadService.Object,
_mockLogger.Object,
_testActions);
}
[Fact]
public void Constructor_WithNullQueue_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new TestableActionSequence(null, _mockThreadService.Object, _mockLogger.Object, _testActions));
}
[Fact]
public void Constructor_WithNullThreadService_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new TestableActionSequence(_mockQueue.Object, null, _mockLogger.Object, _testActions));
}
[Fact]
public void Constructor_WithNullLogger_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new TestableActionSequence(_mockQueue.Object, _mockThreadService.Object, null, _testActions));
}
[Fact]
public void StartSequence_WhenNoThreadRunning_StartsNewThread()
{
// Arrange
_mockThreadService.Setup(x => x.IsThreadRunning(It.IsAny<object>())).Returns(false);
// Act
_actionSequence.StartSequence();
// Assert
_mockLogger.Verify(x => x.LogDebug(_actionSequence, "Starting Action Sequence"), Times.Once);
_mockThreadService.Verify(x => x.CreateAndStartThread(It.IsAny<Func<object, object>>(), null), Times.Once);
}
[Fact]
public void StartSequence_WhenThreadAlreadyRunning_DoesNotStartNewThread()
{
// Arrange
var mockThread = new object();
_mockThreadService.Setup(x => x.CreateAndStartThread(It.IsAny<Func<object, object>>(), null))
.Returns(mockThread);
_mockThreadService.Setup(x => x.IsThreadRunning(mockThread)).Returns(true);
// First call to set up the worker thread
_actionSequence.StartSequence();
// Reset invocations to verify only the second call behavior
_mockLogger.Invocations.Clear();
_mockThreadService.Invocations.Clear();
// Act - Second call should detect running thread
_actionSequence.StartSequence();
// Assert
_mockLogger.Verify(x => x.LogDebug(_actionSequence, "Thread already running. Cannot Start Sequence"), Times.Once);
_mockThreadService.Verify(x => x.CreateAndStartThread(It.IsAny<Func<object, object>>(), null), Times.Never);
}
[Fact]
public void StartSequence_AddsConfiguredActionsToQueue()
{
// Arrange
_mockThreadService.Setup(x => x.IsThreadRunning(It.IsAny<object>())).Returns(false);
// Act
_actionSequence.StartSequence();
// Assert
_mockLogger.Verify(x => x.LogDebug(_actionSequence, "Adding {0} actions to queue", 2), Times.Once);
_mockQueue.Verify(x => x.Enqueue(It.IsAny<TestableSequencedAction>()), Times.Exactly(2));
}
[Fact]
public void StopSequence_SetsAllowActionsToFalseAndAbortsThread()
{
// Arrange
var mockThread = new object();
// Act
_actionSequence.StopSequence();
// Assert
_mockLogger.Verify(x => x.LogDebug(_actionSequence, "Stopping Action Sequence"), Times.Once);
}
[Fact]
public void PendingActionsCount_ReturnsQueueCount()
{
// Arrange
_mockQueue.Setup(x => x.Count).Returns(5);
// Act
var count = _actionSequence.PendingActionsCount;
// Assert
Assert.Equal(5, count);
}
[Fact]
public void TestableSequencedAction_ExecutesActionWhenCalled()
{
// Arrange
bool actionExecuted = false;
var action = new TestableSequencedAction("TestAction", 0, () => actionExecuted = true);
// Act
action.Execute();
// Assert
Assert.True(actionExecuted);
}
[Fact]
public void TestableSequencedAction_WithNullAction_DoesNotThrow()
{
// Arrange
var action = new TestableSequencedAction("TestAction", 0, null);
// Act & Assert
var exception = Record.Exception(() => action.Execute());
Assert.Null(exception);
}
}
}