feat(accueil): météo heure par heure sur la carte « Journée » (openmeteo etm16)

Consigne app §E, vérifiée sur le banc le 2026-10-15 :
- lecture de forecastHourlyWeather (v 1) sur le Thing SITE, parent des
  plans de perField (parentId désormais lu sur les Things) ; v inconnu =
  rien d'affiché, jamais forecastCurveTransport ;
- case i = valeur au DÉBUT du créneau t0 + i·step : posée sur l'heure qui
  commence là, comme la prévision PV ;
- icône (code WMO → icône, variante de nuit par isDay) et température une
  heure sur trois, alignées sur les colonnes du graphe ;
- une case null n'a ni icône ni température : WMO 0 = ciel dégagé, et un
  null lu comme 0 afficherait un soleil. Test à la lecture ajouté après
  qu'une mutation null → 0 n'eut fait échouer aucun test (la fixture du
  banc n'a pas de null).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-15 08:32:28 +02:00
parent c7f857ecb3
commit 335e6a1290
6 changed files with 360 additions and 1 deletions

View File

@ -105,11 +105,16 @@ class _JourneeCardState extends State<JourneeCard> {
snapshot: snap,
bilanHoraire: _bilan,
consoHoraire: _conso);
final etats = service.etatsParNom('openMeteoSite');
// Le site est le PARENT des plans sommés par la prévision ; à défaut, le premier site.
final plan = pv.snapshot?.perField.firstOrNull?.id;
final site = plan == null ? null : service.parentDe(plan);
final etats = service.etatsParNom('openMeteoSite',
preferer: site == null || site.isEmpty ? null : site);
return JourneeView(
heures: heures,
series: _conso.series,
meteo: etats == null ? null : MeteoActuelle.depuisEtats(etats),
meteoHoraire: meteoHoraireDepuis(etats?['forecastHourlyWeather']),
chargement: !_lue,
);
}
@ -120,6 +125,9 @@ class JourneeView extends StatelessWidget {
final List<HeureJournee> heures;
final List<ConsumerSeries> series;
final MeteoActuelle? meteo;
/// Météo par début d'heure (UTC), ou `null` si la box ne la publie pas (openmeteo < etm16).
final Map<DateTime, MeteoHeure>? meteoHoraire;
final bool chargement;
const JourneeView({
@ -127,6 +135,7 @@ class JourneeView extends StatelessWidget {
required this.heures,
this.series = const [],
this.meteo,
this.meteoHoraire,
this.chargement = false,
});
@ -168,6 +177,10 @@ class JourneeView extends StatelessWidget {
const SizedBox(height: 8),
_meteo(context),
const SizedBox(height: 10),
if (meteoHoraire != null) ...[
_rangeeMeteo(context),
const SizedBox(height: 2),
],
SizedBox(
height: 190,
child: chargement
@ -219,6 +232,66 @@ class JourneeView extends StatelessWidget {
]);
}
/// **Icône et température, une heure sur trois**, alignées sur les colonnes du graphe.
///
/// Le graphe réserve 36 px à gauche pour son axe et répartit les heures à parts égales
/// (`spaceAround`) : la rangée reprend exactement ce découpage. Une heure sur trois, parce
/// que 24 icônes ne tiennent pas lisiblement sur la largeur d'un téléphone.
Widget _rangeeMeteo(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(left: 36),
child: Row(children: [
for (final h in heures)
Expanded(
child: h.debut.hour % 3 != 0
? const SizedBox(height: 30)
: Builder(builder: (context) {
final m = meteoHoraire![h.debut.toUtc()];
final icone = m?.code == null ? null : iconeWmo(m!.code!, m.jour);
return SizedBox(
height: 30,
child: OverflowBox(
maxWidth: 40,
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (icone != null)
Icon(icone,
size: 14,
color: m!.code == 0 && m.jour != false
? EtmTokens.solarOf(context)
: muted)
else
const SizedBox(height: 14),
Text(
m?.tempC == null ? '' : '${m!.tempC!.round()}°',
style: EtmTokens.mono(size: 9, color: muted)),
]),
),
);
}),
),
]),
);
}
/// **Code WMO → icône** (table de Patrick du 2026-10-15, inspirée de Weather Icons).
/// `jour == false` donne la variante de nuit. Un code inconnu n'a pas d'icône.
static IconData? iconeWmo(int code, bool? jour) {
final nuit = jour == false;
return switch (code) {
0 => nuit ? Icons.nightlight_round : Icons.wb_sunny_rounded,
1 || 2 => nuit ? Icons.nights_stay_rounded : Icons.wb_cloudy_rounded,
3 => Icons.cloud_rounded,
45 || 48 => Icons.foggy,
51 || 53 || 55 => Icons.grain_rounded,
56 || 57 || 66 || 67 => Icons.cloudy_snowing,
61 || 63 || 65 || 80 || 81 || 82 => Icons.water_drop_rounded,
71 || 73 || 75 || 77 || 85 || 86 => Icons.ac_unit_rounded,
95 || 96 || 99 => Icons.thunderstorm_rounded,
_ => null,
};
}
/// Icône de l'état du ciel publié par le plugin (`weatherCondition`).
static IconData iconeMeteo(String c) => switch (c) {
'clear-day' => Icons.wb_sunny_rounded,

View File

@ -179,3 +179,37 @@ class MeteoActuelle {
_ => condition,
};
}
/// Une heure de météo prévue, lue sur le Thing site. `null` = pas de valeur — jamais 0.
typedef MeteoHeure = ({int? code, bool? jour, double? tempC});
/// **La météo heure par heure** — état `forecastHourlyWeather` du Thing site (openmeteo
/// `etm16`, vérifié sur le banc le 2026-10-15).
///
/// La case `i` porte la valeur **au début du créneau** `t0 + i·step` (valeurs instantanées
/// d'Open-Meteo : `convention` du plugin). La carte la pose donc sur l'heure qui COMMENCE à
/// cet instant — le même `targetAt` que la prévision PV.
///
/// 🔴 `weatherCode` 0 veut dire « ciel dégagé ». Une case `null` reste `null` : pas d'icône,
/// jamais un soleil. Une version de forme inconnue (`v` ≠ 1) ne se lit pas du tout.
Map<DateTime, MeteoHeure>? meteoHoraireDepuis(Object? etat) {
if (etat is! Map) return null;
if (etat['v'] != 1) return null;
final t0 = etat['t0'], step = etat['step'];
if (t0 is! num || step is! num || step <= 0) return null;
List<Object?> serie(String k) => etat[k] is List ? etat[k] as List : const [];
final codes = serie('weatherCode'), jours = serie('isDay'), temps = serie('tempC');
final n = [codes.length, jours.length, temps.length].reduce(max);
final out = <DateTime, MeteoHeure>{};
for (var i = 0; i < n; i++) {
Object? at(List<Object?> l) => i < l.length ? l[i] : null;
final c = at(codes), j = at(jours), t = at(temps);
out[DateTime.fromMillisecondsSinceEpoch(((t0 + i * step) * 1000).toInt(), isUtc: true)] = (
code: c is num ? c.toInt() : null,
jour: j is num ? j != 0 : (j is bool ? j : null),
tempC: t is num ? t.toDouble() : null,
);
}
return out;
}

View File

@ -8,6 +8,9 @@ class NymeaThing {
final List<Map<String, dynamic>> paramValues;
final List<NymeaStateValue> states;
/// Thing parent, quand il y en a un — un plan openmeteo a pour parent son site. Vide sinon.
final String parentId;
const NymeaThing({
required this.id,
required this.name,
@ -15,6 +18,7 @@ class NymeaThing {
required this.setupStatus,
required this.paramValues,
this.states = const [],
this.parentId = '',
});
factory NymeaThing.fromJson(Map<String, dynamic> j) {
@ -27,6 +31,7 @@ class NymeaThing {
states: (j['states'] as List? ?? [])
.map((s) => NymeaStateValue.fromJson(s))
.toList(),
parentId: j['parentId'] as String? ?? '',
);
}
@ -37,6 +42,7 @@ class NymeaThing {
setupStatus: setupStatus,
paramValues: paramValues,
states: states ?? this.states,
parentId: parentId,
);
dynamic settingValue(String settingTypeId) {

View File

@ -1813,6 +1813,15 @@ class NymeaService extends ChangeNotifier {
};
}
/// Le parent d'un Thing (`''` si aucun, ou Thing inconnu) — le site d'un plan openmeteo.
String parentDe(String thingId) {
String nu(String x) => x.replaceAll(RegExp(r'[{}]'), '').toLowerCase();
for (final t in _things) {
if (nu(t.id) == nu(thingId)) return t.parentId;
}
return '';
}
/// Valeur d'un paramètre du Thing par son nom — même logique que [_stateByName].
Object? _paramByName(NymeaThing thing, String name) {
NymeaThingClass? cls;

View File

@ -0,0 +1,163 @@
{
"convention": {
"isDay": "1 day, 0 night, instantaneous at t",
"tempC": "temperature_2m in C, instantaneous at t",
"weatherCode": "WMO code, instantaneous at t"
},
"isDay": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0
],
"issuedAt": 1792033200,
"model": "meteofrance_seamless",
"regime": "forecast",
"step": 3600,
"t0": 1792015200,
"tempC": [
7.6,
7.1,
6.5,
5.6,
5,
5.3,
5.1,
4.1,
4,
4.8,
7.8,
12.3,
14.7,
16.5,
17.8,
18.7,
19.3,
19.6,
18.6,
15.5,
12.6,
10.5,
9.3,
8.5,
8.1,
7.8,
7.2,
6.8,
6.2,
5.8,
5.8,
5.6,
5.2,
6.2,
8.8,
13.3,
15.8,
17.5,
19.4,
20.7,
21,
20.7,
19.5,
16.9,
14.5,
12.9,
12.2,
11.5
],
"v": 1,
"weatherCode": [
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
2,
0,
3,
2,
3,
1,
3,
3,
3,
3,
3,
0,
0,
2,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3
]
}

View File

@ -5,6 +5,9 @@
/// Europe/Paris.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/features/dashboard/widgets/journee_card.dart';
import 'package:etm_powersync_app/models/consumer_series.dart';
import 'package:etm_powersync_app/models/energy_data.dart';
@ -175,4 +178,75 @@ void main() {
expect(find.text('V2C Trydan'), findsOneWidget);
expect(find.text('Maison'), findsOneWidget);
});
group('météo heure par heure (openmeteo etm16)', () {
final banc = jsonDecode(
File('test/fixtures/forecast_hourly_weather_hems_etm16.json').readAsStringSync())
as Map<String, dynamic>;
test('la réponse du banc : 48 cases, posées sur le DÉBUT de chaque heure', () {
final m = meteoHoraireDepuis(banc)!;
expect(m, hasLength(48));
final t0 = DateTime.fromMillisecondsSinceEpoch((banc['t0'] as int) * 1000, isUtc: true);
expect(m[t0]!.code, (banc['weatherCode'] as List).first);
expect(m[t0.add(const Duration(hours: 1))]!.tempC,
((banc['tempC'] as List)[1] as num).toDouble());
});
test('à la LECTURE, une case null reste null — pas un 0, donc pas un soleil', () {
// La fixture du banc n'a aucune null : sans ce test, une lecture qui écrirait 0 à la
// place passerait inaperçue (mesuré : la mutation ne faisait échouer aucun test).
final m = meteoHoraireDepuis({
...banc,
'weatherCode': [0, null],
'isDay': [1, null],
'tempC': [12.0, null],
})!;
final t0 = DateTime.fromMillisecondsSinceEpoch((banc['t0'] as int) * 1000, isUtc: true);
expect(m[t0]!.code, 0);
final apres = m[t0.add(const Duration(hours: 1))]!;
expect(apres.code, isNull);
expect(apres.jour, isNull);
expect(apres.tempC, isNull);
});
test('forme de version inconnue : rien n\'est lu', () {
expect(meteoHoraireDepuis({...banc, 'v': 2}), isNull);
});
testWidgets('un vrai 0 affiche un soleil, un null n\'affiche RIEN', (t) async {
// Le piège nommé par la consigne : WMO 0 = ciel dégagé.
final dix = DateTime(2026, 10, 14, 9); // heure multiple de 3 : affichée
final douze = DateTime(2026, 10, 14, 12);
final hs = construireJournee(
jourLocal: jour,
snapshot: null,
bilanHoraire: const [],
consoHoraire: ConsumerHistory.vide);
await t.pumpWidget(MaterialApp(
home: Scaffold(
body: ListView(children: [
JourneeView(heures: hs, meteoHoraire: {
dix.toUtc(): (code: 0, jour: true, tempC: 12.0),
douze.toUtc(): (code: null, jour: true, tempC: null),
}),
]))));
expect(find.byIcon(Icons.wb_sunny_rounded), findsOneWidget);
expect(find.text('12°'), findsOneWidget);
// Aucune autre icône météo dans la rangée : la case null n'en a pas reçu.
for (final i in [
Icons.nightlight_round,
Icons.cloud_rounded,
Icons.wb_cloudy_rounded,
]) {
expect(find.byIcon(i), findsNothing);
}
});
test('la nuit a sa variante, un code inconnu n\'a pas d\'icône', () {
expect(JourneeView.iconeWmo(0, false), Icons.nightlight_round);
expect(JourneeView.iconeWmo(95, true), Icons.thunderstorm_rounded);
expect(JourneeView.iconeWmo(42, true), isNull);
});
});
}