Chapter 6 综合练习1

本文示例源码地址是: StdUnit.Tags.WPFDemo ,位于分支 eg-1 中.

你可以直接使用StdUnit.Tags.Templates 模板,执行命令dotnet new tags.wpf来创建一个脚手架,并基于它来微调成这里代码。

我们打算新建一个简单的WPF项目,该项目涉及PLC两个信号:

  • 心跳请求,位于 DB200.100.0, 固定以0.5Hz的频率闪烁。
  • 心跳响应,位于 DB201.100.0, 跟随心跳请求这个信号跳动

创建完一个模板项目后,请添加如下依赖:

  • StdUnit.Tags.Core
  • StdUnit.Tags
  • StdUnit.Tags.S7
  • StdUnit.Tags.RxExtensions

以及日志相关依赖:

  • Serilog
  • Serilog.AspNetCore
  • Serilog.Sinks.Console
  • Serilog.Sinks.File

项目描述和逻辑

让我们建立一个xml文件来表示通道和这两个测点:

<?xml version="1.0" encoding="utf-8"?>
<Project>
	<Channel name="S7-1" driver="S7" >
		<IpAddr>172.16.10.20</IpAddr>
		<Rack>0</Rack>
		<Slot>1</Slot>
	</Channel>

	<TagGrp name="IoBox" isEntry="true" isEnabled="true" channel="S7-1" scanInterval="0">
		<TagGrp name="通用状态">
			<TagCbnt name="PLC" address="DB200.100">
				<Tag name="心跳请求" address="$$100.0" type="BIT" access="RO" />
			</TagCbnt>
			<TagCbnt name="MST" address="DB201.100" access="R1W">
				<Tag name="心跳响应" address="$$100.0" type="BIT" />
			</TagCbnt>
		</TagGrp>
	</TagGrp>
</Project>

然后再添加一个心跳随动逻辑组件:

public class HeartBeatLogicet : LogicetBase
{
    private readonly ITag heartReq;
    private readonly ITag heartAck;
    private readonly ILogger<HeartBeatLogicet> _logger;

    public HeartBeatLogicet(ITagsProject project, ILogger<HeartBeatLogicet> logger) : base(project)
    {
        var grp = this.Tags.SelectGrp("IoBox/通用状态");

        heartReq = grp.SelectTag("PLC/心跳请求");
        heartAck = grp.SelectTag("MST/心跳响应");
        this._logger = logger;
    }

    public override int Order => 2;

    public override bool MatchEntry(ITagGrp entry) => entry.TagName() == "IoBox";

    public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel)
    {
        var now = Stopwatch.GetTimestamp();
        heartAck.Value = heartReq.Value;
        return Task.CompletedTask;
    }
}

组合到一起

现在,我们可以把上面这些代码组合到一起了。

首先创建一个静态扩展方法,用于注册Tags相关服务:

public static IServiceCollection AddWpfDemoTags(this IServiceCollection services)
{
    services.AddLogging();
    services.AddTagsProjectServices(builder =>
    {
        builder.EnableXmlSchemaValidation();
        builder.AddS7Support();
    });
    return services;
}

然后为App添加一个静态方法,以构造一个ServiceCollection,调用上面的AddWpfDemoTags():

public partial class App : Application
{
    private static ServiceCollection ConfigureServiceCollections()
    {
        var services = new ServiceCollection();
        services.AddSerilog((sp, lc) => lc
            .ReadFrom.Services(sp)
            .WriteTo.Console()
            .WriteTo.File("logs/log.txt", outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] [{SourceContext}] {Message}{NewLine}{Exception}", rollingInterval: RollingInterval.Day)
            .Enrich.FromLogContext()
        );

        services.AddWpfDemoTags();
        return services;
    }

接着为App指定Startup=Application_Startup,以在程序启动的时候就自动启动测点项目:

public partial class App : Application
{
    public IServiceProvider? Root { get; private set; }
    internal ITagsProjectCtrl? Ctrl { get; private set; }

    private void Application_Startup(object sender, StartupEventArgs e)
    {
        ServiceCollection services = ConfigureServiceCollections();
        this.Root = services.BuildServiceProvider();
        this.Ctrl = this.Root.GetRequiredService<ITagsProjectCtrl>();

        var th = new Thread(async () =>
        {
            var loggerFactory = this.Root.GetRequiredService<ILoggerFactory>();
            var logger = loggerFactory.CreateLogger<App>();

            var dir = Path.Combine(AppContext.BaseDirectory, "Tags");
            await this.Ctrl.StartPollAsync(dir, null, (proj,sp, ct) =>
            {
                proj.Logicets.Add(new HeartBeatLogicet(
                    proj,
                    loggerFactory.CreateLogger<HeartBeatLogicet>()
                ));

                proj.RunnerStarted += (grp, ch) => {
                    logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName());
                    return Task.CompletedTask;
                };
                proj.RunnerCrashed += (grp, ch, ex) => {
                    logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName());
                    return Task.CompletedTask;
                };

                return Task.CompletedTask;
            });
        });
        th.IsBackground = true;
        th.Start();
    }

最后,我们还需要在程序退出的时候自动停止我们的测点项目:

    protected override async void OnExit(ExitEventArgs e)
    {
        if (this.Ctrl is not null)
        {
            await this.Ctrl.StopAsync();
        }
        Environment.Exit(0);
    }

至此,运行这个项目,PLC上已经可以观测到你在跟随它的心跳随动了。

UI 监控

但光有后台逻辑还不够直观,让我们再花两分钟编写一个简单界面来显示这个两个信号的实时值:

MainWindow
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>

        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition />
        </Grid.RowDefinitions>

        <Grid.Resources>
            <Style TargetType="Label" >
                <Setter Property="VerticalAlignment" Value="Center" />
            </Style>
            <Style TargetType="TextBlock" >
                <Setter Property="VerticalAlignment" Value="Center" />
            </Style>
        </Grid.Resources>

        <Label Grid.Row="0" Grid.Column="0">心跳请求</Label>
        <TextBlock  Name="txtReq" Grid.Row="0" Grid.Column="1" />

        <Label Grid.Row="1" Grid.Column="0">心跳响应</Label>
        <TextBlock Name="txtAck" Grid.Row="1" Grid.Column="1" />
    </Grid>

让我们用code-behind的方式把测点的值实时绑定到界面上:

public partial class MainWindow : Window, IDisposable
{
    private CompositeDisposable _disposables;

    public MainWindow()
    {
        InitializeComponent();
        this._disposables = new CompositeDisposable();

        var app = App.Current as App ?? throw new InvalidCastException("App.Current is not of type App");
        var projobs = app.Ctrl.ObserveStartedOrStopped()
            .Select(evt => evt.IsStarted ? evt.Project : null)
            // 测点项目由后台线程在 Application_Startup 中启动,可能早于本窗口构造;
            // StartedOrStopped 是普通事件、不会重放历史记录,因此用当前 Project 作首值兜底。
            .Prepend(app.Ctrl.Project)
            .DistinctUntilChanged()
            .Publish()
            .RefCount();

        this._disposable = projobs.Select(proj => Observable.Create<Unit>(observer => {
                return proj is null ?
                    Disposable.Empty :
                    SubscribeTags(proj!.Tags);
            }))
            .Switch()
            .Subscribe();
    }

    private IDisposable SubscribeTags(ITagGrp tags)
    {
        var req = tags.SelectTag("IoBox/通用状态/PLC/心跳请求");
        var ack = tags.SelectTag("IoBox/通用状态/MST/心跳响应");

        CompositeDisposable d = new CompositeDisposable();

        req.Watch()
            .ObserveOn(DispatcherScheduler.Current)
            .Subscribe(evt =>
            {
                this.Dispatcher.Invoke(() =>
                {
                    this.txtReq.Text = evt.EventArgs.NewValue?.ToString();
                });
            })
            .DisposeWith(d);

        ack.Watch()
            .ObserveOn(DispatcherScheduler.Current)
            .Subscribe(evt =>
            {
                this.Dispatcher.Invoke(() =>
                {
                    this.txtAck.Text = evt.EventArgs.NewValue?.ToString();
                });
            })
            .DisposeWith(d);
        return d;

    }

    public void Dispose()
    {
        _disposables.Dispose();
    }
}

小结

至此,你已经完成了一个简单的测点项目。不管看起来有多简单,我都强烈推荐你亲自动手一试,哪怕是克隆上面的源码仓库到本地,做些实验也很好。