feat(énergie): les deux graphes — flux corrigé, et l'empilement par consommateur
Maquette validée par Patrick (couleurs comprises). Deux graphes, et trois défauts d'affichage trouvés en chemin — dont deux à l'écran seulement. GRAPHE 1 — les trois « on ne voit pas » avaient trois causes. production : publiée NÉGATIVE (−103 W). La magnitude est prise AU RENDU, pas au transport : le champ reste brut (§7.1), et `.abs()` est licite sur une production, dont le signe ne porte aucune information — contrairement au réseau, où le signe dit le sens (d'où gridImportW/gridExportW là-bas). autoconsommation : `production − export` clampé à 0, donc TOUJOURS zéro avec une production négative. Même racine, corrigée par la même magnitude. SOC : correctement absent — aucune batterie sur l'installation. Ce qui manquait, c'était de le DIRE ; c'est maintenant une note sous le graphe. ⚠️ Et un deuxième empêchement, vu seulement à l'écran : le signe réparé, la production restait invisible SOUS l'autoconsommation. Tant que rien n'est exporté les deux sont égales au pixel près, et la verte était tracée par dessus. L'autoconso passe en pointillé — des tirets sur la bande ambre disent « tout est consommé sur place », leur décrochage dit l'export. Deux courbes qui coïncident doivent pouvoir se VOIR coïncider. ⚠️ Ce correctif en créait un autre : le SOC était aussi vert pointillé. Sur une installation AVEC batterie les deux auraient été indiscernables — collision qu'aucun banc sans batterie ne pouvait montrer. Le SOC passe en indigo. GRAPHE 2 — qui consomme, empilé. `Energy.GetThingPowerLogs`, et la liste des appareils vient de GetLoadConfig : chaque charge déclarée porte son `meterThingId`, son label et son domaine — vérifié renseigné sur les quatre charges de .75. Rien n'est deviné d'un nom d'appareil. Un appareil demandé sans réponse sort dans `sansHistorique` et est NOMMÉ sous le graphe, jamais tracé à zéro : la V2C ne déclare pas `smartmeter`, donc elle n'a pas de passé — ce qui n'est pas « elle n'a rien tiré ». Le RESTE du foyer est licite ici, contrairement au Sankey qui l'avait fait interdire : chaque part est un compteur dédié et le total est le compteur du foyer, donc la soustraction ne porte que du mesuré. Et un reste NÉGATIF n'est pas raboté — c'est la preuve qu'un sous-compteur n'est pas dans le périmètre du total, et l'écraser effacerait la seule chose qui le dise. 🔴 Le signe des sous-compteurs n'est PAS vérifié : les trois lisent 0 W à tous les pas, donc aucun échantillon non nul n'existe. Le compteur PV publie en négatif — rien n'autorise à supposer. La valeur est transportée brute et `signeIndetermine` le dit à l'écran. Couleurs par consommateur ajoutées au thème (pac ambre, ecs sarcelle, ev indigo) : distinctes des couleurs de FLUX, qui disent d'où vient l'énergie, là où celles-ci disent qui la prend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
parent
3ff42624f0
commit
9c19791e9a
97
lib/models/consumer_series.dart
Normal file
97
lib/models/consumer_series.dart
Normal file
@ -0,0 +1,97 @@
|
||||
/// **Qui consomme, dans le temps** — `Energy.GetThingPowerLogs`, une série par appareil.
|
||||
///
|
||||
/// ## Ce que cet objet porte en plus des courbes
|
||||
///
|
||||
/// La liste de ce qu'on a **demandé sans l'obtenir**. Six Things sondés sur `.75` le
|
||||
/// 2026-09-08, cinq répondent : la V2C Trydan est absente parce que sa classe ne déclare
|
||||
/// pas l'interface `smartmeter`. Une réponse partielle sans la liste des manquants se lit
|
||||
/// comme une réponse complète, et l'écran tracerait alors quatre courbes en laissant croire
|
||||
/// qu'il n'y en avait que quatre à tracer.
|
||||
///
|
||||
/// **Une série absente ne se dessine pas à zéro.** Une ligne plate affirme « cette charge
|
||||
/// n'a rien tiré » ; l'absence dit « on ne sait pas ». La borne V2C tire peut-être 4 kW en
|
||||
/// ce moment — sa puissance instantanée est lisible — elle n'a simplement pas de passé.
|
||||
///
|
||||
/// ## Le signe des sous-compteurs n'est PAS vérifié
|
||||
///
|
||||
/// 🔴 Sur le banc, `PAC-Meter`, `ECS-Meter` et la borne simulée publient **0 W à tous les
|
||||
/// pas**, toute la journée. Aucun échantillon non nul n'existe, donc **rien ne dit si un
|
||||
/// consommateur publie sa puissance en positif ou en négatif** — le compteur PV, lui, la
|
||||
/// publie en négatif, ce qui interdit de supposer.
|
||||
///
|
||||
/// La valeur est donc transportée **brute**, et [ConsumerHistory.signeIndetermine] dit
|
||||
/// quand la question reste ouverte. Poser un `.abs()` ici trancherait par défaut, et un
|
||||
/// `.abs()` qui devine juste par hasard est indiscernable d'un `.abs()` qui devine faux.
|
||||
library;
|
||||
|
||||
/// Un point d'une série : un instant et la puissance publiée à cet instant.
|
||||
class ConsumerPoint {
|
||||
final DateTime t;
|
||||
final double powerW;
|
||||
const ConsumerPoint(this.t, this.powerW);
|
||||
}
|
||||
|
||||
/// La série d'un appareil, avec de quoi le nommer et le colorer.
|
||||
class ConsumerSeries {
|
||||
final String thingId;
|
||||
final String label;
|
||||
|
||||
/// `ecs` · `heating` · `ev`, ou `null` si la charge n'a pas de domaine déclaré.
|
||||
/// `null` n'appelle pas de couleur par défaut : voir `EtmTokens.domaineOf`.
|
||||
final String? domaine;
|
||||
|
||||
final List<ConsumerPoint> points;
|
||||
|
||||
const ConsumerSeries({
|
||||
required this.thingId,
|
||||
required this.label,
|
||||
required this.domaine,
|
||||
required this.points,
|
||||
});
|
||||
|
||||
/// Vrai si la série ne contient que des zéros — **une mesure, pas un trou**.
|
||||
///
|
||||
/// Un compteur dédié qui lit 0 W dit « rien ne chauffe ». C'est exactement ce qu'il faut
|
||||
/// tracer : une ligne au ras de l'axe, pas une absence.
|
||||
bool get toutAZero => points.isNotEmpty && points.every((p) => p.powerW == 0);
|
||||
|
||||
double get maxW =>
|
||||
points.isEmpty ? 0 : points.map((p) => p.powerW.abs()).reduce((a, b) => a > b ? a : b);
|
||||
}
|
||||
|
||||
/// Le résultat complet d'une interrogation par appareil.
|
||||
class ConsumerHistory {
|
||||
/// Les séries obtenues, dans l'ordre où elles ont été demandées.
|
||||
final List<ConsumerSeries> series;
|
||||
|
||||
/// **Les appareils demandés dont la box n'a rien renvoyé**, par leur nom d'affichage.
|
||||
///
|
||||
/// Ce n'est pas une erreur : un appareil sans interface de comptage n'a pas
|
||||
/// d'historique, et c'est un fait sur l'appareil, pas sur la requête.
|
||||
final List<String> sansHistorique;
|
||||
|
||||
const ConsumerHistory({required this.series, required this.sansHistorique});
|
||||
|
||||
static const ConsumerHistory vide =
|
||||
ConsumerHistory(series: [], sansHistorique: []);
|
||||
|
||||
bool get estVide => series.isEmpty;
|
||||
|
||||
/// 🔴 **Vrai tant qu'aucun échantillon non nul n'a été vu.** Voir la note de tête : sans
|
||||
/// une seule valeur non nulle, la convention de signe des sous-compteurs reste inconnue,
|
||||
/// et l'écran ne doit pas présenter l'empilement comme une décomposition établie.
|
||||
bool get signeIndetermine => series.every((s) => s.maxW == 0);
|
||||
|
||||
/// Les instants communs à toutes les séries — l'axe des abscisses de l'empilement.
|
||||
///
|
||||
/// Pris sur la série la plus longue : les compteurs n'échantillonnent pas tous au même
|
||||
/// instant, et interpoler inventerait des points.
|
||||
List<DateTime> get instants {
|
||||
if (series.isEmpty) return const [];
|
||||
var plusLongue = series.first;
|
||||
for (final s in series) {
|
||||
if (s.points.length > plusLongue.points.length) plusLongue = s;
|
||||
}
|
||||
return [for (final p in plusLongue.points) p.t];
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' show max, min;
|
||||
import 'dart:math' show max;
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/consumer_series.dart';
|
||||
import '../models/energy_data.dart';
|
||||
import '../models/nymea_models.dart';
|
||||
import '../providers/load_config_provider.dart';
|
||||
import '../services/energy_ratios.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
@ -16,6 +18,28 @@ import '../main.dart' show DrawerMenuButton;
|
||||
// 4 KPIs · sélecteur période · line chart double axe (kW / SOC%) · bar chart
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Une ligne d'explication sous un graphe — elle ne sort que quand elle DIT quelque chose.
|
||||
class _NoteEcran extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String texte;
|
||||
const _NoteEcran({required this.icon, required this.texte});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = EtmTokens.mutedOf(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 4, 0),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Icon(icon, size: 14, color: c),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(texte,
|
||||
style: EtmTokens.sans(size: 10.5, color: c, height: 1.35))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab {
|
||||
final String label;
|
||||
final Duration range;
|
||||
@ -42,6 +66,7 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
int _tabIdx = 0;
|
||||
List<PowerBalanceEntry> _data = [];
|
||||
List<HistoryEntry> _socData = [];
|
||||
ConsumerHistory _conso = ConsumerHistory.vide;
|
||||
bool _loading = true;
|
||||
bool _noData = false;
|
||||
DateTime? _selectedDate;
|
||||
@ -105,6 +130,24 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
final service = context.read<NymeaService>();
|
||||
final socSource = service.batterySOCSource;
|
||||
|
||||
// **La liste des consommateurs vient de `GetLoadConfig`, pas d'une devinette.**
|
||||
//
|
||||
// Chaque charge déclarée porte son `meterThingId` — vérifié renseigné sur les quatre
|
||||
// charges de `.75` le 2026-09-08 — plus son `label` et son `domain`. On demande donc
|
||||
// l'historique DES COMPTEURS DES CHARGES DÉCLARÉES : rien n'est deviné à partir d'un
|
||||
// nom d'appareil, et une charge sans compteur déclaré n'entre simplement pas.
|
||||
final cfg = context.read<LoadConfigProvider>();
|
||||
if (cfg.entries.isEmpty) {
|
||||
cfg
|
||||
..attach(service)
|
||||
..load();
|
||||
}
|
||||
final appareils = [
|
||||
for (final e in cfg.entries)
|
||||
if (e.meterThingId.isNotEmpty)
|
||||
(id: e.meterThingId, label: e.label, domaine: e.domain)
|
||||
];
|
||||
|
||||
final results = await Future.wait<dynamic>([
|
||||
service.fetchPowerBalanceLogs(from: from, to: to, sampleRate: tab.sampleRate),
|
||||
socSource != null
|
||||
@ -112,11 +155,14 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
thingId: socSource['thingId']!, stateTypeName: socSource['stateName']!,
|
||||
from: from, to: to, sampleRate: tab.sampleRate)
|
||||
: Future<List<HistoryEntry>>.value([]),
|
||||
service.fetchThingPowerLogs(
|
||||
appareils: appareils, from: from, to: to, sampleRate: tab.sampleRate),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_data = results[0] as List<PowerBalanceEntry>;
|
||||
_socData = results[1] as List<HistoryEntry>;
|
||||
_conso = results[2] as ConsumerHistory;
|
||||
_loading = false;
|
||||
_noData = _data.isEmpty;
|
||||
});
|
||||
@ -225,8 +271,8 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
legend: [
|
||||
_LegendDot(EtmTokens.amber, 'Production'),
|
||||
_LegendDot(EtmTokens.blue, 'Consommation'),
|
||||
_LegendDot(EtmTokens.green, 'Autoconso'),
|
||||
_LegendDot(EtmTokens.green, 'SOC %', dashed: true),
|
||||
_LegendDot(EtmTokens.green, 'Autoconso', dashed: true),
|
||||
_LegendDot(EtmTokens.ev, 'SOC %', dashed: true),
|
||||
],
|
||||
height: 220,
|
||||
loading: _loading,
|
||||
@ -235,19 +281,24 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Graphe Bilan énergétique
|
||||
// Graphe : qui consomme
|
||||
_EtmChartCard(
|
||||
title: 'Bilan énergétique (kWh)',
|
||||
subtitle: 'Énergie par période',
|
||||
title: 'Qui consomme',
|
||||
subtitle: 'Puissance empilée, par charge déclarée',
|
||||
legend: [
|
||||
_LegendDot(EtmTokens.amber, 'Production'),
|
||||
_LegendDot(EtmTokens.blue, 'Consommation'),
|
||||
const _LegendDot(EtmTokens.brand, 'Reste du foyer'),
|
||||
for (final s in _conso.series)
|
||||
_LegendDot(
|
||||
EtmTokens.domaineOf(context, s.domaine) ??
|
||||
EtmTokens.mutedOf(context),
|
||||
s.label),
|
||||
],
|
||||
height: 200,
|
||||
height: 220,
|
||||
loading: _loading,
|
||||
noData: _noData,
|
||||
child: _buildBarChart(),
|
||||
child: _buildConsumerChart(),
|
||||
),
|
||||
_notesConsommateurs(),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Météo & prévision — placeholder
|
||||
@ -275,16 +326,31 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
for (int i = 0; i < _data.length; i++) {
|
||||
final d = _data[i];
|
||||
final x = i.toDouble();
|
||||
// Toujours en kW pour l'axe gauche
|
||||
prodSpots .add(FlSpot(x, d.productionW / 1000));
|
||||
consoSpots.add(FlSpot(x, d.consumptionW / 1000));
|
||||
// 🔴 **La MAGNITUDE, et c'est ici que le signe se corrige — pas au transport.**
|
||||
//
|
||||
// `.75` publie `production` NÉGATIF : −103 W ce matin. Le champ reste brut dans
|
||||
// `PowerBalanceEntry` (§7.1 du contrat), donc c'est au rendu de prendre la valeur
|
||||
// absolue. Sans ça, deux courbes disparaissaient pour la même raison — signalé par
|
||||
// Patrick le 2026-09-08 : la production tracée à −0,1 kW sous une échelle qui monte
|
||||
// à 3,1, et l'autoconsommation clouée à 0 parce que `production − export` est
|
||||
// toujours négatif.
|
||||
//
|
||||
// `.abs()` est licite **sur une production** : une installation ne produit pas une
|
||||
// puissance négative, donc le signe ne porte aucune information à préserver. Ce
|
||||
// n'est pas vrai du réseau, où le signe DIT le sens — d'où `gridImportW` /
|
||||
// `gridExportW` plutôt qu'un `.abs()` là-bas.
|
||||
final prodW = d.productionW.abs();
|
||||
prodSpots .add(FlSpot(x, prodW / 1000));
|
||||
consoSpots.add(FlSpot(x, d.consumptionW.abs() / 1000));
|
||||
// INTERIM (§2.2) — bande autoconso via la formule canonique du seam.
|
||||
autoSpots .add(FlSpot(
|
||||
x, EnergyRatiosInterim.selfConsumptionPower(d.productionW, d.acquisitionW) / 1000));
|
||||
x, EnergyRatiosInterim.selfConsumptionPower(prodW, d.acquisitionW) / 1000));
|
||||
}
|
||||
|
||||
// Plage Y gauche en kW
|
||||
final allKw = _data.expand((d) => [d.productionW / 1000, d.consumptionW / 1000]).toList();
|
||||
final allKw = _data
|
||||
.expand((d) => [d.productionW.abs() / 1000, d.consumptionW.abs() / 1000])
|
||||
.toList();
|
||||
final maxKw = allKw.isEmpty ? 2.5 : allKw.reduce(max);
|
||||
final yMax = max(maxKw * 1.15, 0.5); // min 0.5 kW pour éviter y écrasé
|
||||
|
||||
@ -355,7 +421,7 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
meta: meta,
|
||||
child: Text(
|
||||
'$pct%',
|
||||
style: EtmTokens.sans(size: 9, color: EtmTokens.green),
|
||||
style: EtmTokens.sans(size: 9, color: EtmTokens.ev),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -380,11 +446,28 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// ⚠️ **L'autoconsommation est en POINTILLÉ, et ce n'est pas décoratif.**
|
||||
//
|
||||
// Tant que rien n'est exporté, autoconsommation == production **exactement** :
|
||||
// tracée pleine et par-dessus, elle recouvrait la production au pixel près. Vu à
|
||||
// l'écran le 2026-09-08, juste après avoir corrigé le signe — la courbe ambre
|
||||
// n'apparaissait qu'aux rares moments d'export, c'est-à-dire là où les deux
|
||||
// diffèrent. Le signe était réparé et la production toujours invisible, pour une
|
||||
// deuxième raison qui n'avait rien à voir avec la première.
|
||||
//
|
||||
// En pointillé elle se lit COMME une superposition : sur la bande ambre, des
|
||||
// tirets verts disent « tout ce qui est produit est consommé sur place », et leur
|
||||
// décrochage dit l'export. Deux courbes qui coïncident doivent pouvoir se voir
|
||||
// coïncider — sinon l'une des deux ment par absence.
|
||||
lineBarsData: [
|
||||
_line(prodSpots, EtmTokens.amber),
|
||||
_line(consoSpots, EtmTokens.blue),
|
||||
_line(autoSpots, EtmTokens.green),
|
||||
if (hasSoc) _line(socSpots, EtmTokens.green, dashed: true, width: 1.5),
|
||||
_line(autoSpots, EtmTokens.green, dashed: true, width: 1.8),
|
||||
// Le SOC passe en indigo : il était vert pointillé, comme l'autoconsommation
|
||||
// ci-dessus. Sur une installation AVEC batterie les deux auraient été
|
||||
// indiscernables — collision créée à l'instant où l'autoconso est passée en
|
||||
// pointillé, et qu'aucun banc sans batterie n'aurait montrée.
|
||||
if (hasSoc) _line(socSpots, EtmTokens.ev, dashed: true, width: 1.5),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
@ -421,95 +504,177 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
// ── ② Bar chart : bilan énergétique ────────────────────────────────────────
|
||||
// ── ② Empilement par CONSOMMATEUR ──────────────────────────────────────────
|
||||
|
||||
Widget _buildBarChart() {
|
||||
/// Qui consomme, dans le temps — une aire par charge déclarée, plus le reste du foyer.
|
||||
///
|
||||
/// ## Le RESTE est licite ici, et il ne l'était pas sur le Sankey
|
||||
///
|
||||
/// `CLAUDE.md` interdit « pas de RESTE sans mesure exhaustive » — la règle est née d'un
|
||||
/// Sankey qui calculait « Autres = 22 % » à partir de parts **inventées**. Ici chaque
|
||||
/// part est un **compteur dédié** et le total est le compteur du foyer : la soustraction
|
||||
/// ne porte que des grandeurs mesurées.
|
||||
///
|
||||
/// 🔴 **Et elle porte son propre contrôle** : si un sous-compteur n'est pas réellement à
|
||||
/// l'intérieur du total du foyer — une borne sur une alimentation séparée, par exemple —
|
||||
/// le reste devient **négatif**. On ne le rabote pas à zéro : un reste négatif est la
|
||||
/// preuve d'un périmètre faux, et l'écraser effacerait la seule chose qui le dise.
|
||||
/// « Qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » — exactement ça.
|
||||
Widget _buildConsumerChart() {
|
||||
if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink();
|
||||
if (_conso.estVide) return const SizedBox.shrink();
|
||||
|
||||
final groups = <BarChartGroupData>[];
|
||||
double maxE = 1.0;
|
||||
final bw = _barWidth();
|
||||
final series = _conso.series;
|
||||
final n = _data.length;
|
||||
|
||||
for (int i = 0; i < _data.length; i++) {
|
||||
// Les compteurs sont en kWh — le graphe traçait leur différence en la nommant « Wh »
|
||||
// et en la divisant encore par mille à l'affichage : une journée de 78 kWh s'y lisait
|
||||
// « 78 Wh ». Corrigé le 2026-08-27, l'unité vérifiée par recoupement sur `.75`.
|
||||
final prodKwh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionKwh - _data[i - 1].totalProductionKwh);
|
||||
final consoKwh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionKwh - _data[i - 1].totalConsumptionKwh);
|
||||
maxE = [maxE, prodKwh, consoKwh].reduce(max);
|
||||
groups.add(BarChartGroupData(
|
||||
x: i,
|
||||
barsSpace: 2,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: prodKwh, color: EtmTokens.amber, width: bw,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
|
||||
),
|
||||
BarChartRodData(
|
||||
toY: consoKwh, color: EtmTokens.blue, width: bw,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
|
||||
),
|
||||
],
|
||||
));
|
||||
// Alignement par INDEX, et seulement si les longueurs coïncident : les compteurs
|
||||
// n'échantillonnent pas tous au même instant, et interpoler inventerait des points.
|
||||
double valeurAt(ConsumerSeries s, int i) =>
|
||||
s.points.length == n ? s.points[i].powerW : 0.0;
|
||||
final alignees = series.where((s) => s.points.length == n).toList();
|
||||
|
||||
// Cumuls empilés, base = reste du foyer.
|
||||
final restes = <double>[];
|
||||
for (int i = 0; i < n; i++) {
|
||||
final somme = alignees.fold<double>(0, (a, s) => a + valeurAt(s, i));
|
||||
restes.add(_data[i].consumptionW.abs() - somme);
|
||||
}
|
||||
final resteNegatif = restes.any((v) => v < -1);
|
||||
|
||||
final bandes = <({String label, Color couleur, List<double> valeurs})>[
|
||||
(
|
||||
label: 'Reste du foyer',
|
||||
couleur: EtmTokens.brand,
|
||||
valeurs: restes
|
||||
),
|
||||
for (final s in alignees)
|
||||
(
|
||||
label: s.label,
|
||||
couleur: EtmTokens.domaineOf(context, s.domaine) ??
|
||||
EtmTokens.mutedOf(context),
|
||||
valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i)]
|
||||
),
|
||||
];
|
||||
|
||||
// Cumul croissant, puis tracé du HAUT vers le BAS : chaque aire remplie jusqu'à zéro,
|
||||
// la suivante repeint par-dessus. C'est ce qui donne l'empilement sans `betweenBars`.
|
||||
final cumuls = <List<double>>[];
|
||||
for (int b = 0; b < bandes.length; b++) {
|
||||
final prec = b == 0 ? List<double>.filled(n, 0.0) : cumuls[b - 1];
|
||||
cumuls.add([for (int i = 0; i < n; i++) prec[i] + bandes[b].valeurs[i]]);
|
||||
}
|
||||
|
||||
final maxKw = cumuls.isEmpty
|
||||
? 1.0
|
||||
: cumuls.last.map((v) => v / 1000).fold<double>(0.5, max);
|
||||
final yMax = max(maxKw * 1.15, 0.5);
|
||||
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
|
||||
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
maxY: maxE * 1.15,
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85),
|
||||
getTooltipItem: (group, _, rod, __) => BarTooltipItem(
|
||||
_fmtWh(rod.toY),
|
||||
EtmTokens.mono(size: 10, color: rod.color ?? Colors.white),
|
||||
return Stack(children: [
|
||||
LineChart(
|
||||
LineChartData(
|
||||
clipData: const FlClipData.all(),
|
||||
minY: resteNegatif ? -yMax * 0.25 : 0,
|
||||
maxY: yMax,
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: yMax / 4,
|
||||
getDrawingHorizontalLine: (_) =>
|
||||
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 46,
|
||||
interval: yMax / 4,
|
||||
getTitlesWidget: (v, meta) => v == meta.max
|
||||
? const SizedBox.shrink()
|
||||
: SideTitleWidget(
|
||||
meta: meta,
|
||||
child: Text('${v.toStringAsFixed(1)}kW', style: labelStyle)),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 22,
|
||||
interval: _xInterval(),
|
||||
getTitlesWidget: (v, meta) {
|
||||
final idx = v.round();
|
||||
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
child:
|
||||
Text(_fmtTime(_data[idx].timestamp), style: labelStyle));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
for (int b = bandes.length - 1; b >= 0; b--)
|
||||
LineChartBarData(
|
||||
spots: [
|
||||
for (int i = 0; i < n; i++)
|
||||
FlSpot(i.toDouble(), cumuls[b][i] / 1000)
|
||||
],
|
||||
isCurved: false,
|
||||
color: bandes[b].couleur,
|
||||
barWidth: 1.2,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: bandes[b].couleur.withValues(alpha: 0.55)),
|
||||
),
|
||||
],
|
||||
lineTouchData: const LineTouchData(enabled: false),
|
||||
),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (_) => FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 52,
|
||||
interval: maxE * 1.15 / 4,
|
||||
getTitlesWidget: (v, meta) {
|
||||
if (v == meta.max) return const SizedBox.shrink();
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
child: Text(_fmtWh(v), style: labelStyle),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 22,
|
||||
getTitlesWidget: (v, meta) {
|
||||
final idx = v.toInt();
|
||||
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
||||
final step = _xInterval().toInt().clamp(1, _data.length);
|
||||
if (idx % step != 0) return const SizedBox.shrink();
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
barGroups: groups,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Ce que le graphe des consommateurs **ne peut pas** montrer, nommé sous lui.
|
||||
///
|
||||
/// Une charge absente de l'historique ne se dessine pas à zéro : une ligne plate
|
||||
/// affirmerait « elle n'a rien tiré », quand la vérité est « elle n'a pas de passé ».
|
||||
/// Vu sur `.75` : la V2C Trydan ne déclare pas d'interface `smartmeter`, donc elle
|
||||
/// n'entre pas dans `GetThingPowerLogs` — alors que sa puissance instantanée est lisible.
|
||||
Widget _notesConsommateurs() {
|
||||
final manquants = _conso.sansHistorique;
|
||||
final sansBatterie =
|
||||
(_nymeaService?.thingsLoaded ?? false) && _nymeaService?.batterySOCSource == null;
|
||||
if (manquants.isEmpty && !sansBatterie && !_conso.signeIndetermine) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (manquants.isNotEmpty)
|
||||
_NoteEcran(
|
||||
icon: Icons.timeline_rounded,
|
||||
texte: manquants.length == 1
|
||||
? '${manquants.first} : pas d\'historique de puissance — cet appareil ne '
|
||||
'publie pas de comptage. Sa puissance instantanée reste lisible.'
|
||||
: '${manquants.join(', ')} : pas d\'historique de puissance.',
|
||||
),
|
||||
if (sansBatterie)
|
||||
_NoteEcran(
|
||||
icon: Icons.battery_unknown_rounded,
|
||||
texte: 'Aucune batterie sur cette installation : ni charge, ni décharge, '
|
||||
'ni état de charge. Rien n\'est masqué — il n\'y a rien à tracer.',
|
||||
),
|
||||
if (_conso.signeIndetermine)
|
||||
_NoteEcran(
|
||||
icon: Icons.help_outline_rounded,
|
||||
texte: 'Tous les sous-compteurs lisent 0 W sur la période. C\'est une mesure, '
|
||||
'pas un trou — mais tant qu\'aucune valeur non nulle n\'est vue, la '
|
||||
'convention de signe de ces compteurs reste invérifiée.',
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@ -518,16 +683,9 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
double _xInterval() =>
|
||||
_data.length > 8 ? (_data.length / 6).ceilToDouble() : 1.0;
|
||||
|
||||
double _barWidth() =>
|
||||
_data.length > 40 ? 3 : _data.length > 20 ? 5 : 8;
|
||||
|
||||
/// Les valeurs sont **déjà en kWh** : on descend vers le Wh pour les petites, on ne
|
||||
/// remonte pas. L'ancienne version divisait par mille des kWh — d'où « 78 Wh » pour une
|
||||
/// journée entière.
|
||||
String _fmtWh(double v) {
|
||||
if (v.abs() < 1) return '${(v * 1000).toStringAsFixed(0)} Wh';
|
||||
return '${v.toStringAsFixed(1)} kWh';
|
||||
}
|
||||
|
||||
String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime
|
||||
? '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'
|
||||
|
||||
@ -12,6 +12,7 @@ import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanc
|
||||
import '../models/ev_charger_live.dart';
|
||||
import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort
|
||||
import '../models/nymea_models.dart';
|
||||
import '../models/consumer_series.dart';
|
||||
import 'energy_ratios.dart'; // seam interim ratios (§2.1)
|
||||
|
||||
// ── Protocole de connexion ─────────────────────────────────────────────────────
|
||||
@ -983,6 +984,77 @@ class NymeaService extends ChangeNotifier {
|
||||
// ignore: use_setters_to_change_properties
|
||||
void adoptRatiosForTest(EnergyRatiosSnapshot r) => _ratios = r;
|
||||
|
||||
/// **L'historique de puissance PAR APPAREIL** — `Energy.GetThingPowerLogs`.
|
||||
///
|
||||
/// Rend une entrée par (appareil, instant), regroupée par `thingId`. Les appareils
|
||||
/// demandés dont la box ne renvoie rien sortent dans `sansHistorique` : c'est un fait sur
|
||||
/// l'appareil (pas d'interface de comptage), pas une erreur de la requête, et le taire
|
||||
/// ferait lire une réponse partielle comme une réponse complète.
|
||||
///
|
||||
/// Vérifié sur `.75` le 2026-09-08 : six Things demandés, cinq répondent. La `V2C Trydan`
|
||||
/// est absente — sa classe déclare `evcharger · power`, pas `smartmeter`.
|
||||
///
|
||||
/// La charge utile porte `currentPower`, `totalConsumption`, `totalProduction` et
|
||||
/// `thingId`. **Seul `currentPower` est lu ici**, brut : voir `consumer_series.dart` pour
|
||||
/// pourquoi le signe n'est pas corrigé.
|
||||
Future<ConsumerHistory> fetchThingPowerLogs({
|
||||
required List<({String id, String label, String? domaine})> appareils,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
String sampleRate = 'SampleRate15Mins',
|
||||
}) async {
|
||||
if (appareils.isEmpty) return ConsumerHistory.vide;
|
||||
try {
|
||||
final r = await _sendRequest('Energy.GetThingPowerLogs', {
|
||||
'sampleRate': sampleRate,
|
||||
'thingIds': [for (final a in appareils) a.id],
|
||||
'from': from.millisecondsSinceEpoch ~/ 1000,
|
||||
'to': to.millisecondsSinceEpoch ~/ 1000,
|
||||
});
|
||||
final params = r['params'] as Map<String, dynamic>? ?? r;
|
||||
final logs = params.values.whereType<List>().firstOrNull ?? const [];
|
||||
|
||||
// Les identifiants voyagent avec ou sans accolades selon l'appel (UI_data_contract
|
||||
// §9) : on regroupe sur la forme nue, jamais sur la chaîne brute.
|
||||
String nu(String x) => x.replaceAll('{', '').replaceAll('}', '').toLowerCase();
|
||||
|
||||
final parAppareil = <String, List<ConsumerPoint>>{};
|
||||
for (final e in logs) {
|
||||
if (e is! Map) continue;
|
||||
final m = Map<String, dynamic>.from(e);
|
||||
final id = m['thingId'];
|
||||
final ts = m['timestamp'];
|
||||
final p = m['currentPower'];
|
||||
if (id is! String || ts is! num || p is! num) continue;
|
||||
parAppareil.putIfAbsent(nu(id), () => []).add(ConsumerPoint(
|
||||
DateTime.fromMillisecondsSinceEpoch(ts.toInt() * 1000),
|
||||
p.toDouble(),
|
||||
));
|
||||
}
|
||||
for (final l in parAppareil.values) {
|
||||
l.sort((a, b) => a.t.compareTo(b.t));
|
||||
}
|
||||
|
||||
final series = <ConsumerSeries>[];
|
||||
final absents = <String>[];
|
||||
for (final a in appareils) {
|
||||
final pts = parAppareil[nu(a.id)];
|
||||
if (pts == null || pts.isEmpty) {
|
||||
absents.add(a.label);
|
||||
continue;
|
||||
}
|
||||
series.add(ConsumerSeries(
|
||||
thingId: a.id, label: a.label, domaine: a.domaine, points: pts));
|
||||
}
|
||||
return ConsumerHistory(series: series, sansHistorique: absents);
|
||||
} catch (e) {
|
||||
_log('fetchThingPowerLogs: $e');
|
||||
// Un échec d'appel n'est pas « aucun appareil n'a d'historique » : on rend le vide,
|
||||
// et l'écran dira « non lu », pas « pas d'historique ».
|
||||
return ConsumerHistory.vide;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PowerBalanceEntry>> fetchPowerBalanceLogs({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
|
||||
@ -77,6 +77,37 @@ class EtmTokens {
|
||||
static Color ecoOf(BuildContext c) => _isDark(c) ? ecoDark : eco;
|
||||
static Color solarOf(BuildContext c) => _isDark(c) ? solarDark : solar;
|
||||
|
||||
// ── Couleurs par CONSOMMATEUR ─────────────────────────────────────────────
|
||||
//
|
||||
// Distinctes des couleurs de FLUX (solaire, maison, réseau, batterie), qui disent d'où
|
||||
// vient l'énergie. Celles-ci disent **qui la prend**, et il en fallait de nouvelles :
|
||||
// le thème n'en portait aucune, et réutiliser une couleur de flux ferait lire une
|
||||
// origine là où il n'y a qu'une destination.
|
||||
//
|
||||
// Trois teintes choisies pour ne heurter aucune couleur de flux existante, et validées
|
||||
// par Patrick le 2026-09-08 sur la maquette `docs/mockups/ecran_energie_refonte.html`.
|
||||
static const Color pac = Color(0xFFE88C30); // pompe à chaleur — ambre chaud
|
||||
static const Color pacDark = Color(0xFFF09A45);
|
||||
static const Color ecs = Color(0xFF2AA7A0); // eau chaude — sarcelle
|
||||
static const Color ecsDark = Color(0xFF37BDB5);
|
||||
static const Color ev = Color(0xFF7C6CE4); // bornes — indigo
|
||||
static const Color evDark = Color(0xFF8E80EA);
|
||||
|
||||
static Color pacOf(BuildContext c) => _isDark(c) ? pacDark : pac;
|
||||
static Color ecsOf(BuildContext c) => _isDark(c) ? ecsDark : ecs;
|
||||
static Color evOf(BuildContext c) => _isDark(c) ? evDark : ev;
|
||||
|
||||
/// La couleur d'un domaine de charge, ou `null` s'il n'en a pas.
|
||||
///
|
||||
/// `null` n'est pas un défaut à combler : une charge sans domaine déclaré n'a pas de
|
||||
/// couleur, et lui en attribuer une par défaut la ferait passer pour classée.
|
||||
static Color? domaineOf(BuildContext c, String? domaine) => switch (domaine) {
|
||||
'heating' => pacOf(c),
|
||||
'ecs' => ecsOf(c),
|
||||
'ev' => evOf(c),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static Color lineOf(BuildContext c) => _isDark(c)
|
||||
? const Color(0xFFFFFFFF).withValues(alpha: 0.09)
|
||||
: navy.withValues(alpha: 0.10);
|
||||
|
||||
127
test/consommateurs_test.dart
Normal file
127
test/consommateurs_test.dart
Normal file
@ -0,0 +1,127 @@
|
||||
/// **Le graphe par consommateur — ce qu'il a le droit de dire, et ce qu'il doit taire.**
|
||||
///
|
||||
/// Trois faits mesurés sur `.75` le 2026-09-08 tiennent tout ce fichier :
|
||||
///
|
||||
/// - six Things demandés à `Energy.GetThingPowerLogs`, **cinq répondent** — la V2C Trydan
|
||||
/// ne déclare pas l'interface `smartmeter` ;
|
||||
/// - les trois sous-compteurs (PAC, ECS, borne simulée) lisent **0 W à tous les pas** ;
|
||||
/// - `production` est publiée **négative** par la box.
|
||||
///
|
||||
/// Chacun crée une manière différente de mentir à l'écran, et aucune ne se voit sans y
|
||||
/// penser : une série absente tracée à zéro, un empilement présenté comme établi alors
|
||||
/// qu'aucune valeur non nulle ne l'a validé, et deux courbes invisibles pour une raison
|
||||
/// de signe.
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/models/consumer_series.dart';
|
||||
import 'package:etm_powersync_app/services/energy_ratios.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
ConsumerSeries _serie(String id, String label, String? dom, List<double> w) =>
|
||||
ConsumerSeries(
|
||||
thingId: id,
|
||||
label: label,
|
||||
domaine: dom,
|
||||
points: [
|
||||
for (int i = 0; i < w.length; i++)
|
||||
ConsumerPoint(DateTime(2026, 9, 8, 0, 15 * i), w[i])
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('une série ABSENTE n\'est pas une série à zéro', () {
|
||||
test('l\'appareil sans historique est NOMMÉ, pas escamoté', () {
|
||||
final h = ConsumerHistory(
|
||||
series: [_serie('ecs', 'chauffe-eau', 'ecs', [0, 0, 0])],
|
||||
sansHistorique: const ['V2C Trydan'],
|
||||
);
|
||||
expect(h.series, hasLength(1));
|
||||
expect(h.sansHistorique, ['V2C Trydan']);
|
||||
// Le contrôle qui MORD : la borne ne doit jamais devenir une série de zéros.
|
||||
expect(h.series.any((s) => s.label == 'V2C Trydan'), isFalse,
|
||||
reason: 'une ligne plate affirmerait « elle n\'a rien tiré », '
|
||||
'quand la vérité est « elle n\'a pas de passé »');
|
||||
});
|
||||
|
||||
test('une série à zéro est une MESURE, et elle reste tracée', () {
|
||||
final s = _serie('pac', 'PAC banc', 'heating', [0, 0, 0, 0]);
|
||||
expect(s.toutAZero, isTrue);
|
||||
expect(s.points, hasLength(4),
|
||||
reason: 'un compteur dédié qui lit 0 W dit « rien ne chauffe » — '
|
||||
'c\'est exactement ce qu\'il faut montrer');
|
||||
expect(s.maxW, 0);
|
||||
});
|
||||
|
||||
test('une série vide n\'est pas « toute à zéro »', () {
|
||||
// Deux états distincts sous une même apparence à l'écran : aucun point du tout
|
||||
// (rien demandé, rien reçu) et des points tous nuls (mesurés).
|
||||
const vide = ConsumerSeries(
|
||||
thingId: 'x', label: 'x', domaine: null, points: []);
|
||||
expect(vide.toutAZero, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('le signe des sous-compteurs reste INVÉRIFIÉ tant que tout vaut zéro', () {
|
||||
test('trois compteurs à zéro : la question est ouverte, et le dit', () {
|
||||
final h = ConsumerHistory(series: [
|
||||
_serie('pac', 'PAC banc', 'heating', [0, 0]),
|
||||
_serie('ecs', 'chauffe-eau', 'ecs', [0, 0]),
|
||||
], sansHistorique: const []);
|
||||
expect(h.signeIndetermine, isTrue,
|
||||
reason: 'le compteur PV publie sa puissance en NÉGATIF sur cette box — '
|
||||
'rien n\'autorise à supposer la convention des consommateurs');
|
||||
});
|
||||
|
||||
test('une seule valeur non nulle referme la question', () {
|
||||
final h = ConsumerHistory(series: [
|
||||
_serie('pac', 'PAC banc', 'heating', [0, 0]),
|
||||
_serie('ecs', 'chauffe-eau', 'ecs', [0, 1500]),
|
||||
], sansHistorique: const []);
|
||||
expect(h.signeIndetermine, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('l\'axe des instants ne s\'invente pas', () {
|
||||
test('il vient de la série la plus longue, sans interpolation', () {
|
||||
final h = ConsumerHistory(series: [
|
||||
_serie('a', 'a', null, [1, 2]),
|
||||
_serie('b', 'b', null, [1, 2, 3, 4]),
|
||||
], sansHistorique: const []);
|
||||
expect(h.instants, hasLength(4));
|
||||
});
|
||||
|
||||
test('sans série, aucun instant — et pas une liste de secours', () {
|
||||
expect(ConsumerHistory.vide.instants, isEmpty);
|
||||
expect(ConsumerHistory.vide.estVide, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('la production NÉGATIVE, et les deux courbes qu\'elle effaçait', () {
|
||||
// Signalé par Patrick le 2026-09-08 : « dans le premier chart on ne voit pas la
|
||||
// production ni l'autoconsommation ». Une seule cause pour les deux.
|
||||
const productionPubliee = -103.0; // relevé sur .75
|
||||
const acquisition = 3000.0; // soutirage, donc pas d'export
|
||||
|
||||
test('telle quelle, l\'autoconsommation est clouée à zéro', () {
|
||||
final self = EnergyRatiosInterim.selfConsumptionPower(
|
||||
productionPubliee, acquisition);
|
||||
expect(self, 0,
|
||||
reason: '`production − export` puis clamp à 0 : avec une production '
|
||||
'négative le résultat ne peut JAMAIS être autre chose');
|
||||
});
|
||||
|
||||
test('sur la magnitude, elle rend la vraie valeur', () {
|
||||
final self = EnergyRatiosInterim.selfConsumptionPower(
|
||||
productionPubliee.abs(), acquisition);
|
||||
expect(self, 103,
|
||||
reason: 'rien n\'est exporté, donc tout le produit est autoconsommé');
|
||||
});
|
||||
|
||||
test('et l\'export reste soustrait quand il y en a', () {
|
||||
// Le `.abs()` porte sur la PRODUCTION seule. Le réseau garde son signe, parce que
|
||||
// là le signe dit le SENS — c'est ce qui distingue ce cas de celui du réseau.
|
||||
final self = EnergyRatiosInterim.selfConsumptionPower(5000, -1200);
|
||||
expect(self, 3800);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user