Compare commits

...

56 commits

Author SHA1 Message Date
Neil Dorin
7f05654638
Merge pull request #1469 from PepperDash/hotfix/ssh-stopprog-timing
fix(core-ssh): stop reconnect loop during stopprog
2026-08-20 14:47:33 -06:00
Robert Sanders
4ca59e3186 fix(core-ssh): harden GenericSshClient shutdown reconnect guards 2026-08-20 16:39:20 -04:00
Robert Sanders
a55e4811b3 fix(core-ssh): stop reconnect loop during stopprog 2026-08-20 16:18:04 -04:00
Andrew Welker
5cac0bb6c8
Merge pull request #1468 from PepperDash/fix/ssh-connection-exception 2026-08-20 11:52:03 -05:00
Neil Dorin
44e5ff4c3e
fix: Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-20 10:47:30 -06:00
Neil Dorin
ec9753b19e fix(ssh-client): improve error handling for SSH connection exceptions 2026-08-20 10:26:09 -06:00
Jonathan Arndt
12e5ff752d
Merge pull request #1464 from PepperDash/fix-mobile-control-join-response-caching
fix(mobile-control): update version and improve response caching for …
2026-08-19 10:37:32 -07:00
Jonathan Arndt
85a4b635f6 fix(mobile-control): update version and improve response caching for join requests 2026-08-19 10:24:23 -07:00
Jonathan Arndt
aff4c1e897
Merge pull request #1450 from PepperDash/feature/mobile-control-https-wss-support
feat(mobile-control): support https/wss for direct server URLs when Secure is true
2026-08-13 16:27:47 -07:00
jkdevito
3afc73a446 fix(mobile-control): address PR review feedback
- Use the actual listening Port instead of raw DirectServer.Port config
  when building the app URL and API path, so advertised URLs match the
  listener even when the configured port is 0.
- Keep the remote logging endpoint on http:// since there is no
  dedicated secure flag for the log collector.
- Remove dead Regex.Match/ipa variable in GetUrlWithCorrectIp; the
  scheme-preserving Regex.Replace below it already handles this.
2026-08-13 17:50:26 -05:00
jkdevito
3c9f5e95e0 feat(mobile-control): support https/wss for direct server URLs when Secure is true
- Add HttpScheme/WsScheme helpers driven by directServer.Secure
- Replace hardcoded http/ws literals in UserAppUrlPrefix, touchpanel app URL,
  _config.local.json ApiPath, remote logging POST, join-response WebSocketUrl
  and UserAppUrl
- Pass Secure flag into the HttpServer constructor so the listener actually
  negotiates TLS when a cert is configured
- Drop TLS 1.1 from EnabledSslProtocols, keep TLS 1.2 (TLS 1.3 is not defined
  in the net472 SslProtocols enum used by this project)
- MobileControlTouchpanelController: rewrite the app-URL IP regex to match
  and preserve either http or https instead of assuming http
2026-08-13 17:46:17 -05:00
Neil Dorin
f70c78bcc5
Merge pull request #1456 from PepperDash/feat/touchpanel-communication-monitor 2026-08-12 21:14:52 -06:00
Neil Dorin
5b5619764f
Merge pull request #1455 from PepperDash/feature/touchpanel-1080-types 2026-08-12 21:13:29 -06:00
Jonathan Arndt
d21cef0e60
fix: Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-30 16:22:30 -07:00
Jonathan Arndt
1719de195f feat: implement ICommunicationMonitor on MobileControlTouchpanelController for Health page visibility 2026-07-30 14:10:15 -07:00
Jonathan Arndt
6949253b1a feat: add TS-1080 and TSW-1080 touchpanel support
Add mctsw1080 and mcts1080 Mobile Control touchpanel types and map them to
the Tsw1080 and Ts1080 hardware classes in the touchpanel controller factory.

Bump the Crestron SimplSharp SDK packages (Library, ProgramLibrary, Program)
from 2.21.90 to 2.21.274 across all projects. The Tsw1080 and Ts1080 device
classes do not exist in the SDK prior to 2.21.274, so the SDK bump is required
for the new panel types to compile.
2026-07-29 14:39:36 -07:00
Andrew Knous
e23c987516
Merge pull request #1448 from PepperDash/fix-cecportcontroller-null-streamcec-guard
Fix cecportcontroller null streamcec guard
2026-07-16 09:40:19 -04:00
copilot-swe-agent[bot]
14cd082076
Remove redundant IRoutingInputsOutputs filtering in route mapping loops 2026-07-16 13:26:25 +00:00
copilot-swe-agent[bot]
36c15b8d95
Clarify CecPortController subscription XML comment 2026-07-16 13:25:25 +00:00
erikdred
fbf4c56403
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-16 09:23:18 -04:00
erikdred
f4cb17314d
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-16 09:21:45 -04:00
Erik Meyer
aa76551500 fix: guard CecPortController against null StreamCec and lazily subscribe 2026-07-16 08:40:33 -04:00
Neil Dorin
12828e8dd4
Merge pull request #1446 from PepperDash/feature-add-iHasDspPresetSave
feat: add IHasDspPresetSave interface for DSP preset management
2026-07-14 11:29:44 -06:00
Jonathan Arndt
4dca16e9bd
fix: Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-13 16:09:45 -07:00
Jonathan Arndt
d4284bd59f
fix: Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-13 16:08:10 -07:00
Jonathan Arndt
a8ec831064 feat: add IHasDspPresetSave interface for DSP preset management 2026-07-13 12:58:52 -07:00
Jason DeVito
6ceddcf571
Merge pull request #1439 from PepperDash/feature/version-data-touchpanel-ui-schema
fix(config): version data, packageManifest API, and v1/v2 config detection
2026-07-08 10:25:55 -05:00
jkdevito
02f507ccb1 fix(mobile-control): address PR review feedback on version tracking
- ConnectedClientVersions now deep-copies each ConnectedClientVersionInfo
  when snapshotting, so external callers can't mutate the internally
  tracked, lock-protected instances (Copilot review).
- ShowInfo now prints "(not configured)" for an empty ExpectedAppVersion,
  matching the match/mismatch calculation which already treats empty the
  same as not-configured (Copilot review).
- GetPackageManifestRequestHandler.PopulatePackages filters out null
  entries from the config-supplied packages list before processing, so a
  malformed "packages": [null, ...] in user-edited config JSON degrades
  gracefully instead of throwing (Copilot review).
2026-07-08 10:07:20 -05:00
Jason DeVito
22ef9cf7c2
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-08 09:54:14 -05:00
jkdevito
02216372bc feat(mobile-control): track and validate UI client app versions
Extend the /system/clientJoined handler to accept an optional
`appVersion` field reported by connecting UI clients (e.g. the
React app's build-time APP_VERSION), so Essentials can record and
validate what's actually running against the configured
versions.touchpanelWrapperApp version.

- Add ConnectedClientVersionInfo to capture clientId, roomKey,
  touchpanelKey, reported/expected app version, and last-seen time.
- Add MobileControlSystemController.TrackClientAppVersion(), storing
  results in a new thread-safe ConnectedClientVersions dictionary and
  logging a warning on version mismatch.
- Expose ConnectedClientVersions as a public read-only property for
  diagnostics.
- Surface reported vs. expected versions per client in the
  `mobileinfo` console command output.

No wire protocol changes required; content is passed as-is over the
existing clientJoined message.
2026-07-07 17:41:04 -05:00
jkdevito
c323c872fc fix: match main assembly by PackageId metadata instead of AssemblyName 2026-07-07 17:20:22 -05:00
jkdevito
1e63b10aa4 fix: report main assembly's PackageId (PepperDashEssentials) in packageManifest API 2026-07-07 17:14:34 -05:00
jkdevito
47e186d8f4 fix(config): correctly detect v1 vs v2 config and preserve versions node
- Determine config version by presence of "system" and "template" nodes
  instead of "versions", since a v2 config can also include "versions"
  and was previously being skipped from merging as a result.
- Preserve the "versions" node after merging a v1 config, since
  PortalConfigReader.MergeConfigs does not carry it forward.
2026-07-07 16:55:43 -05:00
jkdevito
2f7789b374 fix: prefer embedded PackageId assembly metadata in packageManifest API
WHAT / WHY
----------
GetPackageManifestRequestHandler previously identified a loaded plugin's
NuGet PackageId by fuzzy-matching AssemblyTitle/AssemblyName against the
config's packageId - a chain that silently fails for any plugin whose
AssemblyTitle/AssemblyName doesn't happen to match its PackageId (verified
against real shipped plugin DLLs; see FINDINGS-nuget-packageid-gaps.md).

epi-symetrix-dsp and epi-shure-mxa have already backported a
Directory.Build.props change that embeds
`<AssemblyMetadata Include="PackageId" Value="$(PackageId)" />`, giving
Essentials an unambiguous, authoritative PackageId via
AssemblyMetadataAttribute("PackageId", ...) instead of guessing. Validated
by building both plugins and inspecting the generated AssemblyInfo.cs.

Changes:
- src/Directory.Build.props: add the same AssemblyMetadata PackageId item,
  so every Essentials-owned assembly (Core, Essentials, Devices.Common,
  MobileControl, MobileControl.Messengers) now embeds its real PackageId
  too - previously none of them did.
- GetPackageManifestRequestHandler.cs:
  - PopulatePackages: read AssemblyMetadataAttribute("PackageId", ...) from
    loaded plugin assemblies and use it as the first-priority match/identity
    signal, ahead of the AssemblyTitle -> AssemblyName -> AssemblyName-minus-
    ".4Series" fallback chain. Loaded-but-unconfigured plugins that carry
    this metadata now report a PackageId in the manifest instead of null.
  - PopulateEssentials: replace the hardcoded "PepperDash.Essentials"
    fallback (which matched none of the real PackageIds) with the reflected
    value from PepperDash.Essentials.Core's own assembly metadata.

Fully backward compatible: plugins without the updated Directory.Build.props
(most existing epi-* repos today) fall through to the prior fallback chain
unchanged.

RECOMMENDATIONS - Essentials & sub-projects (this repo)
--------------------------------------------------------
- AssemblyName/AssemblyTitle drift from PackageId across sub-projects
  (confirmed via generated AssemblyInfo.cs, not assumed):
    PepperDash.Essentials.Core:                 PackageId "PepperDash.Essentials.Core"
                                                 vs AssemblyName "PepperDash_Essentials_Core"
    PepperDash.Essentials.Devices.Common:        PackageId "PepperDash.Essentials.Devices.Common"
                                                 vs AssemblyName "Essentials Devices Common"
    PepperDash.Essentials.MobileControl:         PackageId "PepperDash.Essentials.MobileControl"
                                                 vs AssemblyName "epi-essentials-mobile-control"
    PepperDash.Essentials.MobileControl.Messengers: PackageId "...Messengers"
                                                 vs AssemblyName "mobile-control-messengers"
  Only PepperDash.Essentials and PepperDash.Core happen to agree. Fixing
  AssemblyName changes the physical .dll filename for existing consumers,
  so this needs a deliberate, versioned decision - not bundled here.
- Once this ships and bakes for a release or two, consider deleting the
  now-redundant "PepperDash.Essentials" hardcoded string entirely and the
  Product/AssemblyTitle-based Name fallback, since AssemblyMetadata PackageId
  supersedes both for any assembly built after this change.

RECOMMENDATIONS - EPI plugin repos (epi-*)
-------------------------------------------
- Backport `<AssemblyMetadata Include="PackageId" Value="$(PackageId)" />`
  into every existing epi-* repo's src/Directory.Build.props (recommendation
  E from FINDINGS-nuget-packageid-gaps.md). This is opt-in and additive -
  repos that skip it keep working via the existing fallback chain, but gain
  nothing until they backport it and cut a new release.
- Land the corresponding fix in EssentialsPluginTemplate
  (src/Directory.Build.props + src/epi-make-model.4Series.csproj) so all
  *new* plugin repos get this by default, and fix the template's own
  AssemblyTitle/PackageId drift ("Plugin" vs "Plugins") while there.
- Already-published plugin versions can't be retroactively fixed - this only
  takes effect on a plugin's next release after adopting the template change.

WORKFLOW RECOMMENDATIONS
-------------------------
- Extend workflow-templates' essentialsplugins-4Series-builds.yml "Check
  Package Name" step to validate the built DLL's embedded
  AssemblyMetadataAttribute("PackageId", ...) (and/or AssemblyTitle as a
  fallback) against the repo-derived expected package name - today it only
  compares the .nupkg filename, which would not have caught drift like
  epi-display-samsung-mdc's AssemblyTitle mismatch.
- Sequence this after the template + per-repo backports have landed and
  baked for a release cycle, otherwise it will fail CI for every epi-*
  repo that hasn't picked up the Directory.Build.props change yet. Gate it
  behind the existing bypassPackageCheck input for repos not yet ready.
2026-07-07 16:22:23 -05:00
jkdevito
588057f3ba feat: add packageManifest CWS API route
New GET https://{ip}/cws/app{xx}/api/packageManifest route returning a JSON
package manifest (Essentials + plugins + user interfaces) shaped to hydrate
the vsce-essentials-version-manager extension's VersionsSnapshot object.

- EssentialsConfig.cs: add [JsonProperty("versions")] to EssentialsConfig.Versions
  (was attribute-less); add NugetVersion.Name; NullValueHandling.Ignore on
  PackageId/RepoUrl/Name.
- New GetPackageManifestRequestHandler: deep-copies the config's VersionData
  (never mutates the live config object), then enriches it via reflection:
  - essentials: version from Global.AssemblyVersion, repoUrl/name from the
    RepositoryUrl AssemblyMetadata + AssemblyProduct of
    PepperDash.Essentials.Core's own assembly (PluginLoader.EssentialsAssembly.Assembly
    is null at runtime due to a pre-existing name-matching bug, so this route
    reads its own loaded assembly instead), packageId from config or a constant.
  - packages[]: merges PluginLoader.EssentialsPluginAssemblies (matched to config
    packages by packageId via AssemblyTitle -> AssemblyName -> AssemblyName minus
    a trailing .4Series suffix) with reflection supplying version and filling
    missing repoUrl/name; unmatched loaded assemblies are emitted without a
    packageId; configured-but-not-loaded packages pass through unchanged.
  - userInterfaces/touchpanelWrapperApp are passed through from config as-is.
  - Entries with no resolvable version are skipped (the extension's parser drops
    entries whose version isn't a string).
- EssentialsWebApi.cs: register the new packageManifest route next to versions.

Build verified clean (Core + Essentials program, 0 errors). Not yet tested on
hardware.
2026-07-05 20:57:05 -05:00
jkdevito
64d60dacc2 feat: update version object
Add touchpanelWrapperApp, userInterfaces, and repoUrl to VersionData config schema

VersionData/NugetVersion previously only modeled the essentials and
packages entries under the versions node. This adds support for the
touchpanelWrapperApp (single object) and userInterfaces (array) nodes,
plus a repoUrl property on NugetVersion so it round-trips instead of
being silently dropped, matching the schema produced by the
vsce-essentials-version-manager extension.
2026-07-02 17:13:26 -05:00
aknous
8d3edde28c feat: adds config props to disable power on/off automation 2026-06-17 10:06:00 -04:00
equinoy
a220474101
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 10:05:50 -05:00
equinoy
b982219e2f
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 10:05:36 -05:00
equinoy
e03e45ccf5
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 10:05:25 -05:00
equinoy
a7b839296e
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 10:05:14 -05:00
jkdevito
6a4e3d5be2 feat: add port forwarding functionality for debug websocket in DebugSessionRequestHandler 2026-06-12 11:26:56 -05:00
Neil Dorin
d18fca8a98 fix: handle null properties in DeviceMessageBase and DeviceStateMessageBase 2026-06-11 17:36:30 -04:00
aknous
162a06f9e9 feat: adds interface for wireless sharing 2026-06-11 09:41:39 -04:00
Andrew Welker
b32bab0d33 fix: remove impossibleRoutes cache
The cache wasn't being cleared correctly, and was an unnecessary
add.
2026-06-09 09:32:59 -05:00
Neil Dorin
74a12c9671 fix: add null or empty checks for midpoint keys in RoutingFeedbackManager 2026-05-21 18:47:25 -06:00
Neil Dorin
92d13f29a9 fix: refine routing logic by filtering IRoutingInputs and IRoutingOutputs, enhance logging for route mapping 2026-05-21 16:43:00 -06:00
Neil Dorin
6587444970 fix: update routing logic and enhance logging in Extensions and MockVC classes 2026-05-21 13:11:39 -06:00
Nick Genovese
94c9e75d8f Merge branch 'main' into feature/web-api-updates 2026-05-21 10:27:02 -04:00
Erik Meyer
21217341b5 Merge branch 'main' into feature/web-api-updates 2026-05-19 12:05:48 -04:00
Neil Dorin
d0ab42ccb7 Merge branch 'feature/add-network-switch-poe-interfaces' into feature/web-api-updates 2026-04-01 08:56:21 -06:00
Neil Dorin
1b10910cc2 fix: update GetFeedbacksForDeviceRequestHandler to return JSON response for missing device 2026-04-01 08:56:10 -06:00
Neil Dorin
84c730b7a1 feat: enhance NetworkSwitchPortEventType enum with additional states for VLAN and PoE changes 2026-03-20 14:53:21 -06:00
Neil Dorin
6db7581295 feat: add Unknown event type to NetworkSwitchPortEventType enum for improved event handling 2026-03-20 14:48:58 -06:00
Neil Dorin
76311b83ff feat: add event and arguments for port state changes in network switch management 2026-03-20 13:25:58 -06:00
Neil Dorin
8817d70f07 feat: add interfaces for VLAN and PoE management in network switches 2026-03-20 12:03:00 -06:00
26 changed files with 845 additions and 76 deletions

View file

@ -1,11 +1,11 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.29.0-local</Version> <Version>2.42.1-local</Version>
<InformationalVersion>$(Version)</InformationalVersion> <InformationalVersion>$(Version)</InformationalVersion>
<Authors>PepperDash Technology</Authors> <Authors>PepperDash Technology</Authors>
<Company>PepperDash Technology</Company> <Company>PepperDash Technology</Company>
<Product>PepperDash Essentials</Product> <Product>PepperDash Essentials</Product>
<Copyright>Copyright © 2025</Copyright> <Copyright>Copyright © 2026</Copyright>
<RepositoryUrl>https://github.com/PepperDash/Essentials</RepositoryUrl> <RepositoryUrl>https://github.com/PepperDash/Essentials</RepositoryUrl>
<RepositoryType>git</RepositoryType> <RepositoryType>git</RepositoryType>
<PackageTags>Crestron; 4series</PackageTags> <PackageTags>Crestron; 4series</PackageTags>
@ -20,4 +20,7 @@
<None Include="..\..\LICENSE.md" Pack="true" PackagePath=""/> <None Include="..\..\LICENSE.md" Pack="true" PackagePath=""/>
<None Include="..\..\README.md" Pack="true" PackagePath=""/> <None Include="..\..\README.md" Pack="true" PackagePath=""/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<AssemblyMetadata Include="PackageId" Value="$(PackageId)" />
</ItemGroup>
</Project> </Project>

View file

@ -151,6 +151,8 @@ namespace PepperDash.Core
// Thread-safety lock for state changes // Thread-safety lock for state changes
private readonly object _stateLock = new object(); private readonly object _stateLock = new object();
private volatile bool _isProgramStopping;
private bool disconnectLogged = false; private bool disconnectLogged = false;
/// <summary> /// <summary>
@ -207,11 +209,9 @@ namespace PepperDash.Core
{ {
if (programEventType == eProgramStatusEventType.Stopping) if (programEventType == eProgramStatusEventType.Stopping)
{ {
if (client != null) _isProgramStopping = true;
{ this.LogDebug("Program stopping. Closing connection");
this.LogDebug("Program stopping. Closing connection"); Disconnect();
Disconnect();
}
} }
} }
@ -228,6 +228,12 @@ namespace PepperDash.Core
return; return;
} }
if (_isProgramStopping)
{
this.LogDebug("Skipping connect because program is stopping");
return;
}
ConnectEnabled = true; ConnectEnabled = true;
try try
@ -287,13 +293,7 @@ namespace PepperDash.Core
} }
catch (SshConnectionException e) catch (SshConnectionException e)
{ {
var ie = e.InnerException; // The details are inside!! var ie = e.InnerException; // The details are inside, when present - remote can close the connection with no inner exception at all
if (ie is SocketException)
{
this.LogError("CONNECTION failure: Cannot reach host");
this.LogVerbose(ie, "Exception details: ");
}
if (ie is System.Net.Sockets.SocketException socketException) if (ie is System.Net.Sockets.SocketException socketException)
{ {
@ -301,20 +301,20 @@ namespace PepperDash.Core
Hostname, Port); Hostname, Port);
this.LogVerbose(socketException, "SocketException details: "); this.LogVerbose(socketException, "SocketException details: ");
} }
if (ie is SshAuthenticationException) else if (ie is SshAuthenticationException)
{ {
this.LogError("Authentication failure for username {userName}", Username); this.LogError("Authentication failure for username {userName}", Username);
this.LogVerbose(ie, "AuthenticationException details: "); this.LogVerbose(ie, "AuthenticationException details: ");
} }
else else
{ {
this.LogError("Error on connect: {error}", ie.Message); this.LogError("Error on connect: {error}", ie?.Message ?? e.Message);
this.LogVerbose(ie, "Exception details: "); this.LogVerbose(ie ?? e, "Exception details: ");
} }
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect) if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{ {
this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -326,7 +326,7 @@ namespace PepperDash.Core
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect) if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -338,7 +338,7 @@ namespace PepperDash.Core
this.LogVerbose(e, "Exception details: "); this.LogVerbose(e, "Exception details: ");
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect) if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -473,7 +473,7 @@ namespace PepperDash.Core
{ {
connectLock.Release(); connectLock.Release();
} }
if (AutoReconnect && ConnectEnabled) if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -516,7 +516,10 @@ namespace PepperDash.Core
this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim());
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
StartReconnectTimer(); if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{
StartReconnectTimer();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -549,7 +552,10 @@ namespace PepperDash.Core
this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes));
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
StartReconnectTimer(); if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{
StartReconnectTimer();
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -43,7 +43,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" /> <PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="Crestron.SimplSharp.SDK.Library" Version="2.21.90" /> <PackageReference Include="Crestron.SimplSharp.SDK.Library" Version="2.21.274" />
<PackageReference Include="Serilog" Version="3.1.1" /> <PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Expressions" Version="4.0.0" /> <PackageReference Include="Serilog.Expressions" Version="4.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" /> <PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" />

View file

@ -39,6 +39,8 @@ namespace PepperDash.Essentials.Core
ICec Port; ICec Port;
bool _cecSubscribed;
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
@ -54,7 +56,7 @@ namespace PepperDash.Essentials.Core
{ {
Port = postActivationFunc(config); Port = postActivationFunc(config);
Port.StreamCec.CecChange += StreamCec_CecChange; TryEnsureCecSubscription();
}); });
} }
@ -68,7 +70,29 @@ namespace PepperDash.Essentials.Core
{ {
Port = port; Port = port;
TryEnsureCecSubscription();
}
/// <summary>
/// Subscribes to the CEC change event once <see cref="ICec.StreamCec"/> is available.
/// Safe to call repeatedly; the subscription is only wired a single time.
/// If StreamCec is null during construction, this is retried when send methods invoke
/// this method later.
/// </summary>
void TryEnsureCecSubscription()
{
if (_cecSubscribed)
return;
if (Port?.StreamCec == null)
{
Debug.LogMessage(LogEventLevel.Warning, this, "StreamCec is not available; CEC feedback is deferred until the device is ready");
return;
}
Port.StreamCec.CecChange += new CecChangeEventHandler(StreamCec_CecChange); Port.StreamCec.CecChange += new CecChangeEventHandler(StreamCec_CecChange);
_cecSubscribed = true;
Debug.LogMessage(LogEventLevel.Information, this, "Subscribed to CEC feedback");
} }
void StreamCec_CecChange(Cec cecDevice, CecEventArgs args) void StreamCec_CecChange(Cec cecDevice, CecEventArgs args)
@ -104,8 +128,9 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public void SendText(string text) public void SendText(string text)
{ {
if (Port == null) if (Port?.StreamCec == null)
return; return;
TryEnsureCecSubscription();
this.PrintSentText(text); this.PrintSentText(text);
Port.StreamCec.Send.StringValue = text; Port.StreamCec.Send.StringValue = text;
} }
@ -115,8 +140,9 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public void SendBytes(byte[] bytes) public void SendBytes(byte[] bytes)
{ {
if (Port == null) if (Port?.StreamCec == null)
return; return;
TryEnsureCecSubscription();
var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length);
this.PrintSentBytes(bytes); this.PrintSentBytes(bytes);
Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes));

View file

@ -135,12 +135,14 @@ namespace PepperDash.Essentials.Core.Config
{ {
var parsedConfig = JObject.Parse(fs.ReadToEnd()); var parsedConfig = JObject.Parse(fs.ReadToEnd());
// Check if it's a v2 config (check for "version" node) // A config is v1 if it has separate "system" and "template" nodes that
// this means it's already merged by the Portal API // need to be merged. A v2 config is already merged by the Portal API and
// from the v2 config tool // will not have "system"/"template" nodes. This is independent of whether
var isV2Config = parsedConfig["versions"] != null; // a "versions" node is present, which only carries version metadata and
// can appear on either a v1 or v2 config.
if (isV2Config) var isV1Config = parsedConfig["system"] != null && parsedConfig["template"] != null;
if (!isV1Config)
{ {
Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary."); Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary.");
ConfigObject = parsedConfig.ToObject<EssentialsConfig>(); ConfigObject = parsedConfig.ToObject<EssentialsConfig>();
@ -148,6 +150,8 @@ namespace PepperDash.Essentials.Core.Config
return true; return true;
} }
Debug.LogMessage(LogEventLevel.Information, "Config file is a v1 format, merging system and template.");
// Extract SystemUrl and TemplateUrl into final config output // Extract SystemUrl and TemplateUrl into final config output
ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject<EssentialsConfig>(); ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject<EssentialsConfig>();
@ -160,6 +164,13 @@ namespace PepperDash.Essentials.Core.Config
{ {
ConfigObject.TemplateUrl = parsedConfig["template_url"].Value<string>(); ConfigObject.TemplateUrl = parsedConfig["template_url"].Value<string>();
} }
// MergeConfigs does not carry the "versions" node forward, so it must be
// applied separately to ensure it's preserved in the merged config.
if (parsedConfig["versions"] != null)
{
ConfigObject.Versions = parsedConfig["versions"].ToObject<VersionData>();
}
} }
Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config"); Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config");

View file

@ -105,6 +105,7 @@ namespace PepperDash.Essentials.Core.Config
/// <summary> /// <summary>
/// Gets or sets the Versions /// Gets or sets the Versions
/// </summary> /// </summary>
[JsonProperty("versions")]
public VersionData Versions { get; set; } public VersionData Versions { get; set; }
/// <summary> /// <summary>
@ -134,12 +135,25 @@ namespace PepperDash.Essentials.Core.Config
[JsonProperty("packages")] [JsonProperty("packages")]
public List<NugetVersion> Packages { get; set; } public List<NugetVersion> Packages { get; set; }
/// <summary>
/// Gets or sets the touchpanel wrapper app version
/// </summary>
[JsonProperty("touchpanelWrapperApp")]
public NugetVersion TouchpanelWrapperApp { get; set; }
/// <summary>
/// Gets or sets the list of user interface packages
/// </summary>
[JsonProperty("userInterfaces")]
public List<NugetVersion> UserInterfaces { get; set; }
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="VersionData"/> class. /// Initializes a new instance of the <see cref="VersionData"/> class.
/// </summary> /// </summary>
public VersionData() public VersionData()
{ {
Packages = new List<NugetVersion>(); Packages = new List<NugetVersion>();
UserInterfaces = new List<NugetVersion>();
} }
} }
@ -157,8 +171,20 @@ namespace PepperDash.Essentials.Core.Config
/// <summary> /// <summary>
/// Gets or sets the PackageId /// Gets or sets the PackageId
/// </summary> /// </summary>
[JsonProperty("packageId")] [JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)]
public string PackageId { get; set; } public string PackageId { get; set; }
/// <summary>
/// Gets or sets the RepoUrl
/// </summary>
[JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)]
public string RepoUrl { get; set; }
/// <summary>
/// Gets or sets the human-readable name
/// </summary>
[JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)]
public string Name { get; set; }
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,48 @@
using System;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// Defines the contract for a wireless presentation endpoint that reports whether a wireless
/// sharing session is currently active. Implemented by platforms such as Crestron AirMedia,
/// Mersive Solstice, Barco ClickShare, Miracast/Teams receivers, etc. Allows consumers (e.g.
/// room plugins) to react to wireless sharing activity without taking a dependency on any
/// concrete device implementation.
/// </summary>
/// <remarks>
/// This refers specifically to wireless screen/device mirroring, as distinct from in-call
/// content sharing on a video conference.
/// </remarks>
public interface IHasWirelessSharing
{
/// <summary>
/// Reports whether a wireless sharing session is currently active (content is being presented).
/// </summary>
BoolFeedback IsSharingFeedback { get; }
/// <summary>
/// Raised when wireless sharing starts or stops. The event args carry the new sharing state.
/// </summary>
event EventHandler<WirelessSharingEventArgs> SharingChanged;
}
/// <summary>
/// Event arguments describing a change in wireless sharing state.
/// </summary>
public class WirelessSharingEventArgs : EventArgs
{
/// <summary>
/// True if a wireless sharing session is active (content is being presented), false otherwise.
/// </summary>
public bool IsSharing { get; private set; }
/// <summary>
/// Creates a new <see cref="WirelessSharingEventArgs"/>.
/// </summary>
/// <param name="isSharing">True if a wireless sharing session is active, false otherwise.</param>
public WirelessSharingEventArgs(bool isSharing)
{
IsSharing = isSharing;
}
}
}

View file

@ -0,0 +1,122 @@
using System;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// Interface for network switches that support VLAN assignment on individual ports.
/// </summary>
public interface INetworkSwitchVlanManager
{
/// <summary>
/// Returns the current access VLAN ID configured on the port.
/// Return -1 when the value is unavailable (e.g. the switch has not been polled yet
/// or the implementation does not support VLAN queries).
/// </summary>
/// <param name="port">Switch port identifier</param>
/// <returns>VLAN ID or -1 when unavailable</returns>
int GetPortCurrentVlan(string port);
/// <summary>
/// Changes the access VLAN of a single switch port.
/// The implementation is responsible for entering/exiting privileged/config mode.
/// </summary>
/// <param name="port">Switch port identifier (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco)</param>
/// <param name="vlanId">Target VLAN ID (1-4093)</param>
void SetPortVlan(string port, uint vlanId);
}
/// <summary>
/// Interface for network switches that support Power over Ethernet (PoE) control on individual ports.
/// </summary>
public interface INetworkSwitchPoeManager
{
/// <summary>
/// Enables or disables PoE power delivery on a single switch port.
/// The implementation is responsible for entering/exiting privileged/config mode.
/// </summary>
/// <param name="port">Switch port identifier</param>
/// <param name="enabled">True to enable PoE; false to disable PoE</param>
void SetPortPoeState(string port, bool enabled);
}
/// <summary>
/// Standardized interface for network switch devices that support per-port PoE control
/// and VLAN assignment.
/// </summary>
public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager
{
/// <summary>
/// Event that is raised when the state of a switch port changes, such as a VLAN change or PoE state change.
/// </summary>
event EventHandler<NetworkSwitchPortEventArgs> PortStateChanged;
}
/// <summary>
/// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes.
/// </summary>
public class NetworkSwitchPortEventArgs : EventArgs
{
/// <summary>
/// The identifier of the port that changed state (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco).
/// </summary>
public string Port { get; private set; }
/// <summary>
/// The type of event that occurred on the port (e.g. VLAN change, PoE enabled/disabled).
/// </summary>
public NetworkSwitchPortEventType EventType { get; private set; }
/// <summary>
/// Constructor for NetworkSwitchPortEventArgs
/// </summary>
/// <param name="port">The identifier of the port that changed state</param>
/// <param name="eventType">The type of event that occurred on the port</param>
public NetworkSwitchPortEventArgs(string port, NetworkSwitchPortEventType eventType)
{
Port = port;
EventType = eventType;
}
}
/// <summary>
/// Enumeration of network switch port state change event types (e.g. VLAN changes or PoE state changes).
/// </summary>
public enum NetworkSwitchPortEventType
{
/// <summary>
/// Indicates that the type of event is unknown or cannot be determined.
/// </summary>
Unknown,
/// <summary>
/// Indicates that a VLAN change is in progress on the port, either through a call to SetPortVlan or an external change detected by polling.
/// </summary>
VlanChangeInProgress,
/// <summary>
/// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan or an external change detected by polling.
/// </summary>
VlanChanged,
/// <summary>
/// Indicates that PoE is being disabled on the port, either through a call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoeDisableInProgress,
/// <summary>
/// Indicates that PoE has been disabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoEDisabled,
/// <summary>
/// Indicates that PoE is being enabled on the port, either through a call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoeEnableInProgress,
/// <summary>
/// Indicates that PoE has been enabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoEEnabled
}
}

View file

@ -0,0 +1,14 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines the contract for IHasDspPresetSave
/// </summary>
public interface IHasDspPresetSave : IDspPresets
{
/// <summary>
/// Saves the preset by key
/// </summary>
/// <param name="key">key of preset to save</param>
void SavePreset(string key);
}
}

View file

@ -25,7 +25,7 @@
<DocumentationFile>bin\$(Configuration)\PepperDash_Essentials_Core.xml</DocumentationFile> <DocumentationFile>bin\$(Configuration)\PepperDash_Essentials_Core.xml</DocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="Crestron\CrestronGenericBaseDevice.cs.orig" /> <None Include="Crestron\CrestronGenericBaseDevice.cs.orig" />

View file

@ -343,12 +343,14 @@ namespace PepperDash.Essentials.Core
IndexTieLines(); IndexTieLines();
} }
var sinks = DeviceManager.AllDevices.OfType<IRoutingInputs>(); var sinks = DeviceManager.AllDevices.OfType<IRoutingInputs>()
var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>(); .Where(d => !(d is IRoutingInputsOutputs)).ToList();
var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>()
.Where(d => !(d is IRoutingInputsOutputs)).ToList();
foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs))) foreach (var sink in sinks)
{ {
foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs))) foreach (var source in sources)
{ {
foreach (var inputPort in sink.InputPorts) foreach (var inputPort in sink.InputPorts)
{ {
@ -373,6 +375,10 @@ namespace PepperDash.Essentials.Core
continue; continue;
} }
Debug.LogVerbose("Route mapped: {source} -> {sink} via {input}/{output}, type {type}",
source.Key, sink.Key,
inputPort.Key, outputPort.Key, audioOrSingleRoute.SignalType);
// Add to the appropriate collection(s) based on signal type // 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 // 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)) if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Audio))
@ -404,6 +410,10 @@ namespace PepperDash.Essentials.Core
continue; continue;
} }
Debug.LogVerbose("Video route mapped: {source} -> {sink} via {input}/{output}",
source.Key, sink.Key,
inputPort.Key, outputPort.Key);
RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute); RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute);
} }
} }
@ -609,10 +619,12 @@ namespace PepperDash.Essentials.Core
// 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); .ToList();
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>();

View file

@ -95,6 +95,11 @@ namespace PepperDash.Essentials.Core.Web
Name = "ReportVersions", Name = "ReportVersions",
RouteHandler = new ReportVersionsRequestHandler() RouteHandler = new ReportVersionsRequestHandler()
}, },
new HttpCwsRoute("packageManifest")
{
Name = "GetPackageManifest",
RouteHandler = new GetPackageManifestRequestHandler()
},
new HttpCwsRoute("appdebug") new HttpCwsRoute("appdebug")
{ {
Name = "AppDebug", Name = "AppDebug",

View file

@ -1,13 +1,14 @@
using System.Linq; using System;
using System.Linq;
using Crestron.SimplSharp.WebScripting; using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json; using Newtonsoft.Json;
using PepperDash.Core.Web.RequestHandlers; using PepperDash.Core.Web.RequestHandlers;
namespace PepperDash.Essentials.Core.Web.RequestHandlers namespace PepperDash.Essentials.Core.Web.RequestHandlers
{ {
/// <summary> /// <summary>
/// Represents a GetFeedbacksForDeviceRequestHandler /// Represents a GetFeedbacksForDeviceRequestHandler
/// </summary> /// </summary>
public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler
{ {
/// <summary> /// <summary>
@ -76,7 +77,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Value = feedback.IntValue Value = feedback.IntValue
}; };
var stringFeedback = var stringFeedback =
from feedback in device.Feedbacks.OfType<StringFeedback>() from feedback in device.Feedbacks.OfType<StringFeedback>()
where !string.IsNullOrEmpty(feedback.Key) where !string.IsNullOrEmpty(feedback.Key)
select new select new

View file

@ -0,0 +1,259 @@
using System;
using System.Linq;
using System.Reflection;
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core.Web.RequestHandlers;
using PepperDash.Essentials.Core.Config;
namespace PepperDash.Essentials.Core.Web.RequestHandlers
{
/// <summary>
/// Represents a GetPackageManifestRequestHandler
/// </summary>
public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler
{
/// <summary>
/// Constructor
/// </summary>
/// <remarks>
/// base(true) enables CORS support by default
/// </remarks>
public GetPackageManifestRequestHandler()
: base(true)
{
}
/// <summary>
/// Handles GET method requests
/// </summary>
/// <param name="context"></param>
protected override void HandleGet(HttpCwsContext context)
{
try
{
var result = CloneVersionData(ConfigReader.ConfigObject?.Versions) ?? new VersionData();
PopulateEssentials(result);
PopulatePackages(result);
var js = JsonConvert.SerializeObject(result, Formatting.Indented);
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.ContentType = "application/json";
context.Response.ContentEncoding = System.Text.Encoding.UTF8;
context.Response.Write(js, false);
context.Response.End();
}
catch (Exception ex)
{
PepperDash.Core.Debug.LogMessage(ex, "Exception handling GET /packageManifest request");
context.Response.StatusCode = 500;
context.Response.StatusDescription = "Internal Server Error";
context.Response.End();
}
}
/// <summary>
/// Deep-copies the config's VersionData so the live config object is never mutated
/// </summary>
private static VersionData CloneVersionData(VersionData source)
{
if (source == null)
{
return null;
}
var json = JsonConvert.SerializeObject(source);
return JsonConvert.DeserializeObject<VersionData>(json);
}
/// <summary>
/// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly
/// </summary>
private static void PopulateEssentials(VersionData result)
{
var essentials = result.Essentials ?? new NugetVersion();
essentials.Version = Global.AssemblyVersion;
// The main program assembly (PackageId "PepperDashEssentials") is what's actually published
// to NuGet, but this handler lives in PepperDash.Essentials.Core, which can't reference that
// project's types directly (Essentials -> Core, not the reverse). PluginLoader.EssentialsAssembly.Assembly
// is unreliable (often left null - see PluginLoader.SetEssentialsAssembly), so look it up
// directly from the loaded AppDomain by its Directory.Build.props-embedded PackageId metadata
// (every project's .csproj sets its own PackageId explicitly), falling back to this handler's
// own (Core) assembly if it can't be found.
var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => string.Equals(GetAssemblyMetadataValue(a, "PackageId"), "PepperDashEssentials", StringComparison.OrdinalIgnoreCase))
?? typeof(GetPackageManifestRequestHandler).Assembly;
var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl"));
if (!string.IsNullOrEmpty(repoUrl))
{
essentials.RepoUrl = repoUrl;
}
var name = GetAssemblyProduct(essentialsAssembly);
if (!string.IsNullOrEmpty(name))
{
essentials.Name = name;
}
// Prefer the PackageId embedded via Directory.Build.props' AssemblyMetadata item (the
// authoritative source, matching the actual published PackageId) over any config-supplied
// or hardcoded value.
var reflectedPackageId = GetAssemblyMetadataValue(essentialsAssembly, "PackageId");
if (!string.IsNullOrEmpty(reflectedPackageId))
{
essentials.PackageId = reflectedPackageId;
}
else if (string.IsNullOrEmpty(essentials.PackageId))
{
essentials.PackageId = "PepperDashEssentials";
}
result.Essentials = essentials;
}
/// <summary>
/// Merges reflection data from loaded plugin assemblies with the config's packages list
/// </summary>
private static void PopulatePackages(VersionData result)
{
// Filter out null entries defensively - the packages list is deserialized from user-editable
// config JSON, so a malformed "packages": [null, ...] shouldn't throw and 500 the endpoint.
var configPackages = (result.Packages ?? new System.Collections.Generic.List<NugetVersion>())
.Where(p => p != null)
.ToList();
var matchedConfigPackages = new System.Collections.Generic.HashSet<NugetVersion>();
var mergedPackages = new System.Collections.Generic.List<NugetVersion>();
foreach (var loaded in PluginLoader.EssentialsPluginAssemblies.Where(a => a.Assembly != null))
{
var reflectedVersion = loaded.Version;
if (string.IsNullOrEmpty(reflectedVersion))
{
// Never emit an entry with no version - the extension's parser drops entries
// whose version isn't a string.
continue;
}
var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl"));
var reflectedName = GetAssemblyProduct(loaded.Assembly);
// Plugins built from a Directory.Build.props that embeds
// <AssemblyMetadata Include="PackageId" Value="$(PackageId)" /> carry their PackageId
// directly - this is authoritative and should be preferred over the title/name fallback chain.
var reflectedPackageId = GetAssemblyMetadataValue(loaded.Assembly, "PackageId");
var assemblyTitle = GetAssemblyTitle(loaded.Assembly);
var assemblyName = loaded.Assembly.GetName().Name;
var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName);
var match = configPackages.FirstOrDefault(p =>
!matchedConfigPackages.Contains(p) &&
!string.IsNullOrEmpty(p.PackageId) &&
(string.Equals(p.PackageId, reflectedPackageId, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyNameNoSeriesSuffix, StringComparison.OrdinalIgnoreCase)));
if (match != null)
{
matchedConfigPackages.Add(match);
mergedPackages.Add(new NugetVersion
{
Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName,
RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl,
PackageId = !string.IsNullOrEmpty(reflectedPackageId) ? reflectedPackageId : match.PackageId,
Version = reflectedVersion
});
}
else
{
// Loaded but not present (or not matched) in config - emit the reflected PackageId
// when the assembly carries one, otherwise leave it null as before.
mergedPackages.Add(new NugetVersion
{
Name = reflectedName,
RepoUrl = reflectedRepoUrl,
PackageId = reflectedPackageId,
Version = reflectedVersion,
});
}
}
// Configured but not currently loaded - pass through unchanged
mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p)));
result.Packages = mergedPackages;
}
private static string GetAssemblyMetadataValue(Assembly assembly, string key)
{
if (assembly == null)
{
return null;
}
var match = assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false)
.Cast<AssemblyMetadataAttribute>()
.FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase));
return match?.Value;
}
private static string GetAssemblyProduct(Assembly assembly)
{
if (assembly == null)
{
return null;
}
var attribute = assembly.GetCustomAttributes(typeof(AssemblyProductAttribute), false)
.FirstOrDefault() as AssemblyProductAttribute;
return attribute?.Product;
}
private static string GetAssemblyTitle(Assembly assembly)
{
if (assembly == null)
{
return null;
}
var attribute = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false)
.FirstOrDefault() as AssemblyTitleAttribute;
return attribute?.Title;
}
private static string StripTrailingSeriesSuffix(string assemblyName)
{
const string suffix = ".4Series";
if (string.IsNullOrEmpty(assemblyName) || !assemblyName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return assemblyName;
}
return assemblyName.Substring(0, assemblyName.Length - suffix.Length);
}
private static string TrimTrailingGit(string repoUrl)
{
const string suffix = ".git";
if (string.IsNullOrEmpty(repoUrl) || !repoUrl.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return repoUrl;
}
return repoUrl.Substring(0, repoUrl.Length - suffix.Length);
}
}
}

View file

@ -152,6 +152,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades
private void IsCoolingDownFeedback_OutputChange(object sender, FeedbackEventArgs e) private void IsCoolingDownFeedback_OutputChange(object sender, FeedbackEventArgs e)
{ {
if (Config.DisableAutoRaiseOnPowerOff)
{
this.LogDebug(
"Auto-raise on power-off disabled for {type}; leaving position unchanged (manual control only)",
Type
);
return;
}
if ( if (
!DisplayDevice.IsCoolingDownFeedback.BoolValue !DisplayDevice.IsCoolingDownFeedback.BoolValue
&& Type == eScreenLiftControlType.lift && Type == eScreenLiftControlType.lift
@ -174,6 +183,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades
{ {
if (DisplayDevice.IsWarmingUpFeedback.BoolValue) if (DisplayDevice.IsWarmingUpFeedback.BoolValue)
{ {
if (Config.DisableAutoLowerOnPowerOn)
{
this.LogDebug(
"Auto-lower on power-on disabled for {type}; leaving position unchanged (manual control only)",
Type
);
return;
}
Lower(); Lower();
} }
} }

View file

@ -41,5 +41,23 @@ namespace PepperDash.Essentials.Devices.Common.Shades
/// </summary> /// </summary>
[JsonProperty("muteOnScreenUp")] [JsonProperty("muteOnScreenUp")]
public bool MuteOnScreenUp { get; set; } public bool MuteOnScreenUp { get; set; }
/// <summary>
/// When true, this controller does NOT automatically lower when its assigned display powers on
/// (warms up). Manual Raise/Lower still work, and the power-off auto-raise is unaffected. Intended
/// for a projector screen that must not auto-drop in a public space for safety, while the projector
/// lift (a separate controller) can still drop automatically.
/// </summary>
[JsonProperty("disableAutoLowerOnPowerOn")]
public bool DisableAutoLowerOnPowerOn { get; set; }
/// <summary>
/// When true, this controller does NOT automatically raise when its assigned display powers off
/// (cools down). Manual Raise/Lower still work, and the power-on auto-lower is unaffected. The
/// companion to <see cref="DisableAutoLowerOnPowerOn"/>; together they make a controller fully
/// manual while leaving other controllers (e.g. the lift) on their default automatic behavior.
/// </summary>
[JsonProperty("disableAutoRaiseOnPowerOff")]
public bool DisableAutoRaiseOnPowerOff { get; set; }
} }
} }

View file

@ -29,6 +29,6 @@
<ProjectReference Include="..\PepperDash.Essentials.Core\PepperDash.Essentials.Core.csproj" /> <ProjectReference Include="..\PepperDash.Essentials.Core\PepperDash.Essentials.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -10,7 +10,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// <summary> /// <summary>
/// The device key /// The device key
/// </summary> /// </summary>
[JsonProperty("key")] [JsonProperty("key", NullValueHandling = NullValueHandling.Ignore)]
/// <summary> /// <summary>
/// Gets or sets the Key /// Gets or sets the Key
/// </summary> /// </summary>
@ -19,19 +19,19 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// <summary> /// <summary>
/// The device name /// The device name
/// </summary> /// </summary>
[JsonProperty("name")] [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)]
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// The type of the message class /// The type of the message class
/// </summary> /// </summary>
[JsonProperty("messageType")] [JsonProperty("messageType", NullValueHandling = NullValueHandling.Ignore)]
public string MessageType => GetType().Name; public string MessageType => GetType().Name;
/// <summary> /// <summary>
/// Gets or sets the MessageBasePath /// Gets or sets the MessageBasePath
/// </summary> /// </summary>
[JsonProperty("messageBasePath")] [JsonProperty("messageBasePath", NullValueHandling = NullValueHandling.Ignore)]
public string MessageBasePath { get; set; } public string MessageBasePath { get; set; }
} }

View file

@ -12,7 +12,8 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// <summary> /// <summary>
/// The interfaces implmented by the device sending the messsage /// The interfaces implmented by the device sending the messsage
/// </summary> /// </summary>
[JsonProperty("interfaces")] [JsonProperty("interfaces", NullValueHandling = NullValueHandling.Ignore)]
[Obsolete("Interfaces are 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 List<string> Interfaces { get; private set; } public List<string> Interfaces { get; private set; }
/// <summary> /// <summary>

View file

@ -33,7 +33,7 @@
<Compile Remove="Messengers\SIMPLVtcMessenger.cs" /> <Compile Remove="Messengers\SIMPLVtcMessenger.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PepperDash.Core\PepperDash.Core.csproj" /> <ProjectReference Include="..\PepperDash.Core\PepperDash.Core.csproj" />

View file

@ -0,0 +1,61 @@
using System;
using Newtonsoft.Json;
namespace PepperDash.Essentials
{
/// <summary>
/// Represents the version information reported by a connected Mobile Control UI client
/// </summary>
public class ConnectedClientVersionInfo
{
/// <summary>
/// Gets or sets the client id
/// </summary>
[JsonProperty("clientId")]
public string ClientId { get; set; }
/// <summary>
/// Gets or sets the room key the client joined
/// </summary>
[JsonProperty("roomKey")]
public string RoomKey { get; set; }
/// <summary>
/// Gets or sets the touchpanel key the client joined as, if any
/// </summary>
[JsonProperty("touchpanelKey")]
public string TouchpanelKey { get; set; }
/// <summary>
/// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION)
/// </summary>
[JsonProperty("appVersion")]
public string AppVersion { get; set; }
/// <summary>
/// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured
/// </summary>
[JsonProperty("expectedAppVersion")]
public string ExpectedAppVersion { get; set; }
/// <summary>
/// Gets or sets the UTC time the client last reported this version
/// </summary>
[JsonProperty("lastSeen")]
public DateTime LastSeen { get; set; }
/// <summary>
/// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate
/// without affecting the internally tracked instance
/// </summary>
public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo
{
ClientId = ClientId,
RoomKey = RoomKey,
TouchpanelKey = TouchpanelKey,
AppVersion = AppVersion,
ExpectedAppVersion = ExpectedAppVersion,
LastSeen = LastSeen
};
}
}

View file

@ -69,11 +69,32 @@ namespace PepperDash.Essentials
private readonly Dictionary<string, IMobileControlMessenger> _defaultMessengers = private readonly Dictionary<string, IMobileControlMessenger> _defaultMessengers =
new Dictionary<string, IMobileControlMessenger>(); new Dictionary<string, IMobileControlMessenger>();
private readonly Dictionary<string, ConnectedClientVersionInfo> _connectedClientVersions =
new Dictionary<string, ConnectedClientVersionInfo>(StringComparer.InvariantCultureIgnoreCase);
private readonly object _connectedClientVersionsLock = new object();
/// <summary> /// <summary>
/// Get the custom messengers with subscriptions /// Get the custom messengers with subscriptions
/// </summary> /// </summary>
public ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions> Messengers => new ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions>(_messengers.Values.OfType<IMobileControlMessengerWithSubscriptions>().ToDictionary(k => k.Key, v => v)); public ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions> Messengers => new ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions>(_messengers.Values.OfType<IMobileControlMessengerWithSubscriptions>().ToDictionary(k => k.Key, v => v));
/// <summary>
/// Gets the most recently reported UI app version for each connected client, keyed by clientId
/// </summary>
public ReadOnlyDictionary<string, ConnectedClientVersionInfo> ConnectedClientVersions
{
get
{
lock (_connectedClientVersionsLock)
{
return new ReadOnlyDictionary<string, ConnectedClientVersionInfo>(
_connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone())
);
}
}
}
/// <summary> /// <summary>
/// Get the default messengers /// Get the default messengers
/// </summary> /// </summary>
@ -1782,6 +1803,28 @@ namespace PepperDash.Essentials
" Not Enabled in Config.\r\n" " Not Enabled in Config.\r\n"
); );
} }
var connectedClientVersions = ConnectedClientVersions;
if (connectedClientVersions.Count == 0)
{
CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions: None reported yet\r\n");
}
else
{
CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions:\r\n");
foreach (var kv in connectedClientVersions)
{
var v = kv.Value;
var match = string.IsNullOrEmpty(v.ExpectedAppVersion) || string.Equals(v.ExpectedAppVersion, v.AppVersion, StringComparison.OrdinalIgnoreCase);
CrestronConsole.ConsoleCommandResponse(
$" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" +
$" Reported: {v.AppVersion} Expected: {(string.IsNullOrEmpty(v.ExpectedAppVersion) ? "(not configured)" : v.ExpectedAppVersion)} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" +
$" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n"
);
}
}
} }
/// <summary> /// <summary>
@ -2181,6 +2224,8 @@ namespace PepperDash.Essentials
var roomKey = content["roomKey"].Value<string>(); var roomKey = content["roomKey"].Value<string>();
var touchpanelKey = content.SelectToken("touchpanelKey"); var touchpanelKey = content.SelectToken("touchpanelKey");
TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value<string>(), content.SelectToken("appVersion")?.Value<string>());
if (_roomCombiner == null) if (_roomCombiner == null)
{ {
var message = new MobileControlMessage var message = new MobileControlMessage
@ -2252,6 +2297,50 @@ namespace PepperDash.Essentials
SendTouchpanelKey(clientId, touchpanelKey); SendTouchpanelKey(clientId, touchpanelKey);
} }
/// <summary>
/// Records the app version reported by a connecting UI client (e.g. the mobile control React app's
/// build-time APP_VERSION) and compares it against the configured versions.touchpanelWrapperApp version.
/// </summary>
private void TrackClientAppVersion(string clientId, string roomKey, string touchpanelKey, string appVersion)
{
if (string.IsNullOrEmpty(appVersion))
{
return;
}
var expectedVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version;
var info = new ConnectedClientVersionInfo
{
ClientId = clientId,
RoomKey = roomKey,
TouchpanelKey = touchpanelKey,
AppVersion = appVersion,
ExpectedAppVersion = expectedVersion,
LastSeen = DateTime.UtcNow
};
lock (_connectedClientVersionsLock)
{
_connectedClientVersions[clientId] = info;
}
if (!string.IsNullOrEmpty(expectedVersion) && !string.Equals(expectedVersion, appVersion, StringComparison.OrdinalIgnoreCase))
{
this.LogWarning(
"Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}, which does not match configured versions.touchpanelWrapperApp version {expectedVersion}",
clientId, touchpanelKey, appVersion, expectedVersion
);
}
else
{
this.LogVerbose(
"Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}",
clientId, touchpanelKey, appVersion
);
}
}
private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken) private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken)
{ {
if (touchpanelKeyToken == null) if (touchpanelKeyToken == null)

View file

@ -38,7 +38,7 @@
<Compile Remove="RoomBridges\SourceDeviceMapDictionary.cs" /> <Compile Remove="RoomBridges\SourceDeviceMapDictionary.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" />
<PackageReference Include="WebSocketSharp-netstandard" Version="1.0.1" /> <PackageReference Include="WebSocketSharp-netstandard" Version="1.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -25,11 +25,28 @@ namespace PepperDash.Essentials.Touchpanel
/// Mobile Control touchpanel controller that provides app control, Zoom integration, /// Mobile Control touchpanel controller that provides app control, Zoom integration,
/// and mobile control functionality for Crestron touchpanels. /// and mobile control functionality for Crestron touchpanels.
/// </summary> /// </summary>
public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme, ICommunicationMonitor
{ {
private readonly MobileControlTouchpanelProperties localConfig; private readonly MobileControlTouchpanelProperties localConfig;
private IMobileControlRoomMessenger _bridge; private IMobileControlRoomMessenger _bridge;
/// <summary>
/// Gets the CommunicationMonitor tracking the panel's online/offline state
/// </summary>
public StatusMonitorBase CommunicationMonitor { get; private set; }
private sealed class NullCommunicationMonitor : StatusMonitorBase
{
public NullCommunicationMonitor(IKeyed parent) : base(parent, 120000, 300000)
{
Status = MonitorStatus.InError;
Message = "Panel is not initialized";
}
public override void Start() { }
public override void Stop() { }
}
private string _appUrl; private string _appUrl;
/// <summary> /// <summary>
@ -128,6 +145,11 @@ namespace PepperDash.Essentials.Touchpanel
{ {
localConfig = config; localConfig = config;
if (panel != null)
{
CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000);
}
AddPostActivationAction(SubscribeForMobileControlUpdates); AddPostActivationAction(SubscribeForMobileControlUpdates);
ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme); ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme);
@ -366,6 +388,8 @@ namespace PepperDash.Essentials.Touchpanel
/// </summary> /// </summary>
public override bool CustomActivate() public override bool CustomActivate()
{ {
CommunicationMonitor?.Start();
var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this); var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this);
var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this); var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this);
@ -393,6 +417,17 @@ namespace PepperDash.Essentials.Touchpanel
return base.CustomActivate(); return base.CustomActivate();
} }
/// <summary>
/// Stops the CommunicationMonitor on deactivation.
/// </summary>
/// <returns>True if deactivation was successful; otherwise, false.</returns>
public override bool Deactivate()
{
CommunicationMonitor?.Stop();
return base.Deactivate();
}
/// <summary> /// <summary>
/// Handles device extender signal changes for system reserved signals. /// Handles device extender signal changes for system reserved signals.
/// </summary> /// </summary>
@ -519,15 +554,8 @@ namespace PepperDash.Essentials.Touchpanel
return false; return false;
}) ? csIpAddress.ToString() : processorIp; }) ? csIpAddress.ToString() : processorIp;
var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$"); // replace the host but preserve whatever scheme (http/https) is already present in the URL
if (match.Success) var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}");
{
string ipa = match.Groups[1].Value;
// ip will be "192.168.1.100"
}
// replace ipa with ip but leave the rest of the string intact
var updatedUrl = Regex.Replace(url, @"^http://[^:/]+", $"http://{ip}");
this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl); this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl);
@ -740,7 +768,7 @@ namespace PepperDash.Essentials.Touchpanel
/// </summary> /// </summary>
public MobileControlTouchpanelControllerFactory() public MobileControlTouchpanelControllerFactory()
{ {
TypeNames = new List<string>() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" }; TypeNames = new List<string>() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" };
MinimumEssentialsFrameworkVersion = "2.0.0"; MinimumEssentialsFrameworkVersion = "2.0.0";
factories = new Dictionary<string, Func<uint, CrestronControlSystem, string, BasicTriListWithSmartObject>> factories = new Dictionary<string, Func<uint, CrestronControlSystem, string, BasicTriListWithSmartObject>>
@ -765,6 +793,8 @@ namespace PepperDash.Essentials.Touchpanel
{"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)}, {"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)},
{"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)}, {"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)},
{"ts1070", (id, controlSystem, projectName) => new Ts1070(id, controlSystem)}, {"ts1070", (id, controlSystem, projectName) => new Ts1070(id, controlSystem)},
{"tsw1080", (id, controlSystem, projectName) => new Tsw1080(id, controlSystem)},
{"ts1080", (id, controlSystem, projectName) => new Ts1080(id, controlSystem)},
{"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)} {"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)}
}; };
} }

View file

@ -127,6 +127,16 @@ namespace PepperDash.Essentials.WebSocketServer
/// </summary> /// </summary>
public int Port { get; private set; } public int Port { get; private set; }
/// <summary>
/// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure
/// </summary>
private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http";
/// <summary>
/// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure
/// </summary>
private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws";
/// <summary> /// <summary>
/// Gets the user app URL prefix /// Gets the user app URL prefix
/// </summary> /// </summary>
@ -134,7 +144,8 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
get get
{ {
return string.Format("http://{0}:{1}{2}?token=", return string.Format("{0}://{1}:{2}{3}?token=",
HttpScheme,
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port, Port,
_userAppBaseHref); _userAppBaseHref);
@ -273,7 +284,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
base.Initialize(); base.Initialize();
_server = new HttpServer(Port, false); _server = new HttpServer(Port, _parent.Config.DirectServer.Secure);
_server.OnGet += Server_OnGet; _server.OnGet += Server_OnGet;
@ -291,7 +302,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
ClientCertificateRequired = false, ClientCertificateRequired = false,
CheckCertificateRevocation = false, CheckCertificateRevocation = false,
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 EnabledSslProtocols = SslProtocols.Tls12
}; };
} }
@ -403,11 +414,11 @@ namespace PepperDash.Essentials.WebSocketServer
ip = csIpAddress.ToString(); ip = csIpAddress.ToString();
} }
var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; var appUrl = $"{HttpScheme}://{ip}:{Port}/mc/app?token={touchpanel.Key}";
this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key); this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key);
touchpanel.Touchpanel.SetAppUrl($"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"); touchpanel.Touchpanel.SetAppUrl(appUrl);
} }
} }
@ -487,7 +498,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
var config = new MobileControlApplicationConfig var config = new MobileControlApplicationConfig
{ {
ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port), ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, Port),
GatewayAppPath = "", GatewayAppPath = "",
LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png",
EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false,
@ -1098,6 +1109,7 @@ namespace PepperDash.Essentials.WebSocketServer
res.StatusCode = 200; res.StatusCode = 200;
res.Close(); res.Close();
// remote log collector has no dedicated secure flag; keep it on http regardless of DirectServer.Secure
var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs")
{ {
Content = new StringContent(body, Encoding.UTF8, "application/json"), Content = new StringContent(body, Encoding.UTF8, "application/json"),
@ -1150,6 +1162,11 @@ namespace PepperDash.Essentials.WebSocketServer
var qp = req.QueryString; var qp = req.QueryString;
var token = qp["token"]; var token = qp["token"];
// Each join mints a single-use clientId; the panel webview must never replay a cached
// response, or it reconnects forever with an already-consumed id (1008 loop).
res.Headers.Add("Cache-Control", "no-store");
res.Headers.Add("Pragma", "no-cache");
this.LogVerbose("Join Room Request with token: {token}", token); this.LogVerbose("Join Room Request with token: {token}", token);
byte[] body; byte[] body;
@ -1213,8 +1230,7 @@ namespace PepperDash.Essentials.WebSocketServer
this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now); this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now);
// Construct WebSocket URL with clientId query parameter // Construct WebSocket URL with clientId query parameter
var wsProtocol = "ws"; var wsUrl = $"{WsScheme}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}";
var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}";
// Construct the response object // Construct the response object
JoinResponse jRes = new JoinResponse JoinResponse jRes = new JoinResponse
@ -1226,7 +1242,8 @@ namespace PepperDash.Essentials.WebSocketServer
Config = _parent.GetConfigWithPluginVersion(), Config = _parent.GetConfigWithPluginVersion(),
CodeExpires = new DateTime().AddYears(1), CodeExpires = new DateTime().AddYears(1),
UserCode = bridge.UserCode, UserCode = bridge.UserCode,
UserAppUrl = string.Format("http://{0}:{1}/mc/app", UserAppUrl = string.Format("{0}://{1}:{2}/mc/app",
HttpScheme,
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port), Port),
WebSocketUrl = wsUrl, WebSocketUrl = wsUrl,
@ -1251,6 +1268,8 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
res.StatusCode = 200; res.StatusCode = 200;
res.ContentType = "application/json"; res.ContentType = "application/json";
res.Headers.Add("Cache-Control", "no-store");
res.Headers.Add("Pragma", "no-cache");
var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion }; var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion };
var message = JsonConvert.SerializeObject(version); var message = JsonConvert.SerializeObject(version);
this.LogVerbose("{message}", message); this.LogVerbose("{message}", message);

View file

@ -48,7 +48,7 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.Program" Version="2.21.90" /> <PackageReference Include="Crestron.SimplSharp.SDK.Program" Version="2.21.274" />
<PackageReference Include="System.IO.Compression" Version="4.0.0" /> <PackageReference Include="System.IO.Compression" Version="4.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>