From 7cb3eb2ba330249e2f7325138c169d460c9e250c Mon Sep 17 00:00:00 2001 From: MrCodeLabs <21204857+MrCodeLabs@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:50:45 +0200 Subject: [PATCH] Add evidence-based HA integration choices and fix silent device discovery --- CHANGELOG.md | 3 + Controls/HistoryWorkspace.Actions.cs | 16 +- Controls/HomeAssistantAdviceWindow.cs | 123 ++++++++++ Controls/HomeAssistantOverviewWindow.cs | 98 ++++++++ Controls/ScanWorkspace.xaml | 16 +- Controls/ScanWorkspace/ColumnLayout.cs | 1 + Controls/ScanWorkspace/DeviceActions.cs | 30 +++ Controls/ScanWorkspace/Export.cs | 4 +- Controls/ScanWorkspace/ServiceActions.cs | 1 + Controls/ScanWorkspace/ServicesView.cs | 2 + Engine/Discovery/MulticastDiscovery.Bound.cs | 4 +- Engine/Discovery/MulticastDiscovery.cs | 30 ++- Engine/Models/DeviceIdentification.cs | 1 + Engine/Models/HomeAssistantAdvice.cs | 35 +++ Engine/Models/NetworkDevice.cs | 2 + Engine/Platform/BoundWindowsNetwork.cs | 6 + Engine/Recognition/HomeAssistantAdvisor.cs | 215 +++++++++++++++++ Engine/Scanning/NetworkScanner.BoundProbes.cs | 1 + Engine/Scanning/NetworkScanner.Probes.cs | 7 + Engine/Scanning/NetworkScanner.cs | 34 ++- Features/Cli/CliRunner.cs | 4 +- Modules/KillerScan/KillerScan.psm1 | 1 + README.md | 1 + Services/DemoData.cs | 13 +- Services/HomeAssistantText.cs | 114 +++++++++ Services/ReportExport.cs | 7 +- Services/ScanHistory.cs | 3 + Strings/bn.xaml | 64 ++++++ Strings/cs-CZ.xaml | 64 ++++++ Strings/de-DE.xaml | 64 ++++++ Strings/en-US.xaml | 64 ++++++ Strings/es.xaml | 64 ++++++ Strings/fr-FR.xaml | 64 ++++++ Strings/hu-HU.xaml | 64 ++++++ Strings/it-IT.xaml | 64 ++++++ Strings/ja-JP.xaml | 64 ++++++ Strings/kk-KZ.xaml | 64 ++++++ Strings/nb-NO.xaml | 64 ++++++ Strings/pl-PL.xaml | 64 ++++++ Strings/pt-BR.xaml | 64 ++++++ Strings/ru-RU.xaml | 64 ++++++ Strings/sl-SI.xaml | 64 ++++++ Strings/tr-TR.xaml | 64 ++++++ Strings/uk-UA.xaml | 64 ++++++ Strings/vi-VN.xaml | 64 ++++++ Strings/zh-CN.xaml | 64 ++++++ Strings/zh-TW.xaml | 64 ++++++ docs/home-assistant-advisor.md | 216 ++++++++++++++++++ .../HomeAssistantTests.cs | 155 +++++++++++++ .../MulticastDiscoveryTests.cs | 95 ++++++++ tests/KillerScan.Engine.Tests/Program.cs | 8 + tests/SpeedTest.Tests/CsvExportTests.cs | 2 +- tests/SpeedTest.Tests/HistoryUiTests.cs | 2 +- .../SpeedTest.Tests/HomeAssistantUiChecks.cs | 104 +++++++++ tests/SpeedTest.Tests/ReleaseUiTests.cs | 9 +- tests/Test-EvidenceModule.ps1 | 9 + 56 files changed, 2618 insertions(+), 34 deletions(-) create mode 100644 Controls/HomeAssistantAdviceWindow.cs create mode 100644 Controls/HomeAssistantOverviewWindow.cs create mode 100644 Engine/Models/HomeAssistantAdvice.cs create mode 100644 Engine/Recognition/HomeAssistantAdvisor.cs create mode 100644 Services/HomeAssistantText.cs create mode 100644 docs/home-assistant-advisor.md create mode 100644 tests/KillerScan.Engine.Tests/HomeAssistantTests.cs create mode 100644 tests/KillerScan.Engine.Tests/MulticastDiscoveryTests.cs create mode 100644 tests/SpeedTest.Tests/HomeAssistantUiChecks.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7a7ad4..9762d569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ## [1.8.4] - Unreleased ### Added +- Actionable HA research suggestions for Tuya, Reolink, Denon, Proxmox VE, go-e and original eWeLink devices; ESP modules receive an explicit firmware decision path. Weak name/vendor leads remain research with no claimed compatible integration, and community HACS sources are marked. Nine additional labels in all 20 languages. +- Offline Home Assistant integration choices per device, with captured evidence, reasons, prerequisites and explicit missing/conflicting information. The HA toolbar button prepares choices for every found device; device/service/history menus, JSON/CSV/HTML and PowerShell retain advice. New labels cover all 20 languages. No HA connection or automatic setup is performed. - Slovenian labels for startup update checks and scrollbar page actions, preserving complete coverage of the upstream English dictionary. - A unified Access list with exact captured HTTP/HTTPS endpoints, protocol/port suggestions, response and authentication evidence, and Open/Copy actions using existing clients. Deep web probes retain nonstandard endpoints; history, exports and PowerShell preserve the optional data. All 20 locales include the new labels. - Device recognition evidence and heuristic confidence, source/timestamp details, explicitly reported model declarations, read-only device/service/history inspection, and structured evidence in JSON/CSV/HTML and PowerShell. Missing replies alone no longer imply IoT or Mobile; a MAC vendor never establishes a model. @@ -16,6 +18,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Per-tab scan options for neighbor cache, explicit ARP, ICMP, TCP discovery, DNS, mDNS, SSDP, port scanning and individual HTTP/TLS/SSH/SNMP/NetBIOS fingerprints. Profiles save the choices; scan, quick and deep paths enforce them in the engine. CLI `/skip` and PowerShell `-Skip` provide the same choices. Existing profiles and API calls keep their defaults. ### Fixed +- Discover mDNS/SSDP-only responders inside the requested targets and retain multicast evidence in quick scans. ARP also discovers silent on-link hosts; routed next hops are excluded. Preserve advertised SSDP device types and accept mDNS service-instance names for HA recommendations. - Release Sync skips development forks because the checked marketplace channels belong to the upstream publisher; release API failures report the metadata lookup error instead of an empty tag (23 October 2026). - The shortcuts card shows shadows at its edges when more content is out of view. diff --git a/Controls/HistoryWorkspace.Actions.cs b/Controls/HistoryWorkspace.Actions.cs index a76bac41..e35c92bd 100644 --- a/Controls/HistoryWorkspace.Actions.cs +++ b/Controls/HistoryWorkspace.Actions.cs @@ -50,6 +50,7 @@ void Add(string key, string gesture, string action, int glyph) Add("Str_Ctx_CopyHost", "Ctrl+Alt+C", "host", 0xE8C8); Add("Str_History_CopyDetails", "Ctrl+Shift+Y", "details", 0xE8C8); Add("Str_Evidence_Title", "Ctrl+I", "evidence", 0xE946); + Add("Str_HA_Title", "", "ha", 0xE946); menu.Items.Add(new Separator()); Add("Str_Export_Csv", "Ctrl+E", "export", 0xE896); Add("Str_History_ViewChanges", "Ctrl+Shift+H", "changes", 0xE8FD); @@ -65,7 +66,7 @@ void Add(string key, string gesture, string action, int glyph) item.IsEnabled = action switch { "ip" or "mac" or "host" or "details" => grid != null && CopyValue(action).Length > 0, - "evidence" => grid != null && SelectedHistoryDevice != null, + "evidence" or "ha" => grid != null && SelectedHistoryDevice != null, "export" => _entry != null && VisibleGrid.Items.Count > 0, "changes" => _entry != null && _showAll, "all" => _entry != null && !_showAll, @@ -100,6 +101,15 @@ private string CopyValue(string action) private async void ExecuteAction(string action) { + if (action == "ha" && SelectedHistoryDevice is { } historical) + { + new HomeAssistantAdviceWindow(HomeAssistantText.FromHistory(historical), WebLink.Open, text => + { + try { Clipboard.SetText(text); } + catch (COMException) { HistorySummary.Text = Loc("Str_Clipboard_Failed"); } + }) { Owner = Window.GetWindow(this) }.ShowDialog(); + return; + } if (action == "evidence" && SelectedHistoryDevice is { } selected) { new DeviceEvidenceWindow(new NetworkDevice { IpAddress = selected.IpAddress, Hostname = selected.Hostname, @@ -127,7 +137,7 @@ private string BuildCsv() { static string Cell(string value) => ReportExport.CsvField(value); var lines = new List { string.Join(",", new[] { "Str_History_Change", "Str_Col_Ip", "Str_Col_DeviceName", - "Str_Col_Mac", "Str_Col_Vendor", "Str_Col_Type", "Str_Col_Ports" }.Select(key => Cell(Loc(key)))) + ",Location ID,Location,Identification JSON,Access JSON" }; + "Str_Col_Mac", "Str_Col_Vendor", "Str_Col_Type", "Str_Col_Ports" }.Select(key => Cell(Loc(key)))) + ",Location ID,Location,Identification JSON,Access JSON,Home Assistant JSON" }; foreach (object row in VisibleGrid.Items) { var device = row is HistoryChangeRow change ? change.Device : ((HistoryDeviceRow)row).Device; @@ -135,7 +145,7 @@ private string BuildCsv() device.IpAddress, device.Hostname, device.MacAddress, device.Vendor, DeviceTypeConverter.Display(device.DeviceType), string.Join(", ", device.OpenPorts), _entry?.LocationId ?? "", _entry?.LocationName ?? "", - device.Identification == null ? "" : System.Text.Json.JsonSerializer.Serialize(device.Identification), System.Text.Json.JsonSerializer.Serialize(device.Access) }.Select(Cell))); + device.Identification == null ? "" : System.Text.Json.JsonSerializer.Serialize(device.Identification), System.Text.Json.JsonSerializer.Serialize(device.Access), System.Text.Json.JsonSerializer.Serialize(HomeAssistantAdvisor.ForDevice(HomeAssistantText.FromHistory(device))) }.Select(Cell))); } return string.Join(Environment.NewLine, lines) + Environment.NewLine; } diff --git a/Controls/HomeAssistantAdviceWindow.cs b/Controls/HomeAssistantAdviceWindow.cs new file mode 100644 index 00000000..f64e3ebe --- /dev/null +++ b/Controls/HomeAssistantAdviceWindow.cs @@ -0,0 +1,123 @@ +using System.Windows; +using System.Windows.Controls; +using KillerScan.Engine; +using KillerScan.Services; +using AccessText = KillerScan.Services.AccessText; + +namespace KillerScan.Controls; + +internal sealed class HomeAssistantAdviceWindow : Window +{ + private readonly NetworkDevice _device; + private readonly Action _open; + private readonly Action _copy; + internal readonly TextBox IdentityInput = new() { MaxLength = 160 }; + internal readonly ComboBox ProtocolInput = new(); + internal readonly ComboBox GoalInput = new(); + internal readonly StackPanel Results = new(); + internal HomeAssistantAdvice CurrentAdvice { get; private set; } + private HomeAssistantContext CurrentContext => new() + { ManufacturerModel = IdentityInput.Text, Protocol = ((ComboBoxItem?)ProtocolInput.SelectedItem)?.Tag as string ?? "", + Goal = ((ComboBoxItem?)GoalInput.SelectedItem)?.Tag as string ?? "Monitor" }; + + internal HomeAssistantAdviceWindow(NetworkDevice device, Action open, Action copy) + { + _device = HomeAssistantText.Freeze(device); + _open = open; _copy = copy; CurrentAdvice = HomeAssistantAdvisor.ForDevice(_device); + Title = HomeAssistantText.L("Title"); Width = 920; Height = 760; MinWidth = 540; MinHeight = 380; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + Loaded += (_, _) => KillerFamily.Windowing.WorkAreaFit.Fit(this); + SetResourceReference(BackgroundProperty, "BackgroundBrush"); SetResourceReference(ForegroundProperty, "TextBrush"); SetResourceReference(FontFamilyProperty, "UiFont"); + var panel = new DockPanel { Margin = new Thickness(18) }; + var buttons = new WrapPanel { HorizontalAlignment = HorizontalAlignment.Right }; + var catalogue = Button(HomeAssistantText.L("Docs")); catalogue.Tag = HomeAssistantAdvisor.CatalogueUrl; + catalogue.Click += (_, _) => _open(HomeAssistantAdvisor.CatalogueUrl); + var copyPlan = Button(HomeAssistantText.L("Copy")); copyPlan.Tag = "copy-plan"; + copyPlan.Click += (_, _) => _copy(HomeAssistantText.Describe(_device, CurrentAdvice, CurrentContext)); + var close = Button(Application.Current.TryFindResource("Str_Sys_Close") as string ?? "Close"); close.Click += (_, _) => Close(); + buttons.Children.Add(catalogue); buttons.Children.Add(copyPlan); buttons.Children.Add(close); + DockPanel.SetDock(buttons, Dock.Bottom); panel.Children.Add(buttons); + var header = new StackPanel(); + string deviceLabel = (device.IpAddress + " " + device.Hostname).Trim(); + header.Children.Add(Text(deviceLabel.Length == 0 ? HomeAssistantText.L("Workflow") : deviceLabel, 18)); + var inputs = new StackPanel(); + inputs.Children.Add(Text(HomeAssistantText.L("Identity"))); IdentityInput.SetResourceReference(StyleProperty, "DarkTextBox"); + System.Windows.Automation.AutomationProperties.SetName(IdentityInput, HomeAssistantText.L("Identity")); inputs.Children.Add(IdentityInput); + var fields = new WrapPanel(); + fields.Children.Add(Field(HomeAssistantText.L("Protocol"), ProtocolInput)); + ProtocolInput.Items.Add(new ComboBoxItem { Content = AccessText.L("Unknown"), Tag = "" }); + foreach (string domain in new[] { "matter", "homekit_controller", "mqtt", "dlna_dmr", "rest", "snmp", "zha", "zwave_js", "bluetooth" }) + ProtocolInput.Items.Add(new ComboBoxItem { Content = HomeAssistantAdvisor.Integration(domain)!.Name, Tag = domain }); + ProtocolInput.SelectedIndex = 0; + fields.Children.Add(Field(HomeAssistantText.L("Goal"), GoalInput)); + foreach (var (id, label) in new[] { ("Monitor", "Monitor"), ("Control", "ControlGoal"), ("Media", "MediaGoal") }) + GoalInput.Items.Add(new ComboBoxItem { Content = HomeAssistantText.L(label), Tag = id }); + GoalInput.SelectedIndex = 0; inputs.Children.Add(fields); + header.Children.Add(Expand(HomeAssistantText.L("Inputs"), inputs)); + DockPanel.SetDock(header, Dock.Top); panel.Children.Add(header); + panel.Children.Add(new ScrollViewer { Content = Results, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled }); + Content = panel; + IdentityInput.TextChanged += (_, _) => Refresh(); ProtocolInput.SelectionChanged += (_, _) => Refresh(); GoalInput.SelectionChanged += (_, _) => Refresh(); + Refresh(); + } + private static FrameworkElement Field(string name, ComboBox input) + { + var field = new StackPanel { MinWidth = 230, Margin = new Thickness(0, 0, 14, 10) }; field.Children.Add(Text(name)); field.Children.Add(input); + System.Windows.Automation.AutomationProperties.SetName(input, name); return field; + } + private void Refresh() + { + var context = CurrentContext; + CurrentAdvice = context.ManufacturerModel.Length == 0 && context.Protocol.Length == 0 && context.Goal == "Monitor" + ? HomeAssistantAdvisor.ForDevice(_device) : HomeAssistantAdvisor.Assess(_device, context); + Results.Children.Clear(); + Results.Children.Add(Text(HomeAssistantText.L("BestChoice"), 14)); + Results.Children.Add(Text(HomeAssistantText.Choice(CurrentAdvice), 24)); + Results.Children.Add(Text(HomeAssistantText.Reason(CurrentAdvice))); + var best = HomeAssistantAdvisor.Best(CurrentAdvice) ?? HomeAssistantAdvisor.ResearchChoice(CurrentAdvice); + if (best != null) + { + var entry = HomeAssistantAdvisor.Integration(best.Domain)!; + Results.Children.Add(Text(HomeAssistantText.L(best.Basis))); + Results.Children.Add(Text(HomeAssistantText.L("Requirements") + ": " + string.Join("; ", best.Requirements.Select(HomeAssistantText.L)))); + Results.Children.Add(Text(HomeAssistantText.Next(CurrentAdvice))); + if (CurrentAdvice.Steps.Contains("Control")) Results.Children.Add(Text(HomeAssistantText.L("Control"))); + var docs = Button(HomeAssistantText.L("Docs") + " — " + entry.Name); docs.Tag = entry.DocumentationUrl; + docs.Click += (_, _) => _open(entry.DocumentationUrl); Results.Children.Add(docs); + } + Results.Children.Add(Text(HomeAssistantText.Catalogue(CurrentAdvice))); + Results.Children.Add(Text(HomeAssistantText.L("Note"))); + var details = new StackPanel(); + details.Children.Add(Text(HomeAssistantText.L("Fresh"))); + foreach (var candidate in CurrentAdvice.Candidates) + { + var entry = HomeAssistantAdvisor.Integration(candidate.Domain); + if (entry == null) continue; + details.Children.Add(Text(entry.Name + " — " + HomeAssistantText.L(candidate.Basis), 17)); + foreach (var fact in candidate.Evidence) + details.Children.Add(Text(fact.Source + " · " + HomeAssistantText.Time(fact.ObservedAtUtc) + + (fact.Reused ? " [" + IdentificationText.L("Cached") + "]" : "") + "\n" + fact.Value)); + details.Children.Add(Text(HomeAssistantText.L("Requirements") + ": " + string.Join("; ", candidate.Requirements.Select(HomeAssistantText.L)))); + if (candidate.Domain != best?.Domain) + { + var docs = Button(HomeAssistantText.L("Docs") + " — " + entry.Name); docs.Tag = entry.DocumentationUrl; + docs.Click += (_, _) => _open(entry.DocumentationUrl); details.Children.Add(docs); + } + } + details.Children.Add(Text(HomeAssistantText.L("Workflow"), 18)); + for (int index = 0; index < CurrentAdvice.Steps.Length; index++) details.Children.Add(Text((index + 1) + ". " + HomeAssistantText.L(CurrentAdvice.Steps[index]))); + var expander = Expand(HomeAssistantText.L("Details"), details); expander.IsExpanded = best == null; Results.Children.Add(expander); + } + private static Expander Expand(string header, object content) + { + var expander = new Expander { Header = header, Content = content, Margin = new Thickness(0, 8, 0, 10) }; + expander.SetResourceReference(ForegroundProperty, "TextBrush"); return expander; + } + private static TextBlock Text(string value, double size = 13) => new() + { Text = value, FontSize = size, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 5, 0, 7) }; + private static Button Button(string text) + { + var button = new Button { Content = text, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 5, 10, 10), Padding = new Thickness(10, 5, 10, 5) }; + button.SetResourceReference(StyleProperty, "OutlineButton"); return button; + } +} diff --git a/Controls/HomeAssistantOverviewWindow.cs b/Controls/HomeAssistantOverviewWindow.cs new file mode 100644 index 00000000..ffd83833 --- /dev/null +++ b/Controls/HomeAssistantOverviewWindow.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using KillerScan.Engine; +using KillerScan.Services; + +namespace KillerScan.Controls; + +public sealed class HomeAssistantChoiceConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value is HomeAssistantAdvice advice + ? HomeAssistantText.Choice(advice) : HomeAssistantText.L("NoChoice"); + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; +} + +/// One frozen row per found device; does not launch network discovery or HA setup. +internal sealed class HomeAssistantOverviewWindow : Window +{ + internal sealed class Row(NetworkDevice device) + { + internal NetworkDevice Device { get; } = device; + internal HomeAssistantAdvice Advice { get; } = HomeAssistantAdvisor.ForDevice(device); + public string Address => Device.IpAddress; + public string Name => Device.Hostname.Length > 0 ? Device.Hostname : Device.Identification?.ReportedModel ?? ""; + public string Choice => HomeAssistantText.Choice(Advice); + public string Reason => HomeAssistantText.Reason(Advice); + public string Requirements => (HomeAssistantAdvisor.Best(Advice) ?? HomeAssistantAdvisor.ResearchChoice(Advice)) is { } best + ? string.Join("; ", best.Requirements.Select(HomeAssistantText.L)) : HomeAssistantAdvisor.FirmwareResearch(Advice) ? HomeAssistantText.L("FirmwareRoute") : HomeAssistantText.L("Identify"); + } + internal IReadOnlyList Rows { get; } + internal readonly DataGrid Grid = new() { AutoGenerateColumns = false, IsReadOnly = true, CanUserAddRows = false, + SelectionMode = DataGridSelectionMode.Single, HeadersVisibility = DataGridHeadersVisibility.Column, + RowHeaderWidth = 0, MinRowHeight = 54, HorizontalScrollBarVisibility = ScrollBarVisibility.Auto }; + private readonly Action _open, _copy; + internal HomeAssistantOverviewWindow(IEnumerable devices, Action open, Action copy) + { + _open = open; _copy = copy; + Rows = devices.Select(HomeAssistantText.Freeze).OrderBy(device => device.IpSortKey).Select(device => new Row(device)).ToArray(); + Title = HomeAssistantText.L("AllTitle"); Width = 1180; Height = 720; MinWidth = 660; MinHeight = 380; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + Loaded += (_, _) => KillerFamily.Windowing.WorkAreaFit.Fit(this); + SetResourceReference(BackgroundProperty, "BackgroundBrush"); SetResourceReference(ForegroundProperty, "TextBrush"); SetResourceReference(FontFamilyProperty, "UiFont"); + Grid.SetResourceReference(Control.BackgroundProperty, "BackgroundBrush"); Grid.SetResourceReference(Control.ForegroundProperty, "TextBrush"); + Grid.SetResourceReference(DataGrid.RowStyleProperty, "DarkDataGridRow"); Grid.SetResourceReference(DataGrid.CellStyleProperty, "DarkDataGridCell"); + Grid.SetResourceReference(DataGrid.ColumnHeaderStyleProperty, "ThemedDataGridColumnHeader"); + Grid.GridLinesVisibility = DataGridGridLinesVisibility.Horizontal; Grid.AlternationCount = 2; Grid.BorderThickness = new Thickness(0); + Grid.SetResourceReference(DataGrid.HorizontalGridLinesBrushProperty, "PaneBorderBrush"); + var root = new DockPanel { Margin = new Thickness(18) }; + var header = new StackPanel(); + header.Children.Add(new TextBlock { Text = Title, FontSize = 22, Margin = new Thickness(0, 0, 0, 8) }); + header.Children.Add(new TextBlock { Text = string.Format(CultureInfo.CurrentCulture, HomeAssistantText.L("AllSummary"), Rows.Count, + Rows.Count(row => HomeAssistantAdvisor.Best(row.Advice) != null || HomeAssistantAdvisor.ResearchChoice(row.Advice) != null || HomeAssistantAdvisor.FirmwareResearch(row.Advice))), Margin = new Thickness(0, 0, 0, 8) }); + header.Children.Add(new TextBlock { Text = Rows.Count == 0 ? HomeAssistantText.L("NoDevices") : HomeAssistantText.L("Note"), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 14) }); + DockPanel.SetDock(header, Dock.Top); root.Children.Add(header); + var buttons = new WrapPanel { HorizontalAlignment = HorizontalAlignment.Right }; + Button AddButton(string label, string tag, Action action) + { + var button = new Button { Content = label, Tag = tag, Padding = new Thickness(10, 6, 10, 6), Margin = new Thickness(8, 12, 0, 0) }; + button.SetResourceReference(StyleProperty, "OutlineButton"); button.Click += (_, _) => action(); buttons.Children.Add(button); return button; + } + var detail = AddButton(HomeAssistantText.L("Details"), "detail", OpenSelected); detail.IsEnabled = false; + AddButton(HomeAssistantText.L("Copy"), "copy-all", () => _copy(Describe())); + AddButton(Application.Current.TryFindResource("Str_Sys_Close") as string ?? "Close", "close", Close); + DockPanel.SetDock(buttons, Dock.Bottom); root.Children.Add(buttons); + void Column(string title, string path, double width) + { + var style = new Style(typeof(TextBlock)); style.Setters.Add(new Setter(TextBlock.TextWrappingProperty, TextWrapping.Wrap)); + style.Setters.Add(new Setter(TextBlock.MarginProperty, new Thickness(5, 7, 5, 7))); + Grid.Columns.Add(new DataGridTextColumn { Header = title, Binding = new Binding(path), ElementStyle = style, + Width = new DataGridLength(width), MinWidth = 100 }); + } + Column(Application.Current.TryFindResource("Str_Col_Ip") as string ?? "IP", "Address", 128); + Column(Application.Current.TryFindResource("Str_Col_DeviceName") as string ?? "Device", "Name", 145); + Column(HomeAssistantText.L("BestChoice"), "Choice", 180); + Column(HomeAssistantText.L("Details"), "Reason", 260); + Column(HomeAssistantText.L("Requirements"), "Requirements", 350); + Grid.ItemsSource = Rows; + Grid.SelectionChanged += (_, _) => detail.IsEnabled = Grid.SelectedItem is Row; + Grid.MouseDoubleClick += (_, e) => { if (ItemsControl.ContainerFromElement(Grid, e.OriginalSource as DependencyObject) is DataGridRow) OpenSelected(); }; + root.Children.Add(Grid); Content = root; + } + internal HomeAssistantAdviceWindow? CreateSelected() => Grid.SelectedItem is Row row + ? new HomeAssistantAdviceWindow(row.Device, _open, _copy) : null; + private void OpenSelected() + { + if (CreateSelected() is not { } dialog) return; + if (PresentationSource.FromVisual(this) != null) dialog.Owner = this; + dialog.ShowDialog(); + } + internal string Describe() + { + var text = new StringBuilder().AppendLine(HomeAssistantText.L("AllTitle")); + foreach (var row in Rows) text.AppendLine().AppendLine(HomeAssistantText.Describe(row.Device, row.Advice)); + return text.ToString(); + } +} diff --git a/Controls/ScanWorkspace.xaml b/Controls/ScanWorkspace.xaml index 33c1078f..d61f0457 100644 --- a/Controls/ScanWorkspace.xaml +++ b/Controls/ScanWorkspace.xaml @@ -1,6 +1,13 @@ - + + + + + @@ -122,6 +129,9 @@