Avatar

Making an IoT smart home device scanner using Flutter

← Back to list
Posted on 22.04.2023
Image by AI on Midjourney
Refill!

In the previous article I made an Arduino-based weather indicator, a dream of my childhood. Since it exposes REST API, it is time to build some front-end.

Initially I was thinking about making some desktop application with already familiar React and Go using Wails, but then I thought that maybe in the future I might want to make something that my kid could use too, some kind of application suitable for running on a mobile or a tablet.

My second option was a PWA. While PWAs nowadays are amazing and powerful, and embody a whole ecosystem in itself, it still lacked a key feature I needed - to perform an ICMP scan of a Wi-Fi network.

Then I thought about Kotlin. I have some teeny-weeny experience with Kotlin, but I honestly I didn't want to develop myself in that direction and dedicate a week or so to a refresher.

Eventually, since I knew React pretty damn well, I chose Flutter instead. The Dart language Flutter is based on bears a lot of similarities to TypeScript, and the Flutter itself was designed after React, so the choice was a promising investment.

Unlike ReactNative, Flutter compiles into native code for every supported platform.

Installation

Flutter has amazing documentation, but some steps were not entirely covered. So, here is just a quick summary of the installation process:

  1. Install Dart
  2. Install Flutter
    1. Add path to .bash_profile: export PATH=$HOME/bin/flutter/bin:$PATH
  3. Install Android Studio, add the Flutter plugin, then add the Android CLI tools
  4. Run flutter doctor to see what else is missing. Ignore everything xcode-related in case if you don't plan to build for iOS/MacOS (like myself).

Device info

A data structure that holds the information about a device is declared as the DeviceInfo class.

πŸ‘‰ πŸ“ƒΒ Β lib/services/device_info.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
typedef DeviceInfoMap = Map<String, String>;
typedef DeviceInfoResult = Map<String, String>;
class DeviceInfo {
static DeviceInfo fromMap(DeviceInfoMap value) {
return DeviceInfo(ip: value['ip']!, kind: value['kind']!, hostName: value['hostName']!, name: value['name']!);
}
DeviceInfo({
required this.ip,
required this.kind,
required this.hostName,
required this.name,
});
final String ip;
final String kind;
final String hostName;
final String name;
DeviceInfoMap toMap() {
return DeviceInfoMap.from({'ip': ip, 'kind': kind, 'hostName': hostName, 'name': name});
}
}
class DeviceInfoLoader {
Future<DeviceInfo> getInfoByIP(String ip) async {
var info = await _getInfo(ip);
if (info['kind'] == "") {
throw Exception("Not a device we are looking for");
}
var hostName = await _getHostName(ip);
return DeviceInfo(ip: ip, kind: info['kind']!, hostName: hostName, name: info['name']!);
}
Future<DeviceInfoResult> _getInfo(String ip) async {
DeviceInfoResult result = {};
try {
final response = await http.get(Uri.parse("http://$ip:80/info")).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
Map<String, dynamic> jsonMap = jsonDecode(response.body);
result['kind'] = jsonMap['kind'];
result['name'] = jsonMap['name'];
}
} catch(error) {
print(error);
}
return result;
}
Future<String> _getHostName(String ip) async {
try {
final address = await InternetAddress.lookup(ip);
if (address.isNotEmpty && address.first.host != "") {
return address.first.host;
}
} catch(error) {}
return "";
}
}
The code is licensed under the MIT license

There was also the device loader, a service that takes device's IP address and tries to call the GET /info endpoint. If the call was successful, it checks what kind of device this is.

I should have probably moved that class out into a separate file.

Scanner

The heart of the application is a scanner of the Wi-Fi network, based on the lan_scanner package. It does not support iOS (why am I not surprised?), but for this particular project only Android was required. I could afford performing such sort of scan, because it's a C-class network, that can serve only up to 254 devices.

πŸ‘‰ πŸ“ƒΒ Β lib/services/scanner.dart
import 'dart:async';
import 'package:network_info_plus/network_info_plus.dart';
import 'package:lan_scanner/lan_scanner.dart';
import '../services/device_info.dart';
typedef OnProgressCallback = void Function(double);
class Scanner {
final OnProgressCallback onProgress;
final DeviceInfoLoader _loader = DeviceInfoLoader();
Scanner({
required this.onProgress,
});
Future<Stream<DeviceInfo>> scanWiFi() async {
var wifiIP = await NetworkInfo().getWifiIP();
if (wifiIP == null) {
throw const FormatException("Could not get WiFi IP. Is WiFi connection on?");
}
var subnetIP = ipToCSubnet(wifiIP);
final scanner = LanScanner();
var scanStream = scanner.icmpScan(subnetIP, progressCallback: (progress) {
onProgress(progress);
});
print("Subnet IP = "+subnetIP);
return scanStream.transform(StreamTransformer.fromHandlers(
handleData: (HostModel host, EventSink<DeviceInfo> sink) async {
var info = await _loader.getInfoByIP(host.ip);
if (info.kind != "") {
sink.add(info);
} else {
print("Device ${host.ip} was not recognised");
}
},
handleError: (error, stackTrace, sink) => sink.addError(error),
handleDone: (sink) => sink.close(),
));
}
}
The code is licensed under the MIT license

Just don't forget to add permissions to Android manifest:

πŸ‘‰ πŸ“ƒΒ Β android/app/src/main/AndroidManifest.xml
...
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
...
The code is licensed under the MIT license

Device list storage

I don't scan the network every time the application is launched. As soon as I get the results, I store it into a file and later re-use for rendering the list of available devices. A user can any time re-do scanning by pressing a button.

πŸ‘‰ πŸ“ƒΒ Β lib/services/device_list_storage.dart
import 'dart:io';
import 'dart:convert';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
const filePathRelative = "./devices.json";
typedef Devices = List<Map<String, String>>;
typedef StoredDevices = Map<String, Devices>;
class DeviceListStorage {
Future<Devices> load() async {
final filePath = await _getFilePath(filePathRelative);
final file = File(filePath);
String content = await file.readAsString();
Map<String, dynamic> json = jsonDecode(content);
if (json.containsKey('devices')) {
var devices = json['devices'];
Devices result = [];
devices.forEach((device) {
result.add(Map<String, String>.from({
'ip': device['ip'],
'kind': device['kind'],
'hostName': device['hostName'],
'name': device['name'],
}));
});
return result;
}
return [];
}
Future<void> store(Devices devices) async {
final filePath = await _getFilePath(filePathRelative);
final file = File(filePath);
StoredDevices content = {'devices': devices};
final jsonString = jsonEncode(content);
await file.writeAsString(jsonString);
}
Future<String> _getFilePath(String filePath) async {
final directory = await getApplicationDocumentsDirectory();
return path.join(directory.path, filePath);
}
}
The code is licensed under the MIT license

Entry point and the router

The entry point to a Flutter application is the main() function. Here I used go_router as an alternative to a default one. It supports page transitions and a lot of cool stuff, so I thought better stick to it in the first place.

πŸ‘‰ πŸ“ƒΒ Β lib/main.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import './views/home.dart';
import './views/device.dart';
void main() {
runApp(MaterialApp.router(routerConfig: router));
}
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (_, __) => const HomePage(),
routes: [
GoRoute(
path: 'device/:ip',
builder: (context, state) {
return DevicePage(
ip: state.params["ip"]!,
);
},
),
],
),
],
);
The code is licensed under the MIT license

Home screen

So a typical Flutter view with a state consists of two class definitions: the widget and the state. The state class has setState() method that works in the same manner as in React, and reminds me of how MobX does it.

The build() method is an analog of render() from React.

There is also a constructor and life cycle methods such as initState() and dispose(), similar to React's componentDidMount() and componentWillUnmount().

πŸ‘‰ πŸ“ƒΒ Β lib/views/home.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../services/device_info.dart';
import '../services/device_list_storage.dart';
import '../util.dart';
import '../services/scanner.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
final String title = "Available devices";
@override
State<HomePage> createState() => HomePageState();
}
class HomePageState extends State<HomePage> {
bool _scanning = false;
double _progress = 0;
StreamSubscription<DeviceInfo>? _subscription;
List<DeviceInfo> _devices = [];
DeviceListStorage deviceListStorage = DeviceListStorage();
late Scanner _scanner;
@override
HomePageState(): super() {
_scanner = Scanner(onProgress: (double value) {
setState(() {
_progress = value;
});
});
}
@override
void initState() {
super.initState();
_loadDevices();
}
@override
void dispose() {
// todo: stop the process, if running
super.dispose();
}
Future<void> _loadDevices() async {
var result = await deviceListStorage.load();
List<DeviceInfo> storedDevices = result.map((element) => DeviceInfo.fromMap(element)).toList();
setState(() {
_devices = storedDevices;
});
}
void _startScan() async {
if (_subscription != null) {
return;
}
try {
var stream = await _scanner.scanWiFi();
_subscription = stream.listen((deviceInfo) {
setState(() {
_devices.add(deviceInfo);
});
}, onDone: () {
_resetScan();
if (_devices.isEmpty) {
showNotification("No devices detected");
} else {
deviceListStorage.store(_devices.map((device) => device.toMap()).toList());
}
});
setState(() {
_scanning = true;
_progress = 0;
_devices = [];
});
} catch (e) {
if (e is FormatException) {
showError(e.message);
}
}
}
void _stopScan() async {
_subscription?.cancel();
_resetScan();
}
void _onActionButtonClick () {
if (_isScanning()) {
_stopScan();
} else {
_startScan();
}
}
void _resetScan() {
_subscription = null;
setState(() {
_scanning = false;
_progress = 0;
});
}
bool _isScanning() {
return _subscription != null;
}
Future<void> _refreshList() async {
if (!_isScanning()) {
_startScan();
await Future.delayed(const Duration(seconds: 3));
}
}
Icon _getActionButtonIcon() {
if (!_scanning) {
return const Icon(Icons.refresh);
} else {
return const Icon(Icons.stop);
}
}
void _onDeviceClick(BuildContext context, DeviceInfo device) {
GoRouter.of(context).go('/device/${device.ip}');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Stack(
children: [
LinearProgressIndicator(value: _progress),
RefreshIndicator(
onRefresh: _refreshList,
child:ListView.builder(
itemCount: _devices.length,
itemBuilder: (BuildContext context, int index) {
return Card(
child: ListTile(
title: Text(_devices[index].name),
subtitle: Text('IP: ${_devices[index].ip}, hostname: ${_devices[index].hostName}'),
onTap: () => _onDeviceClick(context, _devices[index]),
),
);
},
),
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: _onActionButtonClick,
child: _getActionButtonIcon(),
),
);
}
}
The code is licensed under the MIT license

I show notifications using the fluttertoast package, so I made a small wrapper around it and put to the util file:

πŸ‘‰ πŸ“ƒΒ Β lib/util.dart
import 'package:fluttertoast/fluttertoast.dart';
import 'package:flutter/material.dart';
String convertSnakeToHuman(String input) {
return input.toLowerCase().replaceAll(RegExp(r'_+'), ' ');
}
void showError(String message) {
Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.CENTER,
timeInSecForIosWeb: 1,
backgroundColor: Colors.red,
textColor: Colors.white,
fontSize: 16.0
);
}
void showNotification(String message) {
Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.CENTER,
timeInSecForIosWeb: 1,
backgroundColor: Colors.black,
textColor: Colors.white,
fontSize: 16.0
);
}
The code is licensed under the MIT license

Device screen

The second view that shows the specific device UI is structured in a similar way.

πŸ‘‰ πŸ“ƒΒ Β lib/views/device.dart
import 'package:flutter/material.dart';
import '../services/device_info.dart';
import '../util.dart';
import '../widgets/devices/weather_indicator.dart';
import '../widgets/drawer.dart';
Map<String, Widget Function(DeviceInfo device)> widgetMap = {
"WEATHER_INDICATOR": (DeviceInfo device) => WeatherIndicator(device: device),
};
class DevicePage extends StatefulWidget {
const DevicePage({
super.key,
required this.ip,
});
final String ip;
final String title = "Device";
@override
State<DevicePage> createState() => DevicePageState();
}
class DevicePageState extends State<DevicePage> {
DeviceInfo? _device;
final DeviceInfoLoader _deviceLoader = DeviceInfoLoader();
@override
void initState() {
super.initState();
_loadDevice();
}
Future<void> _loadDevice() async {
var ip = widget.ip;
try {
var info = await _deviceLoader.getInfoByIP(ip);
setState(() {
_device = info;
});
} catch(e) {
if (e is FormatException) {
showError(e.message);
}
}
}
Widget _getDeviceWidget() {
final dev = _device;
if (dev != null) {
var kind = dev.kind;
if (widgetMap.containsKey(kind)) {
return widgetMap[kind]!(dev);
}
}
return const SizedBox.shrink();
}
@override
Widget build(BuildContext context) {
var loading = _device == null;
var name = _device == null ? "" : _device!.name;
return Scaffold(
appBar: AppBar(
title: Text('${widget.title}: $name'),
),
drawer: const DrawerWidget(),
body: Stack(
children: [
loading ?
const Center(child: Text("Loading...")) :
_getDeviceWidget(),
],
),
);
}
}
The code is licensed under the MIT license

Weather indicator service and widget

The service just reads the current indications.

πŸ‘‰ πŸ“ƒΒ Β lib/service/devices/weather_indicator.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class WeatherIndicatorService {
Future<int> getReadings(String ip) async {
try {
final response = await http.get(Uri.parse("http://$ip:80/readings")).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
Map<String, dynamic> jsonMap = jsonDecode(response.body);
return jsonMap['readings'];
} else {
return 0;
}
} catch(error) {
print(error);
}
return 0;
}
}
The code is licensed under the MIT license

... and the widget displays the result, polling the device every 5 seconds:

πŸ‘‰ πŸ“ƒΒ Β lib/widgets/devices/weather_indicator.dart
import 'dart:async';
import 'package:flutter/material.dart';
import '../../services/device_info.dart';
import '../../services/devices/weather_indicator.dart';
const int undefinedTemperature = 1000;
class WeatherIndicator extends StatefulWidget {
final DeviceInfo device;
const WeatherIndicator({super.key, required this.device});
@override
State<WeatherIndicator> createState() => WeatherIndicatorState();
}
class WeatherIndicatorState extends State<WeatherIndicator> {
int _indication = undefinedTemperature;
late Timer _timer;
final Duration _pollingInterval = const Duration(milliseconds: 5000);
final WeatherIndicatorService _service = WeatherIndicatorService();
@override
void initState() {
super.initState();
_pollDevice();
}
@override
void dispose() {
super.dispose();
_cancelDevicePoll();
}
Future<void> _pollDevice() async {
var ip = widget.device.ip;
_timer = Timer(_pollingInterval, () {
_service.getReadings(ip).then((int readings) {
setState(() {
_indication = readings;
});
_pollDevice();
});
});
}
void _cancelDevicePoll() {
_timer.cancel();
}
@override
Widget build(BuildContext context) {
if (_indication == undefinedTemperature) {
return const Center(child: Text('Loading...'));
}
var icon = _indication <= 10 ? "❄️" : "β˜€οΈ";
return Center(
child: Text(
"$_indication ℃ $icon️",
style: const TextStyle(
fontSize: 60, // specify the font size here
),
)
);
}
}
The code is licensed under the MIT license

Setting up a launcher name

I have decided to call the app "Ground Control" (like in that song). Turned out, it was not that easy to set the name, it was not just some kind of variable I had to define somewhere. There was a whole package created for changing the app launcher name.

This is how I did it:

$
flutter pub global activate rename
flutter pub global run rename --appname "Ground Control"
The code is licensed under the MIT license

Setting up an icon

To create an icon itself I used Midjourney. This was my prompt:

Squared with round edges mobile app logo design, flat vector app icon of an open box, minimalistic, white background.

To put the icon in place there was also a package.

Uploading to the mobile device

I plugged my device in with a cord, and got the app uploaded just by typing:

$
flutter build apk
flutter install
The code is licensed under the MIT license

Eventually, this is what the application looked like in the launcher and after launching :)

Conclusion

Yeh, this is essentially how most of the IoT smart house apps work: they scan the network, find "their" devices and provide an interface for them.

There is no limit for perfection. That was just a basic application built with MaterialUI (which is nowadays considered to be old-fashioned and dated). Flutter can do so much more, it has a lot of packages and supports making completely custom interfaces. Here is a good overview of the basic Material components, and here is a lab by the vendor teaching how to make a Flutter application warm and cozy.

Even though I don't think I am gonna become a mobile developer any time soon, but it is always good to know that among ReactNative and native development there is also such an amazing piece of technology like Flutter.


Avatar

Sergei Gannochenko

Business-oriented fullstack engineer, in ❀️ with Tech.
React, Node, Go, Docker, AWS, Jamstack.
15+ years in dev.