Chapter 5 练习1

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

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

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

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

  • Itminus.Tags.Core
  • Itminus.Tags
  • Itminus.Tags.S7
  • Itminus.Tags.RxExtensions

以及日志相关依赖:

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

备注:暂时还有一个间接依赖 FSharp.Core(>= 8.0.100),需要我们手动安装。我们计划在将来调整这个nuget依赖关系,让用户不再需要手动安装。

项目配置和构建

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

<?xml version="1.0" encoding="utf-8"?>
<root>
	<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>
</root>

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

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

    public HeartBeatLogicet(IReadOnlyList<ITagChannel> channels, ITagGrp tags, ILogger<HeartBeatLogicet> logger) : base(channels, tags)
    {
        var grp = 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.Name == "IoBox";

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

项目启停控制

测点项目的启停并不是一个简单的过程,在启动时可能需要构造很多东西,而在停止时往往需要释放一些资源。让我们创建一个TagsProjectCtrl来控制ITagsProject的启停。这里TagsProjectCtrl提供了两个核心方法:

  • 在运行StartPollAsync(dir, hook)的时候,它会创建ITagsProject实例,并调用hook函数对所创建的项目实例进行操作,最后运行该项目实例。
  • 在运行StopAsync()的时候,它会尝试断开底层设备通道,并释放之前创建的项目实例。
public class TagsProjectCtrl
{
    private readonly IServiceScopeFactory _ssf;
    private readonly ILogger<TagsProjectCtrl> _logger;

    public TagsProjectCtrl(IServiceScopeFactory ssf,ILogger<TagsProjectCtrl> logger) 
    {
        this._ssf = ssf;
        this._logger = logger;
    }

    public ITagsProject? Project { get; private set; }

    CancellationTokenSource? _cts;


    public async Task StartPollAsync(string? dir, Func<ITagsProject, CancellationToken, Task> hook)
    {
        if(this.Project != null)
        {
            throw new Exception("当前测点项目已经启动!");
        }

        using var scope = this._ssf.CreateScope();
        var sp = scope.ServiceProvider;


        try
        {
            this._cts = new CancellationTokenSource();
            this.Project = sp.MakeProject(dir);
            var ct = _cts.Token;
            await hook(this.Project, ct);
            this.StartedOrStopped?.Invoke(this, new TagsProjectEventArgs(true, this.Project));
            await this.Project.RunAsync(ct);
        }
        catch(Exception ex)
        {
            this._logger.LogError(ex, "TagsProjectCtrl.StartPollAsync error");
        }
        finally
        {
            if (this.Project is not null)
            {
                try
                {
                    this.Project.Dispose();
                }
                catch { }
                finally
                {
                    this.Project = null;
                }
            }

            this._cts = null;
        }
    }

    public async Task StopAsync()
    {
        // reset proj ctrl
        var oldchannels = this.Project?.Channels;
        try
        {
            if (this._cts != null)
            {
                this._cts.Cancel();
            }
            if (this.Project is not null)
            {
                try
                {
                    this.Project.Dispose();
                }
                catch { }
                finally
                {
                    this.Project = null;
                }
            }
        }
        catch
        {
            // ignore error when cancelling
        }

        try
        {
            // disconnect from each channel
            if (oldchannels is not null)
            {
                foreach (var ch in oldchannels)
                {
                    try
                    {
                        if (ch is not null)
                        {
                            await ch.DisconnectAsync(CancellationToken.None);
                        }
                    }
                    catch
                    {

                    }
                }
            }

            this.StartedOrStopped?.Invoke(this, new TagsProjectEventArgs(false, this.Project!));
        }
        catch
        {
            // ignore errors thrown by StartedOrStopped event handlers
        }
    }

    public event TagsProjectStartedOrStopped? StartedOrStopped;
}

这里的两个方法都会触发StartedOrStopped事件,该事件的定义是:

/// 项目启动或者停止事件
public delegate void TagsProjectStartedOrStopped(TagsProjectCtrl sender, TagsProjectEventArgs args);

public class TagsProjectEventArgs: EventArgs
{
    public TagsProjectEventArgs(bool isstarted, ITagsProject project)
    {
        this.IsStarted = isstarted;
        Project = project;
    }

    public ITagsProject Project { get; }

    public bool IsStarted { get; }
}

组合到一起

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

首先为App添加一个静态方法,以构造一个ServiceCollection,并注册相关服务:

public partial class App : Application
{
    private static ServiceCollection ConfigureServiceCollections()
    {
        var services = new ServiceCollection();
        services.AddWpfDemoTags();
        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()
        );
        return services;
    }

其中AddWpfDemoTags是我们为这个示例项目编写的扩展方法:

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

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

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

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

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

            var dir = Directory.GetParent(Assembly.GetExecutingAssembly().Location);
            await this.Ctrl.StartPollAsync(Path.Combine(dir!.FullName, "Tags"), (proj, ct) =>
            {
                // 注册心跳业务逻辑
                proj.Logicets.Add(new HeartBeatLogicet(
                    proj.Channels,
                    proj.Tags,
                    loggerFactory.CreateLogger<HeartBeatLogicet>()
                ));
                // ... 可以引入更多业务逻辑

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

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

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

    protected override async void OnExit(ExitEventArgs e)
    {
        using (var mre = new ManualResetEventSlim(false))
        {
            if(this.Ctrl is not null)
            {
                await this.Ctrl.StopAsync();
            }
            mre.Wait(TimeSpan.FromSeconds(10));
        }
        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 tags = app.Ctrl!.Project!.Tags;
        SubscribeTags(tags);
    }

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


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

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

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

小结

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