交运行工位桩:三口契约、运行服务、六态 VM、跨线程切 UI。

采集/PLC/Inspect 均为桩,验收 1–8 可测。不部署现场。
This commit is contained in:
C# 开发
2026-08-19 01:16:32 +00:00
commit bcddcd0bed
36 changed files with 1432 additions and 0 deletions
+12
View File
@@ -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>
+7
View File
@@ -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();
}
+110
View File
@@ -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();
}
}
+15
View File
@@ -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>
+64
View File
@@ -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; }
}
+8
View File
@@ -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);
}
+9
View File
@@ -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);
}
+6
View File
@@ -0,0 +1,6 @@
namespace VisionHost.Contracts;
public interface IUiDispatch
{
void Post(Action action);
}
+8
View File
@@ -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);
+12
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
namespace VisionHost.Runtime;
public sealed record Alarm(string Text, DateTimeOffset RaisedAt);
+10
View File
@@ -0,0 +1,10 @@
using VisionHost.Contracts;
namespace VisionHost.Runtime;
public sealed class AlwaysConfirm : IUserConfirm
{
public bool ConfirmStart() => true;
public bool ConfirmStop() => true;
}
+314
View File
@@ -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();
}
}
+98
View File
@@ -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();
}
}
+31
View File
@@ -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();
}
}
+22
View File
@@ -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);
+72
View File
@@ -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));
}
}
+15
View File
@@ -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>