Monday, 1 August 2022

Behaviors library for .NET MAUI

Many years ago I wrote a behaviours library for Xamarin.Forms. The conventional view was that behaviours extend the functionality of controls, with typical examples validating user text input. Inspired by the then Blend SDK, my thought was that behaviors can be split into two concepts. Behaviours are attached to a control and listen for something to happen. When the something happens, it triggers one or more actions in response. So actions are invoked by behaviours and executed on a specified control. Typical behaviors are listening for an event firing, or data changing. Typical actions are invoking a command, invoking a method, setting a property etc.

I ended up producing a behaviours library for Xamarin.Forms, based on the Blend SDK, that I shipped on NuGet and for a while it was moderately successful. I updated it from time to time, but eventually forgot all about it.

I’ve now resurrected it for .NET MAUI. But I’m not going to ship it as a NuGet. Doing so for the previous version caused me lots of work that I’d like to avoid now.

What is it?

Behaviours for .NET MAUI is a class library I’ve created that can be consumed by .NET MAUI apps. It supports the following scenarios:

  • Invoking commands from XAML when an event fires or when data changes.
  • Invoking methods from XAML (in the view or view model) when an event fires or when data changes.
  • Setting properties from XAML (in the view or view model) when an event fires or when data changes.
  • Invoke animations from XAML, including compound animations, when an event fires or when data changes.
  • Triggering a specified VisualState on a VisualElement from XAML, when an event fires or when data changes.

The result of using the library is that you can eliminate lots of boiler plate C# code, instead moving it to XAML.

Behaviours

The library contains the following behaviours:

  • EventHandlerBehavior - listens for a specific event to occur, and executes one or more actions in response.
  • DataChangedBehavior - listens for the bound data to meet a specified condition, and executes one or more actions in response.

Actions

The library contains the following actions:

  • InvokeCommandAction - executes a specified ICommand when invoked.
  • InvokeMethodAction - executes a method on a specified object when invoked.
  • SetPropertyAction - changes a specified property to a specified value.
  • FadeAction - performs a fade animation when invoked,
  • RotateAction - performs a rotate animation when invoked.
  • ScaleAction - performs a scale animation when invoked.
  • TranslateAction - performs a translate animation when invoked.
  • GoToStateAction - invokes visual state changes.

Where is it?

You can download the library and a sample that demos it from its repo.

How do I use it?

Using the library is a three step process:

  1. Clone the library from its repo and add the BehaviorsLibrary class library project to your .NET MAUI solution.
  2. Add a reference to the BehaviorsLibrary project to your app project.
  3. Add an xmlns to the library to your XAML file, and then consume the required behaviors/actions from XAML.

The following code example shows an example of using the EventHandlerBehavior to invoke two commands:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:behaviors="clr-namespace:Behaviors;assembly=Behaviors"
             ...>

    <ContentPage.Resources>
        <converters:SelectedItemEventArgsToSelectedItemConverter x:Key="SelectedItemConverter" />
    </ContentPage.Resources>
	...
    <ListView x:Name="listView"
                 ItemsSource="{Binding People}">
        <ListView.Behaviors>
            <behaviors:EventHandlerBehavior EventName="ItemSelected">
                <behaviors:InvokeCommandAction Command="{Binding ItemSelectedCommand}"
                                               Converter="{StaticResource SelectedItemConverter}" />
                <behaviors:InvokeCommandAction Command="{Binding OutputAgeCommand}"
                                               Converter="{StaticResource SelectedItemConverter}"
                                               ConverterParameter="35" />
            </behaviors:EventHandlerBehavior>
        </ListView.Behaviors>
    </ListView>
</ContentPage>

In this example, when the ListView.ItemSelected event is raised, the ItemSelectedCommand and OutputAgeCommand are sequentially executed on the bound view model (the InvokeCommandAction class expects to find the Command objects on the BindingContext of the attached object). The advantage of this approach is that it enables commands to be associated with controls that weren’t designed to interact with commands, thereby removing boiler-plate event handling code from code-behind files.

In the coming weeks I’ll explore all the functionality the library offers in more detail.

Friday, 29 July 2022

Display a map with .NET MAUI

Many apps, whether mobile or desktop, require the ability to show a map. However, .NET MAUI doesn’t currently have a view (control) capable of displaying a map. That’s frustrating because the underlying platforms that .NET MAUI supports all largely have native map views. Android has the MapView. iOS/MacCatalyst has MKMapView. WinUI has…nothing.

So, most of the platforms .NET MAUI runs on can display a map, but .NET MAUI itself lacks a cross-platform view to display a map. Therefore I’ve created a very simple cross-platform Map view. It was written primarily for me to understand how to write handlers, rather than being a fully featured control. It displays a map, lets you scroll it, zoom it, show your location, show traffic data, and change the map imagery (street/satellite/hybrid). There’s plenty it doesn’t do - no initialising the map to a specific location, no map pins, no routes, no drawing on the map surface etc. A fully featured Map view is beyond the scope of what I was attempting to do, and besides, there will be one appearing in .NET MAUI in the not too distant future.

As it’s a simpler example than the Video view I published yesterday, I thought I’d share it.

Handler architecture

.NET MAUI has an extension mechanism, known as handlers, that you can use to customise existing .NET MAUI controls, and write your own cross-platform views whose implementations are provided by native views.

Each .NET MAUI view has an interface representation, that abstracts a cross-platform view. Cross-platform views that implement these interfaces are known as virtual views. Handlers map these virtual views to native views on each platform, and are responsible for creating the underlying native view, and mapping their API to the cross-platform control.

Handlers are accessed through their view-specific interface. This avoids the cross-platform view having to reference its handler, and the handler having to reference the cross-platform view. Each handler typically provides a property mapper, and sometimes a command mapper, that maps the cross-platform view API to the native view API.

The following diagram shows the handler architecture for the Map view:

The Map view implements the IMap interface. On iOS/MacCatalyst, the MapHandler class maps the cross-platform Map view to an iOS/MacCatalyst MKMapView. On Android, the Map view is mapped to a MapView that's provided by the Xamarin.GooglePlayServices.Maps NuGet package. There’s no Windows implementation, due to the lack of a map control on WinUI.

The PropertyMapper in the MapHandler class maps the cross-platform view properties to native view APIs via mapper methods. Each platform then provides implementations of the mapper methods, which manipulate the native view API as appropriate. The overall effect is that when a property is set on the cross-platform view, the underlying native view is updated as required.

Handler implementations on each platform must override the CreatePlatformView method, and optionally the ConnectHandler and DisconnectHandler methods. The CreatePlatformView method should return the native view that implements the cross-platform view. The ConnectHandler method should perform any required native view setup, and the DisconnectHandler method should perform any required native view cleanup. Note that the DisconnectHandler override is intentionally not invoked by .NET MAUI - you have to invoke it yourself from a suitable place in your app’s lifecycle.

Code

I’m not going to provide a walkthrough of the code. But you can download it, and step through it yourself, by cloning the repo. However, I will give you some pointers to working through the code.

The important files in the solution are highlighted below:

  • Controls - the cross-platform view implementation. IMap abstracts the Map view and exposes members that the handler needs to be able to access, and derives from .NET MAUI’s View. The Map class, which derives from .NET MAUI’s View class, provides the cross-platform implementation, and is simply a collection of BindableProperty objects.
  • Handlers - The IMapHandler interface, which derives from .NET MAUI’s IViewHandler, specifies VirtualView and PlatformView properties. The VirtualViewproperty is used to access the cross-platform view from the handler, and the PlatformView property is used to access the native view that implements the Map view. The MapHandler class is a partial class, whose platform-specific implementations are in the MapHandler.Android.cs, MapHandler.iOS.cs and MapHandler.Windows.cs files.

A handler must be registered against its cross-platform view, and this takes place in MauiProgram.cs with the ConfigureMauiHandler/AddHandler methods.

On Android you’ll need to insert your Google Map API key into the Android Manifest for the map to appear.

I hope this ends up being useful to folks on their journey to understand how to write custom controls backed by .NET MAUI handlers.

Thursday, 28 July 2022

Playing video with .NET MAUI

Many apps, whether mobile or desktop, require the ability to play video. That video may be remote, stored in the app bundle, or be chosen from the user’s device. However, .NET MAUI currently doesn’t have a view (control) capable of playing video. That’s frustrating because the underlying platforms that .NET MAUI supports all largely have native views for playing video. Android has the VideoView. iOS/MacCatalyst has AVPlayer. WinUI has…nothing yet, but I understand it’s coming soon.

So, most of the platforms .NET MAUI runs on can play video, but .NET MAUI itself lacks a cross-platform view to play video. Therefore I’ve created a cross-platform Video view. It changed name as I iterated over it. It started out as VideoPlayer, changed to VideoView, then I settled on Video, purely because .NET MAUI’s image view is called Image. Obviously the Video view plays video. Specifically, it plays video from URLs, from videos embedded in your app package (and hence embedded in your single project), and files chosen by the user on your device. As well as using the in-built transport controls to control video playback, you can provide your own transport controls. Does it play audio? Potentially but I’ve not tried it. It will most likely require some work to turn it into a Media view.

Handler architecture

.NET MAUI has an extension mechanism, known as handlers, that you can use to customise existing .NET MAUI controls, and write your own cross-platform views whose implementations are provided by native views.

Each .NET MAUI view has an interface representation, that abstracts a cross-platform view. Cross-platform views that implement these interfaces are known as virtual views. Handlers map these virtual views to native views on each platform, and are responsible for creating the underlying native view, and mapping their API to the cross-platform control.

Handlers are accessed through their view-specific interface. This avoids the cross-platform view having to reference its handler, and the handler having to reference the cross-platform view. Each handler typically provides a property mapper, and potentially a command mapper, that maps the cross-platform view API to the native view API.

The following diagram shows the handler architecture for the Video view:

The Video view implements the IVideo interface. On iOS/MacCatalyst, the VideoHandler class maps the cross-platform Video view to an iOS/MacCatalyst AVPlayer. On Android, the Video view is mapped to a VideoView. There’s currently no WinUI implementation (due to the lack of a MediaElement control on WinUI) but I’ll add one once WinUI supports playing video.

The PropertyMapper in the VideoHandler class maps the cross-platform view properties to native view APIs via mapper methods. Each platform then provides implementations of the mapper methods, which manipulate the native view API as appropriate. The overall effect is that when a property is set on the cross-platform view, the underlying native view is updated as required.

The CommandMapper in the VideoHandler class maps cross-platform view commands to native view APIs via mapper methods. Command mappers provide a way for cross-platform controls to send commands to native views on each platform. They’re similar to property mappers, but allow for additional data to be passed. Note that commands, in this context, doesn’t mean ICommand implementations. In this context, a command is just a way of invoking some functionality on a native control. For example, the ScrollView in .NET MAUI uses a command mapper so that the ScrollView asks its handler to instruct the native views to scroll to a specific location, passing along the scroll arguments (such as the position or element it wants to scroll to). The ScrollView handler on each platform unpacks the scroll arguments and invokes native view functionality to perform the desired scroll. This was analogous in Xamarin.Forms to having an event on the cross-platform view, with the renderer subscribing to the event. The advantage of the command mapper approach is that it decouples the native view from the cross-platform view, and avoids the need to unsubscribe from events. It also allows for easy customisation - the command mapper can be modified by consumers without subclassing.

Handler implementations on each platform must override the CreatePlatformView method, and optionally the ConnectHandler and DisconnectHandler methods. The CreatePlatformView method should return the native view that implements the cross-platform view. The ConnectHandler method should perform any required native view setup, and the DisconnectHandler method should perform any required native view cleanup. Note that the DisconnectHandler override is intentionally not invoked by .NET MAUI - you have to invoke it yourself from a suitable place in your app's lifecycle.

Code

I’m not going to provide a walkthrough of the code. But you can download it, and step through it yourself, by cloning the repo. However, I will give you some pointers to working through the code.

The solution is structured as follows:

The important folders in the solution are:

  • Controls - the cross-platform view implementation.

    IVideo abstracts the Video view and exposes members that the handler needs to be able to access, and derives from .NET MAUI’s IView. The Video class, which derives from .NET MAUI’s View class, provides the cross-platform implementation, and is a collection of BindableProperty objects, events, and public methods.

  • Handlers - the handler implementation.

    The IVideoHandler interface, which derives from .NET MAUI’s IViewHandler, specifies VirtualView and PlatformView properties. The VirtualView property is used to access the cross-platform view from the handler/native view layer, and the PlatformView property is used to access the native view that implements the Video view. The VideoHandler class is a partial class, whose platform-specific implementations are in the VideoHandler.Android.cs, VideoHandler.iOS.cs and VideoHandler.Windows.cs files.

  • Platforms - the native view implementations.

    Rather than implement the native views directly in the handler, I’ve split them out into native view implementations called MauiVideoPlayer. On Android, MauiVideoPlayer derives from RelativeLayout (for positioning the video on the page) and uses a VideoView to play videos (along with a MediaController for the transport controls). On Android there’s also a VideoProvider class, which is a content provider that retrieves the embedded video files from the assets folder of its bundle. On iOS/MacCatalyst, MauiVideoPlayer derives from UIView and uses an AVPlayer to play videos (along with an AVPlayerViewController for the transport controls).

  • Resources/Raw - three embedded video files.

    The video files have a build action of MauiAsset.

  • Views - pages that exercise the Video view. An event handler for the Unloaded event on each page invokes the DisconnectHandler override of the VideoHandler.

A handler must be registered against its cross-platform view, and this takes place in MauiProgram.cs with the ConfigureMauiHandler/AddHandler methods.

Next steps

There are currently two bugs I’m aware of in the implementation. Firstly, on Android the video is meant to be centred on the page, but it insists on aligning itself to the top of the page. I have suspicions for why this is happening. Secondly, on iOS, when using a Slider as a custom positioning bar, the scale of the Slider isn’t updated at runtime when setting its Maximum property. This makes it impossible right now to use a Slider to control the video’s position. I’ve pin pointed this to a bug in .NET MAUI on iOS, and logged it.

I’m planning on turning this into an official sample during August, and writing official docs on how to create custom controls using .NET MAUI handlers.

Thursday, 6 January 2022

Implicit usings in .NET MAUI

.NET MAUI Preview 11 now uses implicit usings to reduce the number of using statements you need to specify at the top of each file. For more information about implicit usings, see this blog post.

Specifically, from Preview 11 onwards you don’t need to add using statements for the following namespaces, which are all now available implicitly in .NET MAUI projects:

  • Microsoft.Extensions.DependencyInjection
  • Microsoft.Maui
  • Microsoft.Maui.Controls
  • Microsoft.Maui.Controls.Hosting
  • Microsoft.Maui.Controls.Xaml
  • Microsoft.Maui.Graphics
  • Microsoft.Maui.Essentials
  • Microsoft.Maui.Hosting

If you’re new to .NET MAUI, implicit usings really make life easier as you don’t have to hunt around to find out which namespaces specific types are in. However, note that there are sometimes types you’ll need to use that do reside in other namespaces for which you’ll have to add using statements (e.g. the types in Microsoft.Maui.Layouts).

The project templates also now use file-scoped namespaces. All I’ll say is it’s a syntax I’m still getting used to.

.NET Android

.NET Android projects now include the following implicit usings:

  • Android.App
  • Android.Widget
  • Android.OS.Bundle

Therefore, it’s not necessary to add using statements for the above namespaces.

.NET iOS

.NET iOS projects (and MacCatalyst, and tvOS) now include the following implicit usings:

  • CoreGraphics
  • Foundation
  • UIKit

Therefore, it’s not necessary to add using statements for the above namespaces.

.NET macOS

.NET macOS projects now include the following implicit usings:

  • AppKit
  • CoreGraphics
  • Foundation

Therefore, it’s not necessary to add using statements for the above namespaces.

Friday, 12 November 2021

Invoke platform code in .NET MAUI

Xamarin.Forms has the DependencyService class, which is a service locator that enables apps to invoke native platform functionality from cross-platform code. There’s a four step process for using the DependencyService to invoke native platform functionality:

  1. Create an interface for the native platform functionality, in cross-platform code.
  2. Implement the interface in the required platform projects.
  3. Register the platform implementations with the DependencyService.
  4. Resolve the platform implementations from cross-platform code, and invoke them.

For more information about the process, see Xamarin.Forms DependencyService.

While DependencyService works well, it’s not an ideal approach to invoking native code from cross-platform code in 2021 - .NET MAUI can do better! DependencyService is still present in .NET MAUI, via the compatibility layer, for ease of migration of Xamarin.Forms apps to .NET MAUI. However, there’s a better way that combines partial methods and classes with the power of multi-targeting.

In order to demonstrate this approach, I’ve created a simple sample that just returns a string that represents the platform the app is running on. This isn’t the ideal way of determining the platform the app is running on - it’s just demonstrating how to call into platform code in .NET MAUI without resorting to using DependencyService.

As always, the sample this code comes from can be found on GitHub.

Background

In a .NET MAUI app, the app project contains a Platforms folder:

Each child folder of the Platforms folder represents a platform that .NET MAUI can target, and each child folder contains platform-specific code. At build time, the compiler only includes the code from each folder when building for that specific platform. For example, when you build for Android, the files in the Platforms > Android folder will be built into the app package, but the files in the other Platform folders won’t be. This approach, known as multi-targeting can be combined with partial methods and classes to invoke native platform functionality from cross-platform code.

Implementation

In your cross-platform code, define a partial method in a partial class that represents the the operation you want to invoke on each platform. In this example, I’ve created a GetPlatform method in a MyService class:

namespace MyMauiApp
{
    public partial class MyService
    {
        public partial string GetPlatform();
    }
}

Then, in each Platform folder in the project, define the same partial class (in the same namespace) and the same partial method, but also provide the method implementation. This means you’ll have a MyService class in each of the four child folders of the Platforms folder in the solution:

Therefore, the MyService class in the Platforms > Android folder will be:

namespace MyMauiApp
{
    public partial class MyService
    {
        public partial string GetPlatform()
        {
            return "Android";
        }
    }
}

Similarly, the MyService class in the Platforms > Windows folder will be:

namespace MyMauiApp
{
    public partial class MyService
    {
        public partial string GetPlatform()
        {
            return "Windows";
        }
    }
}

Then, in your cross-platform code, create a MyService object and call its GetPlatform method to return the string that represents the platform the code is running on:

MyService service = new MyService();
label.Text = service.GetPlatform();

At build time, the compiler will (via the magic of multi-targeting) combine the cross-platform MyService class with the MyService class for the target platform and build it into the app package. No more DependencyService required!

It’s worth noting that while this approach requires you to provide implementations for all four platforms (Android, iOS, macOS, and Windows), you can also use conditional compilation to limit this to specific platforms if required (e.g. Android and iOS only).

It’s also possible to perform multi-targeting based on filenames, rather than using the Platforms folders. With this approach, the platform is included in the filename (e.g. MyService.Android.cs, MyService.Windows.cs etc.). This enables you to structure your project so that you don’t have to place all your platform code into the different Platforms folders. Maybe that’s a post for another time.

Thursday, 5 August 2021

Getting FastLED and ArduinoBLE to co-operate

I recently finished building an Arduino-driven Nixie tube device, that I’ve called Fermenixie, which uses the FastLED library to control the RGB LEDs that act as backlights to the tubes. The device also uses a modified version of ArduinoBLE library, that allows you to communicate with a BLE device (specifically, a tilt hydrometer). The library modifications enabled the retrieval of advertising data using the iBeacon protocol.

Using FastLED on its own worked perfectly. Using ArduinoBLE on its own worked perfectly. Every time I combined the two, bluetooth communication became unreliable at best. Sometimes it worked. Sometimes it didn’t. But there were no hints as to why. After much experimentation it became obvious that the interaction of the two libraries was the problem, because when I commented out any calls to FastLED the bluetooth communication reliability problems vanished.

After some digging, the problem ultimately turned out to be this: FastLED disables interrupts when issuing data, then re-enables them once its done. If you’re updating LEDs in your loop function, it doesn’t leave you with much scope for using other functionality (ArduinoBLE in my case) that requires interrupts. There were two solutions: (1) use a constant LED colour that’s set in your setup function, or (2) ensure in your loop function that you don’t invoke FastLED while you are using ArduinoBLE functionality (scanning, polling etc.).

Anyway, I’ve put this in a blog post purely in case it’s useful to someone else some day, and saves them the week of time that it took me to figure it out.

Wednesday, 14 July 2021

Get data from a tilt hydrometer on an Arduino

A tilt hydrometer is a submersible thermometer and hydrometer for monitoring fermentation. It’s a Bluetooth LE device that reports data via the iBeacon protocol. Specifically, it broadcasts a major and minor value, which represent the temperature and gravity of the liquid its submersed in, via the manufacturer data. These values can be extracted from the manufacturer data and converted into decimal values.

The manufacturer data it broadcasts is a hex string, such as the following:

4c000215a495bb30c5b14b44b5121370f02d74de0047042b00

This data breaks down as follows:

Apple beacon Type Length Device UUID Major (temperature) Minor (gravity) ???
4c 00 02 15 a4 95 bb 30 c5 b1 4b 44 b5 12 13 70 f0 2d 74 de 00 47 04 2b 00

The Device UUID is shared between devices of a specific colour (it’s unimportant what this means, other than to know that the Device UUID above identifies it as a black tilt).

The temperature is in degrees Fahrenheit, and is a 16 bit unsigned integer in big endian format. The gravity is a 16 bit unsigned integer in big endian format, that must be divided by 1000 to obtain the correct value. For the message above, the temperature is 71F (21.67 C) and the gravity is 1.067.

Normally I monitor the data the tilt returns via an app on my phone, but for various reasons I decided to build my own device to display the data.

My microcontroller of choice is Arduino. They are fantastic, cheap, reliable, and powerful devices and I’ve used them in several projects. The Arduino IDE is a bit basic, but I’m still constantly surprised that Arduino’s “just work”, particularly when I can’t say that about many other technology stacks.

My Arduino of choice, when I require connectivity, is the Arduino Nano 33 IoT. It’s perfect for small devices that require WiFi and Bluetooth functionality. The process for using the Arduino to get data from the tilt hydrometer is as follows:

  • Start bluetooth.
  • Scan for your tilt hydrometer. Once the tilt is found, stop scanning.
  • Retrieve the manufacturer data from the tilt, and extract the temperature and gravity.
  • Stop bluetooth.

Note that because an iBeacon device broadcasts its data, there’s no need to connect to the device.

The ArduinoBLE library can be used to manage bluetooth connectivity. If you’re interested in how the library works, see its GitHub repo. The problem with the library is that it doesn’t support reading manufacturer data. However, an unmerged PR has added that functionality. This version of the library must be installed to your Arduino IDE’s library directory for the sketch below to work (clone the ArduinoBLE repo, switch to the branch containing the PR, zip the repo contents, place the zip in the library directory for the Arduino IDE).

My sketch that gets the manufacturer data from the tilt, and decodes/extracts the temperature and gravity is shown below:

#include <ArduinoBLE.h>
 
char tiltMacAddress[] = "your tilt MAC address goes here e.g. aa:bb:cc:dd:ee:ff";
float temperature = 0;
float gravity = 0;
 
void setup()
{
    Serial.begin(9600);
    while (!Serial); 
    
    BLE.setEventHandler(BLEDiscovered, OnBLEDiscovered);
    StartBluetooth();
}
 
void loop()
{
    if (temperature == 0 || gravity == 0)
    {
        BLE.poll();
    }
}
 
void StartBluetooth()
{
    if (!BLE.begin())
    {
        Serial.println("Can't start BLE");
        return;
    }
    Serial.println("Started bluetooth");
    BLE.scanForAddress(tiltMacAddress);
    Serial.println("Started scan");
}
 
void OnBLEDiscovered(BLEDevice peripheral)
{
    if (peripheral.hasManufacturerData())
    {
        Serial.println("Tilt detected");
        StopScan();
        GetTiltData(peripheral);
        StopBluetooth();
    }
}
 
void StopScan()
{
    BLE.stopScan();
    Serial.println("Stopped scan");
}
 
void StopBluetooth()
{
    BLE.end();
    Serial.println("Stopped bluetooth");
}
 
void GetTiltData(BLEDevice peripheral)
{
    Serial.println("Address: " + peripheral.address());
    Serial.println("RSSI: " + String(peripheral.rssi()));
   
    String tiltData = peripheral.manufacturerData();
    Serial.println("Data: " + tiltData);
   
    String tempHex = tiltData.substring(40, 44);
    String gravityHex = tiltData.substring(44, 48);
   
    char tempHexChar[5];
    tempHex.toCharArray(tempHexChar, 5);
    float tempF = strtol(tempHexChar, NULL, 16);
    temperature = (tempF - 32) * .5556;
    Serial.println("Temp: " + String(temperature));
   
    char gravChar[5]; 
    gravityHex.toCharArray(gravChar, 5);
    long grav = strtol(gravChar, NULL, 16);
    gravity = grav / 1000.0f;
    Serial.println("Gravity: " + String(gravity, 3));
}

After starting bluetooth on the Arduino, a tilt hydrometer can be scanned for using BLE.scanForAddress(tiltMacAddress). I’d recommend using the scanForAddress method over the scan method, as it will take less time to find your tilt. Obviously, this requires knowing the MAC address of your tilt, which can easily be obtained by free bluetooth scanners on most platforms.

Once the tilt with the specified MAC address is discovered, the BLEDiscovered event fires, which in turn executes the OnBLEDiscovered handler. This handler retrieves the manufacturer data with the BLEDevice.manufacturerData method, which returns a hex string. The major and minor values can then be extracted from the hex string, and converted into decimal-based temperature and gravity values.

Outputting the data to the serial port shows that it’s been successfully retrieved:

Having successfully retrieved the tilt data, it’s then possible to output it to Nixie tubes. This involved some refactoring of the above code to make it more robust to the appearance and disappearance of the tilt, and to only retrieve data every hour, rather than continuously (the data only changes very slowly).

So here it is - a one of a kind Nixie device (using 4x IN12A and 2x IN12B tubes) that displays the time (set on startup from an NTP server, and resynchronised every 24 hours):

When a tilt hydrometer is detected, it also displays the fermentation data. Temperature:

Gravity:

Provided that a tilt hydrometer is detected, the device displays the time for a minute, followed by temperature for 30 seconds, and gravity for 30 seconds. If there’s no tilt detected, the time is displayed permanently. The device also includes programmable RGB leds, which act as backlights to each Nixie tube.