From 5e57ded3a94fd2bf4dd2ddcf7694289e05c610f1 Mon Sep 17 00:00:00 2001 From: Mike Carr Date: Mon, 3 Mar 2025 11:24:04 -0800 Subject: [PATCH] add pulsing connect button based on ping status --- OpenIPC_Config/App.axaml.cs | 21 +- OpenIPC_Config/Models/OpenIPC.cs | 1 + OpenIPC_Config/Services/PingService.cs | 82 +++++ .../ViewModels/FirmwareTabViewModel.cs | 2 + .../ViewModels/GlobalSettingsViewModel.cs | 4 +- OpenIPC_Config/ViewModels/MainViewModel.cs | 258 ++++++++++--- OpenIPC_Config/ViewModels/WfbTabViewModel.cs | 11 + OpenIPC_Config/Views/HeaderView.axaml | 344 +++++++++++------- OpenIPC_Config/appsettings.Development.json | 2 +- 9 files changed, 545 insertions(+), 180 deletions(-) create mode 100644 OpenIPC_Config/Services/PingService.cs diff --git a/OpenIPC_Config/App.axaml.cs b/OpenIPC_Config/App.axaml.cs index 28c43fb..331ca1c 100644 --- a/OpenIPC_Config/App.axaml.cs +++ b/OpenIPC_Config/App.axaml.cs @@ -29,7 +29,13 @@ public class App : Application public static string OSType { get; private set; } + + +#if DEBUG private bool _ShouldCheckForUpdates = false; +#else + private bool _ShouldCheckForUpdates = true; +#endif private void DetectOsType() { @@ -62,6 +68,7 @@ private IConfigurationRoot LoadConfiguration() Log.Information($"Default appsettings.json created at {configPath}"); //} + Console.WriteLine($"Loading configuration from: {configPath}"); // Build configuration var configuration = new ConfigurationBuilder() .AddJsonFile(configPath, false, true) @@ -192,6 +199,8 @@ private async Task CheckForUpdatesAsync() var configPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Assembly.GetExecutingAssembly().GetName().Name, "appsettings.json"); + Console.WriteLine($"Loading configuration from: {configPath}"); + // Create an IConfiguration instance var configuration = new ConfigurationBuilder() .AddJsonFile(configPath, false, true) @@ -269,7 +278,7 @@ private void ConfigureServices(IServiceCollection services, IConfiguration confi private static void RegisterViewModels(IServiceCollection services) { // Register ViewModels - services.AddTransient(); + services.AddSingleton(); // Register tab ViewModels as singletons services.AddSingleton(); @@ -327,7 +336,12 @@ private JObject createDefaultAppSettings() new JProperty("WriteTo", new JArray( new JObject( - new JProperty("Name", "Console") + new JProperty("Name", "Console"), + new JProperty("Args", // Add Args here for Console + new JObject( + new JProperty("outputTemplate", "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}") + ) + ) ), new JObject( new JProperty("Name", "File"), @@ -337,7 +351,8 @@ private JObject createDefaultAppSettings() new JProperty("rollingInterval", "Day"), new JProperty("retainedFileCountLimit", - "5") + "5"), + new JProperty("outputTemplate", "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}") ) ) ) diff --git a/OpenIPC_Config/Models/OpenIPC.cs b/OpenIPC_Config/Models/OpenIPC.cs index 78895b5..fc6a5ba 100644 --- a/OpenIPC_Config/Models/OpenIPC.cs +++ b/OpenIPC_Config/Models/OpenIPC.cs @@ -20,6 +20,7 @@ public enum FileType public const string OpenIPCBuilderGitHubApiUrl = "https://api.github.com/repos/OpenIPC/builder/releases/latest"; public const string MajesticFileLoc = "/etc/majestic.yaml"; public const string WfbConfFileLoc = "/etc/wfb.conf"; + public const string WfbYamlFileLoc = "/etc/wfb.yaml"; public const string TelemetryConfFileLoc = "/etc/telemetry.conf"; // Radxa files diff --git a/OpenIPC_Config/Services/PingService.cs b/OpenIPC_Config/Services/PingService.cs new file mode 100644 index 0000000..dc27856 --- /dev/null +++ b/OpenIPC_Config/Services/PingService.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Concurrent; +using System.Net.NetworkInformation; +using System.Threading; +using System.Threading.Tasks; +using Serilog; + +namespace OpenIPC_Config.Services; + +public class PingService +{ + private static PingService _instance; + private static readonly object _lock = new object(); + + private readonly SemaphoreSlim _pingSemaphore = new SemaphoreSlim(1, 1); + private readonly TimeSpan _defaultTimeout = TimeSpan.FromMilliseconds(500); + private readonly ILogger _logger; + + // Private constructor for singleton pattern + private PingService(ILogger logger) + { + _logger = logger; + } + + // Singleton instance getter + public static PingService Instance(ILogger logger) + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + _instance = new PingService(logger); + } + } + } + return _instance; + } + + // Ping method with default timeout + public async Task SendPingAsync(string ipAddress) + { + return await SendPingAsync(ipAddress, (int)_defaultTimeout.TotalMilliseconds); + } + + // Ping method with custom timeout + public async Task SendPingAsync(string ipAddress, int timeout) + { + // Log which IP is being pinged + _logger.Debug($"Attempting to ping IP: {ipAddress}"); + + if (await _pingSemaphore.WaitAsync(timeout)) + { + try + { + using (var ping = new Ping()) + { + return await ping.SendPingAsync(ipAddress, timeout); + } + } + finally + { + // Release the semaphore when done + _pingSemaphore.Release(); + } + } + else + { + _logger.Warning("Timeout waiting to acquire ping semaphore for {IpAddress}", ipAddress); + + // Since we can't create a PingReply directly, throw a meaningful exception instead + throw new TimeoutException($"Ping operation to {ipAddress} was delayed due to concurrent requests"); + } + } + + // Dispose method to clean up all resources + public void Dispose() + { + _pingSemaphore.Dispose(); + } +} \ No newline at end of file diff --git a/OpenIPC_Config/ViewModels/FirmwareTabViewModel.cs b/OpenIPC_Config/ViewModels/FirmwareTabViewModel.cs index 29e2d64..254c540 100644 --- a/OpenIPC_Config/ViewModels/FirmwareTabViewModel.cs +++ b/OpenIPC_Config/ViewModels/FirmwareTabViewModel.cs @@ -344,8 +344,10 @@ private void UpdateCanExecuteCommands() OnPropertyChanged(nameof(CanUseDropdownsBySoc)); OnPropertyChanged(nameof(CanUseSelectFirmware)); + (DownloadFirmwareAsyncCommand as RelayCommand)?.NotifyCanExecuteChanged(); (PerformFirmwareUpgradeAsyncCommand as RelayCommand)?.NotifyCanExecuteChanged(); + (SelectFirmwareCommand as RelayCommand)?.NotifyCanExecuteChanged(); } private async Task FetchFirmwareListAsync() diff --git a/OpenIPC_Config/ViewModels/GlobalSettingsViewModel.cs b/OpenIPC_Config/ViewModels/GlobalSettingsViewModel.cs index c3a399d..f080861 100644 --- a/OpenIPC_Config/ViewModels/GlobalSettingsViewModel.cs +++ b/OpenIPC_Config/ViewModels/GlobalSettingsViewModel.cs @@ -73,10 +73,12 @@ private async Task CheckWfbYamlSupport(CancellationToken cancellationToken) try { var cmdResult = await GetIsWfbYamlSupported(cancellationToken); + + IsWfbYamlEnabled = bool.TryParse(Utilities.RemoveLastChar(cmdResult?.Result), out var result) && result; // TODO: check if wfb.yaml exists when all parameters are supported // https://github.com/svpcom/wfb-ng/wiki/Drone-auto-provisioning - IsWfbYamlEnabled = false; + //IsWfbYamlEnabled = false; Logger.Debug($"WFB YAML support status: {IsWfbYamlEnabled}"); } diff --git a/OpenIPC_Config/ViewModels/MainViewModel.cs b/OpenIPC_Config/ViewModels/MainViewModel.cs index 63ec62e..31a0462 100644 --- a/OpenIPC_Config/ViewModels/MainViewModel.cs +++ b/OpenIPC_Config/ViewModels/MainViewModel.cs @@ -1,6 +1,8 @@ using System; using System.Collections.ObjectModel; +using System.Diagnostics; using System.Linq; +using System.Net.NetworkInformation; using System.Threading; using System.Threading.Tasks; using System.Windows.Input; @@ -20,18 +22,31 @@ namespace OpenIPC_Config.ViewModels; public partial class MainViewModel : ViewModelBase { + + // With this singleton service + private readonly PingService _pingService; + + private DispatcherTimer _pingTimer; + private readonly TimeSpan _pingInterval = TimeSpan.FromSeconds(1); + private readonly TimeSpan _pingTimeout = TimeSpan.FromMilliseconds(500); + + [ObservableProperty] private string _svgPath; private bool _isTabsCollapsed; - + [ObservableProperty] private bool _isMobile; private DeviceType _selectedDeviceType; - + [ObservableProperty] private string _soc; [ObservableProperty] private string _sensor; [ObservableProperty] private string _networkCardType; private readonly IServiceProvider _serviceProvider; private readonly GlobalSettingsViewModel _globalSettingsSettingsViewModel; + + + [ObservableProperty] private bool _isWaiting; + [ObservableProperty] private bool _isConnected; public MainViewModel(ILogger logger, @@ -41,24 +56,25 @@ public MainViewModel(ILogger logger, GlobalSettingsViewModel globalSettingsSettingsViewModel) : base(logger, sshClientService, eventSubscriptionService) { + LoadSettings(); + + // Initialize the ping service + _pingService = PingService.Instance(logger); + CanConnect = false; + IsMobile = false; _serviceProvider = serviceProvider; _appVersionText = GetFormattedAppVersion(); - CanConnect = false; _globalSettingsSettingsViewModel = globalSettingsSettingsViewModel; - - LoadSettings(); - + Tabs = new ObservableCollection { }; // Subscribe to device type change events EventSubscriptionService.Subscribe( OnDeviceTypeChangeEvent); - + ToggleTabsCommand = new RelayCommand(() => IsTabsCollapsed = !IsTabsCollapsed); - LoadSettings(); - EntryBoxBgColor = new SolidColorBrush(Colors.White); ConnectCommand = new RelayCommand(() => Connect()); @@ -74,6 +90,9 @@ public MainViewModel(ILogger logger, Sensor = string.Empty; NetworkCardType = string.Empty; IsVRXEnabled = false; + + // initialize tabs with Camera + InitializeTabs(DeviceType.Camera); } private void InitializeTabs(DeviceType deviceType) @@ -81,7 +100,7 @@ private void InitializeTabs(DeviceType deviceType) Tabs.Clear(); // if Mobile apps default to tabs collapsed - if(OperatingSystem.IsAndroid() || OperatingSystem.IsIOS()) + if (OperatingSystem.IsAndroid() || OperatingSystem.IsIOS()) { IsMobile = true; IsTabsCollapsed = true; @@ -109,11 +128,9 @@ private void InitializeTabs(DeviceType deviceType) _serviceProvider.GetRequiredService(), IsTabsCollapsed)); Tabs.Add(new TabItemViewModel("Setup", "avares://OpenIPC_Config/Assets/Icons/iconoir_settings_dark.svg", _serviceProvider.GetRequiredService(), IsTabsCollapsed)); - } - } - + public bool IsTabsCollapsed { get => _isTabsCollapsed; @@ -129,8 +146,7 @@ public bool IsTabsCollapsed public ObservableCollection Tabs { get; set; } public ObservableCollection DeviceTypes { get; set; } - [ObservableProperty] - private SolidColorBrush _entryBoxBgColor; + [ObservableProperty] private SolidColorBrush _entryBoxBgColor; public int SelectedTabIndex { get; set; } public ICommand ConnectCommand { get; private set; } @@ -152,7 +168,7 @@ private void UpdateSvgPath() ? "/Assets/Icons/drawer-open.svg" : "/Assets/Icons/drawer-close.svg"; } - + public DeviceType SelectedDeviceType { get => _selectedDeviceType; @@ -191,10 +207,34 @@ private void CheckIfCanConnect() var isValidIp = Utilities.IsValidIpAddress(IpAddress); CanConnect = !string.IsNullOrWhiteSpace(Password) && isValidIp - && !SelectedDeviceType.Equals(DeviceType.None); + && !SelectedDeviceType.Equals(DeviceType.None) + && IsConnected; + }); } + partial void OnIpAddressChanged(string value) + { + Logger.Debug($"IP Address changed to: {value}"); + if (!string.IsNullOrEmpty(value)) + { + if (Utilities.IsValidIpAddress(value)) + { + Logger.Debug($"Starting ping timer for valid IP: {value}"); + StartPingTimer(); + } + else + { + Logger.Debug($"Stopping ping timer for invalid IP: {value}"); + StopPingTimer(); + } + } + else + { + Logger.Debug("Stopping ping timer for empty IP"); + StopPingTimer(); + } + } partial void OnPortChanged(int value) { CheckIfCanConnect(); @@ -215,6 +255,124 @@ private void SendDeviceTypeMessage(DeviceType deviceType) EventSubscriptionService.Publish(deviceType); } + private void StopPingTimer() + { + if (_pingTimer != null) + { + CanConnect = false; + Logger.Debug($"Stopping ping timer for IP: {IpAddress}"); + + _pingTimer.Tick -= PingTimer_Tick; // Remove the event handler + _pingTimer.Stop(); + _pingTimer.IsEnabled = false; + _pingTimer = null; // Set to null to ensure it's recreated + + IsWaiting = true; + } + } + private void StartPingTimer() + { + CanConnect = false; + + // First, stop any existing timer to avoid duplicates + StopPingTimer(); + + _pingTimer = new DispatcherTimer + { + Interval = _pingInterval + }; + Logger.Debug($"************************* Adding PickerTimer_Tick event handler for IP: {IpAddress}"); + _pingTimer.Tick += PingTimer_Tick; + + Logger.Debug($"Starting ping timer for IP: {IpAddress}"); + _pingTimer.Start(); + } + + // PingTimer_Tick method + private async void PingTimer_Tick(object? sender, EventArgs e) + { + try + { + string currentIpAddress = IpAddress; // Assuming this is the property holding the IP + + // Check if can connect + CheckIfCanConnect(); + + // Important: Store the IP address that was used for this ping operation + string ipBeingPinged = currentIpAddress; + + Logger.Debug($"PingTimer_Tick executing ping to: {ipBeingPinged}"); + + if (string.IsNullOrEmpty(ipBeingPinged)) + { + Logger.Debug("Empty IP - Setting IsConnected=false, IsWaiting=true"); + IsConnected = false; + IsWaiting = true; + return; + } + + try + { + var reply = await _pingService.SendPingAsync(ipBeingPinged, (int)_pingTimeout.TotalMilliseconds); + + // Only update the UI state if the IP we pinged is still the current IP + if (ipBeingPinged == IpAddress) + { + if (reply.Status == IPStatus.Success) + { + Logger.Debug("Ping successful - Setting IsConnected=true, IsWaiting=false"); + + // used for status color changes + IsConnected = true; + IsWaiting = false; + + // enable connect button + CanConnect = true; + } + else + { + Logger.Debug("Ping failed - Setting IsConnected=false, IsWaiting=true"); + + // used for status color changes + IsConnected = false; + IsWaiting = true; + + // disable connect button, device not ready + CanConnect = false; + } + } + else + { + Logger.Debug($"IP changed during ping from {ipBeingPinged} to {IpAddress} - ignoring result"); + } + } + catch (Exception pingEx) + { + CanConnect = false; + // Only update the UI state if the IP we pinged is still the current IP + if (ipBeingPinged == IpAddress) + { + Logger.Error(pingEx, $"Error occurred during ping to {ipBeingPinged}"); + IsConnected = false; + IsWaiting = true; + Logger.Debug("Current state after exception: IsConnected=False, IsWaiting=True"); + } + else + { + Logger.Debug($"IP changed during ping from {ipBeingPinged} to {IpAddress} - ignoring error"); + } + } + + Logger.Debug($"After ping to {ipBeingPinged}: IsConnected={IsConnected}, IsWaiting={IsWaiting}"); + } + catch (Exception ex) + { + Logger.Error(ex, "Unhandled error in PingTimer_Tick"); + IsConnected = false; + IsWaiting = true; + } + } + private async void Connect() { var appMessage = new AppMessage(); @@ -232,7 +390,7 @@ private async void Connect() Log.Error("Failed to get hostname, stopping"); return; } - + var validator = App.ServiceProvider.GetRequiredService(); if (!validator.IsDeviceConfigValid(_deviceConfig)) { @@ -248,13 +406,13 @@ private async void Connect() return; } } - + await getSensorType(_deviceConfig); Sensor = _deviceConfig.SensorType; - + await getNetworkCardType(_deviceConfig); NetworkCardType = _deviceConfig.NetworkCardType; - + await getChipType(_deviceConfig); Soc = _deviceConfig.ChipType; // Save the config to app settings @@ -265,7 +423,7 @@ private async void Connect() // set the background to gray EntryBoxBgColor = new SolidColorBrush(Colors.Gray); - + appMessage.DeviceConfig = _deviceConfig; if (_deviceConfig != null) @@ -285,9 +443,10 @@ private async void Connect() } UpdateUIMessage("Connected"); - } + + private void SaveConfig() { _deviceConfig.DeviceType = SelectedDeviceType; @@ -337,7 +496,7 @@ await SshClientService.ExecuteCommandWithResponseAsync(deviceConfig, DeviceComma // Cleanup cts.Dispose(); } - + /// /// Retrieves the chip type or SOC of the device asynchronously using SSH. /// @@ -370,12 +529,12 @@ await SshClientService.ExecuteCommandWithResponseAsync(deviceConfig, DeviceComma var chipType = Utilities.RemoveSpecialCharacters(cmdResult.Result); deviceConfig.ChipType = chipType; - + // Cleanup cts.Dispose(); } - + /// /// Retrieves the snesor type of the device asynchronously using SSH. /// @@ -408,12 +567,12 @@ await SshClientService.ExecuteCommandWithResponseAsync(deviceConfig, DeviceComma var sensorType = Utilities.RemoveSpecialCharacters(cmdResult.Result); deviceConfig.SensorType = sensorType; - + // Cleanup cts.Dispose(); } - + /// /// Retrieves the network card type of the device asynchronously using SSH. /// @@ -445,9 +604,9 @@ await SshClientService.ExecuteCommandWithResponseAsync(deviceConfig, DeviceComma } var lsusbResults = Utilities.RemoveLastChar(cmdResult.Result); - var networkCardType= WifiCardDetector.DetectWifiCard(lsusbResults); + var networkCardType = WifiCardDetector.DetectWifiCard(lsusbResults); deviceConfig.NetworkCardType = networkCardType; - + // Cleanup cts.Dispose(); @@ -456,15 +615,31 @@ await SshClientService.ExecuteCommandWithResponseAsync(deviceConfig, DeviceComma private async void processCameraFiles() { // read device to determine configurations - _globalSettingsSettingsViewModel.ReadDevice(); - - + await _globalSettingsSettingsViewModel.ReadDevice(); Logger.Debug($"IsWfbYamlEnabled = {_globalSettingsSettingsViewModel.IsWfbYamlEnabled}"); + + // remove when ready + _globalSettingsSettingsViewModel.IsWfbYamlEnabled = false; + if (_globalSettingsSettingsViewModel.IsWfbYamlEnabled) { - Logger.Debug($"Reading wfb.yaml"); + try + { + // download file wfb.yaml + Logger.Debug($"Reading wfb.yaml"); + + var wfbContent = await SshClientService.DownloadFileAsync(_deviceConfig, OpenIPC.WfbYamlFileLoc); + + if (wfbContent != null) + EventSubscriptionService.Publish(new WfbYamlContentUpdatedMessage(wfbContent)); + } + catch (Exception e) + { + Log.Error(e.Message); + } } - else + else { Logger.Debug($"Reading legacy settings"); // download file wfb.conf @@ -533,8 +708,8 @@ private async void processCameraFiles() } EventSubscriptionService.Publish(new AppMessage { CanConnect = DeviceConfig.Instance.CanConnect, DeviceConfig = _deviceConfig}); - + AppMessage>(new AppMessage { CanConnect = DeviceConfig.Instance.CanConnect, DeviceConfig = _deviceConfig }); + Log.Information("Done reading files from device."); } @@ -646,14 +821,13 @@ await MessageBoxManager.GetMessageBoxStandard("Error", "Failed to download /etc/ EventSubscriptionService.Publish(new AppMessage { CanConnect = DeviceConfig.Instance.CanConnect, DeviceConfig = _deviceConfig }); - + Log.Information("Done reading files from device."); - - } private void LoadSettings() { + IsWaiting = true; // Load settings via the SettingsManager var settings = SettingsManager.LoadSettings(); _deviceConfig = DeviceConfig.Instance; @@ -671,7 +845,7 @@ private void OnDeviceTypeChangeEvent(DeviceType deviceTypeEvent) Log.Debug($"Device type changed to: {deviceTypeEvent}"); InitializeTabs(deviceTypeEvent); - + // Update IsVRXEnabled based on the device type //IsVRXEnabled = deviceTypeEvent == DeviceType.Radxa || deviceTypeEvent == DeviceType.NVR; @@ -695,4 +869,4 @@ private void OnDeviceTypeChangeEvent(DeviceType deviceTypeEvent) [ObservableProperty] private TabItemViewModel _selectedTab; #endregion -} +} \ No newline at end of file diff --git a/OpenIPC_Config/ViewModels/WfbTabViewModel.cs b/OpenIPC_Config/ViewModels/WfbTabViewModel.cs index 437a02d..41ae189 100644 --- a/OpenIPC_Config/ViewModels/WfbTabViewModel.cs +++ b/OpenIPC_Config/ViewModels/WfbTabViewModel.cs @@ -77,6 +77,7 @@ public WfbTabViewModel( InitializeCollections(); InitializeCommands(); SubscribeToEvents(); + } #endregion @@ -104,6 +105,9 @@ private void InitializeCommands() private void SubscribeToEvents() { + EventSubscriptionService.Subscribe( + OnWfbYamlContentUpdated); + EventSubscriptionService.Subscribe( OnWfbConfContentUpdated); EventSubscriptionService.Subscribe(OnAppMessage); @@ -116,6 +120,13 @@ private void OnWfbConfContentUpdated(WfbConfContentUpdatedMessage message) WfbConfContent = message.Content; ParseWfbConfContent(); } + + private void OnWfbYamlContentUpdated(WfbYamlContentUpdatedMessage message) + { + //WfbContent = message.Content; + //ParseWfbConfContent(); + //TODO + } private void OnAppMessage(AppMessage message) { diff --git a/OpenIPC_Config/Views/HeaderView.axaml b/OpenIPC_Config/Views/HeaderView.axaml index 4359d33..16804f2 100644 --- a/OpenIPC_Config/Views/HeaderView.axaml +++ b/OpenIPC_Config/Views/HeaderView.axaml @@ -8,143 +8,221 @@ x:DataType="vm:MainViewModel" Height="80"> + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenIPC_Config/appsettings.Development.json b/OpenIPC_Config/appsettings.Development.json index 8e1da65..c01067f 100644 --- a/OpenIPC_Config/appsettings.Development.json +++ b/OpenIPC_Config/appsettings.Development.json @@ -11,7 +11,7 @@ { "Name": "Console", "Args": { - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}" + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {SourceContext}: {Message}{NewLine}{Exception}" } } ],