Introduction: Nano 33 IoT + EC/pH/ORP + WebAPK

About: Add the ability to measure pH, ORP, EC or salinity to your Arduino or Raspberry Pi project.

A device to measure EC, pH, ORP, and temperature. It could be used to monitor a pool or hydroponic setup. It will communicate through Bluetooth Low Energy and display the information on a webpage using Web Bluetooth. And for fun, we will turn this into a Progressive Web App that you can install from the web.

Step 1: What Are All Those Terms?

  • EC/pH/ORP/temperature are some of the most common water quality measurements. Electrical conductivity(EC) is used in hydroponics to measure nutrient solution, pH for how acidic/basic the water is, and ORP is used to help determine the ability of the water to disinfect itself.
  • Bluetooth Low Energy is a wireless protocol to easily send and receive information. The Arduino board used in this project is the Nano 33 IoT and comes with WiFi and BLE interfaces.
  • Web Bluetooth is a set of APIs implemented in Google's Chrome browser (and Opera) that allow a webpage to directly communicate with a BLE device.
  • Progressive Web Apps are basically webpages that act like regular apps. Android and iPhone's handle them differently, and they are different on desktops, so you'll need to do a bit of reading for specifics.

Step 2: The Hardware

Before we can assemble the hardware, there's one thing to address. The uFire ISE sensor devices come with the same I2C address and we are using two, so one will have to be changed. For this project, we are going to pick one of the ISE boards and use it to measure ORP. Following the steps here, change the address to 0x3e.

Now that the address is changed, putting the hardware together is easy. All the sensor devices use the Qwiic connect system so just connect everything together in a chain. You'll need one Qwiic to Male wire to connect one of the sensors to the Nano 33. The wires are consistent and color coded. Connect black to the Nano's GND, red to either the +3.3V or +5V pin, blue to the SDA pin which is A4, and yellow to the SCL pin on A5.

For this project, it will expect the temperature information to come from the EC sensor, so be sure to attach a temperature sensor to the EC board. All the boards have the ability to measure temperature though. Don't forget to attach the EC, pH and ORP probes to the appropriate sensors. They are easily attached with BNC connectors.

If you have an enclosure, putting all this inside would be a good idea, especially considering water is going to be involved.

Step 3: The Software

The software portion of this is split up into two main sections: the firmware on the Nano 33, and the webpage.

The basic flow is this:

  • The webpage connects to the Nano through BLE
  • The webpage sends text-based commands to ask for information or take actions
  • The Nano listens for those commands, executes them, and returns information
  • The webpage receives the responses and updates the UI accordingly

This setup allows for the webpage to perform all the required functions you would expect, like take a measurement or calibrate the sensors.

Step 4: BLE Services and Characteristics

One of the first things to learn are the basics of how BLE works.

There are a lot of analogies, so lets pick a book. A service would be a book, and a characteristic would be the pages. In this "BLE book", the pages have a few non-book properties like being able to change what the page says and receiving a notification when it happens.

A BLE device can make as many services as it wants. Some are predefined and act as a way to standardize commonly used information like Tx Power or losing a connection, to more specific things like Insulin or Pulse Oximetry. You can also just make one and do whatever you want with it. They are defined in software and are identified with a UUID. You can make UUIDs here.

In the firmware for this device, there is one service, defined as:

BLEService uFire_Service("4805d2d0-af9f-42c1-b950-eae78304c408");

and two characteristics:

BLEStringCharacteristic tx_Characteristic("50fa7d80-440a-44d2-967a-ec7731ec736a", BLENotify, 20);
BLEStringCharacteristic rx_Characteristic("50fa7d80-440b-44d2-967b-ec7731ec736b", BLEWrite, 20); 

The tx_Characteristic will be where the devices sends out the information, like EC measurements, for the webpage to display. The rx_Characteristic is where it will receive commands from the webpage to execute.

This project uses the ArduinoBLE library. If you look, you'll see there are a couple different was to a declare a characteristic. This project uses BLEStringCharacteristic because we will be dealing with the String type and it's just easier, but you could also choose BLECharCharacteristic or BLEByteCharacteristic from among a handful of others.

Additionally, there are some properties you can give the characteristic. tx_Characteristic has BLENotify as an option. That means that our webpage will receive a notification when the value of it changes. rx_Characteristic has BLEWrite which will allow our webpage to modify it. There are others.

Then there is a bit of code-glue to tie all this stuff together:

BLE.setLocalName("uFire BLE"); 
rx_Characteristic.setEventHandler(BLEWritten, rxCallback); 

It's more or less self-explanatory, but let's touch on a few points.

rx_Characteristic.setEventHandler(BLEWritten, rxCallback);

Is where you take advantage of being notified of the value being changed. The line tells the class to execute the function rxCallback when the value is changed.


is what starts the whole thing going. A BLE device will periodically send out a small packet of information announcing that it's out there and available to connect to. Without it, it will be invisible.

Step 5: Text Commands

As mentioned earlier, this device will talk to the webpage through simple text commands. The whole thing is easy to implement because the hard work has already been done. The uFire sensors come with a JSON and MsgPack based library for sending and receiving commands. You can read more about the EC and ISE commands on their documentation pages.

This project will use JSON because it's a little bit easier to work with and readable, unlike the MsgPack format which is binary.

Here's an example of how it all ties together:

  • The webpage asks the device for an EC measurement by sending ec (or more specifically writing ec to the rx_Characteristic characteristic)
  • The device receives the command and executes it. It then sends back a JSON formatted response of {"ec":1.24} by writing to the tx_Characteristic characteristic.
  • The webpage receives the information and displays it

Step 6: The Webpage

The webpage for this project will use Vue.js for the front-end. No backend is needed. Additionally, to keep things a little simpler, no build system is used. It's split up into the usual folders, js for javascript, css for CSS, assets for icons.
The html portion of it is nothing special. It uses for styling and creates the user interface. You'll notice a lot in the section. It's adding all the css and icons, but also adding one line in particular.

That's loading our manifest.json file which is what makes all the PWA stuff happen. It declares some information that tells our phone this webpage can be turned into an app.

The javascript is where most of the interesting things happen. It's broken up into files, app.js contains the basics of getting a Vue webpage going along with all the UI-related variables and a few other things. ble.js has the bluetooth stuff.

Step 7: Javascript and Web Bluetooth

First, this only works on Chrome and Opera. I wish other browsers would support this, but for whatever reason, they do not.
Have a look at app.js and you'll see those same UUIDs we used in our firmware. One for the uFire Service, and one each for the tx and rx characteristics.

Now if you look in ble.js, you'll see the connect() and disconnect() functions.

The connect() function contains some logic to keep the UI in sync, but it's mostly setting things up to send and receive information on the characteristics.

There are some idiosyncrasies when dealing with Web Bluetooth. The connection must be initiated by some sort of physical user interaction, like tapping a button. You can't programatically connect when the webpage is loaded, for example.

The code to start a connection looks like this:

this.device = await navigator.bluetooth.requestDevice({ 
filters: [
         namePrefix: "uFire"
   ], optionalServices: [this.serviceUuid]

The filters: and optionalServices section is needed to avoid seeing every single BLE device out there. You'd think just the filter section would be fine, but you also need the optionalServices part as well.

The above code will show a connection dialog. It's part of the Chrome interface and can't be changed. The user will select from the list. Even if there is only one device the app would ever connect to, the user still needs to go through this selection dialog, due to security concerns.

The rest of the code is setting up the service and characteristics. Take note that we set up a callback routine, similar to the firmware's notification callback:

service = await server.getPrimaryService(this.serviceUuid);
characteristic = await service.getCharacteristic(this.txUuid);
await characteristic.startNotifications();

this.value_update will now be called every time there is new information on the tx characteristic.

One of the last things it does is set a timer to update the information every 5 seconds.

value_update() is just a long function that waits for new JSON information to come in and updates the UI with it.

ec.js, ph.js, and orp.js contain many small functions that send out the commands to retrieve information and calibrate the devices.

To try this, you'll need to keep in mind that to use Web Bluetooth, it must be served over HTTPS. One of many options for a local HTTPS server is serve-https. With the firmware uploaded, everything connected, and the webpage being served, you should be able to see everything working.

Step 8: The PWA Part

There are a few steps to turn the webpage into an actual app. Progressive Web Apps can do a lot more than this project uses them for.

  • Webpage installation
  • Once installed, offline access is possible
  • Started and runs as a normal app with a regular-looking app icon

To get started, we will need to generate a bunch of files. The first is a manifest.json file. There are a handful of sites that will do this for you, App Manifest Generator, being one of them.

A couple things to understand:

  • Application scope is important. I put this webpage at That means my application scope is /uFire-BLE/.
  • Start URL is important too. It is the path to your particular webpage with the base domain already assumed. So because I put this at, the start URL is /uFire-BLE/ too.
  • Display Mode will determine how the app looks, Standalone will make it appear to be a regular app without any Chrome buttons or interface.

You'll end up with a json file. It must be placed at the root of the webpage, right along with index.html.

The next thing you'll need is a Service Worker. Again, they can do a lot, but this project will only be using the caching to let this app be accessed offline. The service worker implementation is mostly boilerplate. This project used the Google example and changed the list of files to be cached. You can't cache files outside your domain.

Head over to FavIcon Generator and make some icons.

The last thing is to add some code in the Vue mounted() function.

mounted: function () {
if ('serviceWorker' in navigator) { navigator.serviceWorker.register('service-worker.js'); } }

This will register the worker with the browser.

You can check that everything is working, and if not, maybe figure out why by using Lighthouse, it will analyze the site and tell you all sorts of things.

If everything worked, when you go to the webpage, Chrome will ask if you want to install it with a popup banner. You can see it in action at if you're on mobile Chrome. If you're on a desktop, you can find a menu item to install it.