Загрузка данных
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:fl_chart/fl_chart.dart';
import 'dart:typed_data';
import 'package:open_file/open_file.dart';
void main() => runApp(const MyApp());
const String xmlLogPath = r'c:\Users\mrana\modem_app\ModemStatusLog.xml';
const String packetLogXmlPath = r'c:\Users\mrana\modem_app\logPacketXml.xml';
const String modemMetricsLogDir = r'c:\Users\mrana\modem_app\modem_metrics';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Управление нагрузками (Flutter)',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.cyan,
brightness: Brightness.dark,
),
scaffoldBackgroundColor: Colors.black,
),
debugShowCheckedModeBanner: false,
home: const MainPage(),
);
}
}
enum DeviceState { off, on, offline }
class Device {
final String name;
final String imei;
DeviceState state;
bool online;
int rssi;
List extra;
final int id;
Device({
required this.name,
required this.imei,
this.state = DeviceState.offline,
this.online = false,
this.rssi = 0,
List? extra,
required this.id,
}) : extra = extra ?? List.filled(4, 0.0);
}
class MetricsRow {
final DateTime timestamp;
final int deviceId;
final String deviceName;
final String imei;
final double voltage;
final double current;
final double activePower;
final double reactivePower;
final double apparentPower;
final double powerFactor;
final double frequency;
final double temperature;
final double energy;
final int rssi;
MetricsRow({
required this.timestamp,
required this.deviceId,
required this.deviceName,
required this.imei,
required this.voltage,
required this.current,
required this.activePower,
required this.reactivePower,
required this.apparentPower,
required this.powerFactor,
required this.frequency,
required this.temperature,
required this.energy,
required this.rssi,
});
}
class ScheduleItem {
final int index;
final int hour;
final int minute;
final bool enabled;
final bool turnOn;
ScheduleItem({
required this.index,
required this.hour,
required this.minute,
required this.enabled,
required this.turnOn,
});
String get timeText =>
'${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
}
class ScheduleConfig {
final int count;
final bool enabled;
final List<ScheduleItem> items;
ScheduleConfig({
required this.count,
required this.enabled,
required this.items,
});
}
Future<List<MetricsRow>> loadMetricsForDay(DateTime day) async {
final fileName =
'metrics_${day.year.toString().padLeft(4, '0')}-'
'${day.month.toString().padLeft(2, '0')}-'
'${day.day.toString().padLeft(2, '0')}.csv';
final file = File('$modemMetricsLogDir\\$fileName');
if (!await file.exists()) {
return [];
}
final lines = await file.readAsLines();
if (lines.length <= 1) return [];
final result = <MetricsRow>[];
for (int i = 1; i < lines.length; i++) {
final line = lines[i].trim();
if (line.isEmpty) continue;
final parts = line.split(';');
if (parts.length < 14) continue;
try {
result.add(
MetricsRow(
timestamp: DateTime.parse(parts[0]),
deviceId: int.tryParse(parts[1]) ?? 0,
deviceName: parts[2].replaceAll('"', ''),
imei: parts[3],
voltage: double.tryParse(parts[4].replaceAll(',', '.')) ?? 0.0,
current: double.tryParse(parts[5].replaceAll(',', '.')) ?? 0.0,
activePower: double.tryParse(parts[6].replaceAll(',', '.')) ?? 0.0,
reactivePower: double.tryParse(parts[7].replaceAll(',', '.')) ?? 0.0,
apparentPower: double.tryParse(parts[8].replaceAll(',', '.')) ?? 0.0,
powerFactor: double.tryParse(parts[9].replaceAll(',', '.')) ?? 0.0,
frequency: double.tryParse(parts[10].replaceAll(',', '.')) ?? 0.0,
temperature: double.tryParse(parts[11].replaceAll(',', '.')) ?? 0.0,
energy: double.tryParse(parts[12].replaceAll(',', '.')) ?? 0.0,
rssi: int.tryParse(parts[13]) ?? 0,
),
);
} catch (_) {}
}
return result;
}
Future<void> logModemMetricsCsv({
required Device d,
required double voltage,
required double current,
required double activePower,
required double reactivePower,
required double apparentPower,
required double powerFactor,
required double frequency,
required double temperature,
required double energy,
required int rssi,
}) async {
final now = DateTime.now();
final dir = Directory(modemMetricsLogDir);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final fileName =
'metrics_${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}.csv';
final file = File('${dir.path}\\$fileName');
if (!await file.exists()) {
await file.writeAsString(
'timestamp;device_id;device_name;imei;voltage;current;active_power;reactive_power;apparent_power;power_factor;frequency;temperature;energy;rssi\n',
mode: FileMode.write,
);
}
String esc(String s) => '"${s.replaceAll('"', '""')}"';
final row =
'${now.toIso8601String()};'
'${d.id};'
'${esc(d.name)};'
'${d.imei};'
'${voltage.toStringAsFixed(3)};'
'${current.toStringAsFixed(3)};'
'${activePower.toStringAsFixed(3)};'
'${reactivePower.toStringAsFixed(3)};'
'${apparentPower.toStringAsFixed(3)};'
'${powerFactor.toStringAsFixed(4)};'
'${frequency.toStringAsFixed(3)};'
'${temperature.toStringAsFixed(3)};'
'${energy.toStringAsFixed(3)};'
'$rssi\n';
await file.writeAsString(row, mode: FileMode.append);
}
enum ConnState { disconnected, connecting, connected }
class MainPage extends StatefulWidget {
const MainPage({super.key});
@override
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
final List<Device> devices = [
Device(name: 'Device 1', id: 1, imei: '869924004196284'),
Device(name: 'Device 2', id: 2, imei: '865617035626683'),
Device(name: 'Device 3', id: 3, imei: '865617035649313'),
];
int _nextId = 4;
bool _telegramPollingStarted = false;
bool _telegramPollingStopped = false;
Timer? _telegramPollTimer;
bool _telegramPollingBusy = false;
Future<void> _pollTelegramCommands() async {
final updates = await getTelegramUpdates();
for (final upd in updates) {
final message = upd['message'] as Map<String, dynamic>?;
if (message == null) continue;
final chat = message['chat'] as Map<String, dynamic>?;
final chatId = '${chat?['id'] ?? ''}';
if (chatId != telegramChatId) continue;
final text = (message['text'] ?? '').toString().trim();
if (text.isEmpty) continue;
await _handleTelegramCommand(text);
}
}
Future<void> _handleTelegramCommand(String text) async {
final cmd = text.trim().toLowerCase();
if (cmd == '/start' || cmd == '/help') {
await sendTelegramMessage(
'Команды:\n'
'/status - статус устройств\n'
'/help - помощь\n'
'/on_1 - включить Device 1\n'
'/off_1 - выключить Device 1\n'
'/on_2 - включить Device 2\n'
'/off_2 - выключить Device 2',
);
return;
}
if (cmd == '/status') {
final lines = devices.map((d) {
final conn = _client.isDeviceConnected(d.imei) ? 'онлайн' : 'оффлайн';
final state = switch (d.state) {
DeviceState.on => 'ВКЛ',
DeviceState.off => 'ВЫКЛ',
DeviceState.offline => 'неизвестно',
};
return '${d.id}: ${d.name} — $conn, $state';
}).join('\n');
await sendTelegramMessage('Статус устройств:\n$lines');
return;
}
if (cmd.startsWith('/on_') || cmd.startsWith('/off_')) {
final parts = cmd.split('_');
if (parts.length != 2) {
await sendTelegramMessage('Неверная команда. Пример: /on_2');
return;
}
final id = int.tryParse(parts[1]);
if (id == null) {
await sendTelegramMessage('Неверный ID устройства');
return;
}
Device? d;
try {
d = devices.firstWhere((x) => x.id == id);
} catch (_) {
d = null;
}
if (d == null) {
await sendTelegramMessage('Устройство с ID $id не найдено');
return;
}
final turnOn = cmd.startsWith('/on_');
await _setDeviceState(d, turnOn, fromTelegram: true);
return;
}
await sendTelegramMessage(
'Неизвестная команда: $text\n'
'Используй /help',
);
}
Future<void> _testTelegram() async {
try {
await sendTelegramMessage(
'ТЕСТОВОЕ сообщение из приложения ${DateTime.now()}',
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Тестовое сообщение отправлено в Telegram')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка при отправке в Telegram: $e')),
);
}
}
String _xmlEscape(String s) {
return s
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
String _two(int v) => v.toString().padLeft(2, '0');
Future<void> logStatusChangeXml(Device d, bool on) async {
final file = File(xmlLogPath);
if (!await file.exists()) {
await file.writeAsString(
'''<?xml version="1.0" encoding="utf-8"?>
<DIADATASET Version="2.0">
<METADATA>
<FIELDS>
<FIELD attrname="ID" fieldtype="i4"/>
<FIELD attrname="Date" fieldtype="date"/>
<FIELD attrname="Time" fieldtype="time"/>
<FIELD attrname="State" fieldtype="boolean"/>
<FIELD attrname="ModemName" fieldtype="string"/>
<FIELD attrname="IMEI" fieldtype="string"/>
</FIELDS>
</METADATA>
<ROWDATA>
</ROWDATA>
</DIADATASET>
''',
mode: FileMode.write,
);
}
String content = await file.readAsString();
final marker = '</ROWDATA>';
final idx = content.indexOf(marker);
if (idx == -1) {
return;
}
final existingRows = RegExp(r'<ROW').allMatches(content).length;
final newId = existingRows + 1;
final now = DateTime.now();
final dateStr =
'${now.year.toString().padLeft(4, '0')}${_two(now.month)}${_two(now.day)}'; // 20251222
final timeStr =
'${_two(now.hour)}:${_two(now.minute)}:${_two(now.second)}'; // 10:45:03
final stateStr = on ? 'TRUE' : 'FALSE';
final row = ' <ROW ID="$newId" Date="$dateStr" Time="$timeStr" '
'State="$stateStr" ModemName="${_xmlEscape(d.name)}" '
'IMEI="${_xmlEscape(d.imei)}"/>\n';
final newContent =
content.substring(0, idx) + row + content.substring(idx);
await file.writeAsString(newContent, mode: FileMode.write);
}
late final PppClient _client;
ConnState _conn = ConnState.disconnected;
StreamSubscription<ConnState>? _connSub;
void _startTelegramPolling() {
if (_telegramPollingStarted) return;
_telegramPollingStarted = true;
_telegramPollingStopped = false;
_telegramPollingLoop();
}
Future<void> _telegramPollingLoop() async {
while (!_telegramPollingStopped) {
try {
await _pollTelegramCommands();
} catch (e) {
debugPrint('Telegram polling error: $e');
}
await Future.delayed(const Duration(seconds: 2));
}
}
@override
void initState() {
super.initState();
_client = PppClient(port: ServerSettings.instance.serverPort);
_connSub = _client.connStream.listen((s) => setState(() => _conn = s));
_client.startAsServer();
Future.microtask(() async {
await deleteTelegramWebhook();
_startTelegramPolling();
});
}
@override
void dispose() {
_connSub?.cancel();
_client.dispose();
_telegramPollingStopped = true;
super.dispose();
}
void _openSchedulePage() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SchedulePage(
devices: devices,
client: _client,
conn: _conn,
),
),
);
}
void _openConsumptionView() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ConsumptionPage(
devices: devices,
client: _client,
),
),
);
}
Future<void> _openMetricsLogForAnyDay() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: now,
firstDate: DateTime(2024, 1, 1),
lastDate: DateTime(now.year + 1, 12, 31),
helpText: 'Выберите день лога',
cancelText: 'Отмена',
confirmText: 'Открыть',
);
if (picked == null) return;
final yyyy = picked.year.toString().padLeft(4, '0');
final mm = picked.month.toString().padLeft(2, '0');
final dd = picked.day.toString().padLeft(2, '0');
final filePath = '$modemMetricsLogDir\\metrics_$yyyy-$mm-$dd.csv';
final file = File(filePath);
if (!await file.exists()) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Файл за $yyyy-$mm-$dd не найден'),
),
);
return;
}
final result = await OpenFile.open(file.path);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.type == ResultType.done
? 'Файл открыт: metrics_$yyyy-$mm-$dd.csv'
: 'Не удалось открыть файл: ${result.message}',
),
),
);
}
Future<void> _addDeviceDialog() async {
final nameCtl = TextEditingController(text: 'Device $_nextId');
final imeiCtl = TextEditingController(text: '86561703563553${_nextId % 10}');
final formKey = GlobalKey<FormState>();
final added = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Добавить устройство'),
content: Form(
key: formKey,
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextFormField(
controller: nameCtl,
autofocus: true,
decoration: const InputDecoration(labelText: 'Название'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Введите название' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: imeiCtl,
decoration: const InputDecoration(labelText: 'IMEI (15 цифр)'),
validator: (v) => (v == null || v.trim().length != 15) ? 'Введите 15-значный IMEI' : null,
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Отмена')),
FilledButton(
onPressed: () {
if (formKey.currentState!.validate()) Navigator.pop(ctx, true);
},
child: const Text('Добавить'),
),
],
),
);
if (added == true) {
setState(() {
devices.add(Device(name: nameCtl.text.trim(), id: _nextId, imei: imeiCtl.text.trim()));
_nextId++;
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Добавлено: ${nameCtl.text.trim()}')),
);
}
}
Future<void> _confirmDelete(Device d) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить устройство?'),
content: Text('"${d.name}" (ID: ${d.id}) будет удалено.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Отмена')),
FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('Удалить')),
],
),
);
if (ok == true) {
setState(() => devices.removeWhere((x) => x.id == d.id));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Удалено: ${d.name}')),
);
}
}
Future<void> _setDeviceState(Device d, bool on, {bool fromTelegram = false}) async {
if (!_client.isDeviceConnected(d.imei)) {
try {
await sendTelegramMessage(
'ПОПЫТКА: ${on ? 'включить' : 'выключить'} модем\n'
'Устройство: ${d.name}\n'
'ID: ${d.id}\n'
'IMEI: ${d.imei}\n'
'Подключён: НЕТ\n'
'Время: ${DateTime.now()}',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Не удалось отправить сообщение в Telegram: $e')),
);
}
}
if (fromTelegram) {
await sendTelegramMessage('${d.name} не подключён');
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${d.name} не подключён')),
);
}
return;
}
final ok = await _client.setRelayState(d.imei, on);
if (ok) {
if (mounted) {
setState(() => d.state = on ? DeviceState.on : DeviceState.off);
} else {
d.state = on ? DeviceState.on : DeviceState.off;
}
try {
await logStatusChangeXml(d, on);
} catch (e) {
if (!fromTelegram && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка записи XML-лога: $e')),
);
}
}
try {
await sendTelegramMessage(
'Модем ${on ? 'ВКЛЮЧЕН' : 'ВЫКЛЮЧЕН'}\n'
'Устройство: ${d.name}\n'
'ID: ${d.id}\n'
'IMEI: ${d.imei}\n'
'Время: ${DateTime.now()}',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Не удалось отправить сообщение в Telegram: $e')),
);
}
}
if (!fromTelegram && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${d.name}: ${on ? 'включено' : 'выключено'}'),
),
);
}
} else {
if (fromTelegram) {
await sendTelegramMessage(
'Не удалось ${on ? 'включить' : 'выключить'} ${d.name}',
);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Не удалось ${on ? 'включить' : 'выключить'} ${d.name}')),
);
}
}
}
void _showDeviceInfo(Device d) {
final stateText = switch (d.state) {
DeviceState.on => 'ВКЛ',
DeviceState.off => 'ВЫКЛ',
DeviceState.offline => 'Не в сети',
};
DateTime? lastTime;
bool busy = false;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: Text(d.name),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('IMEI: ${d.imei}'),
Text('Состояние: $stateText'),
Text('RSSI: ${d.rssi}'),
const SizedBox(height: 8),
Text('U = ${d.extra[0]} В'),
Text('I = ${d.extra[1]} А'),
Text('P = ${d.extra[2]} Вт'),
const Divider(height: 20),
Row(
children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(
color: _conn == ConnState.connected
? Colors.greenAccent
: _conn == ConnState.connecting
? Colors.amberAccent
: Colors.grey,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
'Состояние связи: ${_conn == ConnState.connected ? 'есть' : _conn == ConnState.connecting ? 'подключение...' : 'нет'}',
style: const TextStyle(color: Colors.white70),
),
],
),
const SizedBox(height: 8),
Text(
'Текущий порт прослушивания: ${ServerSettings.instance.serverPort}',
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 12),
if (lastTime != null)
Text('Время устройства: $lastTime', style: const TextStyle(color: Colors.white70)),
],
),
actions: [
TextButton(
onPressed: busy
? null
: () async {
if (!_client.isDeviceConnected(d.imei)) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Нет связи с модемом')));
return;
}
setLocal(() => busy = true);
final t = await _client.getTime(d.imei);
setLocal(() {
busy = false;
lastTime = t;
});
if (t == null && mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('GET_TIME: нет ответа')));
}
},
child: const Text('Получить время'),
),
FilledButton(
onPressed: busy
? null
: () async {
if (!_client.isDeviceConnected(d.imei)) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Нет связи с модемом')));
return;
}
setLocal(() => busy = true);
final ok = await _client.setTime(d.imei, DateTime.now());
setLocal(() => busy = false);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(ok ? 'SET_TIME: установлено' : 'SET_TIME: ошибка')),
);
},
child: const Text('Обновить Время'),
),
TextButton(
onPressed: busy ? null : () => Navigator.pop(ctx),
child: const Text('Закрыть'),
),
],
),
),
);
}
Color _connDotColor() {
switch (_conn) {
case ConnState.connected:
return Colors.greenAccent;
case ConnState.connecting:
return Colors.amberAccent;
case ConnState.disconnected:
default:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Приложение для обмена данными между модемами'),
actions: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(children: [
Icon(Icons.circle, size: 12, color: _connDotColor()),
const SizedBox(width: 6),
Text(_conn.name, style: const TextStyle(color: Colors.white70)),
]),
),
PopupMenuButton<String>(
tooltip: 'Подключение',
onSelected: (v) async {
if (v == 'tg_test') {
await _testTelegram();
}
},
itemBuilder: (ctx) => const [
PopupMenuItem(value: 'tg_test', child: Text('Тест Telegram')),
PopupMenuItem(value: 'settings', child: Text('Настройки сервера')),
],
icon: const Icon(Icons.link),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addDeviceDialog,
icon: const Icon(Icons.add),
label: const Text('Добавить'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => PacketExchangePage(client: _client)),
);
},
child: const Text('Обмен пакетов'),
),
ElevatedButton(
onPressed: _openSchedulePage,
child: const Text('Расписание'),
),
ElevatedButton(
onPressed: _openConsumptionView,
child: const Text('Просмотр потребления'),
),
ElevatedButton(
onPressed: _openMetricsLogForAnyDay,
child: const Text('Открыть лог по дате'),
),
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ServerSettingsPage(),
),
);
},
child: const Text('Настройки сервера'),
),
],
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 260,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1,
),
itemCount: devices.length,
itemBuilder: (_, i) {
final d = devices[i];
return DeviceCard(
device: d,
onOn: () => _setDeviceState(d, true),
onOff: () => _setDeviceState(d, false),
onDelete: () => _confirmDelete(d),
onInfo: () => _showDeviceInfo(d),
);
},
),
),
],
),
);
}
}
class DeviceCard extends StatelessWidget {
final Device device;
final VoidCallback onOn;
final VoidCallback onOff;
final VoidCallback? onDelete;
final VoidCallback? onInfo;
const DeviceCard({
super.key,
required this.device,
required this.onOn,
required this.onOff,
this.onDelete,
this.onInfo,
});
Color _stateColor(DeviceState s) {
switch (s) {
case DeviceState.on:
return Colors.green;
case DeviceState.off:
return Colors.red;
case DeviceState.offline:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Card(
color: Colors.blueGrey.shade900,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(device.name, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 6),
AnimatedContainer(
duration: const Duration(milliseconds: 250),
height: 8,
width: 40,
decoration: BoxDecoration(
color: _stateColor(device.state),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_circleButton('ВКЛ', Colors.green, onOn),
const SizedBox(width: 8),
_circleButton('ВЫКЛ', Colors.red, onOff),
],
),
const SizedBox(height: 10),
TextButton(
onPressed: onInfo,
child: const Text('ИНФО', style: TextStyle(color: Colors.white70)),
),
],
),
),
if (onDelete != null)
Positioned(
top: 4,
right: 4,
child: Tooltip(
message: 'Удалить',
child: IconButton(
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(backgroundColor: Colors.black38),
onPressed: onDelete,
icon: const Icon(Icons.delete_outline, size: 18),
),
),
),
],
);
}
Widget _circleButton(String text, Color color, VoidCallback onPressed) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(18),
backgroundColor: Colors.black54,
),
child: Text(text, style: TextStyle(color: color, fontSize: 12)),
);
}
}
// ------------- Просмотр потребления -------------
class ConsumptionPage extends StatefulWidget {
final List<Device> devices;
final PppClient client;
const ConsumptionPage({
super.key,
required this.devices,
required this.client,
});
@override
State<ConsumptionPage> createState() => _ConsumptionPageState();
}
class _ConsumptionPageState extends State<ConsumptionPage> {
String? selectedDevice;
Device? get _selectedDeviceObj {
if (selectedDevice == null) return null;
try {
return widget.devices.firstWhere((d) => d.name == selectedDevice);
} catch (_) {
return null;
}
}
String selectedParam = 'Ток';
String selectedPhase = 'A';
bool onlineView = false;
bool showCombinedChart = false;
bool showTodayHistory = false;
bool archiveMode = false;
DateTime selectedArchiveDate = DateTime.now();
bool archiveLoading = false;
bool _loadingHistory = false;
final List<FlSpot> _points = <FlSpot>[];
final List<FlSpot> _voltagePoints = <FlSpot>[];
final List<FlSpot> _powerPoints = <FlSpot>[];
final List<FlSpot> _energyPoints = <FlSpot>[];
Timer? _timer;
double _x = 0;
DateTime _t0 = DateTime.now();
final Map<String, DateTime> _lastArchiveSaveByImei = {};
final List<String> params = [
'Ток',
'Напряжение',
'Активная мощность',
'Реактивная мощность',
'Полная мощность',
'Коэффициент мощности',
'Частота',
'Температура'
];
final List<String> phases = ['A', 'B', 'C', 'Сумма'];
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
static const String _tsWriteKey = '8HND5ZTQMHAKXTAN';
int _tsCounter = 0;
Future<void> _sendToThingSpeak({
required int state,
required double voltage,
required double current,
required double power,
required int rssi,
required double energy,
}) async {
_tsCounter++;
if (_tsCounter < 15) return;
_tsCounter = 0;
final uri = Uri.https(
'api.thingspeak.com',
'/update',
{
'api_key': _tsWriteKey,
'field1': state.toString(),
'field2': voltage.toString(),
'field3': current.toString(),
'field4': power.toString(),
'field5': rssi.toString(),
'field6': energy.toString(),
},
);
try {
await http.get(uri);
} catch (_) {
}
}
String _todayFilePath() {
final now = DateTime.now();
final fileName =
'metrics_${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}.csv';
return '$modemMetricsLogDir\\$fileName';
}
void _clearChartBuffers() {
_points.clear();
_voltagePoints.clear();
_powerPoints.clear();
_energyPoints.clear();
_x = 0;
_t0 = DateTime.now();
}
double _safeParse(String s) => double.tryParse(s.replaceAll(',', '.')) ?? 0.0;
Future _loadTodayHistory() async {
final dev = _selectedDeviceObj;
if (dev == null) return;
setState(() {
_loadingHistory = true;
onlineView = false;
});
_timer?.cancel();
_clearChartBuffers();
final file = File(_todayFilePath());
if (!await file.exists()) {
if (!mounted) return;
setState(() => _loadingHistory = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Файл лога за сегодня ещё не создан')),
);
return;
}
try {
final lines = await file
.openRead()
.transform(utf8.decoder)
.transform(const LineSplitter())
.toList();
DateTime? firstTs;
double localX = 0;
for (final line in lines.skip(1)) {
if (line.trim().isEmpty) continue;
final parts = line.split(';');
if (parts.length < 14) continue;
final imei = parts[3].replaceAll('"', '').trim();
if (imei != dev.imei) continue;
final ts = DateTime.tryParse(parts[0].trim());
if (ts == null) continue;
firstTs ??= ts;
localX = ts.difference(firstTs).inSeconds.toDouble();
final voltage = _safeParse(parts[4]);
final current = _safeParse(parts[5]);
final activePower = _safeParse(parts[6]);
final reactivePower = _safeParse(parts[7]);
final apparentPower = _safeParse(parts[8]);
final powerFactor = _safeParse(parts[9]);
final frequency = _safeParse(parts[10]);
final temperature = _safeParse(parts[11]);
final energy = _safeParse(parts[12]);
_voltagePoints.add(FlSpot(localX, voltage));
_powerPoints.add(FlSpot(localX, activePower));
_energyPoints.add(FlSpot(localX, energy));
double y;
switch (selectedParam) {
case 'Ток':
y = current;
break;
case 'Напряжение':
y = voltage;
break;
case 'Активная мощность':
y = activePower;
break;
case 'Реактивная мощность':
y = reactivePower;
break;
case 'Полная мощность':
y = apparentPower;
break;
case 'Коэффициент мощности':
y = powerFactor;
break;
case 'Частота':
y = frequency;
break;
case 'Температура':
y = temperature;
break;
default:
y = activePower;
}
_points.add(FlSpot(localX, y));
}
if (firstTs != null) {
_t0 = firstTs;
_x = localX;
}
if (!mounted) return;
setState(() {
_loadingHistory = false;
showTodayHistory = true;
archiveMode = false;
});
if (_points.isEmpty &&
_voltagePoints.isEmpty &&
_powerPoints.isEmpty &&
_energyPoints.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('За сегодня нет данных для ${dev.name}')),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _loadingHistory = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка чтения лога за сегодня: $e')),
);
}
}
bool _shouldSaveArchiveNow(String imei) {
final now = DateTime.now();
final last = _lastArchiveSaveByImei[imei];
if (last == null) {
_lastArchiveSaveByImei[imei] = now;
return true;
}
if (now.difference(last).inMinutes >= 10) {
_lastArchiveSaveByImei[imei] = now;
return true;
}
return false;
}
Future<void> _pollOnce() async {
if (!mounted) return;
final dev = _selectedDeviceObj;
if (dev == null) return;
try {
final data = await widget.client.getAllData(dev.imei);
final energy = await widget.client.getEnergy(dev.imei) ?? 0.0;
final rssiFromModem = await widget.client.getRssi(dev.imei);
if (!mounted) return;
if (data == null) return;
final voltage = ((data['voltage'] ?? 0.0) as num).toDouble();
final current = ((data['current'] ?? 0.0) as num).toDouble();
final activePower = voltage * current;
final powerFactor = ((data['power_factor'] ?? 0.0) as num).toDouble();
final frequency = await widget.client.getFrequency(dev.imei) ?? 0.0;
final temperature = await widget.client.getTemperature(dev.imei) ?? 0.0;
dev.extra[2] = activePower;
_powerPoints.add(FlSpot(_x, activePower));
final apparentPower = voltage * current;
final q2 = max(0.0, apparentPower * apparentPower - activePower * activePower);
final reactivePower = sqrt(q2);
final state = dev.state == DeviceState.on ? 1 : 0;
final rssi = rssiFromModem ?? dev.rssi;
await _sendToThingSpeak(
state: state,
voltage: voltage,
current: current,
power: activePower,
rssi: rssi,
energy: energy,
);
if (_shouldSaveArchiveNow(dev.imei)) {
await logModemMetricsCsv(
d: dev,
voltage: voltage,
current: current,
activePower: activePower,
reactivePower: reactivePower,
apparentPower: apparentPower,
powerFactor: powerFactor,
frequency: frequency,
temperature: temperature,
energy: energy,
rssi: rssi,
);
}
setState(() {
dev.extra[0] = voltage;
dev.extra[1] = current;
dev.extra[2] = activePower;
dev.rssi = rssi;
_x += 1;
_voltagePoints.add(FlSpot(_x, voltage));
_powerPoints.add(FlSpot(_x, activePower));
_energyPoints.add(FlSpot(_x, energy));
double y;
switch (selectedParam) {
case 'Ток':
y = current;
break;
case 'Напряжение':
y = voltage;
break;
case 'Активная мощность':
y = activePower;
break;
case 'Реактивная мощность':
y = reactivePower;
break;
case 'Полная мощность':
y = apparentPower;
break;
case 'Коэффициент мощности':
y = powerFactor;
break;
case 'Частота':
y = frequency;
break;
case 'Температура':
y = temperature;
break;
default:
y = activePower;
}
_points.add(FlSpot(_x, y));
final maxVisible = ServerSettings.instance.maxVisiblePoints;
final maxBuffer = ServerSettings.instance.maxPoints;
while (_points.length > maxBuffer) {
_points.removeAt(0);
}
while (_voltagePoints.length > maxBuffer) {
_voltagePoints.removeAt(0);
}
while (_powerPoints.length > maxBuffer) {
_powerPoints.removeAt(0);
}
while (_energyPoints.length > maxBuffer) {
_energyPoints.removeAt(0);
}
if (_points.length > maxVisible) {
_points.removeRange(0, _points.length - maxVisible);
}
if (_voltagePoints.length > maxVisible) {
_voltagePoints.removeRange(0, _voltagePoints.length - maxVisible);
}
if (_powerPoints.length > maxVisible) {
_powerPoints.removeRange(0, _powerPoints.length - maxVisible);
}
if (_energyPoints.length > maxVisible) {
_energyPoints.removeRange(0, _energyPoints.length - maxVisible);
}
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка чтения данных с модема: $e')),
);
}
}
Future<void> _loadArchiveForSelectedDay() async {
final dev = _selectedDeviceObj;
if (dev == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Сначала выберите устройство')),
);
return;
}
setState(() {
archiveLoading = true;
_points.clear();
_voltagePoints.clear();
_powerPoints.clear();
_energyPoints.clear();
_x = 0;
_t0 = DateTime(
selectedArchiveDate.year,
selectedArchiveDate.month,
selectedArchiveDate.day,
0,
0,
0,
);
});
final rows = await loadMetricsForDay(selectedArchiveDate);
final filtered = rows.where((r) => r.imei == dev.imei).toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
if (!mounted) return;
setState(() {
for (final row in filtered) {
final seconds = row.timestamp.difference(_t0).inSeconds.toDouble();
_voltagePoints.add(FlSpot(seconds, row.voltage));
_powerPoints.add(FlSpot(seconds, row.activePower));
_energyPoints.add(FlSpot(seconds, row.energy));
double y;
switch (selectedParam) {
case 'Ток':
y = row.current;
break;
case 'Напряжение':
y = row.voltage;
break;
case 'Активная мощность':
y = row.activePower;
break;
case 'Реактивная мощность':
y = row.reactivePower;
break;
case 'Полная мощность':
y = row.apparentPower;
break;
case 'Коэффициент мощности':
y = row.powerFactor;
break;
case 'Частота':
y = row.frequency;
break;
case 'Температура':
y = row.temperature;
break;
default:
y = row.activePower;
}
_points.add(FlSpot(seconds, y));
}
archiveLoading = false;
});
if (filtered.isEmpty && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('За выбранный день данных нет')),
);
}
}
void _toggleOnline() {
setState(() {
onlineView = !onlineView;
if (onlineView) {
showTodayHistory = false;
}
});
}
double _getMaxY(String param) {
switch (param) {
case 'Ток':
return 400;
case 'Напряжение':
return 250;
case 'Активная мощность':
case 'Реактивная мощность':
case 'Полная мощность':
return 1000;
case 'Коэффициент мощности':
return 3;
case 'Частота':
return 55;
case 'Температура':
return 100;
default:
return 10;
}
}
double _getYStep(String param) {
switch (param) {
case 'Ток':
return 50;
case 'Напряжение':
return 50;
case 'Активная мощность':
case 'Реактивная мощность':
case 'Полная мощность':
return 100;
case 'Коэффициент мощности':
return 0.5;
case 'Частота':
return 10;
case 'Температура':
return 20;
default:
return 1;
}
}
String _getParamUnit(String param) {
switch (param) {
case 'Ток':
return 'A';
case 'Напряжение':
return 'В';
case 'Активная мощность':
case 'Реактивная мощность':
case 'Полная мощность':
return 'Вт';
case 'Коэффициент мощности':
return '';
case 'Частота':
return 'Гц';
case 'Температура':
return '°C';
default:
return '';
}
}
String _formatXAxisLabel(double v) {
final ts = _t0.add(Duration(seconds: v.toInt()));
if (archiveMode) {
return '${ts.hour.toString().padLeft(2, '0')}:00';
}
return '${ts.hour.toString().padLeft(2, '0')}:'
'${ts.minute.toString().padLeft(2, '0')}:'
'${ts.second.toString().padLeft(2, '0')}';
}
Widget _buildSingleChart() {
final double minX = _points.isEmpty ? 0 : _points.first.x;
final double maxX = _points.isEmpty ? 30 : _points.last.x;
return Container(
decoration: BoxDecoration(
color: Colors.blueGrey.shade900,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(12),
child: _points.isEmpty
? const Center(child: Text('Нет данных'))
: LineChart(
LineChartData(
minX: minX,
maxX: maxX,
minY: 0,
maxY: _getMaxY(selectedParam),
lineBarsData: [
LineChartBarData(
isCurved: true,
color: Colors.cyanAccent,
spots: _points,
barWidth: 2,
dotData: const FlDotData(show: true),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.black54,
getTooltipItems: (spots) => spots
.map((s) {
final ts = _t0.add(Duration(seconds: s.x.toInt()));
final time =
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
return LineTooltipItem(
'$time\n${s.y.toStringAsFixed(2)} ${_getParamUnit(selectedParam)}',
const TextStyle(color: Colors.white),
);
})
.toList(),
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
axisNameWidget: Text(
'$selectedParam, ${_getParamUnit(selectedParam)}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
axisNameSize: 24,
sideTitles: SideTitles(
showTitles: true,
interval: _getYStep(selectedParam),
reservedSize: 40,
getTitlesWidget: (v, _) => Text(
v.toStringAsFixed(0),
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
),
),
bottomTitles: AxisTitles(
axisNameWidget: const Text(
'Время',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
axisNameSize: 24,
sideTitles: SideTitles(
showTitles: true,
interval: _points.length <= 1? 5 : ((_points.last.x - _points.first.x) / 4).clamp(1, 3600),
reservedSize: 36,
getTitlesWidget: (v, _) {
return Text(
_formatXAxisLabel(v),
style: const TextStyle(color: Colors.white70, fontSize: 9),
);
},
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: const FlGridData(show: true, drawVerticalLine: true),
borderData: FlBorderData(show: true),
),
),
);
}
Widget _buildCombinedChart() {
final double minX = _voltagePoints.isEmpty ? 0 : _voltagePoints.first.x;
final double maxX = _voltagePoints.isEmpty ? 30 : _voltagePoints.last.x;
return Container(
decoration: BoxDecoration(
color: Colors.blueGrey.shade900,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(12),
child: _voltagePoints.isEmpty
? const Center(child: Text('Нет данных'))
: LineChart(
LineChartData(
minX: minX,
maxX: maxX,
minY: 0,
maxY: 1000,
lineBarsData: [
LineChartBarData(
spots: _voltagePoints,
color: Colors.purple,
isCurved: true,
barWidth: 3,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
// ignore: deprecated_member_use
color: Colors.purple.withOpacity(0.3),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
// ignore: deprecated_member_use
Colors.purple.withOpacity(0.4),
// ignore: deprecated_member_use
Colors.purple.withOpacity(0.1),
],
),
),
),
LineChartBarData(
spots: _powerPoints,
color: Colors.lightBlue,
isCurved: true,
barWidth: 2,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
// ignore: deprecated_member_use
color: Colors.lightBlue.withOpacity(0.3),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
// ignore: deprecated_member_use
Colors.lightBlue.withOpacity(0.4),
// ignore: deprecated_member_use
Colors.lightBlue.withOpacity(0.1),
],
),
),
),
LineChartBarData(
spots: _energyPoints,
color: Colors.green,
isCurved: false,
barWidth: 3,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: Colors.green.withOpacity(0.3),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.green.withOpacity(0.4),
Colors.green.withOpacity(0.1),
],
),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.black54,
getTooltipItems: (spots) {
return spots.map((s) {
final ts = _t0.add(Duration(seconds: s.x.toInt()));
final time =
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
String valueText = '';
// Определяем какой параметр отображать по индексу линии
if (spots.indexOf(s) == 0) {
valueText = 'Напряжение: ${s.y.toStringAsFixed(1)} В';
} else if (spots.indexOf(s) == 1) {
valueText = 'Мощность: ${s.y.toStringAsFixed(1)} Вт';
} else if (spots.indexOf(s) == 2) {
valueText = 'Энергия: ${s.y.toStringAsFixed(1)} Вт·ч';
}
return LineTooltipItem(
'$time\n$valueText',
const TextStyle(color: Colors.white),
);
}).toList();
},
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
axisNameWidget: const Text(
'Значения',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
axisNameSize: 24,
sideTitles: SideTitles(
showTitles: true,
interval: 50,
reservedSize: 40,
getTitlesWidget: (v, _) => Text(
v.toDouble().toStringAsFixed(0),
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
),
),
bottomTitles: AxisTitles(
axisNameWidget: const Text(
'Время',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
axisNameSize: 24,
sideTitles: SideTitles(
showTitles: true,
interval: archiveMode ? 3600 : (_voltagePoints.length <= 1 ? 5 : ((_voltagePoints.last.x - _voltagePoints.first.x) / 4).clamp(1, 3600)),
reservedSize: 36,
getTitlesWidget: (v, _) {
return Text(
_formatXAxisLabel(v),
style: const TextStyle(color: Colors.white70, fontSize: 9),
);
},
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: const FlGridData(show: true, drawVerticalLine: true),
borderData: FlBorderData(show: true),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Просмотр потребления')),
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
DropdownButton<String>(
value: selectedDevice,
hint: const Text('Выберите устройство'),
items: widget.devices
.map((d) => DropdownMenuItem(value: d.name, child: Text(d.name)))
.toList(),
onChanged: (v) => setState(() => selectedDevice = v),
),
if (!showCombinedChart) // Показываем выбор параметра только для одиночного графика
DropdownButton<String>(
value: selectedParam,
items: params.map((p) => DropdownMenuItem(value: p, child: Text(p))).toList(),
onChanged: (v) => setState(() => selectedParam = v!),
),
if (!showCombinedChart) // Показываем выбор фазы только для одиночного графика
DropdownButton<String>(
value: selectedPhase,
items: phases.map((p) => DropdownMenuItem(value: p, child: Text('Фаза $p'))).toList(),
onChanged: (v) => setState(() => selectedPhase = v!),
),
ElevatedButton(
onPressed: archiveMode ? null : _toggleOnline,
style: ElevatedButton.styleFrom(
backgroundColor: onlineView ? Colors.redAccent : Colors.green,
),
child: Text(onlineView ? 'Остановить просмотр' : 'Запуск просмотра'),
),
// Кнопка переключения между графиками
ElevatedButton(
onPressed: _loadingHistory
? null
: () async {
if (selectedDevice == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Сначала выберите устройство')),
);
return;
}
await _loadTodayHistory();
},
style: ElevatedButton.styleFrom(
backgroundColor: showTodayHistory ? Colors.orange : Colors.blueGrey,
),
child: Text(_loadingHistory ? 'Загрузка...' : 'График за сегодня'),
),
ElevatedButton(
onPressed: () {
setState(() {
archiveMode = !archiveMode;
if (archiveMode) {
onlineView = false;
_timer?.cancel();
}
});
},
style: ElevatedButton.styleFrom(
backgroundColor: archiveMode ? Colors.orange : Colors.blueGrey,
),
child: Text(archiveMode ? 'Режим: Архив' : 'Режим: Онлайн'),
),
if (archiveMode)
ElevatedButton(
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedArchiveDate,
firstDate: DateTime(2024),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() => selectedArchiveDate = picked);
}
},
child: Text(
'Дата: '
'${selectedArchiveDate.day.toString().padLeft(2, '0')}.'
'${selectedArchiveDate.month.toString().padLeft(2, '0')}.'
'${selectedArchiveDate.year}',
),
),
if (archiveMode)
ElevatedButton(
onPressed: archiveLoading ? null : _loadArchiveForSelectedDay,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
),
child: Text(archiveLoading ? 'Загрузка...' : 'Загрузить архив'),
),
ElevatedButton(
onPressed: () => setState(() => showCombinedChart = !showCombinedChart),
style: ElevatedButton.styleFrom(
backgroundColor: showCombinedChart ? Colors.cyan : Colors.blueGrey,
),
child: Text(showCombinedChart ? 'Обычный график' : 'Общий график'),
),
],
),
const SizedBox(height: 20),
if (showTodayHistory)
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text(
'Показаны данные за текущий день из файла лога',
style: TextStyle(color: Colors.orangeAccent, fontSize: 12),
),
),
if (onlineView)
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text(
'Показаны онлайн-данные модема',
style: TextStyle(color: Colors.greenAccent, fontSize: 12),
),
),
if (showCombinedChart) ...[
const Text(
'Общий график потребления',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Row(
children: [
Icon(Icons.circle, size: 12, color: Colors.purple),
SizedBox(width: 4),
Text('Напряжение', style: TextStyle(fontSize: 12)),
SizedBox(width: 16),
Icon(Icons.circle, size: 12, color: Colors.lightBlue),
SizedBox(width: 4),
Text('Мощность', style: TextStyle(fontSize: 12)),
SizedBox(width: 16),
Icon(Icons.circle, size: 12, color: Colors.green),
SizedBox(width: 4),
Text('Энергия', style: TextStyle(fontSize: 12)),
],
),
const SizedBox(height: 12),
],
if (archiveMode) ...[
const SizedBox(height: 8),
Text(
'Архив за '
'${selectedArchiveDate.day.toString().padLeft(2, '0')}.'
'${selectedArchiveDate.month.toString().padLeft(2, '0')}.'
'${selectedArchiveDate.year}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
],
Expanded(
child: showCombinedChart ? _buildCombinedChart() : _buildSingleChart(),
),
],
),
),
);
}
}
class SchedulePage extends StatefulWidget {
final List<Device> devices;
final PppClient client;
final ConnState conn;
const SchedulePage({
super.key,
required this.devices,
required this.client,
required this.conn,
});
@override
State<SchedulePage> createState() => _SchedulePageState();
}
class _SchedulePageState extends State<SchedulePage> {
String? selectedDeviceName;
bool loading = false;
ScheduleConfig? schedule;
Device? get selectedDevice {
if (selectedDeviceName == null) return null;
try {
return widget.devices.firstWhere((d) => d.name == selectedDeviceName);
} catch (_) {
return null;
}
}
Future<void> _loadSchedule() async {
final dev = selectedDevice;
if (dev == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Сначала выберите устройство')),
);
return;
}
if (!widget.client.isDeviceConnected(dev.imei)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${dev.name} не подключён')),
);
return;
}
setState(() {
loading = true;
schedule = null;
});
try {
final data = await widget.client.loadSchedule(dev.imei);
if (!mounted) return;
setState(() {
loading = false;
schedule = data;
});
if (data == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось загрузить расписание')),
);
}
} catch (e) {
if (!mounted) return;
setState(() => loading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка загрузки расписания: $e')),
);
}
}
Future<void> _setScheduleEnabled(bool enabled) async {
final dev = selectedDevice;
if (dev == null) return;
final ok = await widget.client.setShEnabled(dev.imei, enabled);
if (!mounted) return;
if (ok) {
await _loadSchedule();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(enabled
? 'Расписание включено'
: 'Расписание выключено'),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось изменить состояние расписания')),
);
}
}
Future<void> _deleteAllSchedule() async {
final dev = selectedDevice;
if (dev == null) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить всё расписание?'),
content: const Text('Все точки расписания будут удалены.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Удалить'),
),
],
),
);
if (ok != true) return;
final deleted = await widget.client.deleteAllShItems(dev.imei);
if (!mounted) return;
if (deleted) {
await widget.client.setShCount(dev.imei, 0);
await _loadSchedule();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Расписание очищено')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось очистить расписание')),
);
}
}
Widget _buildScheduleInfo() {
if (loading) {
return const Center(child: CircularProgressIndicator());
}
if (schedule == null) {
return const Center(
child: Text(
'Выберите устройство и нажмите "Загрузить расписание"',
style: TextStyle(color: Colors.white70),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
schedule!.enabled ? Icons.check_circle : Icons.cancel,
color: schedule!.enabled ? Colors.greenAccent : Colors.grey,
),
const SizedBox(width: 8),
Text(
'Расписание: ${schedule!.enabled ? 'включено' : 'выключено'}',
style: const TextStyle(fontSize: 16),
),
const SizedBox(width: 16),
Text(
'Точек: ${schedule!.count}',
style: const TextStyle(color: Colors.white70),
),
],
),
const SizedBox(height: 16),
Expanded(
child: schedule!.items.isEmpty
? const Center(
child: Text(
'Точек расписания нет',
style: TextStyle(color: Colors.white70),
),
)
: ListView.separated(
itemCount: schedule!.items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = schedule!.items[i];
return ListTile(
leading: CircleAvatar(
backgroundColor:
item.turnOn ? Colors.green : Colors.red,
child: Text('${item.index}'),
),
title: Text(item.timeText),
subtitle: Text(
'${item.enabled ? 'Активна' : 'Неактивна'} • '
'${item.turnOn ? 'ВКЛ' : 'ВЫКЛ'}',
),
trailing: Icon(
item.turnOn ? Icons.power : Icons.power_off,
color: item.turnOn ? Colors.greenAccent : Colors.redAccent,
),
);
},
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Расписание'),
),
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
DropdownButton<String>(
value: selectedDeviceName,
hint: const Text('Выберите устройство'),
items: widget.devices
.map((d) => DropdownMenuItem<String>(
value: d.name,
child: Text(d.name),
))
.toList(),
onChanged: (v) => setState(() => selectedDeviceName = v),
),
ElevatedButton(
onPressed: loading ? null : _loadSchedule,
child: const Text('Загрузить расписание'),
),
ElevatedButton(
onPressed: schedule == null || loading
? null
: () => _setScheduleEnabled(!(schedule!.enabled)),
style: ElevatedButton.styleFrom(
backgroundColor:
(schedule?.enabled ?? false) ? Colors.orange : Colors.green,
),
child: Text(
(schedule?.enabled ?? false)
? 'Выключить расписание'
: 'Включить расписание',
),
),
ElevatedButton(
onPressed: schedule == null || loading ? null : _deleteAllSchedule,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
),
child: const Text('Очистить всё'),
),
],
),
const SizedBox(height: 16),
Expanded(child: _buildScheduleInfo()),
],
),
),
);
}
}
// ------------- PPP протокол + лог -------------
enum Opcode {
ping(0x01),
getTime(0x02),
setTime(0x03),
getShCount(0x04),
setShCount(0x05),
getShItem(0x06),
setShItem(0x07),
deleteAllShItems(0x08),
shEna(0x09),
isShEna(0x0A),
getImei(0x0C),
getPollTime(0x0D),
setPollTime(0x0E),
getState(0x10),
setState(0x11),
getMesItem(0x12),
getAllData(0x13),
getIrms(0x14),
getVrms(0x15),
getActivePower(0x16),
getReactivePower(0x17),
getApparentPower(0x18),
getPowerFactor(0x19),
getFrequency(0x1A),
getTemperature(0x1B),
getEnergy(0x1C),
restart(0x1D),
getRssi(0x1E),
sendRssi(0x1F),
updateTime(0x20);
final int code;
const Opcode(this.code);
}
class PppPacket {
static const int start = 0xAA;
final String imei; // 15 ASCII
final Opcode opcode;
final List<int> payload; // 0..64
PppPacket({required this.imei, required this.opcode, List<int>? payload})
: payload = payload ?? const [];
List<int> toBytes() {
final bytes = <int>[];
bytes.add(start);
final imeiBytes = imei.padRight(15, '0').codeUnits.take(15).toList();
bytes.addAll(imeiBytes);
bytes.add(opcode.code);
bytes.addAll(payload);
return bytes;
}
static PppPacket? tryParse(List<int> data) {
if (data.isEmpty || data[0] != start) return null;
if (data.length < 17) return null;
final imei = String.fromCharCodes(data.sublist(1, 16));
final op = data[16];
final payload = data.length > 17 ? data.sublist(17) : <int>[];
final opcode = Opcode.values.firstWhere((o) => o.code == op, orElse: () => Opcode.ping);
return PppPacket(imei: imei, opcode: opcode, payload: payload);
}
}
enum PacketDirection { outbound, inbound }
sealed class LogEvent {
const LogEvent();
}
class PacketLogEntry extends LogEvent {
final DateTime ts;
final PacketDirection dir;
final String imei;
final Opcode opcode;
final List<int> bytes;
final List<int> payload;
const PacketLogEntry({
required this.ts,
required this.dir,
required this.imei,
required this.opcode,
required this.bytes,
required this.payload,
});
}
class ConnLogEntry extends LogEvent {
final DateTime ts;
final String text; // CONNECTED / DISCONNECTED / ERROR: ...
const ConnLogEntry(this.ts, this.text);
}
class ClientSession {
final Socket socket;
final List<int> inBuf = [];
final Map<String, Completer<PppPacket?>> pending = {};
String? imei;
bool disconnectNotified = false;
Future<void> queue = Future.value();
ClientSession(this.socket);
}
class PppClient {
final int port;
String? _lastConnectedImei;
ServerSocket? _server;
final Map<Socket, ClientSession> _sessionsBySocket = {};
final Map<String, ClientSession> _sessionsByImei = {};
final _logCtrl = StreamController<LogEvent>.broadcast();
final List<LogEvent> _history = [];
List<LogEvent> get history => List.unmodifiable(_history);
final _connCtrl = StreamController<ConnState>.broadcast();
Stream<LogEvent> get logStream => _logCtrl.stream;
Stream<ConnState> get connStream => _connCtrl.stream;
ConnState _state = ConnState.disconnected;
ConnState get state => _state;
PppClient({required this.port});
Future<double?> getFrequency(String imei, {int mspTag = 0x01}) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getFrequency, payload: [mspTag]),
tries: 2,
interval: const Duration(seconds: 5),
);
if (resp == null || resp.payload.isEmpty) return null;
return _bytesToDouble(resp.payload);
}
Future<double?> getTemperature(String imei, {int mspTag = 0x01}) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getTemperature, payload: [mspTag]),
tries: 2,
interval: const Duration(seconds: 5),
);
if (resp == null || resp.payload.isEmpty) return null;
return _bytesToDouble(resp.payload);
}
Future<T?> _runInSessionQueue<T>(
String imei,
Future<T?> Function(ClientSession session) action,
) async {
ClientSession? session = _sessionsByImei[imei];
// Fallback: если пока нет сессии для этого IMEI, но есть ровно одна
// сессия без IMEI — считаем, что это он, и используем её для первой команды.
if (session == null) {
final unknown = _sessionsBySocket.values
.where((s) => s.imei == null)
.toList();
if (unknown.length == 1) {
session = unknown.first;
}
}
if (session == null) {
throw StateError('Нет активной сессии для IMEI $imei');
}
final completer = Completer<T?>();
session.queue = session.queue.then((_) async {
// Сессия могла закрыться, проверим ещё раз
if (!_sessionsBySocket.containsKey(session!.socket)) {
throw StateError('Сессия для IMEI $imei уже закрыта');
}
final result = await action(session);
if (!completer.isCompleted) completer.complete(result);
}).catchError((e, st) {
if (!completer.isCompleted) completer.completeError(e, st);
});
return completer.future;
}
void _removeSession(ClientSession session) {
final socket = session.socket;
if (session.imei != null) {
_sessionsByImei.remove(session.imei);
}
_sessionsBySocket.remove(socket);
for (final c in session.pending.values) {
if (!c.isCompleted) c.complete(null);
}
session.pending.clear();
socket.destroy();
_emitConn(_sessionsBySocket.isEmpty
? ConnState.disconnected
: ConnState.connected);
}
bool isDeviceConnected(String imei) {
// Уже есть сессия, привязанная к этому IMEI
if (_sessionsByImei.containsKey(imei)) return true;
// Есть хотя бы одна «неопознанная» сессия — модем физически на связи,
// просто ещё не пришёл первый пакет с IMEI
return _sessionsBySocket.values.any((s) => s.imei == null);
}
void _emitConn(ConnState s) {
_state = s;
_connCtrl.add(s);
}
Future<PppPacket?> _sendAndWaitDirect(
ClientSession session,
PppPacket p, {
int tries = 1,
Duration interval = const Duration(seconds: 5),
}) async {
for (var i = 0; i < tries; i++) {
final key = '${p.imei}_${p.opcode.code}';
final c = Completer<PppPacket?>();
session.pending[key] = c;
final bytes = p.toBytes();
try {
session.socket.add(bytes);
await session.socket.flush();
} catch (e) {
session.pending.remove(key);
_onError(session, e);
return null;
}
final entry = PacketLogEntry(
ts: DateTime.now(),
dir: PacketDirection.outbound,
imei: p.imei,
opcode: p.opcode,
bytes: bytes,
payload: p.payload,
);
_history.add(entry);
_logCtrl.add(entry);
try {
return await c.future.timeout(interval);
} catch (_) {
session.pending.remove(key);
}
}
return null;
}
Future<PppPacket?> _sendAndWait(
PppPacket p, {
int tries = 1,
Duration interval = const Duration(seconds: 5),
}) async {
try {
return await _runInSessionQueue<PppPacket?>(
p.imei,
(session) => _sendAndWaitDirect(
session,
p,
tries: tries,
interval: interval,
),
);
} catch (e) {
_logConn('SEND ERROR [${p.imei} ${p.opcode.name}]: $e');
return null;
}
}
void _logConn(String s) {
final entry = ConnLogEntry(DateTime.now(), s);
_history.add(entry);
_logCtrl.add(entry);
}
void logInfo(String text) => _logConn(text);
Future<void> _notifyModemConnection({
required bool connected,
String? imei,
String? remote,
Object? error,
}) async {
final now = DateTime.now();
final text = connected
? 'Модем вышел на связь с сервером\n'
'IMEI: ${imei ?? 'не определён'}\n'
'Адрес: ${remote ?? '-'}\n'
'Время: $now'
: 'Модем потерял связь с сервером\n'
'IMEI: ${imei ?? _lastConnectedImei ?? 'не определён'}\n'
'Ошибка: ${error ?? 'нет'}\n'
'Время: $now';
try {
await sendTelegramMessage(text);
} catch (_) {}
}
Future<void> startAsServer({int? listenPort}) async {
final lp = listenPort ?? port;
_logConn('СЕРВЕР ЗАПУЩЕН. Ожидание подключений на порту $lp');
_emitConn(ConnState.connecting);
try {
_server = await ServerSocket.bind(InternetAddress.anyIPv4, lp);
_logConn('LISTEN 0.0.0.0:$lp');
_server!.listen((client) {
final session = ClientSession(client);
_sessionsBySocket[client] = session;
_logConn('MODEM connected: ${client.remoteAddress.address}:${client.remotePort}');
_emitConn(ConnState.connected);
sendTelegramMessage(
'Модем подключен\n'
'IP: ${client.remoteAddress.address}\n'
'Порт: ${client.remotePort}\n'
'Время: ${DateTime.now()}',
);
client.listen(
(data) => _onBytes(session, data),
onDone: () => _onDone(session),
onError: (e) => _onError(session, e),
cancelOnError: true,
);
}, onError: (e) => _logConn('SERVER ERROR: $e'));
} catch (e) {
_logConn('SERVER START ERROR: $e');
_emitConn(ConnState.disconnected);
}
}
void _onError(ClientSession session, Object e) {
final ip = session.socket.remoteAddress.address;
final port = session.socket.remotePort;
final imei = session.imei;
_logConn('ERROR [$imei $ip:$port]: $e');
sendTelegramMessage(
'Модем отключен по ошибке\n'
'IMEI: ${imei ?? "не определён"}\n'
'IP: $ip\n'
'Порт: $port\n'
'Ошибка: $e\n'
'Время: ${DateTime.now()}',
);
if (!session.disconnectNotified) {
session.disconnectNotified = true;
_notifyModemConnection(
connected: false,
imei: imei,
error: e,
);
}
_removeSession(session);
}
void _onDone(ClientSession session) {
final ip = session.socket.remoteAddress.address;
final port = session.socket.remotePort;
final imei = session.imei;
_logConn('DISCONNECTED [$imei $ip:$port]');
sendTelegramMessage(
'Модем отключен\n'
'IMEI: ${imei ?? "не определён"}\n'
'IP: $ip\n'
'Порт: $port\n'
'Время: ${DateTime.now()}',
);
if (!session.disconnectNotified) {
session.disconnectNotified = true;
_notifyModemConnection(
connected: false,
imei: imei,
);
}
_removeSession(session);
}
void dispose() {
for (final session in _sessionsBySocket.values.toList()) {
for (final c in session.pending.values) {
if (!c.isCompleted) c.complete(null);
}
session.pending.clear();
session.socket.destroy();
}
_sessionsBySocket.clear();
_sessionsByImei.clear();
_server?.close();
_logCtrl.close();
_connCtrl.close();
}
void _onBytes(ClientSession session, List<int> data) {
session.inBuf.addAll(data);
while (session.inBuf.isNotEmpty) {
if (session.inBuf[0] != PppPacket.start) {
session.inBuf.removeAt(0);
continue;
}
if (session.inBuf.length < 17) return;
final next = session.inBuf.indexOf(PppPacket.start, 1);
final pktBytes = next == -1
? List<int>.from(session.inBuf)
: session.inBuf.sublist(0, next);
if (pktBytes.length < 17) return;
final pkt = PppPacket.tryParse(pktBytes);
if (pkt != null) {
session.imei ??= pkt.imei;
_sessionsByImei[pkt.imei] = session;
_lastConnectedImei = pkt.imei;
final entry = PacketLogEntry(
ts: DateTime.now(),
dir: PacketDirection.inbound,
imei: pkt.imei,
opcode: pkt.opcode,
bytes: List<int>.from(pktBytes),
payload: pkt.payload,
);
_history.add(entry);
_logCtrl.add(entry);
final key = '${pkt.imei}_${pkt.opcode.code}';
final comp = session.pending.remove(key);
comp?.complete(pkt);
}
if (next == -1) {
session.inBuf.clear();
} else {
session.inBuf.removeRange(0, next);
}
}
}
static int packTime(DateTime t) {
final year = (t.year - 2000) & 0x3F;
final month = t.month & 0x0F;
final day = t.day & 0x1F;
final hour = t.hour & 0x1F;
final minute = t.minute & 0x3F;
final second = t.second & 0x3F;
return (year << 26) | (month << 22) | (day << 17) | (hour << 12) | (minute << 6) | second;
}
static DateTime unpackTime(int v) {
final year = 2000 + ((v >> 26) & 0x3F);
final month = (v >> 22) & 0x0F;
final day = (v >> 17) & 0x1F;
final hour = (v >> 12) & 0x1F;
final minute = (v >> 6) & 0x3F;
final second = v & 0x3F;
return DateTime(year, month, day, hour, minute, second);
}
int _readUint32LE(List<int> bytes, int offset) {
if (offset + 4 > bytes.length) return 0;
return (bytes[offset]) |
(bytes[offset + 1] << 8) |
(bytes[offset + 2] << 16) |
(bytes[offset + 3] << 24);
}
Future<bool> ping(String imei) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.ping),
tries: 1,
interval: const Duration(seconds: 3),
);
return resp != null;
}
Future<DateTime?> getTime(String imei) async {
final resp = await _sendAndWait(PppPacket(imei: imei, opcode: Opcode.getTime));
if (resp == null || resp.payload.length < 4) return null;
final v = (resp.payload[3] << 24) |
(resp.payload[2] << 16) |
(resp.payload[1] << 8) |
resp.payload[0];
return unpackTime(v);
}
Future<bool> setTime(String imei, DateTime t) async {
final v = packTime(t);
final pl = [v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF, (v >> 24) & 0xFF];
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.setTime, payload: pl),
);
return resp != null;
}
Future<int?> getShCount(String imei) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getShCount),
tries: 3,
interval: const Duration(seconds: 3),
);
if (resp == null || resp.payload.length < 2) return null;
return resp.payload[0] | (resp.payload[1] << 8);
}
Future<bool> setShCount(String imei, int count) async {
final payload = [count & 0xFF, (count >> 8) & 0xFF];
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.setShCount, payload: payload),
tries: 3,
interval: const Duration(seconds: 3),
);
return resp != null;
}
Future<ScheduleItem?> getShItem(String imei, int index) async {
final resp = await _sendAndWait(
PppPacket(
imei: imei,
opcode: Opcode.getShItem,
payload: [index & 0xFF, (index >> 8) & 0xFF, 0x01],
),
tries: 3,
interval: const Duration(seconds: 3),
);
if (resp == null || resp.payload.length < 4) return null;
final timeRaw = resp.payload[0] | (resp.payload[1] << 8);
final b2 = resp.payload[2];
final b3 = resp.payload[3];
final hour = (timeRaw ~/ 60) % 24;
final minute = timeRaw % 60;
return ScheduleItem(
index: index,
hour: hour,
minute: minute,
enabled: b2 != 0x00,
turnOn: b3 == 0x01,
);
logInfo(
'GET_SH_ITEM index=$index payload=${resp.payload.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'
);
}
Future<bool> setShItem(
String imei, {
required int index,
required int hour,
required int minute,
required bool enabled,
required bool turnOn,
}) async {
final payload = [
index,
hour,
minute,
enabled ? 1 : 0,
turnOn ? 1 : 0,
];
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.setShItem, payload: payload),
tries: 3,
interval: const Duration(seconds: 3),
);
return resp != null;
}
Future<bool> deleteAllShItems(String imei) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.deleteAllShItems),
tries: 3,
interval: const Duration(seconds: 3),
);
return resp != null;
}
Future<bool?> isShEnabled(String imei) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.isShEna),
tries: 3,
interval: const Duration(seconds: 3),
);
if (resp == null || resp.payload.isEmpty) return null;
return resp.payload[0] == 1;
}
Future<bool> setShEnabled(String imei, bool enabled) async {
final resp = await _sendAndWait(
PppPacket(
imei: imei,
opcode: Opcode.shEna,
payload: [enabled ? 1 : 0],
),
tries: 3,
interval: const Duration(seconds: 3),
);
return resp != null;
}
Future<ScheduleConfig?> loadSchedule(String imei) async {
final count = await getShCount(imei);
final enabled = await isShEnabled(imei);
if (count == null || enabled == null) return null;
final items = <ScheduleItem>[];
for (int i = 0; i < count; i++) {
final item = await getShItem(imei, i);
if (item != null) items.add(item);
}
return ScheduleConfig(
count: count,
enabled: enabled,
items: items,
);
}
Future<bool> setRelayState(String imei, bool on) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.setState, payload: [on ? 1 : 0]),
tries: 5,
interval: const Duration(seconds: 5),
);
return resp != null;
}
Future<List<int>?> getMeasurement(String imei) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getMesItem),
);
return resp?.payload;
}
Future<String?> getImei() async {
final resp = await _sendAndWait(
PppPacket(imei: '000000000000000', opcode: Opcode.getImei),
tries: 3,
interval: const Duration(seconds: 3),
);
if (resp == null || resp.payload.isEmpty) return null;
return String.fromCharCodes(resp.payload.take(15));
}
Future<bool?> getDeviceState(String imei) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getState),
tries: 3,
interval: const Duration(seconds: 5),
);
if (resp == null || resp.payload.isEmpty) return null;
return resp.payload[0] == 0x01;
}
Future<int?> getRssi(String imei) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getRssi),
tries: 1,
interval: const Duration(seconds: 3),
);
if (resp == null || resp.payload.isEmpty) return null;
return resp.payload[0];
}
Future<Map<String, double>?> getAllData(String imei, {int mspTag = 0x01}) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getAllData, payload: [mspTag]),
tries: 2,
interval: const Duration(seconds: 5),
);
if (resp == null || resp.payload.isEmpty) return null;
return _parseAllData(resp.payload);
}
Future<double?> getActivePower(String imei, {int mspTag = 0x01}) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getActivePower, payload: [mspTag]),
tries: 2,
interval: const Duration(seconds: 5),
);
if (resp == null || resp.payload.isEmpty) return null;
return _bytesToDouble(resp.payload);
}
Future<double?> getEnergy(String imei, {int mspTag = 0x01}) async {
final resp = await _sendAndWait(
PppPacket(imei: imei, opcode: Opcode.getEnergy, payload: [mspTag]),
tries: 5,
interval: const Duration(seconds: 5),
);
if (resp == null || resp.payload.isEmpty) return null;
return _bytesToDouble(resp.payload);
}
Map<String, double> _parseAllData(List<int> payload) {
// минимум: 1 байт tag + 4*4 байта = 17
if (payload.length < 17) {
return {
'voltage': 0.0,
'current': 0.0,
'active_power': 0.0,
'power_factor': 0.0,
'frequency': 0.0,
'temperature': 0.0,
};
}
final uRaw = _readUint32LE(payload, 1);
final iRaw = _readUint32LE(payload, 5);
final pfRaw = _readUint32LE(payload, 9);
final pRaw = _readUint32LE(payload, 13);
final voltage = uRaw / 100.0;
final current = iRaw / 100.0;
final powerFactor = pfRaw / 1000.0; // при необходимости поправим масштаб
final activePower = pRaw.toDouble();
logInfo(
'GET_ALLDATA RAW=${payload.map((b) => b.toRadixString(16).padLeft(2, "0")).join(" ")} '
'uRaw=$uRaw iRaw=$iRaw pfRaw=$pfRaw pRaw=$pRaw '
'U=$voltage I=$current P=$activePower',
);
return {
'voltage': voltage,
'current': current,
'active_power': activePower,
'power_factor': powerFactor,
'frequency': 0.0,
'temperature': 0.0,
};
}
double _bytesToDouble(List<int> bytes) {
if (bytes.length >= 4) {
final byteData = ByteData(4);
for (int i = 0; i < 4 && i < bytes.length; i++) {
byteData.setUint8(i, bytes[i]);
}
// Модем шлёт float32 в big-endian (как в протоколе для измерений)
return byteData.getFloat32(0, Endian.big);
} else if (bytes.length >= 2) {
final byteData = ByteData(2);
for (int i = 0; i < 2 && i < bytes.length; i++) {
byteData.setUint8(i, bytes[i]);
}
// На всякий случай для 16-битных чисел используем тоже big-endian
return byteData.getUint16(0, Endian.big).toDouble();
}
return bytes.isNotEmpty ? bytes[0].toDouble() : 0.0;
}
}
// ------------- Вкладка «Обмен пакетов» -------------
class ServerSettingsPage extends StatefulWidget {
const ServerSettingsPage({super.key});
@override
State<ServerSettingsPage> createState() => _ServerSettingsPageState();
}
class _ServerSettingsPageState extends State<ServerSettingsPage> {
late TextEditingController _portCtl;
late TextEditingController _pollCtl;
late TextEditingController _visibleCtl;
late TextEditingController _maxCtl;
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
final s = ServerSettings.instance;
_portCtl = TextEditingController(text: s.serverPort.toString());
_pollCtl = TextEditingController(text: s.onlinePollIntervalSec.toString());
_visibleCtl = TextEditingController(text: s.maxVisiblePoints.toString());
_maxCtl = TextEditingController(text: s.maxPoints.toString());
}
@override
void dispose() {
_portCtl.dispose();
_pollCtl.dispose();
_visibleCtl.dispose();
_maxCtl.dispose();
super.dispose();
}
void _save() {
if (!_formKey.currentState!.validate()) return;
final s = ServerSettings.instance;
s.serverPort = int.parse(_portCtl.text);
s.onlinePollIntervalSec = int.parse(_pollCtl.text);
s.maxVisiblePoints = int.parse(_visibleCtl.text);
s.maxPoints = int.parse(_maxCtl.text);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Настройки сервера сохранены')),
);
Navigator.pop(context);
}
String? _intValidator(String? v, {int min = 1, int max = 100000}) {
final p = int.tryParse(v ?? '');
if (p == null) return 'Введите число';
if (p < min || p > max) return '$min..$max';
return null;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Настройки сервера')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: ListView(
children: [
const Text(
'Настройки графика онлайн потребления',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
TextFormField(
controller: _portCtl,
decoration: const InputDecoration(
labelText: 'Порт сервера',
hintText: '1..65535',
),
keyboardType: TextInputType.number,
validator: (v) => _intValidator(v, min: 1, max: 65535),
),
const SizedBox(height: 8),
TextFormField(
controller: _pollCtl,
decoration: const InputDecoration(
labelText: 'Интервал опроса (сек.)',
),
keyboardType: TextInputType.number,
validator: (v) => _intValidator(v, min: 1, max: 600),
),
const Text(
'Параметры сервера',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
TextFormField(
controller: _visibleCtl,
decoration: const InputDecoration(
labelText: 'Максимальное число видимых точек',
),
keyboardType: TextInputType.number,
validator: (v) => _intValidator(v, min: 10, max: 1000),
),
const SizedBox(height: 8),
TextFormField(
controller: _maxCtl,
decoration: const InputDecoration(
labelText: 'Максимальное число точек в буфере',
),
keyboardType: TextInputType.number,
validator: (v) => _intValidator(v, min: 50, max: 5000),
),
const SizedBox(height: 20),
FilledButton(
onPressed: _save,
child: const Text('Сохранить'),
),
],
),
),
),
);
}
}
class PacketExchangePage extends StatefulWidget {
final PppClient client;
const PacketExchangePage({super.key, required this.client});
@override
State<PacketExchangePage> createState() => _PacketExchangePageState();
}
class ServerSettings {
int serverPort;
int onlinePollIntervalSec;
int maxVisiblePoints;
int maxPoints;
ServerSettings({
this.serverPort = 256,
this.onlinePollIntervalSec = 1,
this.maxVisiblePoints = 30,
this.maxPoints = 300,
});
static final instance = ServerSettings();
}
class _PacketExchangePageState extends State<PacketExchangePage> {
final List<LogEvent> _log = [];
StreamSubscription<LogEvent>? _sub;
final ScrollController _scroll = ScrollController();
bool _autoScroll = true;
@override
void initState() {
super.initState();
_log.addAll(widget.client.history);
_sub = widget.client.logStream.listen((e) {
setState(() => _log.add(e));
if (_autoScroll && _scroll.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
}
});
}
});
}
@override
void dispose() {
_sub?.cancel();
_scroll.dispose();
super.dispose();
}
String _hex(List<int> data) =>
data.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
Color _dirColor(PacketDirection d) =>
d == PacketDirection.outbound ? Colors.lightBlueAccent : Colors.orangeAccent;
String _opcodeDescription(Opcode opcode) {
switch (opcode) {
case Opcode.ping:
return 'PING (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getTime:
return 'GET_TIME (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.setTime:
return 'SET_TIME (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getShCount:
return 'GET_SH_COUNT (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.setShCount:
return 'SET_SH_COUNT (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getShItem:
return 'GET_SH_ITEM (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.setShItem:
return 'SET_SH_ITEM (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.deleteAllShItems:
return 'DELETE_ALL_SH_ITEMS (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.shEna:
return 'SH_ENA (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.isShEna:
return 'IS_SH_ENA (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getImei:
return 'GET_IMEI (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getPollTime:
return 'GET_POLL_TIME (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.setPollTime:
return 'SET_POLL_TIME (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getState:
return 'GET_STATE (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.setState:
return 'SET_STATE (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getMesItem:
return 'GET_MES_ITEM (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getAllData:
return 'GET_ALLDATA (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getIrms:
return 'GET_IRMS (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getVrms:
return 'GET_VRMS (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getActivePower:
return 'GET_ACTIVE_POWER (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getReactivePower:
return 'GET_REACTIVE_POWER (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getApparentPower:
return 'GET_APPARENT_POWER (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getPowerFactor:
return 'GET_POWER_FACTOR (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getFrequency:
return 'GET_FREQUENCY (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getTemperature:
return 'GET_TEMPERATURE (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getEnergy:
return 'GET_ENERGY (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.restart:
return 'RESTART (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.getRssi:
return 'GET_RSSI (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.sendRssi:
return 'SEND_RSSI (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
case Opcode.updateTime:
return 'UPDATE_TIME (0x${opcode.code.toRadixString(16).padLeft(2, '0')})';
}
}
String _xmlEscape(String s) {
return s
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
String _two(int v) => v.toString().padLeft(2, '0');
String _bytesToHex(List<int> data) =>
data.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
Future<void> logPacketXml(PacketLogEntry e) async {
final file = File(packetLogXmlPath);
// если файл ещё не существует — создаём базовую структуру
if (!await file.exists()) {
await file.writeAsString(
'''<?xml version="1.0" encoding="utf-8"?>
<DIADATASET Version="2.0">
<METADATA>
<FIELDS>
<FIELD attrname="ID" fieldtype="i4"/>
<FIELD attrname="Date" fieldtype="date"/>
<FIELD attrname="Time" fieldtype="time"/>
<FIELD attrname="Direction" fieldtype="string"/>
<FIELD attrname="IMEI" fieldtype="string"/>
<FIELD attrname="Opcode" fieldtype="string"/>
<FIELD attrname="Bytes" fieldtype="string"/>
<FIELD attrname="Payload" fieldtype="string"/>
</FIELDS>
</METADATA>
<ROWDATA>
</ROWDATA>
</DIADATASET>
''',
mode: FileMode.write,
);
}
String content = await file.readAsString();
// ищем место вставки — перед </ROWDATA>
const marker = '</ROWDATA>';
final idx = content.indexOf(marker);
if (idx == -1) {
// файл поломан — не портим его дальше
return;
}
// считаем количество уже существующих строк <ROW>, чтобы выдать новый ID
final existingRows = RegExp(r'<ROW ').allMatches(content).length;
final newId = existingRows + 1;
final now = e.ts;
final dateStr =
'${now.year.toString().padLeft(4, '0')}${_two(now.month)}${_two(now.day)}'; // 20251222
final timeStr =
'${_two(now.hour)}:${_two(now.minute)}:${_two(now.second)}'; // 10:45:03
final dirStr = e.dir == PacketDirection.outbound ? 'TX' : 'RX';
final imei = _xmlEscape(e.imei);
final opcodeStr = _xmlEscape(e.opcode.name);
final bytesHex = _xmlEscape(_bytesToHex(e.bytes));
final payloadHex = _xmlEscape(_bytesToHex(e.payload));
final row = ' <ROW ID="$newId" Date="$dateStr" Time="$timeStr" '
'Direction="$dirStr" IMEI="$imei" Opcode="$opcodeStr" '
'Bytes="$bytesHex" Payload="$payloadHex"/>\n';
final newContent =
content.substring(0, idx) + row + content.substring(idx);
await file.writeAsString(newContent, mode: FileMode.write);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Обмен пакетов'),
actions: [
IconButton(
tooltip: _autoScroll ? 'Отключить автопрокрутку' : 'Включить автопрокрутку',
onPressed: () => setState(() => _autoScroll = !_autoScroll),
icon: Icon(_autoScroll ? Icons.downloading : Icons.download_for_offline),
),
IconButton(
tooltip: 'Очистить лог',
onPressed: () => setState(() => _log.clear()),
icon: const Icon(Icons.delete_sweep_outlined),
),
],
),
body: _log.isEmpty
? const Center(child: Text('Пока нет событий'))
: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(12),
itemCount: _log.length,
itemBuilder: (_, i) {
final e = _log[i];
if (e is ConnLogEntry) {
return Card(
color: Colors.indigo.shade800,
child: ListTile(
leading: const Icon(Icons.link),
title: Text(e.text),
subtitle: Text(
e.ts.toIso8601String().replaceFirst('T', ' ').split('.').first,
style: const TextStyle(color: Colors.white70),
),
),
);
} else if (e is PacketLogEntry) {
return Card(
color: Colors.blueGrey.shade900,
margin: const EdgeInsets.symmetric(vertical: 6),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _dirColor(e.dir),
borderRadius: BorderRadius.circular(8),
),
child: Text(e.dir == PacketDirection.outbound ? 'TX' : 'RX',
style: const TextStyle(color: Colors.black)),
),
const SizedBox(width: 8),
Text(
e.ts.toIso8601String().replaceFirst('T', ' ').split('.').first,
style: const TextStyle(color: Colors.white70),
),
const Spacer(),
Text(
_opcodeDescription(e.opcode),
style: const TextStyle(color: Colors.white70),
),
]),
const SizedBox(height: 6),
Text('IMEI: ${e.imei}', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 6),
Text('Длина: ${e.bytes.length} байт', style: const TextStyle(color: Colors.white70)),
if (e.payload.isNotEmpty) ...[
const SizedBox(height: 6),
Text('Payload: ${_hex(e.payload)}', style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
const SizedBox(height: 6),
SelectableText('HEX: ${_hex(e.bytes)}', style: const TextStyle(fontFamily: 'monospace')),
],
),
),
);
} else {
return const SizedBox.shrink();
}
},
),
);
}
}
const String telegramToken = "8254555541:AAGLfdhAeeV80r_Z_50xiLPS2hcF_fV_W9I";
const String telegramChatId = "458876021";
Future<List<Map<String, dynamic>>> getTelegramUpdates() async {
final url = Uri.parse(
'https://api.telegram.org/bot$telegramToken/getUpdates?timeout=0&offset=$_telegramOffset',
);
final httpClient = HttpClient()
..badCertificateCallback =
(X509Certificate cert, String host, int port) => host == 'api.telegram.org';
try {
final request = await httpClient.getUrl(url);
final response = await request.close();
final responseText = await response.transform(utf8.decoder).join();
if (response.statusCode != 200) {
throw Exception('Telegram HTTP ${response.statusCode}: $responseText');
}
final json = jsonDecode(responseText) as Map<String, dynamic>;
final result = (json['result'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
if (result.isNotEmpty) {
final lastUpdateId = result.last['update_id'] as int;
_telegramOffset = lastUpdateId + 1;
}
return result;
} finally {
httpClient.close(force: true);
}
}
Future<void> deleteTelegramWebhook() async {
final url = Uri.parse(
'https://api.telegram.org/bot$telegramToken/deleteWebhook?drop_pending_updates=false',
);
final httpClient = HttpClient()
..badCertificateCallback =
(X509Certificate cert, String host, int port) => host == 'api.telegram.org';
try {
final request = await httpClient.getUrl(url);
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
debugPrint('deleteWebhook: $body');
} finally {
httpClient.close(force: true);
}
}
Future<void> sendTelegramMessage(String text) async {
final url = Uri.parse(
"https://api.telegram.org/bot$telegramToken/sendMessage",
);
final httpClient = HttpClient()
..badCertificateCallback =
(X509Certificate cert, String host, int port) {
return host == 'api.telegram.org';
};
try {
final request = await httpClient.postUrl(url);
request.headers.contentType =
ContentType('application', 'x-www-form-urlencoded', charset: 'utf-8');
final body = Uri(queryParameters: {
"chat_id": telegramChatId,
"text": text,
}).query;
request.write(body);
final response = await request.close();
final responseText = await response.transform(utf8.decoder).join();
if (response.statusCode != 200) {
throw Exception(
'Telegram HTTP ${response.statusCode}: $responseText',
);
}
} finally {
httpClient.close(force: true);
}
}
int _telegramOffset = 0;