diff --git a/NINA.Equipment/Equipment/MySwitch/IndiSwitchHub.cs b/NINA.Equipment/Equipment/MySwitch/IndiSwitchHub.cs new file mode 100644 index 000000000..b84c78c5f --- /dev/null +++ b/NINA.Equipment/Equipment/MySwitch/IndiSwitchHub.cs @@ -0,0 +1,220 @@ +#region "copyright" + +/* + Copyright © 2025-2026 Nico Trost and the PI.N.S. contributors + + This file is part of PI 'N' Stars. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +#endregion "copyright" + +using NINA.Core.Locale; +using NINA.Core.Utility; +using NINA.Equipment.Equipment; +using NINA.Equipment.Interfaces; +using NINA.INDI; +using NINA.INDI.Devices; +using NINA.INDI.Interfaces; +using NINA.Profile.Interfaces; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace NINA.Equipment.Equipment.MySwitch { + + /// + /// Equipment-layer wrapper for an INDI AUX device (e.g. Pegasus UPB) that + /// exposes power ports, USB ports, dew-heater duty cycles, and other + /// writable channels as a standard NINA . + /// + /// Switch collection is built once after connection by inspecting all writable + /// INDI property elements that the driver has advertised. + /// + public class IndiSwitchHub : IndiDevice, ISwitchHub, IDisposable { + + public IndiSwitchHub(INDIDeviceInfo info, IProfileService profileService = null) : base(info) { + this.profileService = profileService; + switches = new AsyncObservableCollection(); + } + + private readonly IProfileService profileService; + + private ICollection switches; + public ICollection Switches { + get => switches; + private set { + switches = value; + RaisePropertyChanged(); + } + } + + protected override string ConnectionLostMessage => Loc.Instance["LblSwitchConnectionLost"]; + + protected override IINDISwitchHub GetInstance() { + return device ??= new INDISwitchHub(_device); + } + + protected override Task PreConnect() { + if (profileService != null) { + var s = profileService.ActiveProfile.SwitchSettings; + GetInstance().ConfigureConnectionProperties( + s.IndiConnectionMode, + s.IndiAutoSearch, + s.IndiAddress, + s.IndiPort, + s.IndiBaudRate + ); + } + return Task.CompletedTask; + } + + protected override async Task PostConnect() { + // Give the INDI driver a moment to push all initial property definitions. + await Task.Delay(TimeSpan.FromSeconds(1)); + BuildSwitchCollection(); + GetInstance().ValuesUpdated += OnIndiValuesUpdated; + } + + protected override void PostDisconnect() { + GetInstance().ValuesUpdated -= OnIndiValuesUpdated; + Switches = new AsyncObservableCollection(); + } + + private void OnIndiValuesUpdated(string propertyName) { + foreach (var sw in switches) { + if (sw is IndiReadSwitchItem r && r.PropertyName == propertyName) { + r.Poll(); + } else if (sw is IndiWritableSwitchItem w && w.PropertyName == propertyName) { + // SyncFromDevice syncs TargetValue from the live device state so the + // Vue toggle reflects external changes (e.g. from another INDI client). + w.SyncFromDevice(); + } + } + } + + private void BuildSwitchCollection() { + var list = new AsyncObservableCollection(); + short id = 0; + foreach (var desc in device.GetDescriptors()) { + list.Add(desc.IsWritable + ? (ISwitch)new IndiWritableSwitchItem(id++, desc, device) + : new IndiReadSwitchItem(id++, desc, device)); + } + Switches = list; + Logger.Info($"IndiSwitchHub '{Name}': discovered {list.Count} switch channel(s)"); + } + + #region Unsupported + + public IList SupportedActions { get; } = new List(); + public string Action(string actionName, string actionParameters) => throw new NotImplementedException(); + public void CommandBlind(string command, bool raw = false) => throw new NotImplementedException(); + public bool CommandBool(string command, bool raw = false) => throw new NotImplementedException(); + public string CommandString(string command, bool raw = false) => throw new NotImplementedException(); + + #endregion + } + + // ----------------------------------------------------------------------- + // Read-only switch item (INDI RO property element) + // ----------------------------------------------------------------------- + + internal sealed class IndiReadSwitchItem : BaseINPC, ISwitch { + private readonly INDISwitchDescriptor _descriptor; + private readonly IINDISwitchHub _hub; + + public IndiReadSwitchItem(short id, INDISwitchDescriptor descriptor, IINDISwitchHub hub) { + Id = id; + _descriptor = descriptor; + _hub = hub; + Name = string.IsNullOrWhiteSpace(descriptor.ElementLabel) ? descriptor.ElementName : descriptor.ElementLabel; + Description = string.IsNullOrWhiteSpace(descriptor.PropertyLabel) ? descriptor.PropertyName : descriptor.PropertyLabel; + } + + public short Id { get; } + public string Name { get; } + public string Description { get; } + public string PropertyName => _descriptor.PropertyName; + public double Value => _hub.GetValue(_descriptor); + + public bool Poll() { + RaisePropertyChanged(nameof(Value)); + return true; + } + } + + // ----------------------------------------------------------------------- + // Writable switch item – boolean toggle or numeric control + // ----------------------------------------------------------------------- + + internal sealed class IndiWritableSwitchItem : BaseINPC, IWritableSwitch { + private readonly INDISwitchDescriptor _descriptor; + private readonly IINDISwitchHub _hub; + + public IndiWritableSwitchItem(short id, INDISwitchDescriptor descriptor, IINDISwitchHub hub) { + Id = id; + _descriptor = descriptor; + _hub = hub; + Name = string.IsNullOrWhiteSpace(descriptor.ElementLabel) ? descriptor.ElementName : descriptor.ElementLabel; + Description = string.IsNullOrWhiteSpace(descriptor.PropertyLabel) ? descriptor.PropertyName : descriptor.PropertyLabel; + PropertyName = descriptor.PropertyName; + Maximum = descriptor.Max; + Minimum = descriptor.Min; + StepSize = descriptor.Step > 0 ? descriptor.Step : 1.0; + targetValue = hub.GetValue(descriptor); + } + + public short Id { get; } + public string Name { get; } + public string Description { get; } + public string PropertyName { get; } + public double Maximum { get; } + public double Minimum { get; } + public double StepSize { get; } + + public double Value => _hub.GetValue(_descriptor); + + private double targetValue; + public double TargetValue { + get => targetValue; + set { + if (targetValue != value) { + targetValue = value; + RaisePropertyChanged(); + } + } + } + + public bool Poll() { + RaisePropertyChanged(nameof(Value)); + return true; + } + + /// + /// Called when the INDI server pushes an unsolicited state update (setSwitchVector). + /// Syncs to the actual device state so that the UI + /// toggle reflects external changes without disrupting the SwitchVM set-value + /// polling loop (which relies on Value != TargetValue to detect work-in-progress). + /// + public void SyncFromDevice() { + var liveValue = _hub.GetValue(_descriptor); + if (Math.Abs(targetValue - liveValue) > 0.001) { + targetValue = liveValue; + RaisePropertyChanged(nameof(TargetValue)); + } + RaisePropertyChanged(nameof(Value)); + } + + public void SetValue() { + if (_descriptor.IsBoolSwitch) { + _hub.SetBoolElement(_descriptor, TargetValue >= 0.5); + } else { + _hub.SetNumberElement(_descriptor, TargetValue); + } + } + } +} diff --git a/NINA.Equipment/Utility/INDIInteraction.cs b/NINA.Equipment/Utility/INDIInteraction.cs index 7c2c92201..8bb543e79 100644 --- a/NINA.Equipment/Utility/INDIInteraction.cs +++ b/NINA.Equipment/Utility/INDIInteraction.cs @@ -28,6 +28,7 @@ This Source Code Form is subject to the terms of the Mozilla Public using NINA.Equipment.Equipment.MyFilterWheel; using NINA.Equipment.Equipment.MyFlatDevice; using NINA.Equipment.Equipment.MyWeatherData; +using NINA.Equipment.Equipment.MySwitch; namespace NINA.Equipment.Utility { @@ -111,11 +112,9 @@ public async Task> GetFilterWheels() { return l; } - public async Task> GetFlatDevices() - { + public async Task> GetFlatDevices() { var l = new List(); - if (!await INDIClient.Instance.WaitForServerReadyAsync(TimeSpan.FromSeconds(15))) - { + if (!await INDIClient.Instance.WaitForServerReadyAsync(TimeSpan.FromSeconds(15))) { Logger.Debug("INDI server not ready - skipping INDI flat device enumeration"); return l; } @@ -124,19 +123,16 @@ public async Task> GetFlatDevices() string driver = profileService.ActiveProfile.FlatDeviceSettings.IndiDriver; // Query devices for this driver - foreach (var device in await INDIClient.Instance.GetDevices(DeviceInterface.LIGHTBOX_INTERFACE, driver)) - { + foreach (var device in await INDIClient.Instance.GetDevices(DeviceInterface.LIGHTBOX_INTERFACE, driver)) { IndiFlatDevice flatDevice = new(device, profileService); l.Add(flatDevice); } return l; } - public async Task> GetWeatherData() - { + public async Task> GetWeatherData() { var l = new List(); - if (!await INDIClient.Instance.WaitForServerReadyAsync(TimeSpan.FromSeconds(15))) - { + if (!await INDIClient.Instance.WaitForServerReadyAsync(TimeSpan.FromSeconds(15))) { Logger.Debug("INDI server not ready - skipping INDI weather data enumeration"); return l; } @@ -145,14 +141,28 @@ public async Task> GetWeatherData() string driver = profileService.ActiveProfile.WeatherDataSettings.IndiDriver; // Query devices for this driver - foreach (var device in await INDIClient.Instance.GetDevices(DeviceInterface.WEATHER_INTERFACE, driver)) - { + foreach (var device in await INDIClient.Instance.GetDevices(DeviceInterface.WEATHER_INTERFACE, driver)) { IndiWeatherData weatherData = new(device, profileService); l.Add(weatherData); } return l; } + public async Task> GetSwitches() { + var l = new List(); + if (!await INDIClient.Instance.WaitForServerReadyAsync(TimeSpan.FromSeconds(15))) { + Logger.Debug("INDI server not ready - skipping INDI switch hub enumeration"); + return l; + } + + string driver = profileService.ActiveProfile.SwitchSettings.IndiDriver; + + foreach (var device in await INDIClient.Instance.GetDevices(DeviceInterface.AUX_INTERFACE, driver)) { + l.Add(new IndiSwitchHub(device, profileService)); + } + return l; + } + public static string GetVersion() { return INDIClient.Instance.GetServerVersionString(); } diff --git a/NINA.INDI/Devices/INDIDevice.cs b/NINA.INDI/Devices/INDIDevice.cs index 377cc4d8d..be3e42d1b 100644 --- a/NINA.INDI/Devices/INDIDevice.cs +++ b/NINA.INDI/Devices/INDIDevice.cs @@ -24,21 +24,26 @@ This Source Code Form is subject to the terms of the Mozilla Public using System.Threading; using System.Threading.Tasks; -namespace NINA.INDI.Devices { +namespace NINA.INDI.Devices +{ - public class PropertyEventArgs : EventArgs { + public class PropertyEventArgs : EventArgs + { public INDIProperty Property { get; } - public PropertyEventArgs(INDIProperty property) { + public PropertyEventArgs(INDIProperty property) + { Property = property; } } - public class INDIDevice : IINDIDevice { + public class INDIDevice : IINDIDevice + { private readonly INDIDeviceInfo _device; - public INDIDevice(INDIDeviceInfo device) { + public INDIDevice(INDIDeviceInfo device) + { _device = device; // Register device to receive property updates @@ -50,7 +55,8 @@ public INDIDevice(INDIDeviceInfo device) { // Wait for CONNECTION property (always required, according to INDI) string[] requiredProps = ["CONNECTION"]; var propTimeout = DateTime.Now.AddSeconds(10); - while (!HasProperties(requiredProps) && DateTime.Now < propTimeout) { + while (!HasProperties(requiredProps) && DateTime.Now < propTimeout) + { CoreUtil.Wait(TimeSpan.FromMilliseconds(500)).Wait(); } @@ -71,10 +77,13 @@ public INDIDevice(INDIDeviceInfo device) { private bool _connected; private bool _connectionAttemptFailed; // Track if the last connection attempt failed - public bool Connected { + public bool Connected + { get => _connected; - set { - if (_connected && !value) { + set + { + if (_connected && !value) + { // Transitioning from connected to disconnected Disconnect(); } @@ -94,61 +103,76 @@ public bool Connected { private readonly Dictionary _properties = new(); private TaskCompletionSource _propertiesReadyTcs; - public void AddProperty(INDIProperty property) { - lock (_properties) { + public void AddProperty(INDIProperty property) + { + lock (_properties) + { _properties[property.Name] = property; // Signal when CONNECTION property arrives (if we're waiting) - if (property.Name == "CONNECTION" && _propertiesReadyTcs != null && !_propertiesReadyTcs.Task.IsCompleted) { + if (property.Name == "CONNECTION" && _propertiesReadyTcs != null && !_propertiesReadyTcs.Task.IsCompleted) + { Logger.Debug($"Device {DeviceName}: CONNECTION property received"); _propertiesReadyTcs.TrySetResult(true); } } } - public void RemoveProperty(string propertyName) { - lock (_properties) { - if (_properties.TryGetValue(propertyName, out var prop)) { + public void RemoveProperty(string propertyName) + { + lock (_properties) + { + if (_properties.TryGetValue(propertyName, out var prop)) + { _properties.Remove(propertyName); } } } - public INDIProperty GetProperty(string propertyName) { - lock (_properties) { + public INDIProperty GetProperty(string propertyName) + { + lock (_properties) + { _properties.TryGetValue(propertyName, out var property); return property; } } - public INDINumberProperty GetNumberProperty(string propertyName) { + public INDINumberProperty GetNumberProperty(string propertyName) + { return GetProperty(propertyName) as INDINumberProperty; } - public INDISwitchProperty GetSwitchProperty(string propertyName) { + public INDISwitchProperty GetSwitchProperty(string propertyName) + { return GetProperty(propertyName) as INDISwitchProperty; } - public INDITextProperty GetTextProperty(string propertyName) { + public INDITextProperty GetTextProperty(string propertyName) + { return GetProperty(propertyName) as INDITextProperty; } - public double? GetNumberPropertyValue(string propertyName, string elementName) { + public double? GetNumberPropertyValue(string propertyName, string elementName) + { var prop = GetNumberProperty(propertyName); return prop?.Numbers.FirstOrDefault(n => n.Name == elementName)?.Value; } - public bool? GetSwitchPropertyValue(string propertyName, string elementName) { + public bool? GetSwitchPropertyValue(string propertyName, string elementName) + { var prop = GetSwitchProperty(propertyName); return prop?.Switches.FirstOrDefault(s => s.Name == elementName)?.Value; } - public string GetTextPropertyValue(string propertyName, string elementName) { + public string GetTextPropertyValue(string propertyName, string elementName) + { var prop = GetTextProperty(propertyName); return prop?.Texts.FirstOrDefault(t => t.Name == elementName)?.Value; } - public void SetNumberValue(string propertyName, string elementName, double value) { + public void SetNumberValue(string propertyName, string elementName, double value) + { var prop = GetNumberProperty(propertyName) ?? throw new ArgumentException($"Number property '{propertyName}' not found"); if (prop == null) return; @@ -159,13 +183,16 @@ public void SetNumberValue(string propertyName, string elementName, double value INDIClient.Instance.SendProperty(prop); } - public void SetNumberValues(string propertyName, params (string elementName, double value)[] values) { + public void SetNumberValues(string propertyName, params (string elementName, double value)[] values) + { var prop = GetNumberProperty(propertyName) ?? throw new ArgumentException($"Number property '{propertyName}' not found"); if (prop == null) return; - foreach (var (elementName, value) in values) { + foreach (var (elementName, value) in values) + { var number = prop.Numbers.FirstOrDefault(n => n.Name == elementName); - if (number != null) { + if (number != null) + { number.Value = value; } } @@ -173,18 +200,22 @@ public void SetNumberValues(string propertyName, params (string elementName, dou INDIClient.Instance.SendProperty(prop); } - public async Task SetNumberValuesAsync(string propertyName, TimeSpan timeout, params (string elementName, double value)[] values) { - try { + public async Task SetNumberValuesAsync(string propertyName, TimeSpan timeout, params (string elementName, double value)[] values) + { + try + { // Create a unique operation ID for this async set var operationId = $"{propertyName}_{Guid.NewGuid()}"; // Create and register the TaskCompletionSource var tcs = new TaskCompletionSource(); - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { _pendingAsyncOperations[operationId] = tcs; } - try { + try + { // Execute the actual set operation SetNumberValues(propertyName, values); @@ -192,13 +223,15 @@ public async Task SetNumberValuesAsync(string propertyName, TimeSpan timeo var timeoutTask = Task.Delay(timeout); var completedTask = await Task.WhenAny(tcs.Task, timeoutTask); - if (completedTask == timeoutTask) { + if (completedTask == timeoutTask) + { Logger.Error($"SetNumberValuesAsync ({propertyName}) - server did not acknowledge within timeout"); return false; } var result = await tcs.Task; - if (!result) { + if (!result) + { Logger.Error($"SetNumberValuesAsync ({propertyName}) - server rejected operation (Alert state)"); return false; } @@ -206,33 +239,44 @@ public async Task SetNumberValuesAsync(string propertyName, TimeSpan timeo // Server acknowledged the operation (state changed to Busy) Logger.Debug($"SetNumberValuesAsync ({propertyName}) - server acknowledged, operation proceeding"); return true; - } finally { + } + finally + { // Clean up the pending operation - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { _pendingAsyncOperations.Remove(operationId); } } - } catch (OperationCanceledException) { + } + catch (OperationCanceledException) + { Logger.Warning($"SetNumberValuesAsync ({propertyName}) was cancelled"); return false; - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error($"SetNumberValuesAsync failed: {ex.Message}"); return false; } } - public async Task SetSwitchValueAsync(string propertyName, string elementName, bool value, TimeSpan timeout) { - try { + public async Task SetSwitchValueAsync(string propertyName, string elementName, bool value, TimeSpan timeout) + { + try + { // Create a unique operation ID for this async set var operationId = $"{propertyName}_{Guid.NewGuid()}"; // Create and register the TaskCompletionSource var tcs = new TaskCompletionSource(); - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { _pendingAsyncOperations[operationId] = tcs; } - try { + try + { // Execute the actual set operation SetSwitchValue(propertyName, elementName, value); @@ -240,13 +284,15 @@ public async Task SetSwitchValueAsync(string propertyName, string elementN var timeoutTask = Task.Delay(timeout); var completedTask = await Task.WhenAny(tcs.Task, timeoutTask); - if (completedTask == timeoutTask) { + if (completedTask == timeoutTask) + { Logger.Error($"SetSwitchValueAsync ({propertyName}) - server did not acknowledge within timeout"); return false; } var result = await tcs.Task; - if (!result) { + if (!result) + { Logger.Error($"SetSwitchValueAsync ({propertyName}) - server rejected operation (Alert state)"); return false; } @@ -254,55 +300,76 @@ public async Task SetSwitchValueAsync(string propertyName, string elementN // Server acknowledged the operation (state changed to Busy) Logger.Debug($"SetSwitchValueAsync ({propertyName}) - server acknowledged, operation proceeding"); return true; - } finally { + } + finally + { // Clean up the pending operation - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { _pendingAsyncOperations.Remove(operationId); } } - } catch (OperationCanceledException) { + } + catch (OperationCanceledException) + { Logger.Warning($"SetSwitchValueAsync ({propertyName}) was cancelled"); return false; - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error($"SetSwitchValueAsync failed: {ex.Message}"); return false; } } - public void SetSwitchValue(string propertyName, string elementName, bool value) { + public void SetSwitchValue(string propertyName, string elementName, bool value) + { var prop = GetSwitchProperty(propertyName) ?? throw new ArgumentException($"Switch property '{propertyName}' not found"); // Handle switch rules - if (prop.Rule == SwitchRule.OneOfMany) { + if (prop.Rule == SwitchRule.OneOfMany) + { // For OneOfMany, only allow setting one switch to true at a time // First, turn off all switches - foreach (var sw in prop.Switches) { + foreach (var sw in prop.Switches) + { sw.Value = false; } // Then turn on only the requested one (if value is true) - if (value) { + if (value) + { var targetSw = prop.Switches.FirstOrDefault(s => s.Name == elementName); - if (targetSw != null) { + if (targetSw != null) + { targetSw.Value = true; } } - } else if (prop.Rule == SwitchRule.AtMostOne) { - if (value) { + } + else if (prop.Rule == SwitchRule.AtMostOne) + { + if (value) + { // Turn off all other switches - foreach (var sw in prop.Switches) { + foreach (var sw in prop.Switches) + { sw.Value = sw.Name == elementName; } - } else { + } + else + { // Just turn off this switch, leave others as is var sw = prop.Switches.FirstOrDefault(s => s.Name == elementName); - if (sw != null) { + if (sw != null) + { sw.Value = false; } } - } else // AnyOfMany - { + } + else // AnyOfMany + { var sw = prop.Switches.FirstOrDefault(s => s.Name == elementName); - if (sw != null) { + if (sw != null) + { sw.Value = value; } } @@ -310,29 +377,37 @@ public void SetSwitchValue(string propertyName, string elementName, bool value) INDIClient.Instance.SendProperty(prop); } - public void SetSwitchProperty(string propertyName, Dictionary values) { + public void SetSwitchProperty(string propertyName, Dictionary values) + { var prop = GetSwitchProperty(propertyName) ?? throw new ArgumentException($"Switch property '{propertyName}' not found"); if (prop == null) return; // Validate based on switch rule - if (prop.Rule == SwitchRule.OneOfMany) { + if (prop.Rule == SwitchRule.OneOfMany) + { // Must have exactly one switch set to true var trueCount = values.Values.Count(v => v); - if (trueCount != 1) { + if (trueCount != 1) + { throw new ArgumentException($"OneOfMany rule requires exactly one switch to be true, got {trueCount}"); } - } else if (prop.Rule == SwitchRule.AtMostOne) { + } + else if (prop.Rule == SwitchRule.AtMostOne) + { // Can have at most one switch set to true var trueCount = values.Values.Count(v => v); - if (trueCount > 1) { + if (trueCount > 1) + { throw new ArgumentException($"AtMostOne rule allows at most one switch to be true, got {trueCount}"); } } // AnyOfMany has no restrictions // Apply the values - foreach (var sw in prop.Switches) { - if (values.TryGetValue(sw.Name, out bool value)) { + foreach (var sw in prop.Switches) + { + if (values.TryGetValue(sw.Name, out bool value)) + { sw.Value = value; } } @@ -340,7 +415,8 @@ public void SetSwitchProperty(string propertyName, Dictionary valu INDIClient.Instance.SendProperty(prop); } - public void SetTextValue(string propertyName, string elementName, string value) { + public void SetTextValue(string propertyName, string elementName, string value) + { var prop = GetTextProperty(propertyName) ?? throw new ArgumentException($"Text property '{propertyName}' not found"); if (prop == null) return; @@ -351,18 +427,22 @@ public void SetTextValue(string propertyName, string elementName, string value) INDIClient.Instance.SendProperty(prop); } - public async Task SetTextValueAsync(string propertyName, string elementName, string value, TimeSpan timeout) { - try { + public async Task SetTextValueAsync(string propertyName, string elementName, string value, TimeSpan timeout) + { + try + { // Create a unique operation ID for this async set var operationId = $"{propertyName}_{Guid.NewGuid()}"; // Create and register the TaskCompletionSource var tcs = new TaskCompletionSource(); - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { _pendingAsyncOperations[operationId] = tcs; } - try { + try + { // Execute the actual set operation SetTextValue(propertyName, elementName, value); @@ -370,13 +450,15 @@ public async Task SetTextValueAsync(string propertyName, string elementNam var timeoutTask = Task.Delay(timeout); var completedTask = await Task.WhenAny(tcs.Task, timeoutTask); - if (completedTask == timeoutTask) { + if (completedTask == timeoutTask) + { Logger.Error($"SetTextValueAsync ({propertyName}) - server did not acknowledge within timeout"); return false; } var result = await tcs.Task; - if (!result) { + if (!result) + { Logger.Error($"SetTextValueAsync ({propertyName}) - server rejected operation (Alert state)"); return false; } @@ -384,16 +466,23 @@ public async Task SetTextValueAsync(string propertyName, string elementNam // Server acknowledged the operation (state changed to Busy) Logger.Debug($"SetTextValueAsync ({propertyName}) - server acknowledged, operation proceeding"); return true; - } finally { + } + finally + { // Clean up the pending operation - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { _pendingAsyncOperations.Remove(operationId); } } - } catch (OperationCanceledException) { + } + catch (OperationCanceledException) + { Logger.Warning($"SetTextValueAsync ({propertyName}) was cancelled"); return false; - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error($"SetTextValueAsync failed: {ex.Message}"); return false; } @@ -404,9 +493,12 @@ public async Task SetTextValueAsync(string propertyName, string elementNam private readonly Dictionary> _pendingAsyncOperations = new(); private readonly object _asyncOperationsLock = new(); - public Task Connect(CancellationToken ct) { - return Task.Run(async () => { - if (Connected) { + public Task Connect(CancellationToken ct) + { + return Task.Run(async () => + { + if (Connected) + { Logger.Warning($"Device '{DeviceName}' is already connected"); return true; } @@ -414,7 +506,8 @@ public Task Connect(CancellationToken ct) { Logger.Info($"Connecting to INDI device: {DeviceName}"); // Call hook to configure connection properties before connecting - if (!await OnPreConnect()) { + if (!await OnPreConnect()) + { Logger.Error($"OnPreConnect failed for {DeviceName}"); return false; } @@ -432,29 +525,37 @@ public Task Connect(CancellationToken ct) { connPropBefore?.Switches.FirstOrDefault(s => s.Name == "CONNECT")?.Value == true; bool success; - if (alreadyConnectedAtIndi) { + if (alreadyConnectedAtIndi) + { Logger.Info($"[{DeviceName}] INDI driver already connected at server level — skipping CONNECT command"); success = true; - } else { + } + else + { success = await SetSwitchValueAsync("CONNECTION", "CONNECT", true, TimeSpan.FromSeconds(30)); } - if (success) { + if (success) + { Logger.Info($"Connected to INDI device: {DeviceName}"); _connectionAttemptFailed = false; // Clear failure flag on successful connection // Wait for initial property definitions to arrive from the driver var requiredProps = GetRequiredConnectionProperties(); - if (requiredProps != null && requiredProps.Length > 0) { + if (requiredProps != null && requiredProps.Length > 0) + { Logger.Debug($"Waiting for required properties: {string.Join(", ", requiredProps)}"); // Poll for properties with timeout var propStopwatch = System.Diagnostics.Stopwatch.StartNew(); - while (!HasProperties(requiredProps) && propStopwatch.Elapsed < TimeSpan.FromSeconds(20) && !ct.IsCancellationRequested) { + while (!HasProperties(requiredProps) && propStopwatch.Elapsed < TimeSpan.FromSeconds(20) && !ct.IsCancellationRequested) + { await CoreUtil.Wait(TimeSpan.FromMilliseconds(200), ct); } } - } else { + } + else + { Logger.Error($"Connecting to {DeviceName} failed"); } @@ -465,18 +566,21 @@ public Task Connect(CancellationToken ct) { }); } - public async Task DisconnectAsync() { + public async Task DisconnectAsync() + { Logger.Info($"Disconnecting from INDI device: {DeviceName}"); // Check if INDI client is still connected to server - if (!INDIClient.Instance.IsConnected) { + if (!INDIClient.Instance.IsConnected) + { Logger.Info($"INDI server already disconnected, skipping graceful disconnect for {DeviceName}"); _connected = false; return true; } // If the last connection attempt failed, skip DISCONNECT (device never actually connected) - if (_connectionAttemptFailed) { + if (_connectionAttemptFailed) + { Logger.Info($"Device '{DeviceName}' connection failed previously, skipping graceful disconnect"); _connected = false; _connectionAttemptFailed = false; // Reset flag @@ -485,9 +589,11 @@ public async Task DisconnectAsync() { // Check if device is actually connected before trying to disconnect var connProp = GetSwitchProperty("CONNECTION"); - if (connProp != null) { + if (connProp != null) + { var connectSwitch = connProp.Switches.FirstOrDefault(s => s.Name == "CONNECT"); - if (connectSwitch != null && !connectSwitch.Value) { + if (connectSwitch != null && !connectSwitch.Value) + { Logger.Info($"Device '{DeviceName}' is not connected to server (CONNECT=false), skipping graceful disconnect"); _connected = false; return true; @@ -497,16 +603,22 @@ public async Task DisconnectAsync() { // Send DISCONNECT command using async mechanism bool success = await SetSwitchValueAsync("CONNECTION", "DISCONNECT", true, TimeSpan.FromSeconds(60)); - if (success) { + if (success) + { Logger.Info($"Disconnected from INDI device: {DeviceName}"); - } else { + } + else + { // If the driver was unloaded while we were waiting (e.g. the user switched to a // different INDI driver), the INDI server will never ack the DISCONNECT command. // Skip the failure and treat the device as disconnected. - if (!INDIClient.Instance.IsDeviceKnown(Id)) { + if (!INDIClient.Instance.IsDeviceKnown(Id)) + { Logger.Info($"INDI device '{DeviceName}' driver was unloaded during disconnect — treating as disconnected"); success = true; - } else { + } + else + { Logger.Warning($"Disconnecting from {DeviceName} timed out or failed"); } } @@ -515,19 +627,26 @@ public async Task DisconnectAsync() { return success; } - public void Disconnect() { + public void Disconnect() + { // Use async variant as fire-and-forget for Dispose compatibility - _ = Task.Run(async () => { - try { + _ = Task.Run(async () => + { + try + { await DisconnectAsync(); - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error($"Disconnect failed: {ex.Message}"); } }); } - public void Dispose() { - if (Connected) { + public void Dispose() + { + if (Connected) + { Disconnect(); } @@ -539,14 +658,16 @@ public void Dispose() { /// Override this to specify which properties must be received before Connect() completes. /// Return null/empty to skip waiting (uses fixed delay fallback). /// - protected virtual string[] GetRequiredConnectionProperties() { + protected virtual string[] GetRequiredConnectionProperties() + { return null; } /// /// Override this to configure device properties after driver load but before CONNECT /// - protected virtual async Task OnPreConnect() { + protected virtual async Task OnPreConnect() + { // If the INDI driver is already connected (shared driver, other interface connected // first), skip all pre-connect property writes. Attempting to change CONNECTION_MODE, // DEVICE_PORT or DEVICE_BAUD_RATE while connected causes many drivers to silently @@ -554,7 +675,8 @@ protected virtual async Task OnPreConnect() { // cumulative timeouts before the CONNECT command is even sent. var connPropCurrent = GetSwitchProperty("CONNECTION"); bool alreadyConnected = connPropCurrent?.Switches.FirstOrDefault(s => s.Name == "CONNECT")?.Value == true; - if (alreadyConnected) { + if (alreadyConnected) + { Logger.Info($"[{DeviceName}] OnPreConnect: INDI driver already connected — skipping connection property configuration"); return true; } @@ -571,44 +693,55 @@ protected virtual async Task OnPreConnect() { Logger.Info($"[{DeviceName}] Connection config: HasConnectionMode={HasConnectionMode}, HasAddress={HasAddress}, HasPort={HasPort}, IsAutoMode={IsAutoMode}, IsUsingSerialMode={IsUsingSerialMode}"); // If no connection configuration is available or configured, skip pre-connect (e.g., direct USB devices) - if (!HasConnectionMode && !HasAddress && !HasPort && !IsAutoMode) { + if (!HasConnectionMode && !HasAddress && !HasPort && !IsAutoMode) + { Logger.Info($"[{DeviceName}] No connection configuration needed - device will connect directly"); return true; } // Validate configuration if we're trying to configure something - if (HasConnectionMode || HasPort || HasAddress || IsAutoMode) { - if (!IsAutoMode && !HasPort && !HasConnectionMode) { + if (HasConnectionMode || HasPort || HasAddress || IsAutoMode) + { + if (!IsAutoMode && !HasPort && !HasConnectionMode) + { Logger.Error($"[{DeviceName}] OnPreConnect validation failed: No auto-search and no port specified"); return false; } - if (!IsUsingSerialMode && HasConnectionMode && (!HasAddress || !HasPort)) { + if (!IsUsingSerialMode && HasConnectionMode && (!HasAddress || !HasPort)) + { Logger.Error($"[{DeviceName}] OnPreConnect validation failed: TCP mode requires both address ({HasAddress}) and port ({HasPort})"); return false; } } // Apply connection mode - if (HasConnectionMode) { + if (HasConnectionMode) + { Logger.Info($"[{DeviceName}] Setting CONNECTION_MODE to {_connectionMode}"); - if (!await SetSwitchValueAsync("CONNECTION_MODE", _connectionMode, true, TimeSpan.FromSeconds(10))) { + if (!await SetSwitchValueAsync("CONNECTION_MODE", _connectionMode, true, TimeSpan.FromSeconds(10))) + { Logger.Error($"[{DeviceName}] Failed to set CONNECTION_MODE to {_connectionMode}"); return false; } Logger.Info($"[{DeviceName}] CONNECTION_MODE successfully set to {_connectionMode}"); } - if (IsUsingSerialMode) { + if (IsUsingSerialMode) + { Logger.Info($"[{DeviceName}] Configuring SERIAL mode connection"); // Process SERIAL mode - if (IsAutoMode) { + if (IsAutoMode) + { // Just set auto mode and return Logger.Info($"[{DeviceName}] Enabling DEVICE_AUTO_SEARCH"); bool autoSearchResult = await SetSwitchValueAsync("DEVICE_AUTO_SEARCH", "INDI_ENABLED", true, TimeSpan.FromSeconds(10)); - if (autoSearchResult) { + if (autoSearchResult) + { Logger.Info($"[{DeviceName}] DEVICE_AUTO_SEARCH enabled successfully"); - } else { + } + else + { Logger.Error($"[{DeviceName}] Failed to enable DEVICE_AUTO_SEARCH"); } return autoSearchResult; @@ -616,46 +749,62 @@ protected virtual async Task OnPreConnect() { // Disable auto mode (only if this device actually has the auto-search property — // secondary devices from a multi-device driver typically do not) - if (_hasAutoSearchProperty) { + if (_hasAutoSearchProperty) + { await SetSwitchValueAsync("DEVICE_AUTO_SEARCH", "INDI_DISABLED", true, TimeSpan.FromSeconds(10)); } - if (_hasDevicePortProperty) { + if (_hasDevicePortProperty) + { Logger.Info($"[{DeviceName}] Setting DEVICE_PORT to {_port}"); - if (!await SetTextValueAsync("DEVICE_PORT", "PORT", _port, TimeSpan.FromSeconds(10))) { + if (!await SetTextValueAsync("DEVICE_PORT", "PORT", _port, TimeSpan.FromSeconds(10))) + { Logger.Error($"[{DeviceName}] Failed to set DEVICE_PORT to {_port}"); return false; } Logger.Info($"[{DeviceName}] DEVICE_PORT set to {_port}"); } - if (_hasDeviceBaudRateProperty) { + if (_hasDeviceBaudRateProperty) + { Logger.Info($"[{DeviceName}] Setting DEVICE_BAUD_RATE to {_baudRate}"); - if (!await SetSwitchValueAsync("DEVICE_BAUD_RATE", $"{_baudRate}", true, TimeSpan.FromSeconds(10))) { + if (!await SetSwitchValueAsync("DEVICE_BAUD_RATE", $"{_baudRate}", true, TimeSpan.FromSeconds(10))) + { Logger.Error($"[{DeviceName}] Failed to set DEVICE_BAUD_RATE to {_baudRate}"); return false; } Logger.Info($"[{DeviceName}] DEVICE_BAUD_RATE set to {_baudRate}"); } Logger.Info($"[{DeviceName}] Serial mode configuration complete - DEVICE_PORT={_port}, BAUD_RATE={_baudRate}"); - } else { + } + else + { Logger.Info($"[{DeviceName}] Configuring TCP mode connection"); // Process TCP mode - if (HasAddress) { - if (!_hasDeviceAddressProperty && DeviceName.Contains("Simulator", StringComparison.OrdinalIgnoreCase)) { + if (HasAddress) + { + if (!_hasDeviceAddressProperty && DeviceName.Contains("Simulator", StringComparison.OrdinalIgnoreCase)) + { Logger.Info($"[{DeviceName}] Simulator device has no DEVICE_ADDRESS property - skipping TCP address configuration"); - } else { - try { + } + else + { + try + { Logger.Info($"[{DeviceName}] Setting DEVICE_ADDRESS to {_address}:{_port}"); - if (!await SetTextValueAsync("DEVICE_ADDRESS", "ADDRESS", _address, TimeSpan.FromSeconds(10))) { + if (!await SetTextValueAsync("DEVICE_ADDRESS", "ADDRESS", _address, TimeSpan.FromSeconds(10))) + { Logger.Error($"[{DeviceName}] Failed to set DEVICE_ADDRESS address to {_address}"); return false; } - if (!await SetTextValueAsync("DEVICE_ADDRESS", "PORT", _port, TimeSpan.FromSeconds(10))) { + if (!await SetTextValueAsync("DEVICE_ADDRESS", "PORT", _port, TimeSpan.FromSeconds(10))) + { Logger.Error($"[{DeviceName}] Failed to set DEVICE_ADDRESS port to {_port}"); return false; } Logger.Info($"[{DeviceName}] TCP mode configuration complete - ADDRESS={_address}:{_port}"); - } catch (Exception ex) { + } + catch (Exception ex) + { Logger.Error($"[{DeviceName}] Exception during TCP configuration: {ex.Message}"); return false; } @@ -670,14 +819,19 @@ protected virtual async Task OnPreConnect() { /// /// Check if all required properties have been received /// - private bool HasProperties(string[] props) { - if (props == null || props.Length == 0) { + private bool HasProperties(string[] props) + { + if (props == null || props.Length == 0) + { return true; } - lock (_properties) { - foreach (var propName in props) { - if (!_properties.ContainsKey(propName)) { + lock (_properties) + { + foreach (var propName in props) + { + if (!_properties.ContainsKey(propName)) + { return false; } } @@ -685,7 +839,8 @@ private bool HasProperties(string[] props) { return true; } - public virtual void OnSwitchPropertyUpdated(INDISwitchProperty p) { + public virtual void OnSwitchPropertyUpdated(INDISwitchProperty p) + { /* Logger.Info($"{p.Name}, {p.Label}, {p.State}, {p.Rule}"); foreach (var s in p.Switches) @@ -694,8 +849,10 @@ public virtual void OnSwitchPropertyUpdated(INDISwitchProperty p) { } */ // Track CONNECTION failures to prevent spurious DISCONNECT attempts - if (p.Name == "CONNECTION") { - if (p.State == PropertyState.Alert) { + if (p.Name == "CONNECTION") + { + if (p.State == PropertyState.Alert) + { _connectionAttemptFailed = true; Logger.Warning($"Device '{DeviceName}' connection attempt failed (Alert state)"); } @@ -707,13 +864,16 @@ public virtual void OnSwitchPropertyUpdated(INDISwitchProperty p) { // if there is one, the update is the ack for our own DISCONNECT command and // DisconnectAsync will set _connected = false itself once it completes. var connectSwitch = p.Switches.FirstOrDefault(s => s.Name == "CONNECT"); - if (connectSwitch != null && !connectSwitch.Value && _connected) { + if (connectSwitch != null && !connectSwitch.Value && _connected) + { bool hasPendingConnectionOp; - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { hasPendingConnectionOp = _pendingAsyncOperations.Keys .Any(k => k.StartsWith("CONNECTION_")); } - if (!hasPendingConnectionOp) { + if (!hasPendingConnectionOp) + { Logger.Warning($"Device '{DeviceName}' CONNECTION property shows DISCONNECT while we " + "thought it was connected — marking as externally disconnected"); _connected = false; @@ -722,13 +882,15 @@ public virtual void OnSwitchPropertyUpdated(INDISwitchProperty p) { } // Check if there are any pending async operations for this property - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { // Find all pending operations for this property var operationsForProperty = _pendingAsyncOperations .Where(kvp => kvp.Key.StartsWith(p.Name + "_")) .ToList(); - foreach (var kvp in operationsForProperty) { + foreach (var kvp in operationsForProperty) + { var operationId = kvp.Key; var tcs = kvp.Value; @@ -737,18 +899,26 @@ public virtual void OnSwitchPropertyUpdated(INDISwitchProperty p) { // - Ok: operation completed successfully (some drivers skip Busy and go straight to Ok) // - Alert: server rejected the command // - Idle: operation still pending, keep waiting - if (p.State == PropertyState.Busy) { - if (!tcs.Task.IsCompleted) { + if (p.State == PropertyState.Busy) + { + if (!tcs.Task.IsCompleted) + { Logger.Debug($"Async operation {operationId} acknowledged by server (state: Busy)"); tcs.TrySetResult(true); } - } else if (p.State == PropertyState.Ok) { - if (!tcs.Task.IsCompleted) { + } + else if (p.State == PropertyState.Ok) + { + if (!tcs.Task.IsCompleted) + { Logger.Debug($"Async operation {operationId} completed by server (state: Ok)"); tcs.TrySetResult(true); } - } else if (p.State == PropertyState.Alert) { - if (!tcs.Task.IsCompleted) { + } + else if (p.State == PropertyState.Alert) + { + if (!tcs.Task.IsCompleted) + { Logger.Warning($"Async operation {operationId} rejected by server (state: Alert)"); tcs.TrySetResult(false); } @@ -757,7 +927,8 @@ public virtual void OnSwitchPropertyUpdated(INDISwitchProperty p) { } } - public virtual void OnNumberPropertyUpdated(INDINumberProperty p) { + public virtual void OnNumberPropertyUpdated(INDINumberProperty p) + { /* Logger.Info($"{p.Name}, {p.Label}, {p.State}"); foreach (var n in p.Numbers) { @@ -766,13 +937,15 @@ public virtual void OnNumberPropertyUpdated(INDINumberProperty p) { */ // Check if there are any pending async operations for this property - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { // Find all pending operations for this property var operationsForProperty = _pendingAsyncOperations .Where(kvp => kvp.Key.StartsWith(p.Name + "_")) .ToList(); - foreach (var kvp in operationsForProperty) { + foreach (var kvp in operationsForProperty) + { var operationId = kvp.Key; var tcs = kvp.Value; @@ -781,18 +954,26 @@ public virtual void OnNumberPropertyUpdated(INDINumberProperty p) { // - Ok: operation completed successfully (some drivers skip Busy and go straight to Ok) // - Alert: server rejected the command // - Idle: operation still pending, keep waiting - if (p.State == PropertyState.Busy) { - if (!tcs.Task.IsCompleted) { + if (p.State == PropertyState.Busy) + { + if (!tcs.Task.IsCompleted) + { Logger.Debug($"Async operation {operationId} acknowledged by server (state: Busy)"); tcs.TrySetResult(true); } - } else if (p.State == PropertyState.Ok) { - if (!tcs.Task.IsCompleted) { + } + else if (p.State == PropertyState.Ok) + { + if (!tcs.Task.IsCompleted) + { Logger.Debug($"Async operation {operationId} completed by server (state: Ok)"); tcs.TrySetResult(true); } - } else if (p.State == PropertyState.Alert) { - if (!tcs.Task.IsCompleted) { + } + else if (p.State == PropertyState.Alert) + { + if (!tcs.Task.IsCompleted) + { Logger.Warning($"Async operation {operationId} rejected by server (state: Alert)"); tcs.TrySetResult(false); } @@ -801,7 +982,8 @@ public virtual void OnNumberPropertyUpdated(INDINumberProperty p) { } } - public virtual void OnTextPropertyUpdated(INDITextProperty p) { + public virtual void OnTextPropertyUpdated(INDITextProperty p) + { /* Logger.Info($"{p.Name}, {p.Label}, {p.State}"); foreach (var t in p.Texts) { @@ -810,13 +992,15 @@ public virtual void OnTextPropertyUpdated(INDITextProperty p) { */ // Check if there are any pending async operations for this property - lock (_asyncOperationsLock) { + lock (_asyncOperationsLock) + { // Find all pending operations for this property var operationsForProperty = _pendingAsyncOperations .Where(kvp => kvp.Key.StartsWith(p.Name + "_")) .ToList(); - foreach (var kvp in operationsForProperty) { + foreach (var kvp in operationsForProperty) + { var operationId = kvp.Key; var tcs = kvp.Value; @@ -825,18 +1009,26 @@ public virtual void OnTextPropertyUpdated(INDITextProperty p) { // - Ok: operation completed successfully (some drivers skip Busy and go straight to Ok) // - Alert: server rejected the command // - Idle: operation still pending, keep waiting - if (p.State == PropertyState.Busy) { - if (!tcs.Task.IsCompleted) { + if (p.State == PropertyState.Busy) + { + if (!tcs.Task.IsCompleted) + { Logger.Debug($"Async operation {operationId} acknowledged by server (state: Busy)"); tcs.TrySetResult(true); } - } else if (p.State == PropertyState.Ok) { - if (!tcs.Task.IsCompleted) { + } + else if (p.State == PropertyState.Ok) + { + if (!tcs.Task.IsCompleted) + { Logger.Debug($"Async operation {operationId} completed by server (state: Ok)"); tcs.TrySetResult(true); } - } else if (p.State == PropertyState.Alert) { - if (!tcs.Task.IsCompleted) { + } + else if (p.State == PropertyState.Alert) + { + if (!tcs.Task.IsCompleted) + { Logger.Warning($"Async operation {operationId} rejected by server (state: Alert)"); tcs.TrySetResult(false); } @@ -845,7 +1037,8 @@ public virtual void OnTextPropertyUpdated(INDITextProperty p) { } } - public virtual void OnBlobPropertyUpdated(INDIBlobProperty p) { + public virtual void OnBlobPropertyUpdated(INDIBlobProperty p) + { } private string _connectionMode; @@ -854,7 +1047,8 @@ public virtual void OnBlobPropertyUpdated(INDIBlobProperty p) { private string _port; private int _baudRate; - public void ConfigureConnectionProperties(string connectionMode, bool autoSearch, string address, string port, int baudRate) { + public void ConfigureConnectionProperties(string connectionMode, bool autoSearch, string address, string port, int baudRate) + { _connectionMode = connectionMode; _autoSearch = autoSearch; _address = address; @@ -865,7 +1059,8 @@ public void ConfigureConnectionProperties(string connectionMode, bool autoSearch #region Unsupported public virtual IList SupportedActions => new List(); - public virtual string Action(string actionName, string actionParameters) { + public virtual string Action(string actionName, string actionParameters) + { throw new NotImplementedException(); } #endregion @@ -877,18 +1072,23 @@ public virtual string Action(string actionName, string actionParameters) { private NetworkStream _lx200Stream; private readonly object _lx200Lock = new object(); - private int GetTcpPort() { - if (string.IsNullOrEmpty(_address) || string.IsNullOrEmpty(_port)) { + private int GetTcpPort() + { + if (string.IsNullOrEmpty(_address) || string.IsNullOrEmpty(_port)) + { throw new InvalidOperationException("Cannot send raw command: device is not configured for TCP mode (no address/port)"); } - if (!int.TryParse(_port, out int port)) { + if (!int.TryParse(_port, out int port)) + { throw new InvalidOperationException($"Cannot send raw command: invalid port '{_port}'"); } return port; } - private NetworkStream EnsureLx200Stream() { - if (_lx200Client != null && _lx200Client.Connected) { + private NetworkStream EnsureLx200Stream() + { + if (_lx200Client != null && _lx200Client.Connected) + { return _lx200Stream; } _lx200Client?.Dispose(); @@ -901,15 +1101,18 @@ private NetworkStream EnsureLx200Stream() { return _lx200Stream; } - private void DisposeLx200Connection() { + private void DisposeLx200Connection() + { _lx200Stream?.Dispose(); _lx200Client?.Dispose(); _lx200Stream = null; _lx200Client = null; } - private void SendRawTcpBlind(string command) { - lock (_lx200Lock) { + private void SendRawTcpBlind(string command) + { + lock (_lx200Lock) + { var stream = EnsureLx200Stream(); var bytes = Encoding.ASCII.GetBytes(command); stream.Write(bytes, 0, bytes.Length); @@ -917,8 +1120,10 @@ private void SendRawTcpBlind(string command) { } } - private bool SendRawTcpCommandBool(string command) { - lock (_lx200Lock) { + private bool SendRawTcpCommandBool(string command) + { + lock (_lx200Lock) + { var stream = EnsureLx200Stream(); var bytes = Encoding.ASCII.GetBytes(command); stream.Write(bytes, 0, bytes.Length); @@ -929,15 +1134,18 @@ private bool SendRawTcpCommandBool(string command) { } } - private string SendRawTcpCommand(string command) { - lock (_lx200Lock) { + private string SendRawTcpCommand(string command) + { + lock (_lx200Lock) + { var stream = EnsureLx200Stream(); var bytes = Encoding.ASCII.GetBytes(command); stream.Write(bytes, 0, bytes.Length); stream.Flush(); // Read until '#' terminator var response = new StringBuilder(); - while (true) { + while (true) + { int b = stream.ReadByte(); if (b == -1) break; var ch = (char)b; @@ -948,37 +1156,52 @@ private string SendRawTcpCommand(string command) { } } - public virtual void CommandBlind(string command, bool raw = false) { - if (string.IsNullOrEmpty(_address) || _connectionMode != "CONNECTION_TCP") { + public virtual void CommandBlind(string command, bool raw = false) + { + if (string.IsNullOrEmpty(_address) || _connectionMode != "CONNECTION_TCP") + { throw new NotImplementedException(); } - try { + try + { SendRawTcpBlind(command); - } catch (Exception) { + } + catch (Exception) + { DisposeLx200Connection(); throw; } } - public virtual bool CommandBool(string command, bool raw = false) { - if (string.IsNullOrEmpty(_address) || _connectionMode != "CONNECTION_TCP") { + public virtual bool CommandBool(string command, bool raw = false) + { + if (string.IsNullOrEmpty(_address) || _connectionMode != "CONNECTION_TCP") + { throw new NotImplementedException(); } - try { + try + { return SendRawTcpCommandBool(command); - } catch (Exception) { + } + catch (Exception) + { DisposeLx200Connection(); throw; } } - public virtual string CommandString(string command, bool raw = false) { - if (string.IsNullOrEmpty(_address) || _connectionMode != "CONNECTION_TCP") { + public virtual string CommandString(string command, bool raw = false) + { + if (string.IsNullOrEmpty(_address) || _connectionMode != "CONNECTION_TCP") + { throw new NotImplementedException(); } - try { + try + { return SendRawTcpCommand(command); - } catch (Exception) { + } + catch (Exception) + { DisposeLx200Connection(); throw; } diff --git a/NINA.INDI/Devices/INDISwitchHub.cs b/NINA.INDI/Devices/INDISwitchHub.cs new file mode 100644 index 000000000..f95e35078 --- /dev/null +++ b/NINA.INDI/Devices/INDISwitchHub.cs @@ -0,0 +1,209 @@ +#region "copyright" + +/* + Copyright © 2025-2026 Nico Trost and the PI.N.S. contributors + + This file is part of PI 'N' Stars. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +#endregion "copyright" + +using NINA.INDI.Enums; +using NINA.INDI.Interfaces; +using NINA.INDI.Protocol; +using System.Collections.Generic; + +namespace NINA.INDI.Devices +{ + + /// + /// Generic INDI switch hub that maps an AUX-interface device (e.g. Pegasus + /// UPB / UPB v2) to the NINA switch hub abstraction. + /// + /// After connecting, the device introspects all incoming INDI property + /// definitions and builds a flat list of + /// objects from every writable switch- and number-vector property that is + /// not an infrastructure property (connection management, driver config, …). + /// + /// OneOfMany switch vectors (radio-button style selectors such as tracking + /// rates) are intentionally excluded because they cannot be treated as + /// independent on/off channels. + /// + public class INDISwitchHub : INDIDevice, IINDISwitchHub + { + + // --------------------------------------------------------------------------- + // Properties to ignore entirely – these control INDI infrastructure, not the + // device's user-facing channels. + // --------------------------------------------------------------------------- + private static readonly HashSet SkippedProperties = new(System.StringComparer.OrdinalIgnoreCase) { + "CONNECTION", + "CONNECTION_MODE", + "DEVICE_PORT", + "DEVICE_BAUD_RATE", + "DEVICE_AUTO_SEARCH", + "DEVICE_ADDRESS", + "CONFIG_PROCESS", + "POLLING_PERIOD", + "DEBUG", + "SIMULATION", + "DRIVER_INFO", + "DRIVER_EXEC", + "DEVICE_INFORMATION", + "ACTIVE_DEVICES", + "TELESCOPE_INFO", + "SERVER_INFO" + }; + + // Groups that contain only infrastructure properties. + private static readonly HashSet SkippedGroups = new(System.StringComparer.OrdinalIgnoreCase) { + "Connection", + "Options" + }; + + private readonly List _descriptors = new(); + private readonly object _descriptorsLock = new(); + + public event System.Action ValuesUpdated; + + public INDISwitchHub(INDIDeviceInfo device) : base(device) { } + + // No specific property required – the equipment layer adds its own wait. + protected override string[] GetRequiredConnectionProperties() => System.Array.Empty(); + + // ------------------------------------------------------------------- + // IINDISwitchHub + // ------------------------------------------------------------------- + + public IReadOnlyList GetDescriptors() + { + lock (_descriptorsLock) + { + return _descriptors.ToArray(); + } + } + + public double GetValue(INDISwitchDescriptor descriptor) + { + if (descriptor.IsBoolSwitch) + { + return (GetSwitchPropertyValue(descriptor.PropertyName, descriptor.ElementName) ?? false) ? 1.0 : 0.0; + } + return GetNumberPropertyValue(descriptor.PropertyName, descriptor.ElementName) ?? 0.0; + } + + public void SetBoolElement(INDISwitchDescriptor descriptor, bool value) + { + SetSwitchValue(descriptor.PropertyName, descriptor.ElementName, value); + } + + public void SetNumberElement(INDISwitchDescriptor descriptor, double value) + { + SetNumberValue(descriptor.PropertyName, descriptor.ElementName, value); + } + + // ------------------------------------------------------------------- + // Property update callbacks – build descriptors as properties arrive + // ------------------------------------------------------------------- + + public override void OnSwitchPropertyUpdated(INDISwitchProperty p) + { + base.OnSwitchPropertyUpdated(p); + + if (SkippedProperties.Contains(p.Name)) return; + if (SkippedGroups.Contains(p.Group)) return; + // OneOfMany = radio-button selector; not a discrete on/off channel. + if (p.Rule == SwitchRule.OneOfMany) return; + + bool isWritable = p.Permission != PropertyPermission.ReadOnly; + + lock (_descriptorsLock) + { + // Refresh descriptors for this property (initial def or relabel). + _descriptors.RemoveAll(d => d.PropertyName == p.Name && d.IsBoolSwitch); + + foreach (var sw in p.Switches) + { + _descriptors.Add(new INDISwitchDescriptor + { + PropertyName = p.Name, + PropertyLabel = string.IsNullOrWhiteSpace(p.Label) ? p.Name : p.Label, + ElementName = sw.Name, + ElementLabel = string.IsNullOrWhiteSpace(sw.Label) ? sw.Name : sw.Label, + IsWritable = isWritable, + IsBoolSwitch = true, + Min = 0.0, + Max = 1.0, + Step = 1.0 + }); + } + } + + // Notify the equipment layer that values for this property changed. + ValuesUpdated?.Invoke(p.Name); + } + + public override void OnNumberPropertyUpdated(INDINumberProperty p) + { + base.OnNumberPropertyUpdated(p); + + if (SkippedProperties.Contains(p.Name)) return; + if (SkippedGroups.Contains(p.Group)) return; + + bool isWritable = p.Permission != PropertyPermission.ReadOnly; + + lock (_descriptorsLock) + { + _descriptors.RemoveAll(d => d.PropertyName == p.Name && !d.IsBoolSwitch); + + foreach (var num in p.Numbers) + { + _descriptors.Add(new INDISwitchDescriptor + { + PropertyName = p.Name, + PropertyLabel = string.IsNullOrWhiteSpace(p.Label) ? p.Name : p.Label, + ElementName = num.Name, + ElementLabel = string.IsNullOrWhiteSpace(num.Label) ? num.Name : num.Label, + IsWritable = isWritable, + IsBoolSwitch = false, + Min = num.Min, + Max = num.Max, + Step = num.Step > 0 ? num.Step : 1.0 + }); + } + } + + // Notify the equipment layer that values for this property changed. + ValuesUpdated?.Invoke(p.Name); + } + + public override void OnTextPropertyUpdated(INDITextProperty p) + { + base.OnTextPropertyUpdated(p); + } + + public override void OnBlobPropertyUpdated(INDIBlobProperty p) + { + base.OnBlobPropertyUpdated(p); + } + + // ------------------------------------------------------------------- + // Unsupported IINDIDevice actions + // ------------------------------------------------------------------- + + #region Unsupported + + public System.Collections.Generic.IList SupportedActions { get; } = new List(); + + public string Action(string actionName, string actionParameters) => throw new System.NotImplementedException(); + public void CommandBlind(string command, bool raw = false) => throw new System.NotImplementedException(); + public bool CommandBool(string command, bool raw = false) => throw new System.NotImplementedException(); + public string CommandString(string command, bool raw = false) => throw new System.NotImplementedException(); + + #endregion + } +} \ No newline at end of file diff --git a/NINA.INDI/Enums/DeviceInterfaceEnum.cs b/NINA.INDI/Enums/DeviceInterfaceEnum.cs index d94826f34..7522033cf 100644 --- a/NINA.INDI/Enums/DeviceInterfaceEnum.cs +++ b/NINA.INDI/Enums/DeviceInterfaceEnum.cs @@ -32,6 +32,6 @@ public enum DeviceInterface { DETECTOR_INTERFACE = (1 << 11), ROTATOR_INTERFACE = (1 << 12), SPECTROGRAPH_INTERFACE = (1 << 13), - AUX_INTERFACE = (1 << 14), + AUX_INTERFACE = (1 << 15), } -} \ No newline at end of file +} diff --git a/NINA.INDI/INDISwitchDescriptor.cs b/NINA.INDI/INDISwitchDescriptor.cs new file mode 100644 index 000000000..d37b70508 --- /dev/null +++ b/NINA.INDI/INDISwitchDescriptor.cs @@ -0,0 +1,53 @@ +#region "copyright" + +/* + Copyright © 2025-2026 Nico Trost and the PI.N.S. contributors + + This file is part of PI 'N' Stars. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +#endregion "copyright" + +namespace NINA.INDI { + + /// + /// Describes a single controllable channel discovered from an INDI AUX device + /// (e.g. a DC power port, USB port, or dew-heater duty-cycle on a Pegasus UPB). + /// + public sealed class INDISwitchDescriptor { + + /// INDI property vector name, e.g. "POWER_CHANNELS". + public string PropertyName { get; set; } = string.Empty; + + /// Human-readable label for the property group, e.g. "DC Power Control". + public string PropertyLabel { get; set; } = string.Empty; + + /// INDI element name within the vector, e.g. "POWER_CHANNEL_1". + public string ElementName { get; set; } = string.Empty; + + /// Human-readable label for the element, e.g. "DC Port 1". + public string ElementLabel { get; set; } = string.Empty; + + /// Whether the channel can be written (permissions include Write). + public bool IsWritable { get; set; } + + /// + /// True when the backing INDI property is a switch vector (boolean on/off), + /// false when it is a number vector (e.g. dew-heater duty-cycle 0–100 %). + /// + public bool IsBoolSwitch { get; set; } + + /// Minimum allowed value (0 for boolean switches). + public double Min { get; set; } + + /// Maximum allowed value (1 for boolean switches). + public double Max { get; set; } + + /// Step size (1 for boolean switches). + public double Step { get; set; } + } +} \ No newline at end of file diff --git a/NINA.INDI/Interfaces/IINDISwitchHub.cs b/NINA.INDI/Interfaces/IINDISwitchHub.cs new file mode 100644 index 000000000..e9bf66f5a --- /dev/null +++ b/NINA.INDI/Interfaces/IINDISwitchHub.cs @@ -0,0 +1,53 @@ +#region "copyright" + +/* + Copyright © 2025-2026 Nico Trost and the PI.N.S. contributors + + This file is part of PI 'N' Stars. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +#endregion "copyright" + +using System; +using System.Collections.Generic; + +namespace NINA.INDI.Interfaces +{ + + /// + /// INDI-layer contract for a generic AUX device that exposes its writable + /// switch and numeric properties as a flat list of controllable channels. + /// Used by the Pegasus UPB and similar power/USB hub drivers. + /// + public interface IINDISwitchHub : IINDIDevice + { + + /// + /// Returns all controllable channels discovered after the device connected. + /// Each maps to one INDI property element. + /// + IReadOnlyList GetDescriptors(); + + /// + /// Reads the current value of a channel. + /// Returns 0.0/1.0 for boolean switch elements; the raw numeric value otherwise. + /// + double GetValue(INDISwitchDescriptor descriptor); + + /// Writes a boolean on/off value to an INDI switch element. + void SetBoolElement(INDISwitchDescriptor descriptor, bool value); + + /// Writes a numeric value to an INDI number element. + void SetNumberElement(INDISwitchDescriptor descriptor, double value); + + /// + /// Raised whenever the INDI server pushes an updated value for any property + /// (setSwitchVector or setNumberVector). The argument is the INDI property name. + /// + event System.Action ValuesUpdated; + } +} \ No newline at end of file diff --git a/NINA.Profile/Interfaces/ISwitchSettings.cs b/NINA.Profile/Interfaces/ISwitchSettings.cs index 9e5da64d5..e28d50568 100644 --- a/NINA.Profile/Interfaces/ISwitchSettings.cs +++ b/NINA.Profile/Interfaces/ISwitchSettings.cs @@ -1,7 +1,7 @@ #region "copyright" /* - Copyright © 2016 - 2026 Stefan Berg and the N.I.N.A. contributors + Copyright � 2016 - 2026 Stefan Berg and the N.I.N.A. contributors This file is part of N.I.N.A. - Nighttime Imaging 'N' Astronomy. @@ -18,5 +18,10 @@ public interface ISwitchSettings : ISettings { string Id { get; set; } string LastDeviceName { get; set; } string IndiDriver { get; set; } + string IndiConnectionMode { get; set; } + string IndiPort { get; set; } + int IndiBaudRate { get; set; } + bool IndiAutoSearch { get; set; } + string IndiAddress { get; set; } } } diff --git a/NINA.Profile/SwitchSettings.cs b/NINA.Profile/SwitchSettings.cs index 058a5acc1..8b3956f7a 100644 --- a/NINA.Profile/SwitchSettings.cs +++ b/NINA.Profile/SwitchSettings.cs @@ -32,6 +32,11 @@ protected override void SetDefaultValues() { id = "No_Device"; indiDriver = "None"; lastDeviceName = string.Empty; + indiConnectionMode = "CONNECTION_SERIAL"; + indiPort = "/dev/ttyUSB0"; + indiBaudRate = 9600; + indiAutoSearch = true; + indiAddress = "localhost"; } private string id; @@ -71,5 +76,65 @@ public string IndiDriver { } } } + + private string indiConnectionMode; + [DataMember] + public string IndiConnectionMode { + get => indiConnectionMode; + set { + if (indiConnectionMode != value) { + indiConnectionMode = value; + RaisePropertyChanged(); + } + } + } + + private string indiPort; + [DataMember] + public string IndiPort { + get => indiPort; + set { + if (indiPort != value) { + indiPort = value; + RaisePropertyChanged(); + } + } + } + + private int indiBaudRate; + [DataMember] + public int IndiBaudRate { + get => indiBaudRate; + set { + if (indiBaudRate != value) { + indiBaudRate = value; + RaisePropertyChanged(); + } + } + } + + private bool indiAutoSearch; + [DataMember] + public bool IndiAutoSearch { + get => indiAutoSearch; + set { + if (indiAutoSearch != value) { + indiAutoSearch = value; + RaisePropertyChanged(); + } + } + } + + private string indiAddress; + [DataMember] + public string IndiAddress { + get => indiAddress; + set { + if (indiAddress != value) { + indiAddress = value; + RaisePropertyChanged(); + } + } + } } } diff --git a/NINA.WPF.Base/ViewModel/Equipment/Switch/SwitchChooserVM.cs b/NINA.WPF.Base/ViewModel/Equipment/Switch/SwitchChooserVM.cs index a41c8da9c..615045593 100644 --- a/NINA.WPF.Base/ViewModel/Equipment/Switch/SwitchChooserVM.cs +++ b/NINA.WPF.Base/ViewModel/Equipment/Switch/SwitchChooserVM.cs @@ -1,7 +1,7 @@ #region "copyright" /* - Copyright © 2016 - 2026 Stefan Berg and the N.I.N.A. contributors + Copyright � 2016 - 2026 Stefan Berg and the N.I.N.A. contributors This file is part of N.I.N.A. - Nighttime Imaging 'N' Astronomy. @@ -25,6 +25,7 @@ This Source Code Form is subject to the terms of the Mozilla Public using NINA.Equipment.Interfaces; using NINA.Equipment.Equipment; using NINA.Equipment.Interfaces.ViewModel; +using System.Runtime.InteropServices; namespace NINA.WPF.Base.ViewModel.Equipment.Switch { @@ -63,6 +64,20 @@ public override async Task GetEquipment() { Logger.Error(ex); } + /* INDI (Linux / macOS) */ + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + try { + var indiInteraction = new INDIInteraction(profileService); + var indiSwitches = await indiInteraction.GetSwitches(); + foreach (ISwitchHub s in indiSwitches) { + devices.Add(s); + } + Logger.Info($"Found {indiSwitches?.Count} INDI Switch Hubs"); + } catch (Exception ex) { + Logger.Error(ex); + } + } + DetermineSelectedDevice(devices, profileService.ActiveProfile.SwitchSettings.Id, profileService.ActiveProfile.SwitchSettings.LastDeviceName); } finally {