soil_device_sdk 0.1.0
soil_device_sdk: ^0.1.0 copied to clipboard
USB Type-C soil sensor (CH34x) Modbus-RTU SDK for Flutter with API key verification and offline data queue.
soil_device_sdk #
Flutter SDK for a USB Type‑C soil sensor cable (CH34x/CH340) that talks Modbus‑RTU over USB Serial and reports readings to your server.
Features #
- List USB serial devices (CH34x preferred)
- Connect / disconnect
- Read 8 soil parameters (temp, moisture, EC, salinity, N, P, K, pH)
- Two modes:
onceandlive(stream)
Setup #
Install from pub.dev by adding this to your app pubspec.yaml:
dependencies:
soil_device_sdk: ^0.1.0
Android requirements #
Your app must:
- Enable USB host
- Allow cleartext traffic (for HTTP endpoints)
android/app/src/main/AndroidManifest.xml
<uses-feature android:name="android.hardware.usb.host" />
<application
android:usesCleartextTraffic="true"
... >
<!-- Your activities etc. -->
</application>
Usage #
1) Configure API key in main() #
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Starts verification in background.
SoilDeviceSdk.configure(
apiKey: 'YOUR_API_KEY',
);
runApp(const MyApp());
}
2) Simple controller example (GetX) #
Below is a minimal example of how to use the SDK in a Flutter app with GetX.
import 'package:get/get.dart';
import 'package:soil_device_sdk/soil_device_sdk.dart';
class HomePageController extends GetxController {
HomePageController({SoilReadMode readMode = SoilReadMode.once})
: soilDeviceClient = SoilDeviceClient(
mode: readMode,
proactiveReportingSeconds: 2,
slaveId: 1,
);
final SoilDeviceClient soilDeviceClient;
/// Available USB soil devices.
final RxList<SoilUsbDeviceInfo> soilDevices = <SoilUsbDeviceInfo>[].obs;
/// Index of the selected device in [soilDevices], or -1 when none selected.
final RxInt selectedDeviceIndex = (-1).obs;
/// Whether a connection attempt is currently in progress.
final RxBool isConnecting = false.obs;
/// Human‑readable connection status message.
final RxString connectionStatus = ''.obs;
String _toFriendlyMessage(Object error, {required String fallbackPrefix}) {
final message = error.toString().toLowerCase();
final looksLikeInvalidKey = message.contains('api key') &&
(message.contains('invalid') ||
message.contains('unauthorized') ||
message.contains('forbidden') ||
message.contains('401') ||
message.contains('403')) ||
message.contains('invalid soil device sdk api key');
if (looksLikeInvalidKey) return 'Invalid Soil Device SDK Api Key';
return '$fallbackPrefix: $error';
}
SoilUsbDeviceInfo? get selectedDevice {
final index = selectedDeviceIndex.value;
if (index < 0 || index >= soilDevices.length) return null;
return soilDevices[index];
}
@override
void onInit() {
super.onInit();
getDevices();
}
/// 1) List devices
Future<void> getDevices() async {
try {
final devices = await soilDeviceClient.getDeviceList();
soilDevices.assignAll(devices);
if (devices.isNotEmpty) {
selectedDeviceIndex.value = 0;
}
connectionStatus.value = 'Found ${devices.length} device(s).';
} catch (e) {
connectionStatus.value =
_toFriendlyMessage(e, fallbackPrefix: 'Failed to load devices');
}
}
/// 2) Connect to selected device
Future<void> connectDevice() async {
final device = selectedDevice;
if (device == null) {
connectionStatus.value = 'Please select a device first.';
return;
}
isConnecting.value = true;
try {
await soilDeviceClient.makeConnection(device);
connectionStatus.value = 'Connected to $device';
} catch (e) {
connectionStatus.value =
_toFriendlyMessage(e, fallbackPrefix: 'Connection failed');
} finally {
isConnecting.value = false;
}
}
/// 3) Disconnect current device
Future<void> disconnectDevice() async {
try {
await soilDeviceClient.makeDisconnect();
connectionStatus.value = 'Disconnected';
} catch (e) {
connectionStatus.value =
_toFriendlyMessage(e, fallbackPrefix: 'Disconnect failed');
}
}
/// 4) Read a soil value
Future<SoilData> printValue() async {
final data = await soilDeviceClient.getPrintingValue();
return data;
}
}
3) Using with setState #
You can also manage the client with plain setState in a StatefulWidget:
class SoilPage extends StatefulWidget {
const SoilPage({super.key});
@override
State<SoilPage> createState() => _SoilPageState();
}
class _SoilPageState extends State<SoilPage> {
late final SoilDeviceClient client;
List<SoilUsbDeviceInfo> devices = [];
SoilUsbDeviceInfo? selected;
String status = '';
bool isConnecting = false;
@override
void initState() {
super.initState();
client = SoilDeviceClient(
mode: SoilReadMode.once,
proactiveReportingSeconds: 2,
slaveId: 1,
);
_loadDevices();
}
Future<void> _loadDevices() async {
try {
final list = await client.getDeviceList();
setState(() {
devices = list;
selected = list.isNotEmpty ? list.first : null;
status = 'Found ${list.length} device(s).';
});
} catch (e) {
setState(() {
status = 'Failed to load devices: $e';
});
}
}
Future<void> _connect() async {
if (selected == null) {
setState(() => status = 'Please select a device first.');
return;
}
setState(() => isConnecting = true);
try {
await client.makeConnection(selected!);
setState(() => status = 'Connected to $selected');
} catch (e) {
setState(() => status = 'Connection failed: $e');
} finally {
setState(() => isConnecting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Soil Device (setState)')),
body: Column(
children: [
DropdownButton<SoilUsbDeviceInfo>(
value: selected,
items: devices
.map((d) => DropdownMenuItem(value: d, child: Text(d.toString())))
.toList(),
onChanged: (value) => setState(() => selected = value),
),
ElevatedButton(onPressed: _loadDevices, child: const Text('Refresh')),
ElevatedButton(
onPressed: isConnecting ? null : _connect,
child: const Text('Connect'),
),
Text(status),
],
),
);
}
}
4) Using with Provider #
Wrap the client in a simple ChangeNotifier and expose it via Provider:
class SoilNotifier extends ChangeNotifier {
SoilNotifier()
: client = SoilDeviceClient(
mode: SoilReadMode.once,
proactiveReportingSeconds: 2,
slaveId: 1,
);
final SoilDeviceClient client;
List<SoilUsbDeviceInfo> devices = [];
int selectedIndex = -1;
String status = '';
bool isConnecting = false;
SoilUsbDeviceInfo? get selected =>
(selectedIndex >= 0 && selectedIndex < devices.length)
? devices[selectedIndex]
: null;
Future<void> loadDevices() async {
try {
devices = await client.getDeviceList();
if (devices.isNotEmpty) selectedIndex = 0;
status = 'Found ${devices.length} device(s).';
} catch (e) {
status = 'Failed to load devices: $e';
}
notifyListeners();
}
Future<void> connect() async {
final device = selected;
if (device == null) {
status = 'Please select a device first.';
notifyListeners();
return;
}
isConnecting = true;
notifyListeners();
try {
await client.makeConnection(device);
status = 'Connected to $device';
} catch (e) {
status = 'Connection failed: $e';
} finally {
isConnecting = false;
notifyListeners();
}
}
}
// In your widget tree:
// ChangeNotifierProvider(create: (_) => SoilNotifier()..loadDevices(), child: MyApp());
5) Using with Riverpod #
You can expose the client and state using Riverpod providers:
final soilClientProvider = Provider<SoilDeviceClient>((ref) {
return SoilDeviceClient(
mode: SoilReadMode.once,
proactiveReportingSeconds: 2,
slaveId: 1,
);
});
class SoilState {
const SoilState({
this.devices = const [],
this.selectedIndex = -1,
this.status = '',
this.isConnecting = false,
});
final List<SoilUsbDeviceInfo> devices;
final int selectedIndex;
final String status;
final bool isConnecting;
SoilUsbDeviceInfo? get selected =>
(selectedIndex >= 0 && selectedIndex < devices.length)
? devices[selectedIndex]
: null;
SoilState copyWith({
List<SoilUsbDeviceInfo>? devices,
int? selectedIndex,
String? status,
bool? isConnecting,
}) {
return SoilState(
devices: devices ?? this.devices,
selectedIndex: selectedIndex ?? this.selectedIndex,
status: status ?? this.status,
isConnecting: isConnecting ?? this.isConnecting,
);
}
}
class SoilNotifierRiverpod extends StateNotifier<SoilState> {
SoilNotifierRiverpod(this._client) : super(const SoilState()) {
loadDevices();
}
final SoilDeviceClient _client;
Future<void> loadDevices() async {
try {
final list = await _client.getDeviceList();
state = state.copyWith(
devices: list,
selectedIndex: list.isNotEmpty ? 0 : -1,
status: 'Found ${list.length} device(s).',
);
} catch (e) {
state = state.copyWith(status: 'Failed to load devices: $e');
}
}
Future<void> connect() async {
final device = state.selected;
if (device == null) {
state = state.copyWith(status: 'Please select a device first.');
return;
}
state = state.copyWith(isConnecting: true);
try {
await _client.makeConnection(device);
state = state.copyWith(status: 'Connected to $device');
} catch (e) {
state = state.copyWith(status: 'Connection failed: $e');
} finally {
state = state.copyWith(isConnecting: false);
}
}
}
final soilNotifierProvider =
StateNotifierProvider<SoilNotifierRiverpod, SoilState>((ref) {
final client = ref.watch(soilClientProvider);
return SoilNotifierRiverpod(client);
});
6) Using with Bloc #
Create a simple Bloc that wraps the same operations:
class SoilEvent {}
class LoadDevices extends SoilEvent {}
class ConnectDevice extends SoilEvent {
ConnectDevice(this.index);
final int index;
}
class SoilBlocState {
const SoilBlocState({
this.devices = const [],
this.selectedIndex = -1,
this.status = '',
this.isConnecting = false,
});
final List<SoilUsbDeviceInfo> devices;
final int selectedIndex;
final String status;
final bool isConnecting;
SoilBlocState copyWith({
List<SoilUsbDeviceInfo>? devices,
int? selectedIndex,
String? status,
bool? isConnecting,
}) {
return SoilBlocState(
devices: devices ?? this.devices,
selectedIndex: selectedIndex ?? this.selectedIndex,
status: status ?? this.status,
isConnecting: isConnecting ?? this.isConnecting,
);
}
}
class SoilBloc extends Bloc<SoilEvent, SoilBlocState> {
SoilBloc(this.client) : super(const SoilBlocState()) {
on<LoadDevices>(_onLoadDevices);
on<ConnectDevice>(_onConnectDevice);
}
final SoilDeviceClient client;
Future<void> _onLoadDevices(
LoadDevices event,
Emitter<SoilBlocState> emit,
) async {
try {
final list = await client.getDeviceList();
emit(state.copyWith(
devices: list,
selectedIndex: list.isNotEmpty ? 0 : -1,
status: 'Found ${list.length} device(s).',
));
} catch (e) {
emit(state.copyWith(status: 'Failed to load devices: $e'));
}
}
Future<void> _onConnectDevice(
ConnectDevice event,
Emitter<SoilBlocState> emit,
) async {
if (event.index < 0 || event.index >= state.devices.length) {
emit(state.copyWith(status: 'Please select a valid device.'));
return;
}
final device = state.devices[event.index];
emit(state.copyWith(isConnecting: true));
try {
await client.makeConnection(device);
emit(state.copyWith(status: 'Connected to $device'));
} catch (e) {
emit(state.copyWith(status: 'Connection failed: $e'));
} finally {
emit(state.copyWith(isConnecting: false));
}
}
}
Note: USB OTG serial cables are typically Android. iOS support depends on the accessory.