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: once and live (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.

Libraries

soil_device_sdk