TTTT is part of a bigger solution, but I think it is worth to show it as isolated project since it can be used in a bunch of scenarios and the schema stays the same.
One thing to take care about. What I’m building is a kind of “monitor” for environmental data in my home. And this data is sensitive although it might not look like at first. I’ll come to this a bit later.
Let’s start with the sensor I’m using. The thing is a wall mounted unit which sits in my living room. It has a display which shows (alternating) relative humidity, temperature and CO2 saturation in ppm.I guess most of you have seen such devices (at least for temperature).
This device is available with current, voltage or digital output. I use digital because this allows me to use it without the need to take care about “electric environment” (as with voltage) or the need for AD conversion. I bought it with“Modbus RTU” as digital interface.
What I get is CO2 in ppm, relative humidity in % and the temperature in C°. This is configurable either using an optional configuration adapter, or by ordering the sensor already configured for your needs.
There is no need to have deep knowledge about Modbus – at least not if you (like I do) use a nugget package which does the low level communication and protocol handling for you.
Imagine the thing as a kind of memory where you can read data from. Data cells are named“Registers” and all you have to know is which data is in which Register. The so called Register map tells you where / what kind of and in which format(datatype) you find the data.
All data we are interested in (RH, T, CO2) can be found in a block, so last not least the reading part of the software just reads a block of data from the Modbus. Since the values don’t change very fast it is enough to read the data in period of several seconds (or even minutes would be ok).
This is a bonus because it allows us to spend a lot of time handling the data before we obtain new values.
What can we do with the data? We can store it, use it for climate control analysis and so forth. In the case of this project the things end with obtaining and sending the data – it’s up to you to do something usefully with it.
As mentioned above the data is sensitive. Let me explain this with a simple scenario (which I would say is valid for a public location but not for private environments): we could post the data to the cloud and build a nice graph of the CO2 values.
Especially CO2 shows the presence of “CO2 producers” – or in other words – the presence of humans in the environment. I store and display this data (but never public) and I can clearly see when (for an example) everybody leaves home, anyone comes back home, someone is ventilating the room and so forth. It is not as fast as a motion detector – but you can (and I do) use it as presence detector.
And this is why you should never publish these measurement to the public. I would be something like the (well known) posts in social media telling “We are on holidays” - come and enter my empty house.
On the other hand it would be great to know what’s going on at home (shell I ventilate, lower heating…). I do this (not part of this post) using home automation, but I’m also curious about the values and so I decided to implement“something that informs me”.
Instead of bringing up a website (with authentication and all that stuff) I decided to use a bot. So I have no need to write some kind of frontend software.
Some thoughts about what a bot could do for us. First it could answer a question –in this specific case I could ask the bot about the temperature and it would answer with a value (and possibly a time when the last measurement took place).
I could also ask the bot (if I use long recording intervals) to take a new measurement just now. Or (not part of this project) I could ask it to change the heating /ventilation.
Another thing a bot could do is to become active and tell me (for an example) that it’s time to open the window because the CO2 level is too high. Or it could also“learn” that I normally would return home soon and ask me if it shall turn the heating to a higher level. And I in this case could answer – no, keep it down till later, I’m out for dinner.
In this post I wanted to achieve three things:
1.) Let the bot answer questions about temperature, humidity and CO2 level
2.) Let the bot spread the values to a defined group of people
3.) Let the bot inform me, when CO2 reaches a certain level
4.) To enable point 3 – allow me to set a CO2 warning level
Some final words about the Telegram bots.
Point one –a bot can’t talk to another bot. Unfortunately Telegram made the to avoid “the risk of loops”. From my point of view it would be better to have some kind of“bounce / loop” detection instead. Anyhow for this scenario I have no need for it.
Point two –there are three kind of conversations involved. Private (I initiate a chat with the bot). Groups – several members talk with each other – a bot can participate(listening in groups must be enable for the bot). And last not least there are channels – where ONE can talk to a lot of consumers.
A channel(if you monitor a public location) could be used to publish measurement. For an example the bot could tell interested members about the situation at specific times.
A group is used (in this example) to publish values. Group administrators decide who can participate in the group. So I created a group, invited family members and (of course) the bot.
Private communication is used by me to instruct (control) the bot. The bot checks if it is me – and only in this case it does what it reads in the communication.
Let’s get started. First of all we will create a bot. This is pretty easy.
We contatct "BotFather" via telegram and tell him "/newbot". Then we choose a name and a username (which must contain "bot").
Notice that botfather tells us the token of our bot. We need this later in our code to start the bot. This is a secret - and if something goes wrong botfather allows you to create a new token for your bot.
After creating the bot we can make several settings.
Here you can allow your bot to be part of groups (default on), you can set "Group Privacy" (allow the bot to read what's going on in a group it is member of). This is disabled by default.
One other thing you can do here is creating commands. In your code a command is just plain text (although you can see that it is a command). And even if you (like in the sample above) just add 4 commands a user can enter every "command" he wants. The reason to define commands is to help the user. As sonn as he types a slash telegram will list the defined commands with the description.
Tip: I found no way to "read" the commands back from the bot. And if you want to change (maybe just fix a typo) the commands you need to enter the whole text again. So I (before sending) copy this text and keep it for later use.
LET'S START:Using Visual Studio I create a new project using "Background Application (IoT)".
If you can't see this in your templates just install it from the VS-gallery here:
When you are asked about the platform use 15063 as minimum version. The reason - there is a bug with debugging which doesn't occurs when the minimal version is above 15063.
This can be a bit tricky when working with projects which use.NET Standard > 1.4. BUT - it's only debugging - code runs without problems in releases mode!
Now select the file "Package.appxmanifest", choose open with, select XML Editor and near the end of the file add the following capabilities:
<Capabilities>
<Capability Name="internetClient" />
<Capability Name="privateNetworkClientServer" />
<DeviceCapability Name="usb" />
<DeviceCapability Name="serialcommunication">
<Device Id="any">
<Function Type="name:serialPort" />
</Device>
</DeviceCapability>
</Capabilities>
Important here is "usb" and "serialcommunication" to access the RS485 adapter, "internetClient" is needed to enable communication with telegram.
And to have a working skeleton for the background service just change your StartupTask class like this:
public sealed class StartupTask : IBackgroundTask {
private BackgroundTaskDeferral _ServiceDeferral;
public void Run(IBackgroundTaskInstance taskInstance) {
_ServiceDeferral = taskInstance.GetDeferral();
taskInstance.Canceled += TaskInstance_Canceled;
}
private void TaskInstance_Canceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason) {
ShutDownServices();
_ServiceDeferral.Complete();
}
private void ShutDownServices() {
//TODO: place cleanup code here
}
}
Now we add a class for the bot ("BotRunner") and one for Modbus access ("MBusRunner") to your project.
In the Package Manager console add two packages:
Install-Package Telegram.Bot
And also
Install-Package NModbus.Serial
Now everything is installed and we can start coding. Before dealing with ModBus we will bring up the bot. BotFather gave us a token for our bot, we copy it to the code (take care when publishing code not to include such secrets!!!) and add some basic code.
internal class BotRunner : IDisposable {
private const string _BotToken = "790152630:AAHCxs8ZuG7GKvsLQKWaw-oNfqm-qNGpYLo";
private ITelegramBotClient _TheClient;
public BotRunner() {
_TheClient = new TelegramBotClient(_BotToken);
_TheClient.OnUpdate += BotClient_OnUpdate;
}
public bool StartReceiving() {
if(_TheClient != null) {
StopReceiving(); //just to ensure we are not listening multiple times
_TheClient.StartReceiving();
return (true);
}
return (false);
}
public bool StopReceiving() {
if(_TheClient != null) {
if(_TheClient.IsReceiving) {
_TheClient.StopReceiving();
}
return (true);
}
return (false);
}
private async void BotClient_OnUpdate(object sender, Telegram.Bot.Args.UpdateEventArgs e) {
Message mSG = e.Update.Message;
if(mSG == null) {
mSG = e.Update.EditedMessage;
}
if(mSG == null) {
return;
}
if(mSG.Text != null) {
try {
await _TheClient?.SendTextMessageAsync(mSG.Chat.Id, $"{mSG.From.Id} - {mSG.Chat.Id} - {mSG.Chat.Type}");
}
catch { }
}
}
#region IDisposable Support
#region IsDisposed
private bool _IsDisposed;
public bool IsDisposed {
get { return _IsDisposed; }
}
#endregion
protected virtual void Dispose(bool disposing) {
if(!_IsDisposed) {
if(disposing) {
// TODO: dispose managed state (managed objects).
}
StopReceiving();
_TheClient = null;
_IsDisposed = true;
}
}
~BotRunner() {
Dispose(false);
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
Let me explain the code. IDisposable is not that important here (after a shutdown we just no longer listen), but it is always good to clean up. And if you enhance the bot using WebHooks cleaning up becomes essential.
What we do is - in the constructor we create the bot and assign an event handler for the Update (this is the most basic event, you could also use "OnMessage" or "OnMessageEdited"). You may have also noticed that I use a thing like "try send - catch ignore". The idea behind this is that I don't want my background service to die if I can occasionally not reply to the message. In a real world project I would (at least) implement some logging when I hit the catch.
What the code does at the moment is - it replies to a message and shows the "From.Id" and "Chat.Id" in that reply. We will need this later because we want to post in a group or allow a specific sender to run commands.
To make this run we just need to instantiate our BotRunner in StartupTask.
public sealed class StartupTask : IBackgroundTask {
private BackgroundTaskDeferral _ServiceDeferral;
private BotRunner _BotRunner;
public void Run(IBackgroundTaskInstance taskInstance) {
_ServiceDeferral = taskInstance.GetDeferral();
taskInstance.Canceled += TaskInstance_Canceled;
try {
_BotRunner = new BotRunner();
if(!_BotRunner.StartReceiving()) {
throw new Exception("Couldn't start receiving");
}
}
catch(Exception eX) {
Debug.WriteLine($"Error creating bot: {eX.Message}");
_ServiceDeferral.Complete();
return;
}
}
private void TaskInstance_Canceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason) {
ShutDownServices();
_ServiceDeferral.Complete();
}
private void ShutDownServices() {
//TODO: place cleanup code here
_BotRunner?.Dispose();
}
}
Notice that we added _BotRunner?.Dispose() to our ShutDownServices method.
Now we can choose Debug/ARM/Remote Machine and give the code a try.
If you try writing a slash the bot will offer you his commands like this:
I also added the bot to a group and set "Group privacy" to "disabled". Now when I send a personal message (direct chat with the bot) I get: 1234 - 1234 - Private.If I write something in the group I get: 1234 - -6789 - Group. (Notice the groupIDs are negative!!).
By the way - these numbers are just an example. So what to do with the numbers now? First I add then as constants to my BotRunner class.
This looks like:
internal class BotRunner : IDisposable {
private const string _BotToken = "790152630:AAHCxs8ZuG7GKvsLQKWaw-oNfqm-qNGpYLo";
private const long _ManniATID = 1234;
private const long _MyGroupID = -6789;
private ITelegramBotClient _TheClient;
public BotRunner() {
...
And I also enhance the bot with an event and a method to write - either to the group or to "me". Last not least the bot get's a property for the "data". At the moment we use an object - later we will replace this with EE800D data retrieved from the ModBus.
internal class BotRunner : IDisposable {
private const string _BotToken = "790152630:AAHCxs8ZuG7GKvsLQKWaw-oNfqm-qNGpYLo";
private const long _ManniATID = 1234;
private const long _MyGroupID = -6789;
private ITelegramBotClient _TheClient;
public event DGBotCommand OnBotCommand;
#region EE800DData
private object _EE800DData;
public object EE800DData {
get { return _EE800DData; }
set {
if(_EE800DData != value) {
_EE800DData = value;
}
}
}
#endregion
public BotRunner() {
_TheClient = new TelegramBotClient(_BotToken);
_TheClient.OnUpdate += BotClient_OnUpdate;
}
public bool StartReceiving() {
if(_TheClient != null) {
StopReceiving(); //just to ensure we are not listening multiple times
_TheClient.StartReceiving();
return (true);
}
return (false);
}
public bool StopReceiving() {
if(_TheClient != null) {
if(_TheClient.IsReceiving) {
_TheClient.StopReceiving();
}
return (true);
}
return (false);
}
private async void BotClient_OnUpdate(object sender, Telegram.Bot.Args.UpdateEventArgs e) {
Message mSG = e.Update.Message;
if(mSG == null) {
mSG = e.Update.EditedMessage;
}
if(mSG == null) {
return;
}
if(mSG.Chat.Type == Telegram.Bot.Types.Enums.ChatType.Private
&& mSG.From.Id == _ManniATID
&& (mSG.Text?.StartsWith("/")??false)) {
string strErg = await OnBotCommand?.Invoke(mSG.From.Id, mSG.Text.Substring(1));
await _TheClient?.SendTextMessageAsync(mSG.Chat.Id, $"Command: {strErg}");
return;
}
if(mSG.Text != null) {
try {
await _TheClient?.SendTextMessageAsync(mSG.Chat.Id, $"{mSG.From.Id} - {mSG.Chat.Id} - {mSG.Chat.Type}");
}
catch { }
}
}
public async Task<bool> SendMessage2ID(long pChatID, string pMessage) {
try {
await _TheClient?.SendTextMessageAsync(chatId: pChatID, text: $"Service: {pMessage}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
return (true);
}
catch {
if(pChatID != _MyGroupID) {
return (await SendMessage2WebGRP(pMessage));
}
}
return (false);
}
public async Task<bool> SendMessage2WebGRP(string pMessage) {
try {
await _TheClient?.SendTextMessageAsync(chatId: _MyGroupID, text: $"Service: {pMessage}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
return (true);
}
catch { }
return (false);
}
public async Task<bool> PublishCurrentData() {
double CO2 = 500;
double Temperature = 23.5;
double RH = 45.8;
DateTime dtWhen = DateTime.Now;
string strMessage = $"<pre>Senor Data</pre>\n\n <b>{(int)CO2}</b> ppm\n <b>{Math.Round(Temperature, 1)}</b> C°\n <b>{Math.Round(RH, 2)}</b> %RH\n\n<i>Data from:</i> <b>{dtWhen.ToString("HH:mm:ss")}</b>";
return (await SendMessage2WebGRP(strMessage));
}
#region IDisposable Support
#region IsDisposed
private bool _IsDisposed;
public bool IsDisposed {
get { return _IsDisposed; }
}
#endregion
protected virtual void Dispose(bool disposing) {
if(!_IsDisposed) {
if(disposing) {
// TODO: dispose managed state (managed objects).
}
StopReceiving();
_TheClient = null;
_IsDisposed = true;
}
}
~BotRunner() {
Dispose(false);
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
This is (almost) the final BotRunner. Only the correct data in the method PublishCurrentData is missing at the moment.
Now let's update our "master" class to handle commands.
internal delegate Task<string> DGBotCommand(long senderID, string commandText);
public sealed class StartupTask : IBackgroundTask {
private BackgroundTaskDeferral _ServiceDeferral;
private BotRunner _BotRunner;
private MBusRunner _MBRunner;
public void Run(IBackgroundTaskInstance taskInstance) {
_ServiceDeferral = taskInstance.GetDeferral();
taskInstance.Canceled += TaskInstance_Canceled;
try {
_BotRunner = new BotRunner();
_BotRunner.OnBotCommand += _BotRunner_OnBotCommand;
if(!_BotRunner.StartReceiving()) {
throw new Exception("Couldn't start receiving");
}
}
catch(Exception eX) {
Debug.WriteLine($"Error creating bot: {eX.Message}");
_ServiceDeferral.Complete();
return;
}
_MBRunner = new MBusRunner();
}
private async Task<string> _BotRunner_OnBotCommand(long senderID, string commandText) {
if(string.IsNullOrWhiteSpace(commandText)) {
return ("Empty commands are not allowed");
}
string[] aCommandParts = commandText.Trim().ToLowerInvariant().Split(' ');
switch(aCommandParts[0]) {
case "restart":
if(await _MBRunner.Restart()) {
return ("OK");
}
return ("Couldn't restart ModBus");
case "read":
if(await _MBRunner.Read()) {
return ("OK");
}
return ("Couldn't read from ModBus");
case "publish":
if(await _BotRunner.PublishCurrentData()) {
return ("OK");
}
return ("Couldn't publish data");
case "interval":
if(aCommandParts.Length < 2) {
return ("Interval requires a number as parameter:\n0 ==> cyclic reading off || 10-600 ==> cyclic reading interval in seconds");
}
int nDuration;
if(!int.TryParse(aCommandParts[1], out nDuration)) {
return ($"Couldn't parse {aCommandParts[1]} as integer");
}
if(nDuration == 0) {
_MBRunner.StopInterval();
return ("OK");
}
if(nDuration < 10 || nDuration > 600) {
return ($"Invalid interval {nDuration} - allowed values:\n0 ==> cyclic reading off || 10-600 ==> cyclic reading interval in seconds");
}
_MBRunner.SetInterval(nDuration);
return ("OK");
}
return ($"Unknown command: {aCommandParts[0]}");
}
private void TaskInstance_Canceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason) {
ShutDownServices();
_ServiceDeferral.Complete();
}
private void ShutDownServices() {
//TODO: place cleanup code here
_BotRunner?.Dispose();
_MBRunner?.Dispose();
}
}
Command handling is done in a simple way and I just call methods from the "MobBus Runner" which I implemented as empty skeletons.
When I now run the program and send the command /publish to the bot I get:
The chat with the bot shows - Command: OK and in the group you can see a message from the bot showing the simulated data. Using breakpoints you can also check the other commands.
Since the part with the bot is almost done it is time to talk about the MobBus.
When you unpack your EE800D you will find a nice sheet of paper showing you the ModBus Register Map. I also use 2 other types of sensors (EE160, EE210D) so in the image below you will also see their map.
And you will notice that the placement is the same - so when implementing classes for these sensors I start with the EE160 derive the EE210D and last not least the EE800D. Also notice that there are gaps in the map - we will just ignore what's there (other sensors store data there by the way).
A thing about my experiences regarding to ModBus data. Numbers are sometimes stored different from system (vendor / product) to system. So this is sometimes a matter of trial and error when you have to deal with new devices.
I'll not go to deep in the code for the EExxx Sensors. Instead I'll highlight a few important things.
First thing - I wanted to make the code independent of the real reader (in this case NModbus). This is done via the interface IModBusReader.
public interface IModbusReader {
/// <summary>
/// Asynchronously reads a contiguous block of input registers.
/// </summary>
/// <param name="slaveAddress">Address of device to read values from.</param>
/// <param name="startAddress">Address to begin reading.</param>
/// <param name="numberOfPoints">Number of holding registers to read.</param>
/// <returns>The bytes retrieved from the bus .</returns>
Task<ushort[]> ReadInputRegistersAsync(byte pSlaveAddress, ushort pStartAddress, ushort pNumberOfPoints);
}
The next thing - ModBus reading is a bit complex - so (if possible) it is better to read a complete block of data at once instead of reading several blocks / Registers.
So in my case I'll always read the "empty" bytes and simply ignore them.
The "basic device" has constants like this:
protected const ushort InfoOffset_Serial = 0x0;
protected const ushort InfoOffset_Firmware = 0x8;
protected const ushort InfoOffset_NameTransmitter = 0x9;
protected const ushort InfoOffset_SerialSensor = 0x11;
protected const ushort TotalInfo_Len = 26;
protected const ushort ResultAddress_Base = 0x19;
protected const ushort RegAddress_Temperature = 0x19;
protected const ushort RegAddress_RelativeHumidity = 0x1B;
protected const ushort ResultOffset_Base = 0x19;
protected const ushort ResultOffset_Temperature = RegAddress_Temperature - ResultOffset_Base;
protected const ushort ResultOffset_RelativeHumidity = RegAddress_RelativeHumidity - ResultOffset_Base;
#region ResultReadLength
public virtual ushort ResultReadLength {
get { return ResultOffset_RelativeHumidity + 2; }
}
#endregion
If you compare that numbers with the map you will see that we have "static" information at the beginning (Serial, Name,...) followed by the sensor data which starts at 0x19. So we can simplify this using a virtual "ResultReadLength" and a common base Address - ResultAddress_Base in this case.
The code above shows the EE160 (common for EE210D and EE800D). For the EE800D we have:
protected const ushort RegAddress_CO2Raw = 0x2D;
protected const ushort RegAddress_CO2 = 0x2F;
protected const ushort ResultOffset_CO2Raw = RegAddress_CO2Raw - ResultOffset_Base;
protected const ushort ResultOffset_CO2 = RegAddress_CO2 - ResultOffset_Base;
#region ResultReadLength
public override ushort ResultReadLength {
get { return ResultOffset_CO2 + 2; }
}
#endregion
Notice the changed ResultReadLength. The Reading method can be implemented in the base class like this:
public async Task<string> ReadValues(IModbusReader pReader) {
try {
ushort[] uaResult = await pReader.ReadInputRegistersAsync(ModbusAddress, ResultAddress_Base, ResultReadLength);
return (ReadValues(uaResult));
}
catch(TaskCanceledException) {
return ("Error: Modbus timeout at address " + ModbusAddress.ToString());
}
catch(Exception eX) {
return ("Error: " + eX.Message);
}
}
Notice the interface (the reader implementation) and the use of the common ResultAddress_Base as well as the virtual "ResultReadLength".
This method gets the byte from the ModBus and than calls "ReadValues" a virtual function - implemented in all 2 classes. In the common base class it looks like:
public virtual string ReadValues(ushort[] pResult) {
if(pResult.Length >= ResultReadLength) {
Temperature = GetEESingle(pResult, ResultOffset_Temperature);
RelativeHumidity = GetEESingle(pResult, ResultOffset_RelativeHumidity);
return ("OK");
}
return ("Data length to short - expected: " + ResultReadLength.ToString() + " received: " + pResult.Length.ToString());
}
And for the EE800D it is:
public override string ReadValues(ushort[] pResult) {
string strCheck = base.ReadValues(pResult);
if(strCheck == "OK") {
CO2Raw = GetEESingle(pResult, ResultOffset_CO2Raw);
CO2 = GetEESingle(pResult, ResultOffset_CO2);
}
return (strCheck);
}
The last thing to notice here is the "GetEESingle" method.
protected static float GetEESingle(ushort[] pData, int pIndex) {
return (GetSingle(pData[pIndex + 1], pData[pIndex]));
}
/// <summary>
/// Converts two UInt16 values into a IEEE 32 floating point format.
/// </summary>
/// <param name="highOrderValue">High order ushort value.</param>
/// <param name="lowOrderValue">Low order ushort value.</param>
/// <returns>IEEE 32 floating point value.</returns>
private static float GetSingle(ushort highOrderValue, ushort lowOrderValue) {
byte[] value = BitConverter.GetBytes(lowOrderValue)
.Concat(BitConverter.GetBytes(highOrderValue))
.ToArray();
return BitConverter.ToSingle(value, 0);
}
GetEESingle reverses the bytes (the reversed order is more common) and passes it to a "general conversion" method.
Later I will use a struct (not a class!!) to hold the EE800D data which (except for the methods and address information) has the same properties as the class:
I implemented the things in a "E+E sensor library" which I made.NET Standard 1.4. This means it will support a wide range of targets (Android, iOS, UWP,...). The only thing you need is a ModBus-Reader supporting the ReadModbusRegistersAsync method. You may mention that there is no (I guess) RS485 adapter for your Android phone. You are right - but there are network implementations of ModBus, so it is still possible to use this library.
I simply built a nuget package and added it to the project. Now we can finish the BotRunner and change the EE800DData from object to the real struct.
The property now looks like this:
#region EE800DData
private EE800DData _EE800DData;
public EE800DData EE800DData {
get { return _EE800DData; }
set { _EE800DData = value; }
}
#endregion
And the method PublishCurrentData now uses the property:
public async Task<bool> PublishCurrentData() {
DateTime dtWhen = DateTime.Now;
string strMessage = $"<pre>Senor Data</pre>\n\n <b>{(int)_EE800DData.CO2}</b> ppm\n <b>{Math.Round(_EE800DData.Temperature, 1)}</b> C°"
+ $"\n <b>{Math.Round(_EE800DData.RelativeHumidity, 2)}</b> %RH\n\n<i>Data from:</i> <b>{_EE800DData.When.ToString("HH:mm:ss")}</b>";
return (await SendMessage2WebGRP(strMessage));
}
So now let's go on by implementing the MBusRunner class.
Since this class must poll the data it has to implement some kind of mechanism to do this - it will use a task. That means that the code runs asynchronously from the code on the bot. So we have somehow to take care about data access from different threads. The "easy way" of doing this is by using a lock. But the lock statement is not allowed when you use async calls. So we have to find another way to handle this.
First our needs - we need a mechanism which reads the data at a specific interval. Next we want to be able to read immediately for the bot-command /read. We also want to change the interval or stop cyclic reading at all.
Further it helps a lot to know if the reading thread is running. This is easy since we can use a simple lock. Just use a property like this:
internal class MBusRunner : IDisposable {
private readonly object _Locker = new object();
private AutoResetEvent _AREvent;
private int _FailCounter;
#region ShouldTerminate
private bool _ShouldTerminate;
public bool ShouldTerminate {
get {
lock(_Locker) {
return (_ShouldTerminate);
}
}
private set {
lock(_Locker) {
_ShouldTerminate = value;
}
}
}
#endregion
#region IsRunning
private bool _IsRunning;
public bool IsRunning {
get {
lock(_Locker) {
return (_IsRunning);
}
}
private set {
lock(_Locker) {
_IsRunning = value;
}
}
}
#endregion
#region ReadIntervalInSeconds
private int _ReadIntervalInSeconds;
public int ReadIntervalInSeconds {
get {
lock(_Locker) {
return (_ReadIntervalInSeconds);
}
}
private set {
lock(_Locker) {
_ReadIntervalInSeconds = value;
}
}
}
#endregion
With a single object (_Locker) we can ensure that the access to the 3 properties is thread safe. And there is a further element - a AutoResetEvent. This thing can be used to signal / wait in threads. I'll explain this with the thread method:
private async void ClassThreadRunner() {
IsRunning = true;
while(!ShouldTerminate) {
_AREvent.WaitOne(ReadIntervalInSeconds * 1000); //no matter if signaled or not - do something
if(!await ReadDevice()) {
_FailCounter++;
}
else {
_FailCounter = 0;
}
if(_FailCounter == 3) {
ShouldTerminate = true; //self terminate
}
}
IsRunning = false;
}
The thread method first sets the IsRunning flag to true - setting it to false is done at the end of the thread. This thread will run as long as the ShouldTerminate property is false. So to stop it we simply set this to true.
_AREvent.WaitOne waits for someone to signal this event - if the timeout (given as parameter) is reached the method returns (false). We don't care if there was a signal or if the timeout is reached. By the way - if we can't read three times in a row the thread will also terminate (in a better solution it could try to reopen the serial device or inform the user via telegram).
To influence this thread we signal the event. This is done when we want to terminate the reading. Else the thread would wait (for minutes) that the timeout occurs.
Unfortunately we also have to make the ReadDevice method thread safe since we possibly call it directly from the bot-command. Of course this could be done by checking if the thread runs (then just signal the event) or read directly (then the thread wont read). But what if we have a further component which wants to read. So I decided it is better to make the reader thread safe.
private readonly SemaphoreSlim _SlimSema = new SemaphoreSlim(1);
public async Task<bool> ReadDevice() {
try {
bool bErg = await _SlimSema.WaitAsync(3000);
if(!bErg) {
return (false);
}
if(await _EDevice?.ReadValues(_MBusReader) == "OK") {
OnNewModbusData?.Invoke(new EE800DData(_EDevice));
return (true);
}
return (false);
}
catch {
return (false);
}
finally {
_SlimSema.Release();
}
}
For this I use a semaphore (locks are not allowed due to async calls). This semaphore can have one "user". First the user waits 3 seconds to obtain the semaphore. I choose this long timeout because serial communication can take time and it does only mean "in the worst case wait this time". After reading I release the semaphore. If a different task is in the "WaitAsync" call that would return at the moment I release the semaphore.
In the method I use an EE800D instance. If I make this instance a public property I also had to ensure that the access is thread safe. Instead I choose to use a struct (!!!) which is passed to an event handler (by value) to overcome this need.
You may remember that EE800D has a method ReadValues which needs an IModbusReader. I use NModbus and of course the author didn't know that I need that specific interface. I could have used one of his interfaces, but that would mean, that my EE-devices library has to know about NModbus.
To fill this gap I used a facade pattern. A simple class implementing IModbusReader by using NModbus.
internal class ModbusFacade : IModbusReader {
NModbus.IModbusMaster _Master;
public ModbusFacade(NModbus.IModbusMaster pMaster) {
_Master = pMaster;
}
public async Task<ushort[]> ReadInputRegistersAsync(byte pSlaveAddress, ushort pStartAddress, ushort pNumberOfPoints) {
return await (_Master.ReadInputRegistersAsync(pSlaveAddress, pStartAddress, pNumberOfPoints));
}
}
Now we almost have reached the end. Left are only a few methods to open the serial device, create a NModbus instance an other little helpers.
One uncommon thing is to get a serial device. From PCs you do it giving a name like "COM1:" or so. On a Windows 10 IoT device this is done in a different way. First you have to get a list of devices. From that you must choose one and then you can use this to open the physical device. If you (like me) have only one USB attached serial adapter this is easy. Else use debug and check the list to choose your device. Since I only have one attached I can assume that when I use the only one containing "USB UART" in the name is the correct one. FTDIBUS would also work in my case. So this is how the "finder" method looks:
private async Task<DeviceInformation> FindDeviceInfo() {
string aqs = SerialDevice.GetDeviceSelector();
var dis = await DeviceInformation.FindAllAsync(aqs);
return (dis.FirstOrDefault(a => a.Name.Contains("USB UART"))); //FTDIBUS
}
Here is the complete implementation.
internal class MBusRunner : IDisposable {
public event DGNewModbusData OnNewModbusData;
private readonly object _Locker = new object();
private readonly SemaphoreSlim _SlimSema = new SemaphoreSlim(1);
private readonly AutoResetEvent _AREvent;
private int _FailCounter;
private IModbusMaster _MbusMaster;
private IModbusReader _MBusReader;
public EE800D _EDevice;
private SerialDevice _SerialDevice;
private SerialDeviceAdapter _SerialDeviceAdapter;
#region ShouldTerminate
private bool _ShouldTerminate;
public bool ShouldTerminate {
get {
lock(_Locker) {
return (_ShouldTerminate);
}
}
private set {
lock(_Locker) {
_ShouldTerminate = value;
}
}
}
#endregion
#region IsRunning
private bool _IsRunning;
public bool IsRunning {
get {
lock(_Locker) {
return (_IsRunning);
}
}
private set {
lock(_Locker) {
_IsRunning = value;
}
}
}
#endregion
#region ReadIntervalInSeconds
private int _ReadIntervalInSeconds;
public int ReadIntervalInSeconds {
get {
lock(_Locker) {
return (_ReadIntervalInSeconds);
}
}
private set {
lock(_Locker) {
_ReadIntervalInSeconds = value;
}
}
}
#endregion
public MBusRunner() {
_ReadIntervalInSeconds = 60; //one minute default
_AREvent = new AutoResetEvent(false);
}
public async Task<string> TryToOpen() {
try {
DeviceInformation entry = await FindDeviceInfo();
if(entry == null) {
return ("USB Adapter not found");
}
_SerialDevice = await SerialDevice.FromIdAsync(entry.Id);
ApplyDeviceSettings(_SerialDevice);
_SerialDeviceAdapter = new SerialDeviceAdapter(_SerialDevice);
var factory = new ModbusFactory();
_MbusMaster = factory.CreateRtuMaster(_SerialDeviceAdapter);
_MBusReader = new ModbusFacade(_MbusMaster);
EEModbusDevice eD = await EEModbusDevice.FindDevice(_MBusReader, 11);
_EDevice = eD as EE800D;
if(_EDevice != null) {
return (await ReadDevice() ? "OK" : "Read device failed");
}
return ("Could not load EE800D");
}
catch(Exception eX) {
return ($"Error loading EE800: {eX.Message}");
}
}
private void ApplyDeviceSettings(SerialDevice pDevice) {
pDevice.WriteTimeout = TimeSpan.FromMilliseconds(1000);
pDevice.ReadTimeout = TimeSpan.FromMilliseconds(2000);
pDevice.BaudRate = 38400;
pDevice.Parity = SerialParity.Even;
pDevice.StopBits = SerialStopBitCount.One;
pDevice.DataBits = 8;
pDevice.Handshake = SerialHandshake.None;
}
#region StartClassThread
public bool StartClassThread(int pReadIntervalInSeconds) {
ReadIntervalInSeconds = pReadIntervalInSeconds;
if(IsRunning) {
return (false);
}
try {
ShouldTerminate = false;
#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
Windows.System.Threading.ThreadPool.RunAsync(wi => { ClassThreadRunner(); });
#pragma warning restore CS4010
//new Thread(ClassThreadRunner).Start(); //not available with MinVersion 15063
_AREvent.Set(); //have one run
return (true);
}
catch { }
return (false);
}
#endregion
public void TerminateClassThread() {
ShouldTerminate = true;
_AREvent.Set();
Task.Delay(300).Wait(); //give it a little time to settle
//Thread.Sleep(300); //not available with MinVersion 15063
}
#region ClassThreadRunner
private async void ClassThreadRunner() {
IsRunning = true;
while(!ShouldTerminate) {
_AREvent.WaitOne(ReadIntervalInSeconds * 1000); //no matter if signaled or not - do something
if(!await ReadDevice()) {
_FailCounter++;
}
else {
//OnNewModbusData?.Invoke(new EE800DData(_EDevice)); //done by ReadDevice
_FailCounter = 0;
}
if(_FailCounter == 3) {
ShouldTerminate = true; //self terminate
}
}
IsRunning = false;
}
#endregion
public async Task<bool> ReadDevice() {
try {
bool bErg = await _SlimSema.WaitAsync(3000);
if(!bErg) {
return (false);
}
if(await _EDevice?.ReadValues(_MBusReader) == "OK") {
OnNewModbusData?.Invoke(new EE800DData(_EDevice));
return (true);
}
return (false);
}
catch {
return (false);
}
finally {
_SlimSema.Release();
}
}
public void StopInterval() {
TerminateClassThread();
}
public void SetInterval(int nDuration) {
if(IsRunning) { //if running change time
ReadIntervalInSeconds = nDuration;
_AREvent.Set(); //enable one run to take the new interval
return;
}
StartClassThread(nDuration);
}
public async Task<bool> Read() {
return (await ReadDevice());
}
public async Task<bool> Restart() {
bool bWasRunning = CloseBus();
string strErg = await TryToOpen();
if(strErg != "OK") {
return (false);
}
if(bWasRunning) { //restart reader
return (StartClassThread(_ReadIntervalInSeconds));
}
return (true);
}
private bool CloseBus() {
bool bWasRunning = IsRunning;
TerminateClassThread(); //terminate anyhow
_EDevice = null;
_MBusReader = null;
try {
_MbusMaster.Dispose();
}
catch { }
_MBusReader = null;
try {
_SerialDeviceAdapter?.Dispose(); //could be disposed from _SerialDeviceAdapter.Dispose
}
catch { }
_SerialDeviceAdapter = null;
try {
_SerialDevice?.Dispose(); //could be disposed from _SerialDeviceAdapter.Dispose
}
catch { }
_SerialDevice = null;
return (bWasRunning);
}
private async Task<DeviceInformation> FindDeviceInfo() {
string aqs = SerialDevice.GetDeviceSelector();
var dis = await DeviceInformation.FindAllAsync(aqs);
return (dis.FirstOrDefault(a => a.Name.Contains("USB UART"))); //FTDIBUS
}
#region IDisposable Support
#region IsDisposed
private bool _IsDisposed;
public bool IsDisposed {
get { return _IsDisposed; }
}
#endregion
protected virtual void Dispose(bool disposing) {
if(!_IsDisposed) {
if(disposing) {
// TODO: dispose managed state (managed objects).
}
CloseBus();
_IsDisposed = true;
}
}
~MBusRunner() {
Dispose(false);
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
Finally we have to make some changes to the StartupTask. Only open the serial device, declare a new delegate and handle it - the rest is already done. Here also the final implementation:
internal delegate Task<string> DGBotCommand(long senderID, string commandText);
internal delegate void DGNewModbusData(EE800DData pData);
public sealed class StartupTask : IBackgroundTask {
private BackgroundTaskDeferral _ServiceDeferral;
private BotRunner _BotRunner;
private MBusRunner _MBRunner;
public async void Run(IBackgroundTaskInstance taskInstance) {
_ServiceDeferral = taskInstance.GetDeferral();
taskInstance.Canceled += TaskInstance_Canceled;
try {
_BotRunner = new BotRunner();
_BotRunner.OnBotCommand += _BotRunner_OnBotCommand;
if(!_BotRunner.StartReceiving()) {
throw new Exception("Couldn't start receiving");
}
}
catch(Exception eX) {
Debug.WriteLine($"Error creating bot: {eX.Message}");
_ServiceDeferral.Complete();
return;
}
_MBRunner = new MBusRunner();
_MBRunner.OnNewModbusData += _MBRunner_OnNewModbusData;
string strErg = await _MBRunner.TryToOpen();
if(strErg != "OK") {
Debug.WriteLine($"Error opening Modbus");
_MBRunner?.Dispose();
_ServiceDeferral.Complete();
return;
}
}
private void _MBRunner_OnNewModbusData(EE800DData pData) {
if(_BotRunner != null) {
_BotRunner.EE800DData = pData;
}
}
private async Task<string> _BotRunner_OnBotCommand(long senderID, string commandText) {
if(string.IsNullOrWhiteSpace(commandText)) {
return ("Empty commands are not allowed");
}
string[] aCommandParts = commandText.Trim().ToLowerInvariant().Split(' ');
switch(aCommandParts[0]) {
case "restart":
if(await _MBRunner.Restart()) {
return ("OK");
}
return ("Couldn't restart ModBus");
case "read":
if(await _MBRunner.Read()) {
return ("OK");
}
return ("Couldn't read from ModBus");
case "publish":
if(await _BotRunner.PublishCurrentData()) {
return ("OK");
}
return ("Couldn't publish data");
case "interval":
if(aCommandParts.Length < 2) {
return ("Interval requires a number as parameter:\n0 ==> cyclic reading off || 10-600 ==> cyclic reading interval in seconds");
}
int nDuration;
if(!int.TryParse(aCommandParts[1], out nDuration)) {
return ($"Couldn't parse {aCommandParts[1]} as integer");
}
if(nDuration == 0) {
_MBRunner.StopInterval();
return ("OK");
}
if(nDuration < 10 || nDuration > 600) {
return ($"Invalid interval {nDuration} - allowed values:\n0 ==> cyclic reading off || 10-600 ==> cyclic reading interval in seconds");
}
_MBRunner.SetInterval(nDuration);
return ("OK");
}
return ($"Unknown command: {aCommandParts[0]}");
}
private void TaskInstance_Canceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason) {
ShutDownServices();
_ServiceDeferral.Complete();
}
private void ShutDownServices() {
//TODO: place cleanup code here
_BotRunner?.Dispose();
_MBRunner?.Dispose();
}
}
I
left out the warning level - hint - simply add a property, assign a value and in the OnNewModbusData handler check for the limits. Don't forget a threshold to reset the warning, else the bot becomes a spammer :-)
Comments