Table of Contents
Introduction
I decided to dust off the cobwebs covering my box of NFC tags/readers/antennas and give the new w3.org web-NFC API (draft specification - Sept 2022) a go, as it has been on my wish list ever since I saw this Google Chrome Developer's YouTube video over a year ago.
Basically, web-NFC is a JavaScript API for your webpage, which allows a limited number of Internet browsers to read from or write to nearby NFC cards/tags or multipurpose NFC controller devices using an NFC enabled Android OS mobile phone (IOS is not supported). The following Internet browsers (image source: https://developer.mozilla.org) can be used at the moment:
MDN Web Docs class web-NFC as experimental technology.
My idea for this Project14 competition was to use an NFC tag as a smart-location trigger to get the latest bus or train arrival data on my nfc-enabled phone without having to download an app beforehand to do so. I would simply open up a website, tap on the tag to read data and then use this data to request real-time arrival data. So in this case my NFC project does not involve any build using custom electronics (the hard bit), besides using a mobile phone and some NFC tags, and as I had already developed an application to grab train arrivals data (see blog here) I was almost halfway there on the software side.
So even though there's no Arduino or Raspberry Pi involved, I am still hopeful that others will find this web-NFC example useful when it comes to developing their own future project ideas.
Getting started with web-NFC
The API itself is quite simple, but as this low level API will not work on laptops or desktops the real challenge when developing your application is finding a way to test your app on your phone.
This presented me with a new challenge, as I never had this restriction before, but thankfully Github pages worked really well for this task as there is no need for backoffice server-side code for web-NFC and with GitHub you can also literally code online.
For those who don’t know what GitHub pages is, it is a (free-to-use) static-site hosting service that takes your HTML, CSS, and JavaScript files from a user defined (open) GitHub repository and publishes these files online as a website. To learn more about GitHub pages, click here.
Once you have updated your files, these are available almost immediately after a short validation build process. It’s worth noting though that page caching, especially with any javascript files, can often hinder matters by delaying the file update by a good couple of minutes (I found that this usually happens during peak Internet demand periods, despite using the no cache setting).
Then to debug your app you need to enable Google’s Android developer option on your phone to allow app debugging via USB.
Once this has been enabled and you have plugged your USB cable into your phone and your computer, you can then open up your new GitHub based webpage on your phone’s Chrome browser and then you can activate the really clever bit on your computer’s Chrome browser by entering this command:
chrome://inspect/#devices
This opens up a new screen on your computer:
Then there is a page inspect option, which opens up a new interface showing an emulation of the phone screen and the Chrome debug options such as the screen console and the source files (this screen is useful to check that the file version is the most up-to-date and not a cached version).
With these tools at your disposal, you’ll then be able to develop app code and test.
Developing a web-NFC app
As you are limited by hardware (i.e. phone only) and browser software, it is definitely worth starting with a "if ("NDEFReader" in window)
" is true to make sure you are good to go, otherwise it’s a no-go and you'll have to get your code do something else.
Then the next step very much depends on the type of application you want to develop, as in, will it be read only, write only or both read & write. Also, will you want to restrict/prevent write access by making the tag read only etc. In my use-case, I only needed to read NFC cards/tags and I also planned to read NDEF messages. Thus my requirements fitted very well with web-NFC functionality.
To activate your webpage to read NFC cards, you first need to create a new NDEFreader() object.
const ndef = new NDEFReader();
Then you initiate the scan method, i.e. NDEFReader.scan()
This returns a “Promise”, which either resolves when an NFC tag is read or it rejects if a hardware or permission error is encountered. The online MDN documentation, provides this handy example to demonstrate the use of a promise:
const ndef = new NDEFReader(); ndef.scan().then(() => { console.log("Scan started successfully."); ndef.onreadingerror = (event) => { console.log("Error! Cannot read data from the NFC tag. Try a different one?"); }; ndef.onreading = (event) => { console.log("NDEF message read."); }; }).catch((error) => { console.log(`Error! Scan failed to start: ${error}.`); });
However, I found the more common approach, based on online examples, was to use an “await ndef.scan();
” method within an async function that is triggered by an event like a button press.
According to MDN documentation, the await expression never blocks the main thread and only defers execution of code that actually depends on the result. That’s pretty neat, in my opinion.
An example of this method can be found here (this website also include a demo option, if you open URL on a nfc-enabled Android mobile phone using a compatible browser): https://googlechrome.github.io/samples/web-nfc/
What is really really helpful from a development and user perspective, is that the scan method will also trigger a permission prompt if the "nfc" permission on your mobile phone has not been previously granted.
Finally, we add in some “Event Listeners” to capture either a read event or a read error event.
And that is basically it!
You have data. This can be found within a message.records data structure. You also have options to extract meta information about the data such as recordType and encoding (for text-based NDEF messages). It’s all quite handy.
Writing data to NFC tags
You can also use web-NFC to write to tags. Similar to the read() method, the write() method will attempt to write an NDEF message to a tag, which returns a “Promise” when either a message has been written to the tag or it has been rejected due to some hardware or permission error.
For my project there was no need for the end-user to write to the tag as it would be done by the product owner etc. So, I simply used a mobile phone app called NFC TagWriter from NXP to write to my tags.
For my demo project I decided to use a Geo-location NDEF message, which includes data about the stop and its latitude and longitude coordinates for the bus stop or train station location. This also allowed me to include a text description for additional information.
My web-NFC BAT’s app
For my webpage I used the almost ubiquitous Bootstrap front-end toolkit.
In fact I simply modified the getbootstrap “products” example to suit my purposes, as those mobile phones on the homepage looked like buses (top down view) to me. The mods also included references to the relevant online CDN links.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="description" content="A real-time public transport arrivals demo using web nfc (proof of concept - alpha)"> <meta name="keywords" content="Internet of Things, IoT, NFC, Geolocation, RTPI, Proof of Concept Design" /> <meta name="author" content="C Gerrish"> <meta name="robots" content="noodp"/> <title>Super Bus::Train (NFC Web Demo)</title> <link rel="canonical" href="https://getbootstrap.com/docs/5.2/examples/product/"> <!-- CSS only --> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous"> <style> .bd-placeholder-img { font-size: 1.125rem; text-anchor: middle; -webkit-user-select: none; -moz-user-select: none; user-select: none; } @media (min-width: 768px) { .bd-placeholder-img-lg { font-size: 3.5rem; } } .b-example-divider { height: 3rem; background-color: rgba(0, 0, 0, .1); border: solid rgba(0, 0, 0, .15); border-width: 1px 0; box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15); } .b-example-vr { flex-shrink: 0; width: 1.5rem; height: 100vh; } .bi { vertical-align: -.125em; fill: currentColor; } .nav-scroller { position: relative; z-index: 2; height: 2.75rem; overflow-y: hidden; } .nav-scroller .nav { display: flex; flex-wrap: nowrap; padding-bottom: 1rem; margin-top: -1px; overflow-x: auto; text-align: center; white-space: nowrap; -webkit-overflow-scrolling: touch; } </style> <!-- Custom styles for this template --> <link href="product.css" rel="stylesheet"> </head> <body> <header class="site-header sticky-top py-1"> <nav class="container d-flex flex-column flex-md-row justify-content-between"> <a class="py-2" href="#" aria-label="Product"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="d-block mx-auto" role="img" viewBox="0 0 24 24"><title>Super Bus</title><circle cx="12" cy="12" r="10"/><path d="M14.31 8l5.74 9.94M9.69 8h11.48M7.38 12l5.74-9.94M9.69 16L3.95 6.06M14.31 16H2.83m13.79-4l-5.74 9.94"/></svg> </a> <a class="py-2 d-none d-md-inline-block" href="#">Fares and Tickets</a> <a class="py-2 d-none d-md-inline-block" href="#">Routes and Timetables</a> <a class="py-2 d-none d-md-inline-block" href="#">About Us</a> <a class="py-2 d-none d-md-inline-block" href="#">News Centre</a> <a class="py-2 d-none d-md-inline-block" href="#">Contact Us</a> </nav> </header> <main> <div class="position-relative overflow-hidden p-3 p-md-5 m-md-3 text-center bg-light"> <div id="bus-header" class="col-md-5 p-lg-5 mx-auto my-5"> <h1 class="display-4 fw-normal">It's Super Bus!</h1> <h3 class="display-5 fw-normal">(and train)</h3> <p class="lead fw-normal">Offering you a frequent reliable service and super comfy seating for your posterior.</p> <a class="btn btn-light" href="#">Just wait for it...</a> </div> <div class="product-device shadow-sm d-none d-lg-block"></div> <div class="product-device product-device-2 shadow-sm d-none d-lg-block"></div> </div> <div class="d-md-flex flex-md-equal w-100 my-md-3 ps-md-3"> <div class="text-bg-dark me-md-3 pt-3 px-3 pt-md-5 px-md-5 text-center overflow-hidden"> <p id="nfc-error" class="d-none lead">Web NFC is not available. Use Chrome on Android.</p> <div id="nfc-pass" class="d-none"> <div class="my-3 py-3"> <img src="bus-stop.svg" style="height: 100%; width: auto;" alt="..."> <h2 class="display-5">RTPI</h2> <p class="lead">Real Time Passenger Information.</p> <a id="scan_btn" class="btn btn-lg btn-outline-light" onClick="startScanning()">Where's my transport?</a> </div> <div id="arrivals_canvas" class="d-none bg-light shadow-sm mx-auto" style="width: 94%; height: auto; border-radius: 18px 18px 0 0;"> <div class="row align-items-start"> <div class="col col-md-12"> <div class="card" style="width: 100%;"> <div class="card-body text-dark text-start"> <h5 id="arrivals_header" class="card-title"></h5> <p id="arrivals_data" class="card-text"></p> </div> </div> </div> </div> <div class="row align-items-start"> <div id="bus_btn" class="d-none my-2 py-2"> <a class="btn btn-success" onClick="getBusData()">Get Bus Data...</a> </div> <div id="train_btn" class="d-none my-2 py-2"> <a class="btn btn-primary" onClick="getTrainData()">Get Train Data...</a> </div> <div id="map_btn" class="d-none"> <a class="btn btn-secondary" onClick="getMapData()">Map of Location</a> </div> <div id="stop_btn" class="d-none my-2 py-2"> <a class="btn btn-danger" onClick="stopScan()">Stop Scanning</a> </div> </div> </div> </div> </div> <div class="bg-light me-md-3 pt-3 px-3 pt-md-5 px-md-5 text-center overflow-hidden"> <div class="my-3 p-3"> <h2 class="display-5">Travel Updates</h2> <p class="lead">Latest news about service.</p> <a class="btn btn-lg btn-outline-dark" href="#">Subscribe to RSS</a> </div> <div class="bg-dark shadow-sm mx-auto" style="width: 94%; height: auto; border-radius: 18px 18px 0 0;"> </div> </div> </div> </main> <footer class="container py-5"> <div class="row"> <div class="col-12 col-md"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="d-block mb-2" role="img" viewBox="0 0 24 24"><title>Product</title><circle cx="12" cy="12" r="10"/><path d="M14.31 8l5.74 9.94M9.69 8h11.48M7.38 12l5.74-9.94M9.69 16L3.95 6.06M14.31 16H2.83m13.79-4l-5.74 9.94"/></svg> <small class="d-block mb-3 text-muted">© Gerrikoio 2022</small> </div> <div class="col-6 col-md"> <h5>Features</h5> <ul class="list-unstyled text-small"> <li><a class="link-secondary" href="#">Cool stuff</a></li> <li><a class="link-secondary" href="#">Random feature</a></li> <li><a class="link-secondary" href="#">Team feature</a></li> <li><a class="link-secondary" href="#">Stuff for developers</a></li> <li><a class="link-secondary" href="#">Another one</a></li> <li><a class="link-secondary" href="#">Last time</a></li> </ul> </div> <div class="col-6 col-md"> <h5>About</h5> <ul class="list-unstyled text-small"> <li><a class="link-secondary" href="#">Team</a></li> <li><a class="link-secondary" href="#">Locations</a></li> <li><a class="link-secondary" href="#">Privacy</a></li> <li><a class="link-secondary" href="#">Terms</a></li> </ul> </div> </div> </footer> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.min.js" integrity="sha384-IDwe1+LCz02ROU9k972gdyvl+AESN10+x7tBKgc9I5HFtuNz0wWnPclzo6p9vxnk" crossorigin="anonymous"></script> <script src="arrivals.js"></script> </body> </html>
All I then had to do was add in some buttons and some custom JavaScript. For my JavaScript code I decided to use JQuery as it offers some useful RESTful http(s) GET/POST functions, some helpful event handling functions and some neat animation features.
let ndef; let AbortCtrlr; let geoLoc = ""; let placeID = ""; let stnName = ""; let trainLink = ""; let busLink = ""; $(document).ready(async function() { if (!("NDEFReader" in window)) { $('#nfc-error').hide().removeClass('d-none').fadeIn(); } else { $('#nfc-pass').hide().removeClass('d-none').fadeIn(); try { await getGoogs(); } catch (error) { console.log("Argh fetch! " + error); } } }); async function getGoogs() { let response = await fetch("googs.abc"); if(response.status != 200) { throw new Error("Server Error"); } // read response stream as text let text_data = await response.text(); var textArr = text_data.split(','); if (textArr.length == 2) { trainLink = textArr[0].substring(3); busLink = textArr[1].substring(3); } } async function startScanning() { let URLfind; let TXTfind; $('#arrivals_canvas').hide().removeClass('d-none').fadeIn(); $('html, body').animate({ scrollTop: $("#nfc-pass").offset().top }, 300); try { ndef = new NDEFReader(); AbortCtrlr = new AbortController(); const signal = AbortCtrlr.signal; await ndef.scan({ signal }); $('#scan_btn').text("NFC Scan Active..."); $('#scan_btn').removeClass('btn-outline-light'); $('#scan_btn').addClass('btn-warning disabled'); $('#bus_btn').hide().addClass('d-none'); $('#train_btn').hide().addClass('d-none'); $('#map_btn').hide().addClass('d-none'); $('#stop_btn').hide().addClass('d-none'); $('#arrivals_header').text("Waiting for tag data..."); $('#arrivals_data').text(""); ndef.addEventListener("readingerror", () => { $('#arrivals_header').text("Read Error:"); $('#arrivals_data').text("Argh! Cannot read data from the NFC tag. Try another one?"); }); ndef.addEventListener("reading", ({ message, serialNumber }) => { $('#arrivals_header').text("Your NFC Tag Data:"); $('#arrivals_data').text(`Tag Serial Number: ${serialNumber}`); $('#arrivals_data').append(`<br/>NDEF Records: (${message.records.length})`); if (message.records.length > 0 && message.records[0].recordType != "empty") { const decoder = new TextDecoder(); for (const record of message.records) { $('#arrivals_data').append(`<br/>NDEF Record Type: (${record.recordType})`); $('#arrivals_data').append(`<br/>NDEF Data: (${decoder.decode(record.data)})`); $('#bus_btn').hide().addClass('d-none'); $('#train_btn').hide().addClass('d-none'); $('#map_btn').hide().addClass('d-none'); switch (record.recordType) { case "text": const textDecoder = new TextDecoder(record.encoding); $('#arrivals_data').append(`<br/>Text: ${textDecoder.decode(record.data)} (${record.lang})`); break; case "url": break; case "mime": if (record.mediaType === "application/json") { $('#arrivals_data').append(`<br/>Mime JSON: ${JSON.parse(decoder.decode(record.data))}`); } else { //const textDecoder = new TextDecoder(); //$('#arrivals_data').append(`<br/>Text: ${textDecoder.decode(record.data)}`); } break; case "smart-poster": URLfind = false; TXTfind = 0; for (const sprecord of record.toRecords()) { const spData = decoder.decode(sprecord.data); $('#arrivals_data').append(`<br/>- SP Type: ${sprecord.recordType} | Data: ${spData}`); if (sprecord.recordType == "url" && spData.includes("geo:53.")) { const GEOlat = spData.indexOf("geo:")+4; const GEOlong = spData.indexOf(","); geoLoc = "query="+spData.substring(GEOlat, GEOlong)+"%2C"+spData.substring(GEOlong+1); //console.log("geoLoc: "+geoLoc); URLfind = true; } else if (sprecord.recordType == "text") { if (spData.includes(", stop")) { const StnStart = spData.indexOf(", stop")+2; const StnEnd = spData.indexOf(", Place"); stnName = spData.substring(StnStart, StnEnd); TXTfind = 1; } else if (spData.includes(", StationCode")) { const StnStart = spData.indexOf("ode=")+4; const StnEnd = spData.indexOf(", Place"); stnName = spData.substring(StnStart, StnEnd); TXTfind = 2; } if (spData.includes("ID: ")) { const placeIDstart = spData.indexOf("ID: ")+4; placeID = "query_place_id="+spData.substring(placeIDstart); //console.log("placeID: "+placeID); } } } if (URLfind == true && TXTfind == 1) { $('#bus_btn').hide().removeClass('d-none').fadeIn(); $('#map_btn').hide().removeClass('d-none').fadeIn(); } if (URLfind == true && TXTfind == 2) { $('#train_btn').hide().removeClass('d-none').fadeIn(); $('#map_btn').hide().removeClass('d-none').fadeIn(); } break; default: } } $('#stop_btn').hide().removeClass('d-none').fadeIn(); } }); } catch (error) { $('#arrivals_data').text("Argh! " + error); } } async function stopScan() { await AbortCtrlr.abort(); $('#arrivals_data').text(""); $('#bus_btn').hide().addClass('d-none'); $('#train_btn').hide().addClass('d-none'); $('#map_btn').hide().addClass('d-none'); $('#stop_btn').hide().addClass('d-none'); $('#arrivals_canvas').addClass('d-none').fadeOut(); $('#scan_btn').removeClass('btn-warning disabled'); $('#scan_btn').addClass('btn-outline-light'); $('#scan_btn').text("Where's my transport?"); $('html, body').animate({ scrollTop: $("#bus-header").offset().top }, 500); } function getMapData() { if (geoLoc.length > 5 && placeID.length > 5) { window.open('https://www.google.com/maps/search/?api=1&'+geoLoc+'&'+placeID); } } async function getTrainData() { await AbortCtrlr.abort(); $('#scan_btn').removeClass('btn-warning disabled'); $('#scan_btn').addClass('btn-outline-light'); $('#scan_btn').text("Where's my transport?"); $('#stop_btn').hide().addClass('d-none'); if (trainLink.length > 32 && stnName.length > 1) { const trainULS = "https://script.google.com/macros/s/" + trainLink + "/exec?station=" + stnName; //console.log(trainULS); $.getJSON(trainULS, function(data, status) { console.log("Status (" + status + ")"); var items = []; $('#arrivals_header').text("Your " + stnName + " Train Arrivals:"); $('#arrivals_data').text(""); $.each( data, function( key, val ) { console.log("key:" + key + " | val:" + val); if (key == "T1" || key == "T2" || key == "T3" || key == "T4") { var textArr = val.toString().split(','); $('#arrivals_data').append(key.toString() + ": "+ textArr[0] + " due in " + textArr[1] + "<br/>"); } }); $('#arrivals_data').append("<br/>Click \"Get Train Data...\" to update.<br/>"); }); } } async function getBusData() { await AbortCtrlr.abort(); $('#scan_btn').removeClass('btn-warning disabled'); $('#scan_btn').addClass('btn-outline-light'); $('#scan_btn').text("Where's my transport?"); $('#stop_btn').hide().addClass('d-none'); if (busLink.length > 32 && stnName.length > 1) { const busULS = "https://script.google.com/macros/s/" + busLink + "/exec?station=" + stnName; //console.log(trainULS); $.getJSON(busULS, function(data, status) { console.log("Status (" + status + ")"); var items = []; $('#arrivals_header').text("Your " + stnName + " Bus Arrivals:"); $('#arrivals_data').text(""); $.each( data, function( key, val ) { console.log("key:" + key + " | val:" + val); if (key == "B1" || key == "B2" || key == "B3" || key == "B4") { var textArr = val.toString().split(','); if(textArr[1].length > 3) { if (textArr[1].includes("Now") == false) $('#arrivals_data').append(key.toString() + ": No."+ textArr[0] + " due in " + textArr[1] + "<br/>"); else $('#arrivals_data').append(key.toString() + ": No."+ textArr[0] + " " + textArr[1] + "<br/>"); } else { $('#arrivals_data').append(key.toString() + ": n/a<br/>"); } } }); $('#arrivals_data').append("<br/>Click \"Get Bus Data...\" to update.<br/>"); }); } }
As Github hosting does not provide any back-end functionality, I decided to use Google Apps Script again as this also uses a JavaScript code base. So the reason for choosing Google Apps Script was purely for convenience and not speed as it is rather slow for this purpose but it’s just about acceptable for a proof of concept.
As before (see my previous Pico-W blog), I simply used the URL Fetch Service to retrieve the data from the real-time bus/train GTFS API.
As I was planning to grab bus arrival data, I also had to manipulate some static GTFS files for the relevant bus network. This link provides a comprehensive list of all the static files.
In my case I had to use four static files:
- Stops.txt
- Routes.txt
- Trips.txt
- Stop_times.txt
There is also a fifth file, which is relevant if you have multiple bus operators, namely Agency.txt.
Note that for most city-wide bus networks using GTFS you’ll find that these files are quite large. For example, the size of just Stop_times.txt for my regional bus network is 500MBytes, Shapes.txt (not used) is 120MBytes and Trips.txt 16MBytes.
So, to reduce the size I manually extracted out the relevant details and stored this information in Google sheets, which was attached to the Google Apps script. I won’t go into the details of all this, as it is outside the scope of this NFC project. Here is the pseudo script code for those who are interested.
So, as you can see it is not too complex.
Video Demo
And here is a demonstration of the system in action.