# floor, ceil, trunc and casting

I left a Wisnode Track Lite RAK7200 outside on the deck for a day and the way the positions “snapped” to a grid caught my attention. Based on the size of my property the grid looked to be roughly 10 x 10 meters

The sample Cayenne Low Power Payload Mbed C code uses a cast which is I think is the same as a floor.

```uint8_t CayenneLPP::addGPS(uint8_t channel, float latitude, float longitude, float meters) {
if ((cursor + LPP_GPS_SIZE) > maxsize) {
return 0;
}
int32_t lat = latitude * 10000;
int32_t lon = longitude * 10000;
int32_t alt = meters * 100;

buffer[cursor++] = channel;
buffer[cursor++] = LPP_GPS;

buffer[cursor++] = lat >> 16;
buffer[cursor++] = lat >> 8;
buffer[cursor++] = lat;
buffer[cursor++] = lon >> 16;
buffer[cursor++] = lon >> 8;
buffer[cursor++] = lon;
buffer[cursor++] = alt >> 16;
buffer[cursor++] = alt >> 8;
buffer[cursor++] = alt;

return cursor;
}
```

“These functions round x downwards to the nearest integer, returning that value as a `double`. Thus, `floor (1.5)` is `1.0` and `floor (-1.5)` is `-2.0`.”

In the C code the latitude and longitude values are truncated to four decimal places and the altitude to two decimal places. In my C# code I used Math.Round and I wondered what impact that could have…

```public void GpsLocationAdd(byte channel, float latitude, float longitude, float altitude)
{
IsChannelNumberValid(channel);
IsBfferSizeSufficient(Enumerations.DataType.Gps);

if ((latitude < Constants.LatitudeMinimum ) || (latitude > Constants.LatitudeMaximum))
{
throw new ArgumentException(\$"Latitude must be between {Constants.LatitudeMinimum} and {Constants.LatitudeMaximum}", "latitude");
}

if ((latitude < Constants.LongitudeMinimum) || (latitude > Constants.LongitudeMaximum))
{
throw new ArgumentException(\$"Longitude must be between {Constants.LongitudeMinimum} and {Constants.LongitudeMaximum}", "latitude");
}

if ((altitude < Constants.AltitudeMinimum) || (altitude > Constants.AltitudeMaximum))
{
throw new ArgumentException(\$"Altitude must be between {Constants.AltitudeMinimum} and {Constants.AltitudeMaximum}", "altitude");
}

int lat = (int)Math.Round(latitude * 10000.0f);
int lon = (int)Math.Round(longitude * 10000.0f);
int alt = (int)Math.Round(altitude * 100.0f);

buffer[index++] = channel;
buffer[index++] = (byte)Enumerations.DataType.Gps;

buffer[index++] = (byte)(lat >> 16);
buffer[index++] = (byte)(lat >> 8);
buffer[index++] = (byte)lat;
buffer[index++] = (byte)(lon >> 16);
buffer[index++] = (byte)(lon >> 8);
buffer[index++] = (byte)lon;
buffer[index++] = (byte)(alt >> 16);
buffer[index++] = (byte)(alt >> 8);
buffer[index++] = (byte)alt;
}
```

Using the WGS84 World Geodetic System Distance Calculator to calculate the distance where the Greenwich Meridian and the Equator cross off the coast of Ghana the theoretical maximum error is 15.69m.

I live in Christchurch New Zealand and the theoretical maximum distance is 13.6 m. So, in summary the LPP latitude and longitude values are most probably fine for tracking applications.

# MATH131 Numerical methods was useful

Back in 1986 in my second first year at the University of Canterbury I did “MATH131 Numerical Methods” which was a year of looking at why mathematics in FORTRAN, C, and Pascal sometimes didn’t return the result you were expecting…

While testing my GHI Electronics TinyCLR2 RAK Wireless RAK811 LoRaWAN client I noticed the temperature numbers didn’t quite match…

I have implemented my own Cayenne Low Power Payload encoder in C# based on the sample Mbed C code

```uint8_t CayenneLPP::addTemperature(uint8_t channel, float celsius) {
if ((cursor + LPP_TEMPERATURE_SIZE) > maxsize) {
return 0;
}
int16_t val = celsius * 10;
buffer[cursor++] = channel;
buffer[cursor++] = LPP_TEMPERATURE;
buffer[cursor++] = val >> 8;
buffer[cursor++] = val;

return cursor;
}
```

My translation of that code to C#

```public void TemperatureAdd(byte channel, float celsius)
{
if ((index + TemperatureSize) > buffer.Length)
{
throw new ApplicationException("TemperatureAdd insufficent buffer capacity");
}

short val = (short)(celsius * 10);

buffer[index++] = channel;
buffer[index++] = (byte)DataType.Temperature;
buffer[index++] = (byte)(val >> 8);
buffer[index++] = (byte)val;
}
```

After looking at the code I think the issues was most probably due to the representation of the constant 10(int32), 10.0(double), and 10.0f(single) . To confirm my theory I modified the client to send the temperature with the calculation done with three different constants.

After some trial and error I settled on this C# code for my decoder

```public void TemperatureAdd(byte channel, float celsius)
{
if ((index + TemperatureSize) > buffer.Length)
{
throw new ApplicationException("TemperatureAdd insufficent buffer capacity");
}

short val = (short)(celsius * 10.0f);

buffer[index++] = channel;
buffer[index++] = (byte)DataType.Temperature;
buffer[index++] = (byte)(val >> 8);
buffer[index++] = (byte)val;
}
```

I don’t think this is specifically an issue with the TinyCLR V2 just with number type used for the constant.

# The Things Network Cayenne LPP Support

In my applications the myDevices Cayenne Low power payload(LPP) uplink messages from my *duino devices are decoded by the built in The Things Network(TTN) decoder. I can also see the nicely formatted values in the device data view.

I could successfully download raw data to the device but I found that manually unpacking it on the device was painful.

I really want to send LPP formatted messages to my devices so I could use a standard LPP library. I initially populated the payload fields in the downlink message JSON. The TTN documentation appeared to indicate this was possible.

Initially I tried a more complex data type because I was looking at downloading a location to the device.

I could see nicely formatted values in the device data view but they didn’t arrive at the device. I then tried simpler data type to see if the complex data type was an issue.

At this point I asked a few questions on the TTN forums and started to dig into the TTN source code.

## Learning Go on demand

I had a look at the TTB Go code and learnt a lot as I figured out how the “baked in “encoder/decoder worked. I haven’t done any Go coding so it took a while to get comfortable with the syntax. The code my look a bit odd as a Pascal formatter was the closest I could get to Go.

In core/handler/cayennelpp/encoder.go there was

```func (e *Encoder) Encode(fields map[string]interface{}, fPort uint8) ([]byte, bool, error) and func (d *Decoder) Decode(payload []byte, fPort uint8) (map[string]interface{}, bool, error)
```

Which was a positive sign…

Then in core/handler/convert_fields.go there are these two functions

```> // ConvertFieldsUp converts the payload to fields using the application's payload formatter
> func (h *handler) ConvertFieldsUp(ctx ttnlog.Interface, _ *pb_broker.DeduplicatedUplinkMessage, appUp *types.UplinkMessage, dev *device.Device) error {
> 	// Find Application
```

and

```> // ConvertFieldsDown converts the fields into a payload
> func (h *handler) ConvertFieldsDown(ctx ttnlog.Interface, appDown *types.DownlinkMessage, ttnDown *pb_broker.DownlinkMessage, _ *device.Device) error {
```

Then further down in the second function is this call

```var encoder PayloadEncoder
Encoder: app.CustomEncoder,
Logger:  functions.Ignore,
}
encoder = &cayennelpp.Encoder{}
default:
return nil
Encoder: app.CustomEncoder,
Logger:  functions.Ignore,
}
encoder = &cayennelpp.Encoder{}
default:
return nil
}
```

Which I think calls

```// Encode encodes the fields to CayenneLPP
func (e *Encoder) Encode(fields map[string]interface{}, fPort uint8) ([]byte, bool, error) {
encoder := protocol.NewEncoder()
for name, value := range fields {
key, channel, err := parseName(name)
if err != nil {
continue
}
switch key {
case valueKey:
if val, ok := value.(float64); ok {
}
}
}
return encoder.Bytes(), true, nil
}
```

Then right down at the very bottom of the call stack in keys.go

```func parseName(name string) (string, uint8, error) {
parts := strings.Split(name, "_")
if len(parts) < 2 {
return "", 0, errors.New("Invalid name")
}
key := strings.Join(parts[:len(parts)-1], "_")
if key == "" {
return "", 0, errors.New("Invalid key")
}
channel, err := strconv.Atoi(parts[len(parts)-1])
if err != nil {
return "", 0, err
}
if channel < 0 || channel > 255 {
return "", 0, errors.New("Invalid range")
}
return key, uint8(channel), nil
}
```

At this point I started to hit the limits of my Go skills but with some trial and error I figured it out…

## Executive Summary

The downlink payload values are sent as 2 byte floats with a sign bit, 100 multiplier. The fields have to be named “value_X” where X is is a byte value.

```Dictionary<string, object> payloadFields = new Dictionary<string, object>();
//00-00-00
//01-00-64
//02-00-C8
//03-01-2C
//04-01-90

//00-00-00
//01-FF-9C
//02-FF-38
//03-FE-D4
//04-FE-70
```

I could see these arrive on my TinyCLR plus RAK811 device and could manually unpack them

The stream of bytes can be decoded on an Arduino using the electronic cats library (needs a small modification) with code this

```byte data[] = {0xff,0x38} ; // bytes which represent -2
float value = lpp.getValue( data, 2, 100, 1);
Serial.print("value:");
Serial.println(value);
```

It is possible to use the “baked” in Cayenne Encoder/Decoder to send payload fields to a device but I’m not certain is this is quite what myDevices/TTN intended.

# Cayenne Low Power Payload (LPP) Encoder

Reducing the size of message payloads is important for LoRa/LoRaWAN communications, as it reduces power consumption and bandwidth usage. One of the more common formats is myDevices Cayenne Low Power Payload(LPP) which is based on the IPSO Alliance Smart Objects Guidelines and is natively supported by The Things Network(TTN).

``` private enum DataType : byte
{
DigitalInput = 0, // 1 byte
DigitialOutput = 1, // 1 byte
AnalogInput = 2, // 2 bytes, 0.01 signed
AnalogOutput = 3, // 2 bytes, 0.01 signed
Luminosity = 101, // 2 bytes, 1 lux unsigned
Presence = 102, // 1 byte, 1
Temperature = 103, // 2 bytes, 0.1°C signed
RelativeHumidity = 104, // 1 byte, 0.5% unsigned
Accelerometer = 113, // 2 bytes per axis, 0.001G
BarometricPressure = 115, // 2 bytes 0.1 hPa Unsigned
Gyrometer = 134, // 2 bytes per axis, 0.01 °/s
Gps = 136, // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01m
}
```

My implementation was “inspired” by the myDevices C/C++ sample code. The first step was to allocate a buffer to store the byte encoded values. I pre allocated the buffer to try and reduce the impacts of garbage collection. The code uses a manually incremented index into the buffer for performance reasons, plus the inconsistent support of System.Collections.Generic and Language Integrated Query(LINQ) on my three embedded platforms. The maximum length message that can be sent is limited by coding rate, duty cycle and bandwidth of the LoRa channel.

```public Encoder(byte bufferSize)
{
if ((bufferSize < BufferSizeMinimum) || ( bufferSize > BufferSizeMaximum))
{
throw new ArgumentException(\$"BufferSize must be between {BufferSizeMinimum} and {BufferSizeMaximum}", "bufferSize");
}

buffer = new byte[bufferSize];
}
```

For a simple data types like a digital input a single byte (True or False ) is used. The channel parameter is included so that multiple values of the same data type can be included in a message.

```public void DigitalInputAdd(byte channel, bool value)
{
if ((index + DigitalInputSize) > buffer.Length)
{
throw new ApplicationException("DigitalInputAdd insufficent buffer capacity");
}

buffer[index++] = channel;
buffer[index++] = (byte)DataType.DigitalInput;
// I know this is fugly but it works on all platforms
if (value)
{
buffer[index++] = 1;
}
else
{
buffer[index++] = 0;
}
}
```

For more complex data types like a Global Positioning System(GPS) location (Latitude, Longitude and Altitude) the values are converted to 32bit signed integers and only 3 of the 4 bytes are used.

```public void GpsAdd(byte channel, float latitude, float longitude, float meters)
{
if ((index + GpsSize) > buffer.Length)
{
throw new ApplicationException("GpsAdd insufficent buffer capacity");
}

int lat = (int)(latitude * 10000);
int lon = (int)(longitude * 10000);
int alt = (int)(meters * 100);

buffer[index++] = channel;
buffer[index++] = (byte)DataType.Gps;

buffer[index++] = (byte)(lat >> 16);
buffer[index++] = (byte)(lat >> 8);
buffer[index++] = (byte)lat;
buffer[index++] = (byte)(lon >> 16);
buffer[index++] = (byte)(lon >> 8);
buffer[index++] = (byte)lon;
buffer[index++] = (byte)(alt >> 16);
buffer[index++] = (byte)(alt >> 8);
buffer[index++] = (byte)alt;
}
```

Before the message can be sent it needs to be converted to its Binary Coded Decimal(BCD) representation and all formatting characters removed.

```public string Bcd()
{
StringBuilder payloadBcd = new StringBuilder(BitConverter.ToString(buffer, 0, index));

}
```

The implementation had to be revised a couple of times so It would work with desktop and GHI Electronics TinyCLRV2 powered devices. There maybe some modifications required as I port it to nanoFramework and Wilderness Labs Meadow devices.

# The Things Network HTTP Integration Part3

This is the third in a series of posts about building an HTTP Integration for a The Things Network(TTN) application.

In part 1 & part 2 I had been ignoring the payload_fields property of the Payload class. The documentation indicates that payload_fields property is populated when an uplink message is Decoded.

There is a built in decoder for Cayenne Low Power Payload(LPP) which looked like the simplest option to start with.

I modified the Seeeduino LoRaWAN Over The Air Activation(OTAA) sample application and added the CayenneLPP library from Electronic Cats.

```#include <LoRaWan.h>
#include <CayenneLPP.h>

CayenneLPP lpp(64);
char buffer[256];

void setup(void)
{
SerialUSB.begin(9600);
while(!SerialUSB);

lora.init();

memset(buffer, 0, 256);
lora.getVersion(buffer, 256, 1);
SerialUSB.print("Ver:");
SerialUSB.print(buffer);

memset(buffer, 0, 256);
lora.getId(buffer, 256, 1);
SerialUSB.print("ID:");
SerialUSB.println(buffer);

lora.setKey(NULL, NULL, "12345678901234567890123456789012");
lora.setId(NULL, "1234567890123456", "1234567890123456");

lora.setPort(10);

lora.setDeciveMode(LWOTAA);
lora.setDataRate(DR0, AS923);

lora.setDutyCycle(false);
lora.setJoinDutyCycle(false);

lora.setPower(14);

while(!lora.setOTAAJoin(JOIN, 10));
}

void loop(void)
{
bool result = false;

lpp.reset ();

// Original LPPv1 data types only these work
// https://www.thethingsnetwork.org/docs/devices/arduino/api/cayennelpp.html
// https://loranow.com/cayennelpp/
//

lpp.addGPS (1, -43.5309, 172.6371, 6.192);
lpp.addAccelerometer(0, 0.0, 0.0, 1.0);

lpp.addLuminosity(0, 0);    // Pitch black
lpp.addLuminosity(1, 8000); // 40w fluro

// Additional data types don't think any of these worked
//lpp.addColour(1, 255, 255, 255);

uint8_t *lppBuffer = lpp.getBuffer();
uint8_t lppLen = lpp.getSize();

SerialUSB.print("Length is: ");
SerialUSB.println(lppLen);

// Dump buffer content for debugging
PrintHexBuffer (lppBuffer, lppLen);

//result = lora.transferPacket("Hello World!", 10);
result = lora.transferPacket(lppBuffer, lppLen);

if(result)
{
short length;

memset(buffer, 0, sizeof(buffer));

if(length)
{
SerialUSB.print("Length is: ");
SerialUSB.println(length);
SerialUSB.print("Data is: ");
for(unsigned char i = 0; i < length; i ++)
{
SerialUSB.print("0x");
SerialUSB.print(buffer[i], HEX);
SerialUSB.print(" ");
}
SerialUSB.println();
}
}
delay( 30000);
}

void PrintHexBuffer( uint8_t *buffer, uint8_t size )
{

for( uint8_t i = 0; i < size; i++ )
{
if(buffer[i] < 0x10)
{
Serial.print('0');
}
SerialUSB.print( buffer[i], HEX );
Serial.print(" ");
}
SerialUSB.println( );
}
```

I then copied and saved to files the payloads from the Azure Application Insights events generated when an uplink messages were processed.

```{
"app_id": "rak811wisnodetest",
"dev_id": "seeeduinolorawan4",
"hardware_serial": "1234567890123456",
"port": 10,
"counter": 1,
"is_retry": true,
"analog_in_1": 0.5,
"digital_in_1": 1,
"gps_1": {
"altitude": 755,
"latitude": 4.34,
"longitude": 40.22
},
"luminosity_1": 100,
"temperature_1": 27.2
},
"time": "2020-08-28T10:41:04.496594225Z",
"frequency": 923.4,
"modulation": "LORA",
"data_rate": "SF12BW125",
"coding_rate": "4/5",
"gateways": [
{
"gtw_id": "eui-b827ebfffe6c279d",
"timestamp": 3971612260,
"time": "2020-08-28T10:41:03.313471Z",
"channel": 1,
"snr": 11.2,
"rf_chain": 0,
"latitude": -43.49885,
"longitude": 172.60095,
"altitude": 25
}
]
},
}
```

I used JSON2Csharp to generate C# classes which would deserialise the above uplink message.

```// Third version of classes for unpacking HTTP payload
public class Gps1V3
{
public int altitude { get; set; }
public double latitude { get; set; }
public double longitude { get; set; }
}

{
public double analog_in_1 { get; set; }
public int digital_in_1 { get; set; }
public Gps1V3 gps_1 { get; set; }
public int luminosity_1 { get; set; }
public double temperature_1 { get; set; }
}

public class GatewayV3
{
public string gtw_id { get; set; }
public ulong timestamp { get; set; }
public DateTime time { get; set; }
public int channel { get; set; }
public int rssi { get; set; }
public double snr { get; set; }
public int rf_chain { get; set; }
public double latitude { get; set; }
public double longitude { get; set; }
public int altitude { get; set; }
}

{
public string time { get; set; }
public double frequency { get; set; }
public string modulation { get; set; }
public string data_rate { get; set; }
public string coding_rate { get; set; }
public List<GatewayV3> gateways { get; set; }
}

{
public string app_id { get; set; }
public string dev_id { get; set; }
public string hardware_serial { get; set; }
public int port { get; set; }
public int counter { get; set; }
public bool is_retry { get; set; }
public string payload_raw { get; set; }
public PayloadFieldsV3 payload_fields { get; set; }
public MetadataV3 metadata { get; set; }
public string downlink_url { get; set; }
}
```

I added a new to controller to my application which used the generated classes to deserialise the body of the POST from the TTN Application Integration.

```[Route("[controller]")]
[ApiController]
public class ClassSerialisationV3Fields : ControllerBase
{
private static readonly ILog log = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);

public string Index()
{
return "move along nothing to see";
}

[HttpPost]
{
// Check that the post data is good
if (!this.ModelState.IsValid)
{
log.WarnFormat("ClassSerialisationV3Fields validation failed {0}", this.ModelState.Messages());

}

return this.Ok();
}
}
```

I then updated the TTN application integration to send messages to my new endpoint. In the body of the Application Insights events I could see the devEUI, port, and the payload fields had been extracted from the message.

```DevEUI:1234567890123456 Payload Base64:AWcBEAFlAGQBAAEBAgAyAYgAqYgGIxgBJuw= analog_in_1:0.5 digital_in_1:1 gps_1:4.34,40.22,755 luminosity_1:100 temperature_1:27.2
```

This arrangement was pretty nasty and sort of worked but in the “real world” would not have been viable. I would need to generate lots of custom classes for each application taking into account the channel numbers (e,g, analog_in_1,analog_in_2) and datatypes used.

I also explored which datatypes were supported by the TTN decoder, after some experimentation (Aug 2019) it looks like only the LPPV1 ones are.

• AnalogOutput
• DigitalInput
• DigitalOutput
• GPS
• Accelerometer
• Gyrometer
• Luminosity
• Presence
• BarometricPressure
• RelativeHumidity
• Temperature

What I need is a more flexible way to stored and decode payload_fields property..

# myDevices Cayenne with MQTTnet

As I’m testing my Message Queue Telemetry Transport(MQTT) LoRa gateway I’m building a proof of concept(PoC) .Net core console application for each IoT platform I would like to support.

This PoC was to confirm that I could connect to the myDevices Cayenne MQTT API and format the topics and payloads correctly. The myDevices team have built many platform specific libraries that wrap the MQTT platform APIs to make integration for first timers easier (which is great). Though, as an experienced Bring Your Own Device(BYOD) client developer, I did find myself looking at the C/C++ code to figure out how to implement parts of my .Net test client.

The myDevices screen designer had “widgets” which generated commands for devices so I extended the test client implementation to see this worked.

The MQTT broker, username, password, client ID, channel number and optional subscription channel number are command line options.

```class Program
{
private static IMqttClient mqttClient = null;
private static IMqttClientOptions mqttOptions = null;
private static string server;
private static string username;
private static string password;
private static string clientId;
private static string channelData;
private static string channelSubscribe;

static void Main(string[] args)
{
MqttFactory factory = new MqttFactory();
mqttClient = factory.CreateMqttClient();

if ((args.Length != 5) && (args.Length != 6))
{
Console.WriteLine("[MQTT Server] [UserName] [Password] [ClientID] [ChannelData] [ChannelSubscribe]");
Console.WriteLine("Press <enter> to exit");
return;
}

server = args[0];
clientId = args[3];
channelData = args[4];

if (args.Length == 5)
{
}

if (args.Length == 6)
{
channelSubscribe = args[5];
}

mqttOptions = new MqttClientOptionsBuilder()
.WithTcpServer(server)
.WithClientId(clientId)
.WithTls()
.Build();

mqttClient.ConnectAsync(mqttOptions).Wait();

if (args.Length == 6)
{
string topic = \$"v1/{username}/things/{clientId}/cmd/{channelSubscribe}";

Console.WriteLine(\$"Subscribe Topic:{topic}");
mqttClient.SubscribeAsync(topic).Wait();
// mqttClient.SubscribeAsync(topic, global::MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce).Wait();
// Thought this might help with subscription but it didn't, looks like ACK might be broken in MQTTnet
}
mqttClient.Disconnected += MqttClient_Disconnected;

string topicTemperatureData = \$"v1/{username}/things/{clientId}/data/{channelData}";

Console.WriteLine();

while (true)
{
string value = "22." + DateTime.UtcNow.Millisecond.ToString();
Console.WriteLine(\$"Publish Topic {topicTemperatureData}  Value {value}");

var message = new MqttApplicationMessageBuilder()
.WithTopic(topicTemperatureData)
.WithQualityOfServiceLevel(global::MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce)
//.WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.ExactlyOnce) // Causes publish to hang
.WithRetainFlag()
.Build();

Console.WriteLine("PublishAsync start");

mqttClient.PublishAsync(message).Wait();
Console.WriteLine("PublishAsync finish");
Console.WriteLine();

}
}

private static void MqttClient_ApplicationMessageReceived(object sender, MqttApplicationMessageReceivedEventArgs e)
{
Console.WriteLine();
}

private static async void MqttClient_Disconnected(object sender, MqttClientDisconnectedEventArgs e)
{
Debug.WriteLine("Disconnected");

try
{
await mqttClient.ConnectAsync(mqttOptions);
}
catch (Exception ex)
{
Debug.WriteLine("Reconnect failed {0}", ex.Message);
}
}
}
```

For this PoC I used the MQTTnet package which is available via NuGet. It appeared to be reasonably well supported and has had recent updates. There did appear to be some issues with myDevices Cayenne default quality of service (QoS) and the default QoS used by MQTTnet connections and also the acknowledgement of the receipt of published messages.

Overall the initial configuration went ok, I found the dragging of widgets onto the overview screen had some issues (maybe the caching of control settings (I found my self refreshing the whole page every so often) and I couldn’t save a custom widget icon at all.

I put a button widget on the overview screen and associated it with a channel publication. The client received a message when the button was pressed

But the button widget was disabled until the overview screen was manually refreshed.

I need to revisit how I confirm that the actuator has been set to the desired value and the command completed.

Overall the myDevices Cayenne experience (April 2019) was a bit flaky with basic functionality like the saving of custom widget icons broken, updates of the real-time data viewer didn’t occur or were delayed, and there were other configuration screen update issues.