FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils », jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter). La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une échéance » est retirée : aucun de ces comportements n'existe dans le moteur. ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel. L'écran le nomme, avec la durée de réarmement. GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant. La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15 n'est exposé par aucune méthode. CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box accepte, et le refus remonte sans en inventer la cause. ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone. Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
221 lines
8.6 KiB
Dart
221 lines
8.6 KiB
Dart
/// Ce que `SetLoadConfig` va **réellement** provoquer — miroir de
|
|
/// `EnergyArbitrator::sameHardware()`.
|
|
///
|
|
/// L'enjeu n'est pas cosmétique : une reconstruction ouvre les contacts et réarme les
|
|
/// verrous à froid. Sur une PAC à 900 s, l'installateur qui corrige une ligne fige sa
|
|
/// machine un quart d'heure. Ces tests fixent la frontière exacte entre ce qui se met à
|
|
/// jour en place et ce qui reconstruit.
|
|
///
|
|
/// Le cas le moins intuitif — et il est **vérifié sur `.75`** — est le simple
|
|
/// réordonnancement de `relays[]`.
|
|
library;
|
|
|
|
import 'package:etm_powersync_app/models/load_config_entry.dart';
|
|
import 'package:etm_powersync_app/services/load_hardware.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// Charge `relay-router` à trois contacteurs, calquée sur `chauffe-eau` de `.75`.
|
|
Map<String, dynamic> chauffeEau({
|
|
List<Map<String, dynamic>>? relays,
|
|
int priority = 1,
|
|
String label = 'chauffe-eau',
|
|
bool enabled = true,
|
|
int minOnS = 60,
|
|
int minOffS = 60,
|
|
String domain = 'ecs',
|
|
}) =>
|
|
{
|
|
'adapter': 'relay-router',
|
|
'domain': domain,
|
|
'enabled': enabled,
|
|
'id': 'chauffe-eau',
|
|
'label': label,
|
|
'maxPowerW': 0,
|
|
'minOffS': minOffS,
|
|
'minOnS': minOnS,
|
|
'mode': 'fixed',
|
|
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
|
|
'powerLevels': <dynamic>[],
|
|
'priority': priority,
|
|
'relays': relays ??
|
|
[
|
|
{'powerW': 500, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'},
|
|
{'powerW': 1000, 'thingId': '{2ebe6bef-829b-4695-9d11-ddb4c16c5448}'},
|
|
{'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'},
|
|
],
|
|
'sgReady': <String, dynamic>{},
|
|
};
|
|
|
|
/// Charge `sg-ready` calquée sur `pac-terrain` de `.75`.
|
|
Map<String, dynamic> pacTerrain({
|
|
int minStateHoldS = 300,
|
|
double p4 = 3000,
|
|
int priority = 2,
|
|
bool enabled = true,
|
|
}) =>
|
|
{
|
|
'adapter': 'sg-ready',
|
|
'domain': 'hvac',
|
|
'enabled': enabled,
|
|
'id': 'pac-terrain',
|
|
'label': 'PAC banc',
|
|
'maxPowerW': 0,
|
|
'minOffS': 0,
|
|
'minOnS': 0,
|
|
'mode': 'fixed',
|
|
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
|
|
'powerLevels': <dynamic>[],
|
|
'priority': priority,
|
|
'relays': <dynamic>[],
|
|
'sgReady': {
|
|
'minStateHoldS': minStateHoldS,
|
|
'states': [
|
|
{'state': 1, 'relays': ['{beaf92e1-aedc-4b84-9ce4-e423648638cc}'], 'estimatedPowerW': 0.0},
|
|
{'state': 2, 'relays': <String>[], 'estimatedPowerW': 0.0},
|
|
{'state': 3, 'relays': ['{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}'], 'estimatedPowerW': 1500.0},
|
|
{
|
|
'state': 4,
|
|
'relays': [
|
|
'{beaf92e1-aedc-4b84-9ce4-e423648638cc}',
|
|
'{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}',
|
|
],
|
|
'estimatedPowerW': p4,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
void main() {
|
|
group('sameHardware — ce qui NE reconstruit PAS', () {
|
|
test('le rang seul est une mise à jour en place', () {
|
|
final a = LoadConfigEntry(chauffeEau(priority: 1));
|
|
final b = LoadConfigEntry(chauffeEau(priority: 3));
|
|
expect(sameHardware(a, b), isTrue);
|
|
expect(hardwareDiff(a, b), isEmpty);
|
|
|
|
final impacts = impactOfSave(avant: [a], apres: [b]);
|
|
expect(impacts.single.impact, LoadChangeImpact.inPlace);
|
|
expect(impacts.single.needsWarning, isFalse);
|
|
});
|
|
|
|
test('le libellé et le domaine ne touchent pas au matériel', () {
|
|
final a = LoadConfigEntry(chauffeEau());
|
|
final b = LoadConfigEntry(chauffeEau(label: 'Ballon 200 L', domain: 'heating'));
|
|
expect(sameHardware(a, b), isTrue);
|
|
|
|
// Ni reconstruction NI mise à jour en place : le moteur réutilise l'adaptateur
|
|
// tel quel. Vérifié sur `.75` — un changement de libellé seul journalise
|
|
// « 2 inchangée(s) », et le journal continue d'afficher l'ANCIEN libellé
|
|
// jusqu'à la reconstruction suivante.
|
|
final impacts = impactOfSave(avant: [a], apres: [b]);
|
|
expect(impacts.single.impact, LoadChangeImpact.none);
|
|
});
|
|
});
|
|
|
|
group('sameHardware — ce qui RECONSTRUIT', () {
|
|
test('RÉORDONNER les relais suffit — la comparaison du moteur est indexée', () {
|
|
final origine = chauffeEau();
|
|
final inverse = chauffeEau(
|
|
relays: (origine['relays'] as List)
|
|
.cast<Map<String, dynamic>>()
|
|
.reversed
|
|
.toList(),
|
|
);
|
|
final a = LoadConfigEntry(origine);
|
|
final b = LoadConfigEntry(inverse);
|
|
|
|
// Même ensemble, mêmes Things, mêmes puissances — et pourtant :
|
|
expect(sameHardware(a, b), isFalse,
|
|
reason: 'vérifié sur .75 : relays[] inversé → adaptateur reconstruit, '
|
|
'ancien passé par applySafeState (contacts ouverts, force)');
|
|
expect(hardwareDiff(a, b), ['relays']);
|
|
|
|
final impacts = impactOfSave(avant: [a], apres: [b]);
|
|
expect(impacts.single.impact, LoadChangeImpact.rebuild);
|
|
expect(impacts.single.needsWarning, isTrue);
|
|
expect(impacts.single.rearmSeconds, 60); // max(minOnS, minOffS)
|
|
});
|
|
|
|
test('changer une puissance de contacteur reconstruit', () {
|
|
final a = LoadConfigEntry(chauffeEau());
|
|
final b = LoadConfigEntry(chauffeEau(relays: [
|
|
{'powerW': 800, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'},
|
|
{'powerW': 1000, 'thingId': '{2ebe6bef-829b-4695-9d11-ddb4c16c5448}'},
|
|
{'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'},
|
|
]));
|
|
expect(hardwareDiff(a, b), ['relays']);
|
|
});
|
|
|
|
test('les temporisations sont du matériel au sens du moteur', () {
|
|
final a = LoadConfigEntry(chauffeEau(minOnS: 60));
|
|
final b = LoadConfigEntry(chauffeEau(minOnS: 120));
|
|
expect(hardwareDiff(a, b), ['minOnS']);
|
|
expect(impactOfSave(avant: [a], apres: [b]).single.rearmSeconds, 120);
|
|
});
|
|
|
|
test('une estimation SG-Ready revue reconstruit, et réarme minStateHoldS', () {
|
|
final a = LoadConfigEntry(pacTerrain(p4: 3000));
|
|
final b = LoadConfigEntry(pacTerrain(p4: 2800));
|
|
expect(hardwareDiff(a, b), ['sgReady']);
|
|
|
|
final impact = impactOfSave(avant: [a], apres: [b]).single;
|
|
expect(impact.impact, LoadChangeImpact.rebuild);
|
|
// C'est CE nombre qu'il faut annoncer : sur une vraie PAC il vaut 900.
|
|
expect(impact.rearmSeconds, 300);
|
|
});
|
|
|
|
test('changer de mécanisme reconstruit', () {
|
|
final a = LoadConfigEntry(chauffeEau());
|
|
final b = LoadConfigEntry({...chauffeEau(), 'adapter': 'etmvariableload'});
|
|
expect(hardwareDiff(a, b), contains('adapter'));
|
|
});
|
|
});
|
|
|
|
group('entrée et sortie de l\'arbitrage', () {
|
|
test('enabled vrai → faux : la charge SORT, elle passe par l\'état sûr', () {
|
|
final a = LoadConfigEntry(pacTerrain(enabled: true));
|
|
final b = LoadConfigEntry(pacTerrain(enabled: false));
|
|
final impact = impactOfSave(avant: [a], apres: [b]).single;
|
|
expect(impact.impact, LoadChangeImpact.removed);
|
|
expect(impact.needsWarning, isTrue);
|
|
});
|
|
|
|
test('enabled faux → vrai : la charge ENTRE', () {
|
|
final a = LoadConfigEntry(pacTerrain(enabled: false));
|
|
final b = LoadConfigEntry(pacTerrain(enabled: true));
|
|
expect(impactOfSave(avant: [a], apres: [b]).single.impact,
|
|
LoadChangeImpact.created);
|
|
});
|
|
|
|
test('désactivée avant comme après : aucun adaptateur, donc rien à reconstruire', () {
|
|
final a = LoadConfigEntry(pacTerrain(enabled: false, minStateHoldS: 300));
|
|
final b = LoadConfigEntry(pacTerrain(enabled: false, minStateHoldS: 900));
|
|
expect(impactOfSave(avant: [a], apres: [b]).single.impact,
|
|
LoadChangeImpact.none);
|
|
});
|
|
|
|
test('une charge retirée de l\'ensemble sort par l\'état sûr', () {
|
|
final a = LoadConfigEntry(chauffeEau());
|
|
final b = LoadConfigEntry(pacTerrain());
|
|
final impacts = impactOfSave(avant: [a, b], apres: [b]);
|
|
final ce = impacts.firstWhere((i) => i.loadId == 'chauffe-eau');
|
|
expect(ce.impact, LoadChangeImpact.removed);
|
|
});
|
|
});
|
|
|
|
test('un enregistrement mixte rend un verdict par charge', () {
|
|
final avant = [LoadConfigEntry(chauffeEau()), LoadConfigEntry(pacTerrain())];
|
|
final apres = [
|
|
LoadConfigEntry(chauffeEau(priority: 5)), // en place
|
|
LoadConfigEntry(pacTerrain(minStateHoldS: 900)), // reconstruction
|
|
];
|
|
final impacts = {
|
|
for (final i in impactOfSave(avant: avant, apres: apres)) i.loadId: i
|
|
};
|
|
expect(impacts['chauffe-eau']!.impact, LoadChangeImpact.inPlace);
|
|
expect(impacts['pac-terrain']!.impact, LoadChangeImpact.rebuild);
|
|
expect(impacts['pac-terrain']!.rearmSeconds, 900);
|
|
expect(impacts.values.where((i) => i.needsWarning).length, 1);
|
|
});
|
|
}
|