hondavert-dev/lib/main.dart
HVBT Dev 11cf7c2b63 v1.0.1 — Phase 1 complete + BT connection test UI
- Phase 1 core protocol: temp_table, neg8, sensor_state, s300_parser,
  kpro_parser, dtc_map, sensor_defs (50/50 tests passing)
- BT layer: bt_service.dart, bt_poller.dart (100ms poll, NEG8 validation)
- Connection test UI: device picker, protocol selector, live sensor screen
  with LIVE DATA + DEBUG LOG tabs
- Runtime BT permission request (Android 12+) + auto-enable Bluetooth
- Android: minSdk=26, all BT+location permissions in manifest
- Fixed flutter_bluetooth_serial namespace for AGP compatibility
2026-04-12 18:07:37 +05:30

788 lines
25 KiB
Dart

import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:permission_handler/permission_handler.dart';
import 'core/bluetooth/bt_poller.dart';
import 'core/bluetooth/bt_service.dart';
import 'core/protocol/kpro_parser.dart';
import 'core/protocol/s300_parser.dart';
import 'core/protocol/sensor_state.dart';
void main() {
runApp(const HvbtApp());
}
class HvbtApp extends StatelessWidget {
const HvbtApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'HV BT Dashboard',
theme: ThemeData.dark(useMaterial3: true).copyWith(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFFF4444),
brightness: Brightness.dark,
),
),
home: const BtPickerScreen(),
debugShowCheckedModeBanner: false,
);
}
}
// ─── Bluetooth Device Picker ──────────────────────────────────────────────────
class BtPickerScreen extends StatefulWidget {
const BtPickerScreen({super.key});
@override
State<BtPickerScreen> createState() => _BtPickerScreenState();
}
class _BtPickerScreenState extends State<BtPickerScreen> {
final BtService _btService = BtService();
List<BluetoothDevice> _devices = [];
bool _loading = true;
String? _connectingAddress;
String? _error;
@override
void initState() {
super.initState();
_initBluetooth();
}
Future<void> _initBluetooth() async {
setState(() { _loading = true; _error = null; });
// 1. Request all required runtime permissions
final statuses = await [
Permission.bluetoothConnect,
Permission.bluetoothScan,
Permission.locationWhenInUse,
].request();
final denied = statuses.entries
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied)
.map((e) => e.key.toString())
.toList();
if (denied.isNotEmpty) {
setState(() {
_loading = false;
_error = 'Permissions denied: ${denied.join(", ")}\n\n'
'Go to Settings → Apps → HV BT Dashboard → Permissions and allow Bluetooth + Location, then tap Refresh.';
});
return;
}
// 2. Make sure Bluetooth is enabled (shows system dialog to turn it on)
final btState = await FlutterBluetoothSerial.instance.state;
if (btState != BluetoothState.STATE_ON) {
await FlutterBluetoothSerial.instance.requestEnable();
// Give the radio a moment to come up
await Future<void>.delayed(const Duration(seconds: 1));
}
await _loadDevices();
}
Future<void> _loadDevices() async {
setState(() { _loading = true; _error = null; });
try {
final devices = await _btService.getPairedDevices();
setState(() {
_devices = devices;
_loading = false;
});
} catch (e) {
setState(() {
_error = 'Failed to load devices:\n$e\n\nMake sure Bluetooth is ON and tap Refresh.';
_loading = false;
});
}
}
Future<void> _connect(BluetoothDevice device, EcuType ecuType) async {
setState(() => _connectingAddress = device.address);
try {
await _btService.connect(device);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => LiveDataScreen(
btService: _btService,
deviceName: device.name ?? device.address,
ecuType: ecuType,
),
),
);
} catch (e) {
if (!mounted) return;
setState(() {
_connectingAddress = null;
_error = 'Connection to ${device.name ?? device.address} failed:\n$e\n\nMake sure the device is powered on and paired.';
});
}
}
void _showProtocolDialog(BluetoothDevice device) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(device.name ?? device.address),
content: const Text('Select ECU protocol:'),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
_connect(device, EcuType.s300);
},
child: const Text('S300'),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
_connect(device, EcuType.kpro);
},
child: const Text('KPro'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('HV BT — Select Device'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loading ? null : _initBluetooth,
tooltip: 'Refresh',
),
],
),
body: Column(
children: [
// Error banner
if (_error != null)
_ErrorBanner(
message: _error!,
onDismiss: () => setState(() => _error = null),
),
Expanded(
child: _loading
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Loading paired devices…'),
],
),
)
: _devices.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bluetooth_disabled,
size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No paired Bluetooth devices found.',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
'Go to Android Settings → Bluetooth and pair your ECU module first, then come back and tap Refresh.',
style: TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
)
: ListView.separated(
itemCount: _devices.length,
separatorBuilder: (_, __) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final device = _devices[index];
final isConnecting =
_connectingAddress == device.address;
return ListTile(
leading: Icon(
Icons.bluetooth,
color: isConnecting
? Colors.blue
: Colors.grey,
),
title:
Text(device.name ?? 'Unknown Device'),
subtitle: Text(device.address),
trailing: isConnecting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(
Icons.arrow_forward_ios,
size: 16,
color: Colors.grey,
),
onTap: isConnecting
? null
: () => _showProtocolDialog(device),
);
},
),
),
],
),
);
}
}
// ─── Live Data Screen ─────────────────────────────────────────────────────────
class LiveDataScreen extends StatefulWidget {
final BtService btService;
final String deviceName;
final EcuType ecuType;
const LiveDataScreen({
super.key,
required this.btService,
required this.deviceName,
required this.ecuType,
});
@override
State<LiveDataScreen> createState() => _LiveDataScreenState();
}
class _LiveDataScreenState extends State<LiveDataScreen> {
late final BtPoller _poller;
StreamSubscription<Uint8List>? _frameSub;
SensorState? _state;
int _frameCount = 0;
int _dropped = 0;
DateTime? _lastFrameTime;
// Debug log — last 20 events shown on screen
final List<_LogEntry> _log = [];
@override
void initState() {
super.initState();
_addLog('Connecting to ${widget.deviceName}', LogLevel.info);
_addLog(
'Protocol: ${widget.ecuType == EcuType.s300 ? "S300" : "KPro"}',
LogLevel.info);
_poller = BtPoller(
widget.btService,
ecuType: widget.ecuType,
pollingInterval: const Duration(milliseconds: 100),
);
_poller.start();
_addLog('Poller started — sending requests every 100ms', LogLevel.info);
_frameSub = _poller.frameStream.listen(
(Uint8List frame) {
try {
final SensorState s = widget.ecuType == EcuType.s300
? parseS300(frame)
: parseKPro(frame);
final now = DateTime.now();
if (mounted) {
setState(() {
_state = s;
_frameCount++;
_dropped = _poller.droppedFrames;
_lastFrameTime = now;
});
// Log first frame and then every 100 frames
if (_frameCount == 1) {
_addLog('First frame received! RPM=${s.rpm.toStringAsFixed(0)}',
LogLevel.ok);
} else if (_frameCount % 100 == 0) {
_addLog(
'Frame #$_frameCount RPM=${s.rpm.toStringAsFixed(0)} ECT=${s.ect.toStringAsFixed(1)}°C',
LogLevel.info);
}
}
} catch (e) {
_addLog('Parse error: $e', LogLevel.error);
}
},
onError: (Object e) {
_addLog('BT stream error: $e', LogLevel.error);
},
onDone: () {
_addLog('BT stream closed (device disconnected?)', LogLevel.error);
},
);
}
void _addLog(String msg, LogLevel level) {
if (!mounted) return;
setState(() {
_log.insert(
0,
_LogEntry(
message: msg,
level: level,
time: DateTime.now(),
));
if (_log.length > 20) _log.removeLast();
});
}
@override
void dispose() {
_frameSub?.cancel();
_poller.dispose();
widget.btService.dispose();
super.dispose();
}
Future<void> _disconnect() async {
_poller.stop();
await widget.btService.disconnect();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
);
}
String get _statusText {
if (_frameCount == 0) return 'Waiting for data…';
final ago = _lastFrameTime == null
? '?'
: '${DateTime.now().difference(_lastFrameTime!).inMilliseconds}ms ago';
return 'LIVE • last frame $ago';
}
Color get _statusColor {
if (_frameCount == 0) return Colors.orange;
final ms = _lastFrameTime == null
? 999
: DateTime.now().difference(_lastFrameTime!).inMilliseconds;
return ms < 500 ? Colors.green : Colors.orange;
}
@override
Widget build(BuildContext context) {
final protocol =
widget.ecuType == EcuType.s300 ? 'S300' : 'KPro';
return DefaultTabController(
length: 2,
child: Scaffold(
backgroundColor: const Color(0xFF0A0A0A),
appBar: AppBar(
backgroundColor: const Color(0xFF1A1A1A),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.deviceName,
style: const TextStyle(fontSize: 15)),
Row(
children: [
Icon(_frameCount == 0
? Icons.hourglass_empty
: Icons.circle,
size: 10,
color: _statusColor),
const SizedBox(width: 4),
Text(
'$protocol$_statusText • dropped: $_dropped',
style: const TextStyle(
fontSize: 10, color: Colors.grey),
),
],
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled,
color: Colors.redAccent),
onPressed: _disconnect,
tooltip: 'Disconnect',
),
],
bottom: const TabBar(
tabs: [
Tab(text: 'LIVE DATA'),
Tab(text: 'DEBUG LOG'),
],
),
),
body: TabBarView(
children: [
// ── Tab 1: Live Sensor Data ──
_state == null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(
color: Colors.orange),
const SizedBox(height: 20),
const Text('Waiting for ECU response…',
style: TextStyle(fontSize: 16)),
const SizedBox(height: 8),
Text(
'Sending ${protocol} request every 100ms\nMake sure ECU is powered on',
style: const TextStyle(
color: Colors.grey, fontSize: 13),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
const Text(
'Check DEBUG LOG tab for details',
style: TextStyle(
color: Colors.orange, fontSize: 13),
),
],
),
)
: _SensorGrid(state: _state!, frameCount: _frameCount),
// ── Tab 2: Debug Log ──
_DebugLogView(log: _log),
],
),
),
);
}
}
// ─── Sensor Grid ─────────────────────────────────────────────────────────────
class _SensorGrid extends StatelessWidget {
final SensorState state;
final int frameCount;
const _SensorGrid({required this.state, required this.frameCount});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
children: [
_BigValue(
label: 'RPM',
value: state.rpm.toStringAsFixed(0),
unit: 'rpm',
color: _rpmColor(state.rpm),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 6,
children: [
_FlagPill('MIL', state.mil, Colors.amber),
_FlagPill('VTEC', state.vtec, Colors.blue),
_FlagPill('KNOCK', state.knock, Colors.red),
_FlagPill('FUEL CUT', state.fuelCut, Colors.red),
_FlagPill('REV LIM', state.revLimit, Colors.orange),
_FlagPill('FAN', state.fanOut, Colors.green),
_FlagPill('LAUNCH', state.launch, Colors.purple),
],
),
const SizedBox(height: 10),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 6,
crossAxisSpacing: 6,
childAspectRatio: 2.4,
children: [
_SensorTile('VSS', '${state.vss.toStringAsFixed(1)} km/h'),
_SensorTile('MAP', '${state.map.toStringAsFixed(1)} kPa'),
_SensorTile('TPS', '${state.tps.toStringAsFixed(1)} %'),
_SensorTile('ECT', '${state.ect.toStringAsFixed(1)} °C'),
_SensorTile('IAT', '${state.iat.toStringAsFixed(1)} °C'),
_SensorTile('BAT', '${state.bat.toStringAsFixed(2)} V'),
_SensorTile('IGN', '${state.ign.toStringAsFixed(1)} °'),
_SensorTile('INJ', '${state.inj.toStringAsFixed(2)} ms'),
_SensorTile('O2', state.o2.toStringAsFixed(0)),
_SensorTile('AFR', state.afr.toStringAsFixed(2)),
_SensorTile('ETH', '${state.eth.toStringAsFixed(0)} %'),
_SensorTile('GEAR', state.gear.toString()),
],
),
const SizedBox(height: 8),
Text(
'Frames received: $frameCount',
style: const TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
);
}
Color _rpmColor(double rpm) {
if (rpm > 7000) return Colors.red;
if (rpm > 5500) return Colors.orange;
return const Color(0xFF00E676);
}
}
// ─── Debug Log View ───────────────────────────────────────────────────────────
class _DebugLogView extends StatelessWidget {
final List<_LogEntry> log;
const _DebugLogView({required this.log});
@override
Widget build(BuildContext context) {
if (log.isEmpty) {
return const Center(
child: Text('No log entries yet.',
style: TextStyle(color: Colors.grey)),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: log.length,
itemBuilder: (context, index) {
final entry = log[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_ts(entry.time),
style: const TextStyle(
color: Colors.grey,
fontSize: 11,
fontFamily: 'monospace'),
),
const SizedBox(width: 8),
Icon(
entry.level == LogLevel.error
? Icons.error_outline
: entry.level == LogLevel.ok
? Icons.check_circle_outline
: Icons.info_outline,
size: 14,
color: entry.level == LogLevel.error
? Colors.red
: entry.level == LogLevel.ok
? Colors.green
: Colors.grey,
),
const SizedBox(width: 6),
Expanded(
child: Text(
entry.message,
style: TextStyle(
fontSize: 12,
color: entry.level == LogLevel.error
? Colors.redAccent
: entry.level == LogLevel.ok
? Colors.greenAccent
: Colors.white70,
),
),
),
],
),
);
},
);
}
String _ts(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
}
enum LogLevel { info, ok, error }
class _LogEntry {
final String message;
final LogLevel level;
final DateTime time;
const _LogEntry(
{required this.message, required this.level, required this.time});
}
// ─── Error Banner ─────────────────────────────────────────────────────────────
class _ErrorBanner extends StatelessWidget {
final String message;
final VoidCallback onDismiss;
const _ErrorBanner({required this.message, required this.onDismiss});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: const Color(0xFF4A0000),
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline,
color: Colors.redAccent, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.redAccent, fontSize: 13),
),
),
IconButton(
icon: const Icon(Icons.close,
color: Colors.redAccent, size: 20),
onPressed: onDismiss,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
}
// ─── UI helpers ──────────────────────────────────────────────────────────────
class _BigValue extends StatelessWidget {
final String label;
final String value;
final String unit;
final Color color;
const _BigValue(
{required this.label,
required this.value,
required this.unit,
required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withAlpha(120), width: 1.5),
),
child: Column(
children: [
Text(label,
style: TextStyle(
color: color,
fontSize: 12,
letterSpacing: 2,
fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(value,
style: TextStyle(
color: color,
fontSize: 48,
fontWeight: FontWeight.bold,
letterSpacing: -2)),
Text(unit,
style: TextStyle(
color: color.withAlpha(160), fontSize: 12)),
],
),
);
}
}
class _SensorTile extends StatelessWidget {
final String label;
final String value;
const _SensorTile(this.label, this.value);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label,
style: const TextStyle(
color: Colors.grey,
fontSize: 11,
letterSpacing: 1)),
Text(value,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600)),
],
),
);
}
}
class _FlagPill extends StatelessWidget {
final String label;
final bool active;
final Color color;
const _FlagPill(this.label, this.active, this.color);
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: active ? color : Colors.grey.withAlpha(60),
width: 1.5,
),
),
child: Text(
label,
style: TextStyle(
color: active ? color : Colors.grey,
fontSize: 11,
fontWeight: active ? FontWeight.bold : FontWeight.normal,
letterSpacing: 1,
),
),
);
}
}