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 监控
但光有后台逻辑还不够直观,让我们再花两分钟编写一个简单界面来显示这个两个信号的实时值:
<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();
}
}
小结
至此,你已经完成了一个简单的测点项目。不管看起来有多简单,我都强烈推荐你亲自动手一试,哪怕是克隆上面的源码仓库到本地,做些实验也很好。