交运行工位桩:三口契约、运行服务、六态 VM、跨线程切 UI。
采集/PLC/Inspect 均为桩,验收 1–8 可测。不部署现场。
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
*.dll
|
||||||
|
*.pdb
|
||||||
|
*.cache
|
||||||
|
TestResults/
|
||||||
|
.dotnet/
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
# VisionHost 运行工位桩
|
||||||
|
|
||||||
|
本迭代只交操作员运行壳(验收 1–7)。采集 / PLC / Inspect 均为桩,不做真检测、不改主干、不部署。
|
||||||
|
|
||||||
|
## 工程
|
||||||
|
|
||||||
|
- `VisionHost.Contracts` 三口:`IFrameSource` / `IInspectService` / `IPlcIo`
|
||||||
|
- `VisionHost.Runtime` 运行服务 + `RunViewModel`(绑定名对齐 WPF 骨架)
|
||||||
|
- `VisionHost.Runtime.Tests` 运行壳用例
|
||||||
|
- `VisionHost.App` 操作员运行页(WPF,hex 转笔刷)
|
||||||
|
|
||||||
|
可点界面:`src/VisionHost.App`(Windows)。`LastResultBrush`/`AlarmBrush` 用 `HexToBrushConverter`。
|
||||||
|
|
||||||
|
## 行为
|
||||||
|
|
||||||
|
- 确认启动才清零并开跑;停机保持计数
|
||||||
|
- 未连接 / 重连中:不弹确认,`CanStart=false`
|
||||||
|
- Inspect 桩:1–4 OK,第 5 拍 `NG STUB`,拍号在算法桩内计
|
||||||
|
- 六态由运行服务推,VM 不自判
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
dotnet test VisionHost.sln
|
||||||
|
```
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VisionHost.Contracts", "src\VisionHost.Contracts\VisionHost.Contracts.csproj", "{A1111111-1111-1111-1111-111111111111}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VisionHost.Runtime", "src\VisionHost.Runtime\VisionHost.Runtime.csproj", "{A2222222-2222-2222-2222-222222222222}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VisionHost.Runtime.Tests", "tests\VisionHost.Runtime.Tests\VisionHost.Runtime.Tests.csproj", "{A3333333-3333-3333-3333-333333333333}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VisionHost.App", "src\VisionHost.App\VisionHost.App.csproj", "{A4444444-4444-4444-4444-444444444444}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A1111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A2222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A2222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A2222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A2222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A3333333-3333-3333-3333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A3333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A3333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A3333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A4444444-4444-4444-4444-444444444444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4444444-4444-4444-4444-444444444444}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A4444444-4444-4444-4444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A4444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Application x:Class="VisionHost.App.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
StartupUri="Views/RunStationView.xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="Themes/Station.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace VisionHost.App;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace VisionHost.App.Converters;
|
||||||
|
|
||||||
|
public sealed class HexToBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is not string hex || hex.Length < 7 || hex[0] != '#')
|
||||||
|
return Brushes.White;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Brushes.White;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!-- 1920x1080 · 工位约 1.5m · 暗底高对比 -->
|
||||||
|
<Color x:Key="BgColor">#1A1D21</Color>
|
||||||
|
<Color x:Key="PanelColor">#252A31</Color>
|
||||||
|
<Color x:Key="LineColor">#3A414B</Color>
|
||||||
|
<Color x:Key="TextColor">#F2F4F7</Color>
|
||||||
|
<Color x:Key="MutedColor">#9AA3AF</Color>
|
||||||
|
<Color x:Key="OkColor">#22C55E</Color>
|
||||||
|
<Color x:Key="NgColor">#EF4444</Color>
|
||||||
|
<Color x:Key="AlarmColor">#F59E0B</Color>
|
||||||
|
<Color x:Key="BusyColor">#38BDF8</Color>
|
||||||
|
<Color x:Key="OfflineColor">#9CA3AF</Color>
|
||||||
|
<Color x:Key="StartColor">#16A34A</Color>
|
||||||
|
<Color x:Key="StopColor">#DC2626</Color>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="BgBrush" Color="{StaticResource BgColor}"/>
|
||||||
|
<SolidColorBrush x:Key="PanelBrush" Color="{StaticResource PanelColor}"/>
|
||||||
|
<SolidColorBrush x:Key="LineBrush" Color="{StaticResource LineColor}"/>
|
||||||
|
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}"/>
|
||||||
|
<SolidColorBrush x:Key="MutedBrush" Color="{StaticResource MutedColor}"/>
|
||||||
|
<SolidColorBrush x:Key="OkBrush" Color="{StaticResource OkColor}"/>
|
||||||
|
<SolidColorBrush x:Key="NgBrush" Color="{StaticResource NgColor}"/>
|
||||||
|
<SolidColorBrush x:Key="AlarmBrush" Color="{StaticResource AlarmColor}"/>
|
||||||
|
<SolidColorBrush x:Key="BusyBrush" Color="{StaticResource BusyColor}"/>
|
||||||
|
<SolidColorBrush x:Key="OfflineBrush" Color="{StaticResource OfflineColor}"/>
|
||||||
|
<SolidColorBrush x:Key="StartBrush" Color="{StaticResource StartColor}"/>
|
||||||
|
<SolidColorBrush x:Key="StopBrush" Color="{StaticResource StopColor}"/>
|
||||||
|
|
||||||
|
<Style x:Key="StationWindow" TargetType="Window">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BgBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||||
|
<Setter Property="FontFamily" Value="Microsoft YaHei UI"/>
|
||||||
|
<Setter Property="FontSize" Value="18"/>
|
||||||
|
<Setter Property="MinWidth" Value="1280"/>
|
||||||
|
<Setter Property="MinHeight" Value="720"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="TopBarText" TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||||
|
<Setter Property="FontSize" Value="22"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="BadgeText" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="48"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="ShotText" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="48"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="CountText" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="28"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||||
|
<Setter Property="Margin" Value="0,8"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="AlarmText" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="20"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="RunButton" TargetType="Button">
|
||||||
|
<Setter Property="Width" Value="160"/>
|
||||||
|
<Setter Property="Height" Value="64"/>
|
||||||
|
<Setter Property="FontSize" Value="22"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter TargetName="Bd" Property="Opacity" Value="0.35"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Opacity" Value="0.8"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="StartButton" TargetType="Button" BasedOn="{StaticResource RunButton}">
|
||||||
|
<Setter Property="Background" Value="{StaticResource StartBrush}"/>
|
||||||
|
<Setter Property="Content" Value="启动"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="StopButton" TargetType="Button" BasedOn="{StaticResource RunButton}">
|
||||||
|
<Setter Property="Background" Value="{StaticResource StopBrush}"/>
|
||||||
|
<Setter Property="Content" Value="停止"/>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<Window x:Class="VisionHost.App.Views.RunStationView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:cvt="clr-namespace:VisionHost.App.Converters"
|
||||||
|
xmlns:rt="clr-namespace:VisionHost.Runtime;assembly=VisionHost.Runtime"
|
||||||
|
Title="运行工位"
|
||||||
|
Width="1920" Height="1080"
|
||||||
|
WindowState="Maximized"
|
||||||
|
Style="{StaticResource StationWindow}">
|
||||||
|
<Window.Resources>
|
||||||
|
<cvt:HexToBrushConverter x:Key="HexToBrush"/>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="72"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="88"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Border Grid.Row="0" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource LineBrush}" BorderThickness="0,0,0,1" Padding="24,0">
|
||||||
|
<Grid>
|
||||||
|
<TextBlock Style="{StaticResource TopBarText}">
|
||||||
|
<Run Text="配方:"/><Run Text="{Binding RecipeName, FallbackValue=—}"/>
|
||||||
|
</TextBlock>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Ellipse Width="14" Height="14" Margin="0,0,10,0" VerticalAlignment="Center">
|
||||||
|
<Ellipse.Style>
|
||||||
|
<Style TargetType="Ellipse">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource OfflineBrush}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding ConnectionText}" Value="已连接">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource OkBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding ConnectionText}" Value="重连中">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource AlarmBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Ellipse.Style>
|
||||||
|
</Ellipse>
|
||||||
|
<TextBlock Text="{Binding ConnectionText, FallbackValue=未连接}" Style="{StaticResource TopBarText}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Margin="16">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="360"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Border Grid.Column="0" Background="#0B0D10" BorderBrush="{StaticResource LineBrush}" BorderThickness="1" Margin="0,0,16,0">
|
||||||
|
<Grid>
|
||||||
|
<ContentControl Content="{Binding LiveImage}" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<ContentControl.Resources>
|
||||||
|
<DataTemplate DataType="{x:Type rt:StubFrame}">
|
||||||
|
<TextBlock FontSize="36" Foreground="{StaticResource MutedBrush}"
|
||||||
|
Text="{Binding Seq, StringFormat=桩图 #{0}}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ContentControl.Resources>
|
||||||
|
</ContentControl>
|
||||||
|
<ContentControl Content="{Binding OverlayImage}" IsHitTestVisible="False"/>
|
||||||
|
<Border Width="200" Height="72" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="16"
|
||||||
|
CornerRadius="4" Background="#CC0B0D10">
|
||||||
|
<TextBlock Text="{Binding BadgeText, FallbackValue=就绪}">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock" BasedOn="{StaticResource BadgeText}">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding BadgeText}" Value="OK">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource OkBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding BadgeText}" Value="NG">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource NgBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding BadgeText}" Value="报警">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AlarmBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding BadgeText}" Value="检测中">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BusyBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding BadgeText}" Value="断连">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource OfflineBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Column="1" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource LineBrush}" BorderThickness="1" Padding="24">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Grid.Row="0" Text="本拍" Foreground="{StaticResource MutedBrush}" FontSize="18"/>
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Text="{Binding LastResultText, FallbackValue=—}"
|
||||||
|
Style="{StaticResource ShotText}"
|
||||||
|
Foreground="{Binding LastResultBrush, Converter={StaticResource HexToBrush}}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Row="2">
|
||||||
|
<TextBlock Style="{StaticResource CountText}">
|
||||||
|
<Run Text="OK "/><Run Text="{Binding OkCount, FallbackValue=0}"/>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Style="{StaticResource CountText}">
|
||||||
|
<Run Text="NG "/><Run Text="{Binding NgCount, FallbackValue=0}"/>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Style="{StaticResource CountText}">
|
||||||
|
<Run Text="合计 "/><Run Text="{Binding TotalCount, FallbackValue=0}"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border Grid.Row="2" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource LineBrush}" BorderThickness="0,1,0,0" Padding="24,0">
|
||||||
|
<Grid>
|
||||||
|
<TextBlock Text="{Binding AlarmText, FallbackValue=无报警}" Style="{StaticResource AlarmText}"
|
||||||
|
Foreground="{Binding AlarmBrush, Converter={StaticResource HexToBrush}}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Style="{StaticResource StartButton}"
|
||||||
|
Command="{Binding StartCommand}"
|
||||||
|
IsEnabled="{Binding CanStart}"
|
||||||
|
Margin="0,0,16,0"/>
|
||||||
|
<Button Style="{StaticResource StopButton}"
|
||||||
|
Command="{Binding StopCommand}"
|
||||||
|
IsEnabled="{Binding CanStop}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Windows;
|
||||||
|
using VisionHost.Runtime;
|
||||||
|
|
||||||
|
namespace VisionHost.App.Views;
|
||||||
|
|
||||||
|
public partial class RunStationView : Window
|
||||||
|
{
|
||||||
|
public RunStationView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
var cam = new StubFrameSource();
|
||||||
|
var run = new RunService(
|
||||||
|
cam,
|
||||||
|
cam,
|
||||||
|
new StubInspectService(),
|
||||||
|
new StubPlcIo(),
|
||||||
|
new WpfUserConfirm());
|
||||||
|
DataContext = new RunViewModel(run, new SynchronizationContextDispatch(SynchronizationContext.Current));
|
||||||
|
Closed += (_, _) => (DataContext as RunViewModel)?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<RootNamespace>VisionHost.App</RootNamespace>
|
||||||
|
<AssemblyName>VisionHost.App</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\VisionHost.Runtime\VisionHost.Runtime.csproj" />
|
||||||
|
<ProjectReference Include="..\VisionHost.Contracts\VisionHost.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.App;
|
||||||
|
|
||||||
|
public sealed class WpfUserConfirm : IUserConfirm
|
||||||
|
{
|
||||||
|
public bool ConfirmStart() => Ask("确认启动检测?");
|
||||||
|
public bool ConfirmStop() => Ask("确认停止检测?");
|
||||||
|
|
||||||
|
private static bool Ask(string message)
|
||||||
|
{
|
||||||
|
var w = new Window
|
||||||
|
{
|
||||||
|
Width = 420,
|
||||||
|
Height = 180,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
|
ResizeMode = ResizeMode.NoResize,
|
||||||
|
WindowStyle = WindowStyle.None,
|
||||||
|
Background = (System.Windows.Media.Brush)Application.Current.FindResource("PanelBrush"),
|
||||||
|
Owner = Application.Current.MainWindow
|
||||||
|
};
|
||||||
|
var grid = new Grid { Margin = new Thickness(24) };
|
||||||
|
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||||
|
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||||
|
var text = new TextBlock
|
||||||
|
{
|
||||||
|
Text = message,
|
||||||
|
FontSize = 20,
|
||||||
|
Foreground = (System.Windows.Media.Brush)Application.Current.FindResource("TextBrush"),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
Grid.SetRow(text, 0);
|
||||||
|
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
||||||
|
var ok = new Button
|
||||||
|
{
|
||||||
|
Content = "确认",
|
||||||
|
Style = (Style)Application.Current.FindResource("RunButton"),
|
||||||
|
Background = (System.Windows.Media.Brush)Application.Current.FindResource("StartBrush"),
|
||||||
|
IsDefault = true,
|
||||||
|
Margin = new Thickness(0, 0, 12, 0)
|
||||||
|
};
|
||||||
|
var cancel = new Button
|
||||||
|
{
|
||||||
|
Content = "取消",
|
||||||
|
Style = (Style)Application.Current.FindResource("RunButton"),
|
||||||
|
Background = (System.Windows.Media.Brush)Application.Current.FindResource("LineBrush"),
|
||||||
|
IsCancel = true
|
||||||
|
};
|
||||||
|
var accepted = false;
|
||||||
|
ok.Click += (_, _) => { accepted = true; w.Close(); };
|
||||||
|
cancel.Click += (_, _) => w.Close();
|
||||||
|
buttons.Children.Add(ok);
|
||||||
|
buttons.Children.Add(cancel);
|
||||||
|
Grid.SetRow(buttons, 1);
|
||||||
|
grid.Children.Add(text);
|
||||||
|
grid.Children.Add(buttons);
|
||||||
|
w.Content = grid;
|
||||||
|
w.ShowDialog();
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public enum ConnectionState
|
||||||
|
{
|
||||||
|
Disconnected,
|
||||||
|
Connected,
|
||||||
|
Reconnecting
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DeviceStatusInfo(
|
||||||
|
ConnectionState Connection,
|
||||||
|
string? Detail);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public sealed record FrameReadyEvent(
|
||||||
|
string CameraId,
|
||||||
|
object Frame,
|
||||||
|
DateTimeOffset Timestamp);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public interface IDeviceStatus
|
||||||
|
{
|
||||||
|
event Action<DeviceStatusInfo>? Changed;
|
||||||
|
DeviceStatusInfo Current { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public interface IFrameSource
|
||||||
|
{
|
||||||
|
event Action<FrameReadyEvent>? FrameReady;
|
||||||
|
ConnectionState Connection { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public interface IInspectService
|
||||||
|
{
|
||||||
|
Task<InspectResult> Inspect(object frameHandle, string recipeId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public interface IPlcIo
|
||||||
|
{
|
||||||
|
event Action<DeviceStatusInfo>? StatusChanged;
|
||||||
|
Task WriteAsync(string tag, object value, TimeSpan timeout, CancellationToken cancellationToken);
|
||||||
|
Task<object?> ReadAsync(string tag, TimeSpan timeout, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public interface IUiDispatch
|
||||||
|
{
|
||||||
|
void Post(Action action);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public interface IUserConfirm
|
||||||
|
{
|
||||||
|
bool ConfirmStart();
|
||||||
|
bool ConfirmStop();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public sealed record InspectResult(
|
||||||
|
bool Ok,
|
||||||
|
string? NgCode,
|
||||||
|
int ElapsedMs,
|
||||||
|
object? Overlay);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public enum RunState
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Busy,
|
||||||
|
Ok,
|
||||||
|
Ng,
|
||||||
|
Alarm,
|
||||||
|
Disconnected
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Contracts;
|
||||||
|
|
||||||
|
public sealed class RuntimeOptions
|
||||||
|
{
|
||||||
|
public int QueueCapacity { get; set; } = 8;
|
||||||
|
public int InspectTimeoutMs { get; set; } = 2000;
|
||||||
|
public int PlcTimeoutMs { get; set; } = 500;
|
||||||
|
public string RecipeId { get; set; } = "STUB";
|
||||||
|
public string RecipeName { get; set; } = "STUB";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>VisionHost.Contracts</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed record Alarm(string Text, DateTimeOffset RaisedAt);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed class AlwaysConfirm : IUserConfirm
|
||||||
|
{
|
||||||
|
public bool ConfirmStart() => true;
|
||||||
|
public bool ConfirmStop() => true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed class RunService : IDisposable
|
||||||
|
{
|
||||||
|
public const string OkBrush = "#22C55E";
|
||||||
|
public const string NgBrush = "#EF4444";
|
||||||
|
public const string AlarmBrushHex = "#F59E0B";
|
||||||
|
public const string TextBrush = "#F2F4F7";
|
||||||
|
public const string MutedBrush = "#9AA3AF";
|
||||||
|
|
||||||
|
private readonly IFrameSource _frames;
|
||||||
|
private readonly IDeviceStatus _devices;
|
||||||
|
private readonly IInspectService _inspect;
|
||||||
|
private readonly IPlcIo _plc;
|
||||||
|
private readonly IUserConfirm _confirm;
|
||||||
|
private readonly RuntimeOptions _opt;
|
||||||
|
private readonly ConcurrentQueue<FrameReadyEvent> _queue = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly CancellationTokenSource _life = new();
|
||||||
|
|
||||||
|
private bool _running;
|
||||||
|
private bool _busy;
|
||||||
|
private int _ok;
|
||||||
|
private int _ng;
|
||||||
|
private InspectResult? _last;
|
||||||
|
private object? _live;
|
||||||
|
private Alarm? _alarm;
|
||||||
|
private DeviceStatusInfo _device;
|
||||||
|
private CancellationTokenSource? _runCts;
|
||||||
|
private Task? _worker;
|
||||||
|
|
||||||
|
public event Action? Changed;
|
||||||
|
|
||||||
|
public RunService(
|
||||||
|
IFrameSource frames,
|
||||||
|
IDeviceStatus devices,
|
||||||
|
IInspectService inspect,
|
||||||
|
IPlcIo plc,
|
||||||
|
IUserConfirm confirm,
|
||||||
|
RuntimeOptions? options = null)
|
||||||
|
{
|
||||||
|
_frames = frames;
|
||||||
|
_devices = devices;
|
||||||
|
_inspect = inspect;
|
||||||
|
_plc = plc;
|
||||||
|
_confirm = confirm;
|
||||||
|
_opt = options ?? new RuntimeOptions();
|
||||||
|
_device = devices.Current;
|
||||||
|
|
||||||
|
_frames.FrameReady += OnFrame;
|
||||||
|
_devices.Changed += OnDevice;
|
||||||
|
_plc.StatusChanged += OnPlc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StationSnapshot Snapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var state = ComputeState();
|
||||||
|
var lastText = FormatLast(_last);
|
||||||
|
var alarmText = FormatAlarm();
|
||||||
|
var connected = _device.Connection == ConnectionState.Connected;
|
||||||
|
return new StationSnapshot(
|
||||||
|
RunState: state,
|
||||||
|
RecipeName: _opt.RecipeName,
|
||||||
|
ConnectionText: _device.Connection switch
|
||||||
|
{
|
||||||
|
ConnectionState.Connected => "已连接",
|
||||||
|
ConnectionState.Reconnecting => "重连中",
|
||||||
|
_ => "未连接"
|
||||||
|
},
|
||||||
|
LiveImage: _live,
|
||||||
|
OverlayImage: _last?.Overlay,
|
||||||
|
BadgeText: state switch
|
||||||
|
{
|
||||||
|
RunState.Busy => "检测中",
|
||||||
|
RunState.Ok => "OK",
|
||||||
|
RunState.Ng => "NG",
|
||||||
|
RunState.Alarm => "报警",
|
||||||
|
RunState.Disconnected => "断连",
|
||||||
|
_ => "就绪"
|
||||||
|
},
|
||||||
|
LastResultText: lastText,
|
||||||
|
LastResultBrush: lastText.StartsWith("NG", StringComparison.Ordinal) ? NgBrush
|
||||||
|
: lastText == "OK" ? OkBrush
|
||||||
|
: TextBrush,
|
||||||
|
OkCount: _ok,
|
||||||
|
NgCount: _ng,
|
||||||
|
TotalCount: _ok + _ng,
|
||||||
|
AlarmText: alarmText,
|
||||||
|
AlarmBrush: alarmText == "无报警" ? MutedBrush : AlarmBrushHex,
|
||||||
|
CanStart: connected && !_running,
|
||||||
|
CanStop: _running,
|
||||||
|
IsRunning: _running);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool RequestStart()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_running) return false;
|
||||||
|
if (_device.Connection != ConnectionState.Connected) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_confirm.ConfirmStart()) return false;
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_running) return false;
|
||||||
|
if (_device.Connection != ConnectionState.Connected) return false;
|
||||||
|
_ok = 0;
|
||||||
|
_ng = 0;
|
||||||
|
_last = null;
|
||||||
|
_alarm = null;
|
||||||
|
_busy = false;
|
||||||
|
_running = true;
|
||||||
|
_runCts = CancellationTokenSource.CreateLinkedTokenSource(_life.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_frames is StubFrameSource stub)
|
||||||
|
stub.StartGrab();
|
||||||
|
|
||||||
|
_worker = Task.Run(() => PumpAsync(_runCts.Token));
|
||||||
|
Raise();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool RequestStop()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_running) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_confirm.ConfirmStop()) return false;
|
||||||
|
StopCore();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseAlarm(string text)
|
||||||
|
{
|
||||||
|
lock (_gate) _alarm = new Alarm(text, DateTimeOffset.UtcNow);
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearAlarm()
|
||||||
|
{
|
||||||
|
lock (_gate) _alarm = null;
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopCore()
|
||||||
|
{
|
||||||
|
CancellationTokenSource? cts;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_running) return;
|
||||||
|
_running = false;
|
||||||
|
_busy = false;
|
||||||
|
cts = _runCts;
|
||||||
|
_runCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_frames is StubFrameSource stub)
|
||||||
|
stub.StopGrab();
|
||||||
|
|
||||||
|
try { cts?.Cancel(); } catch (ObjectDisposedException) { }
|
||||||
|
cts?.Dispose();
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFrame(FrameReadyEvent e)
|
||||||
|
{
|
||||||
|
if (!Snapshot().IsRunning && !_running) return;
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_running) return;
|
||||||
|
_live = e.Frame;
|
||||||
|
if (_queue.Count >= _opt.QueueCapacity)
|
||||||
|
{
|
||||||
|
_queue.TryDequeue(out _);
|
||||||
|
_alarm = new Alarm("采集队列已满,已丢最旧帧", DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
_queue.Enqueue(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDevice(DeviceStatusInfo info)
|
||||||
|
{
|
||||||
|
lock (_gate) _device = info;
|
||||||
|
if (info.Connection != ConnectionState.Connected)
|
||||||
|
StopCore();
|
||||||
|
else
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlc(DeviceStatusInfo info)
|
||||||
|
{
|
||||||
|
if (info.Connection == ConnectionState.Disconnected)
|
||||||
|
RaiseAlarm(info.Detail ?? "PLC 断连");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PumpAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (!_queue.TryDequeue(out var frame))
|
||||||
|
{
|
||||||
|
try { await Task.Delay(15, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate) _busy = true;
|
||||||
|
Raise();
|
||||||
|
|
||||||
|
InspectResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeout.CancelAfter(_opt.InspectTimeoutMs);
|
||||||
|
result = await _inspect.Inspect(frame.Frame, _opt.RecipeId, timeout.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
result = new InspectResult(false, "TIMEOUT", _opt.InspectTimeoutMs, null);
|
||||||
|
lock (_gate) _alarm = new Alarm("检测超时", DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result = new InspectResult(false, "ERR", 0, null);
|
||||||
|
lock (_gate) _alarm = new Alarm(ex.Message, DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_last = result;
|
||||||
|
_busy = false;
|
||||||
|
if (result.Ok) _ok++;
|
||||||
|
else _ng++;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _plc.WriteAsync(
|
||||||
|
"Result.Ok",
|
||||||
|
result.Ok,
|
||||||
|
TimeSpan.FromMilliseconds(_opt.PlcTimeoutMs),
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
RaiseAlarm("PLC 写出失败: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate) _busy = false;
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
private RunState ComputeState()
|
||||||
|
{
|
||||||
|
if (_device.Connection != ConnectionState.Connected)
|
||||||
|
return RunState.Disconnected;
|
||||||
|
if (_alarm is not null)
|
||||||
|
return RunState.Alarm;
|
||||||
|
if (_busy)
|
||||||
|
return RunState.Busy;
|
||||||
|
if (_last is { Ok: true })
|
||||||
|
return RunState.Ok;
|
||||||
|
if (_last is { Ok: false })
|
||||||
|
return RunState.Ng;
|
||||||
|
return RunState.Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatLast(InspectResult? r)
|
||||||
|
{
|
||||||
|
if (r is null) return "—";
|
||||||
|
if (r.Ok) return "OK";
|
||||||
|
return string.IsNullOrWhiteSpace(r.NgCode) ? "NG" : "NG " + r.NgCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatAlarm()
|
||||||
|
{
|
||||||
|
if (_device.Connection == ConnectionState.Disconnected)
|
||||||
|
return _device.Detail ?? "相机未连接";
|
||||||
|
if (_device.Connection == ConnectionState.Reconnecting)
|
||||||
|
return _device.Detail ?? "相机重连中";
|
||||||
|
return _alarm?.Text ?? "无报警";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Raise() => Changed?.Invoke();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_frames.FrameReady -= OnFrame;
|
||||||
|
_devices.Changed -= OnDevice;
|
||||||
|
_plc.StatusChanged -= OnPlc;
|
||||||
|
StopCore();
|
||||||
|
_life.Cancel();
|
||||||
|
_life.Dispose();
|
||||||
|
if (_frames is IDisposable d) d.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed class RelayCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action _exec;
|
||||||
|
private readonly Func<bool>? _can;
|
||||||
|
|
||||||
|
public RelayCommand(Action exec, Func<bool>? can = null)
|
||||||
|
{
|
||||||
|
_exec = exec;
|
||||||
|
_can = can;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged;
|
||||||
|
public bool CanExecute(object? parameter) => _can?.Invoke() ?? true;
|
||||||
|
public void Execute(object? parameter) => _exec();
|
||||||
|
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunViewModel : INotifyPropertyChanged, IDisposable
|
||||||
|
{
|
||||||
|
private readonly RunService _run;
|
||||||
|
private readonly IUiDispatch _ui;
|
||||||
|
private StationSnapshot _s;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
public RunViewModel(RunService run, IUiDispatch? ui = null)
|
||||||
|
{
|
||||||
|
_run = run;
|
||||||
|
_ui = ui ?? new SynchronizationContextDispatch();
|
||||||
|
_s = run.Snapshot();
|
||||||
|
StartCommand = new RelayCommand(() => _run.RequestStart(), () => CanStart);
|
||||||
|
StopCommand = new RelayCommand(() => _run.RequestStop(), () => CanStop);
|
||||||
|
_run.Changed += OnChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RunViewModel CreateStub(IUserConfirm? confirm = null, IUiDispatch? ui = null)
|
||||||
|
{
|
||||||
|
var cam = new StubFrameSource();
|
||||||
|
var run = new RunService(
|
||||||
|
cam,
|
||||||
|
cam,
|
||||||
|
new StubInspectService(),
|
||||||
|
new StubPlcIo(),
|
||||||
|
confirm ?? new AlwaysConfirm(),
|
||||||
|
new RuntimeOptions());
|
||||||
|
return new RunViewModel(run, ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
public ICommand StartCommand { get; }
|
||||||
|
public ICommand StopCommand { get; }
|
||||||
|
|
||||||
|
public RunState RunState => _s.RunState;
|
||||||
|
public string RecipeName => _s.RecipeName;
|
||||||
|
public string ConnectionText => _s.ConnectionText;
|
||||||
|
public object? LiveImage => _s.LiveImage;
|
||||||
|
public object? OverlayImage => _s.OverlayImage;
|
||||||
|
public string BadgeText => _s.BadgeText;
|
||||||
|
public string LastResultText => _s.LastResultText;
|
||||||
|
public string LastResultBrush => _s.LastResultBrush;
|
||||||
|
public int OkCount => _s.OkCount;
|
||||||
|
public int NgCount => _s.NgCount;
|
||||||
|
public int TotalCount => _s.TotalCount;
|
||||||
|
public string AlarmText => _s.AlarmText;
|
||||||
|
public string AlarmBrush => _s.AlarmBrush;
|
||||||
|
public bool CanStart => _s.CanStart;
|
||||||
|
public bool CanStop => _s.CanStop;
|
||||||
|
|
||||||
|
public RunService Service => _run;
|
||||||
|
|
||||||
|
private void OnChanged() => _ui.Post(ApplyOnUi);
|
||||||
|
|
||||||
|
private void ApplyOnUi()
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _disposed) != 0) return;
|
||||||
|
_s = _run.Snapshot();
|
||||||
|
OnPropertyChanged(string.Empty);
|
||||||
|
((RelayCommand)StartCommand).RaiseCanExecuteChanged();
|
||||||
|
((RelayCommand)StopCommand).RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||||
|
_run.Changed -= OnChanged;
|
||||||
|
_run.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed class ScriptedConfirm : IUserConfirm
|
||||||
|
{
|
||||||
|
private readonly Queue<bool> _start;
|
||||||
|
private readonly Queue<bool> _stop;
|
||||||
|
|
||||||
|
public ScriptedConfirm(IEnumerable<bool>? start = null, IEnumerable<bool>? stop = null)
|
||||||
|
{
|
||||||
|
_start = new Queue<bool>(start ?? Array.Empty<bool>());
|
||||||
|
_stop = new Queue<bool>(stop ?? Array.Empty<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public int StartPrompts { get; private set; }
|
||||||
|
public int StopPrompts { get; private set; }
|
||||||
|
|
||||||
|
public bool ConfirmStart()
|
||||||
|
{
|
||||||
|
StartPrompts++;
|
||||||
|
return _start.Count == 0 || _start.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ConfirmStop()
|
||||||
|
{
|
||||||
|
StopPrompts++;
|
||||||
|
return _stop.Count == 0 || _stop.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed record StationSnapshot(
|
||||||
|
RunState RunState,
|
||||||
|
string RecipeName,
|
||||||
|
string ConnectionText,
|
||||||
|
object? LiveImage,
|
||||||
|
object? OverlayImage,
|
||||||
|
string BadgeText,
|
||||||
|
string LastResultText,
|
||||||
|
string LastResultBrush,
|
||||||
|
int OkCount,
|
||||||
|
int NgCount,
|
||||||
|
int TotalCount,
|
||||||
|
string AlarmText,
|
||||||
|
string AlarmBrush,
|
||||||
|
bool CanStart,
|
||||||
|
bool CanStop,
|
||||||
|
bool IsRunning);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed class StubFrameSource : IFrameSource, IDeviceStatus, IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private ConnectionState _connection = ConnectionState.Connected;
|
||||||
|
private Timer? _timer;
|
||||||
|
private int _frameSeq;
|
||||||
|
|
||||||
|
public event Action<FrameReadyEvent>? FrameReady;
|
||||||
|
public event Action<DeviceStatusInfo>? Changed;
|
||||||
|
|
||||||
|
public ConnectionState Connection
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _connection; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceStatusInfo Current => new(Connection, Detail());
|
||||||
|
|
||||||
|
public void SetConnection(ConnectionState state)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_connection == state) return;
|
||||||
|
_connection = state;
|
||||||
|
if (state != ConnectionState.Connected)
|
||||||
|
StopGrab();
|
||||||
|
}
|
||||||
|
|
||||||
|
Changed?.Invoke(Current);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartGrab(int intervalMs = 200)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_connection != ConnectionState.Connected) return;
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = new Timer(_ => Emit(), null, 0, intervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopGrab()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Emit()
|
||||||
|
{
|
||||||
|
if (Connection != ConnectionState.Connected) return;
|
||||||
|
var n = Interlocked.Increment(ref _frameSeq);
|
||||||
|
FrameReady?.Invoke(new FrameReadyEvent("CAM0", new StubFrame(n), DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? Detail() => Connection switch
|
||||||
|
{
|
||||||
|
ConnectionState.Disconnected => "相机未连接",
|
||||||
|
ConnectionState.Reconnecting => "相机重连中",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Dispose() => StopGrab();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StubFrame(int Seq);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
/// <summary>算法桩:拍号在服务内计,VM 不数拍。1-4 OK,第 5 拍 NG/STUB,循环。</summary>
|
||||||
|
public sealed class StubInspectService : IInspectService
|
||||||
|
{
|
||||||
|
private int _shot;
|
||||||
|
|
||||||
|
public Task<InspectResult> Inspect(object frameHandle, string recipeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var n = Interlocked.Increment(ref _shot);
|
||||||
|
var isNg = n % 5 == 0;
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(isNg
|
||||||
|
? new InspectResult(false, "STUB", (int)sw.ElapsedMilliseconds, Overlay: null)
|
||||||
|
: new InspectResult(true, null, (int)sw.ElapsedMilliseconds, Overlay: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed class StubPlcIo : IPlcIo
|
||||||
|
{
|
||||||
|
public event Action<DeviceStatusInfo>? StatusChanged;
|
||||||
|
|
||||||
|
public Task WriteAsync(string tag, object value, TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<object?> ReadAsync(string tag, TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<object?>(null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using VisionHost.Contracts;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime;
|
||||||
|
|
||||||
|
public sealed class SynchronizationContextDispatch : IUiDispatch
|
||||||
|
{
|
||||||
|
private readonly SynchronizationContext? _ctx;
|
||||||
|
|
||||||
|
public SynchronizationContextDispatch(SynchronizationContext? ctx = null)
|
||||||
|
{
|
||||||
|
_ctx = ctx ?? SynchronizationContext.Current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Post(Action action)
|
||||||
|
{
|
||||||
|
if (_ctx is null)
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ctx.Post(_ => action(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>VisionHost.Runtime</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\VisionHost.Contracts\VisionHost.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
|
||||||
|
using VisionHost.Contracts;
|
||||||
|
using VisionHost.Runtime;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VisionHost.Runtime.Tests;
|
||||||
|
|
||||||
|
public sealed class RunServiceAcceptanceTests
|
||||||
|
{
|
||||||
|
private static (RunService run, StubFrameSource cam, ScriptedConfirm confirm) New(
|
||||||
|
IEnumerable<bool>? start = null,
|
||||||
|
IEnumerable<bool>? stop = null)
|
||||||
|
{
|
||||||
|
var cam = new StubFrameSource();
|
||||||
|
var confirm = new ScriptedConfirm(start, stop);
|
||||||
|
var run = new RunService(cam, cam, new StubInspectService(), new StubPlcIo(), confirm);
|
||||||
|
return (run, cam, confirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disconnected_StartDisabled_NoConfirm()
|
||||||
|
{
|
||||||
|
var (run, cam, confirm) = New();
|
||||||
|
cam.SetConnection(ConnectionState.Disconnected);
|
||||||
|
var s = run.Snapshot();
|
||||||
|
Assert.Equal(RunState.Disconnected, s.RunState);
|
||||||
|
Assert.Equal("断连", s.BadgeText);
|
||||||
|
Assert.False(s.CanStart);
|
||||||
|
Assert.Equal("未连接", s.ConnectionText);
|
||||||
|
Assert.False(run.RequestStart());
|
||||||
|
Assert.Equal(0, confirm.StartPrompts);
|
||||||
|
run.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reconnecting_StartDisabled_NoConfirm()
|
||||||
|
{
|
||||||
|
var (run, cam, confirm) = New();
|
||||||
|
cam.SetConnection(ConnectionState.Reconnecting);
|
||||||
|
Assert.False(run.Snapshot().CanStart);
|
||||||
|
Assert.False(run.RequestStart());
|
||||||
|
Assert.Equal(0, confirm.StartPrompts);
|
||||||
|
run.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Start_CancelKeepsIdle()
|
||||||
|
{
|
||||||
|
var (run, _, confirm) = New(start: new[] { false });
|
||||||
|
Assert.True(run.Snapshot().CanStart);
|
||||||
|
Assert.False(run.RequestStart());
|
||||||
|
Assert.Equal(1, confirm.StartPrompts);
|
||||||
|
Assert.False(run.Snapshot().IsRunning);
|
||||||
|
Assert.Equal(RunState.Idle, run.Snapshot().RunState);
|
||||||
|
run.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_Confirm_ClearsCounts_AndRuns()
|
||||||
|
{
|
||||||
|
var (run, _, _) = New(start: new[] { true }, stop: new[] { true });
|
||||||
|
Assert.True(run.RequestStart());
|
||||||
|
await WaitAsync(() => run.Snapshot().TotalCount >= 1);
|
||||||
|
var mid = run.Snapshot();
|
||||||
|
Assert.True(mid.IsRunning);
|
||||||
|
Assert.True(mid.CanStop);
|
||||||
|
Assert.False(mid.CanStart);
|
||||||
|
Assert.True(run.RequestStop());
|
||||||
|
Assert.True(mid.TotalCount >= 1);
|
||||||
|
|
||||||
|
var confirm2 = new ScriptedConfirm(new[] { true }, new[] { true });
|
||||||
|
run.Dispose();
|
||||||
|
var cam = new StubFrameSource();
|
||||||
|
var run2 = new RunService(cam, cam, new StubInspectService(), new StubPlcIo(), confirm2);
|
||||||
|
Assert.True(run2.RequestStart());
|
||||||
|
Assert.Equal(0, run2.Snapshot().OkCount);
|
||||||
|
Assert.Equal(0, run2.Snapshot().NgCount);
|
||||||
|
run2.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stop_CancelKeepsRunning_ConfirmKeepsCounts()
|
||||||
|
{
|
||||||
|
var confirm = new ScriptedConfirm(start: new[] { true }, stop: new[] { false, true });
|
||||||
|
var cam = new StubFrameSource();
|
||||||
|
using var run = new RunService(cam, cam, new StubInspectService(), new StubPlcIo(), confirm);
|
||||||
|
Assert.True(run.RequestStart());
|
||||||
|
await WaitAsync(() => run.Snapshot().TotalCount >= 2);
|
||||||
|
var before = run.Snapshot().TotalCount;
|
||||||
|
Assert.False(run.RequestStop());
|
||||||
|
Assert.True(run.Snapshot().IsRunning);
|
||||||
|
Assert.Equal(1, confirm.StopPrompts);
|
||||||
|
Assert.True(run.RequestStop());
|
||||||
|
var after = run.Snapshot();
|
||||||
|
Assert.False(after.IsRunning);
|
||||||
|
Assert.True(after.TotalCount >= before);
|
||||||
|
Assert.True(after.CanStart);
|
||||||
|
Assert.False(after.CanStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FifthShot_IsNgStub()
|
||||||
|
{
|
||||||
|
var (run, _, _) = New(start: new[] { true }, stop: new[] { true });
|
||||||
|
run.RequestStart();
|
||||||
|
await WaitAsync(() => run.Snapshot().TotalCount >= 5, 5000);
|
||||||
|
var s = run.Snapshot();
|
||||||
|
Assert.True(s.NgCount >= 1);
|
||||||
|
Assert.Contains(s.LastResultText, new[] { "NG STUB", "OK" });
|
||||||
|
await WaitAsync(() => run.Snapshot().LastResultText == "NG STUB" || run.Snapshot().NgCount >= 1);
|
||||||
|
run.RequestStop();
|
||||||
|
run.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LastResult_Dash_WhenNoShot()
|
||||||
|
{
|
||||||
|
var (run, _, _) = New();
|
||||||
|
Assert.Equal("—", run.Snapshot().LastResultText);
|
||||||
|
run.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LastResult_NgWithoutCode_IsNgOnly()
|
||||||
|
{
|
||||||
|
var cam = new StubFrameSource();
|
||||||
|
var inspect = new FixedInspect(new InspectResult(false, null, 1, null));
|
||||||
|
using var run = new RunService(cam, cam, inspect, new StubPlcIo(), new AlwaysConfirm());
|
||||||
|
run.RequestStart();
|
||||||
|
cam.GetType(); // keep
|
||||||
|
// inject via queue by starting grab
|
||||||
|
WaitAsync(() => run.Snapshot().TotalCount >= 1).GetAwaiter().GetResult();
|
||||||
|
Assert.Equal("NG", run.Snapshot().LastResultText);
|
||||||
|
run.RequestStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Alarm_WhileConnected_CanStopIfRunning()
|
||||||
|
{
|
||||||
|
var (run, _, _) = New(start: new[] { true });
|
||||||
|
run.RequestStart();
|
||||||
|
run.RaiseAlarm("光源异常");
|
||||||
|
var s = run.Snapshot();
|
||||||
|
Assert.Equal(RunState.Alarm, s.RunState);
|
||||||
|
Assert.Equal("报警", s.BadgeText);
|
||||||
|
Assert.Equal("光源异常", s.AlarmText);
|
||||||
|
Assert.True(s.CanStop);
|
||||||
|
run.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disconnect_OverridesAlarm_StartDisabled()
|
||||||
|
{
|
||||||
|
var (run, cam, confirm) = New();
|
||||||
|
run.RaiseAlarm("光源异常");
|
||||||
|
cam.SetConnection(ConnectionState.Disconnected);
|
||||||
|
var s = run.Snapshot();
|
||||||
|
Assert.Equal(RunState.Disconnected, s.RunState);
|
||||||
|
Assert.False(s.CanStart);
|
||||||
|
Assert.False(run.RequestStart());
|
||||||
|
Assert.Equal(0, confirm.StartPrompts);
|
||||||
|
run.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Counts_PersistAcrossStop_ResetOnNextStart()
|
||||||
|
{
|
||||||
|
var confirm = new ScriptedConfirm(start: new[] { true, true }, stop: new[] { true });
|
||||||
|
var cam = new StubFrameSource();
|
||||||
|
using var run = new RunService(cam, cam, new StubInspectService(), new StubPlcIo(), confirm);
|
||||||
|
run.RequestStart();
|
||||||
|
WaitAsync(() => run.Snapshot().TotalCount >= 3).GetAwaiter().GetResult();
|
||||||
|
run.RequestStop();
|
||||||
|
var kept = run.Snapshot().TotalCount;
|
||||||
|
Assert.True(kept >= 3);
|
||||||
|
run.RequestStart();
|
||||||
|
Assert.Equal(0, run.Snapshot().TotalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitAsync(Func<bool> pred, int ms = 3000)
|
||||||
|
{
|
||||||
|
var until = DateTime.UtcNow.AddMilliseconds(ms);
|
||||||
|
while (DateTime.UtcNow < until)
|
||||||
|
{
|
||||||
|
if (pred()) return;
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
throw new TimeoutException("condition not met");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Changed_FromWorker_PostsToUiDispatch()
|
||||||
|
{
|
||||||
|
var ui = new RecordingDispatch();
|
||||||
|
using var vm = RunViewModel.CreateStub(new AlwaysConfirm(), ui);
|
||||||
|
Assert.True(vm.Service.RequestStart());
|
||||||
|
await WaitAsync(() => vm.TotalCount >= 1);
|
||||||
|
Assert.True(ui.PostCount >= 1);
|
||||||
|
vm.Service.RequestStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingDispatch : IUiDispatch
|
||||||
|
{
|
||||||
|
public int PostCount;
|
||||||
|
|
||||||
|
public void Post(Action action)
|
||||||
|
{
|
||||||
|
PostCount++;
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedInspect : IInspectService
|
||||||
|
{
|
||||||
|
private readonly InspectResult _r;
|
||||||
|
public FixedInspect(InspectResult r) => _r = r;
|
||||||
|
public Task<InspectResult> Inspect(object frameHandle, string recipeId, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(_r);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\VisionHost.Runtime\VisionHost.Runtime.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user