Deux constats du brief moteur, qui touchent le même fichier de lecture. 1. kCodesReserveBatterie était vide, et les clés cherchées n'ont JAMAIS existé côté plugin : batteryLevel/soc et threshold/batteryLevelConsideration contre socPercent, reservePercent, withheldW. Le motif sortait donc en repli — c'est-à-dire en anglais brut — sur le seul cas que le client ne peut déduire d'aucune mesure : « il y a du surplus, une règle l'interdit ». Vérifié contre decisionreason.h. Les trois paramètres sont des entiers DÉJÀ en pourcentage : reservePercent vaut 40, pas 0,4 — à ne pas confondre avec batteryLevelConsideration, qui est la fraction du réglage. Et withheldW mène la phrase, comme côté plugin : le moteur en fait la condition d'émission du motif, parce qu'un motif à 0 W dirait « une règle vous bloque » là où la vérité est « il n'y a pas de surplus ». Le motif n'a PAS pu être observé en vrai sur .75 : la bascule du seuil à 0,9 a produit surplusW à −3 551 W, donc rien à confisquer, donc garde du plugin active et motif tu — ce qui est le comportement juste. Il faut du surplus réel sous le seuil. Seuil remis à 0,4. 2. funding (« surplus » / « grid ») est lu, et avec lui l'identité que le plugin déclare : somme des allocatedW financés au surplus == budget.allocatedW. Sommer sans filtrer compte une borne servie au réseau et creuse un écart qu'on ne peut pas interpréter. Absent = surplus, pas zéro : c'est le seul financement qui existait avant ce champ. 3. Les bornes sont entrées dans loads[] (+etm22), et adjustEvChargers() re-décide après le waterfall : pour une borne, allocatedW et son motif sont la DÉCISION DE L'ARBITRE, pas l'état de la borne. L'écran affiche donc d'abord ce que le matériel fait (mechanism.chargingEnabled / currentA, qui viennent du Thing), ensuite ce que le moteur a voulu, étiqueté comme tel. L'écart mesuré/commandé se tait sur une borne — « sous la consigne » y supposerait une consigne. La réserve ne touche PAS l'ECS ni la PAC, où l'allocation publiée est bien la commande : elle est portée par allocationIsCommand, et tombe avec le lot plugin 3g-2. EV_SURPLUS, EV_ECO_MIN et EV_IDLE ne sont plus émis. Les branches restent : les retirer rendrait illisible un journal antérieur, pas l'app plus juste. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
431 lines
18 KiB
Dart
431 lines
18 KiB
Dart
/// Vue **en lecture** au-dessus de la charge utile de `NymeaEnergy.GetLoadTelemetry`.
|
||
///
|
||
/// ## Ce que ce type n'est pas
|
||
///
|
||
/// Ce n'est **pas** un état que l'app entretient. C'est l'instantané du dernier cycle
|
||
/// d'arbitrage **publié par la box**, transporté tel quel. Rien n'y est extrapolé entre
|
||
/// deux trames : ni les secondes restantes d'un verrou (elles décroissent sur la box, et
|
||
/// le plugin les exclut explicitement de sa détection de changement), ni une allocation
|
||
/// qui « devrait » avoir bougé. Une valeur affichée ici a été dite par la box.
|
||
///
|
||
/// ## Omis, jamais nul
|
||
///
|
||
/// Trois champs sont optionnels au schéma, et leur **absence porte un sens** que zéro ne
|
||
/// porterait pas :
|
||
///
|
||
/// - `timestamp` absent = **aucun cycle d'arbitrage depuis le démarrage du service**. En
|
||
/// forger un se lirait « cycle exécuté, rien à allouer ».
|
||
/// - `budget` absent = **mode dégradé L2** : la planification est suspendue, il n'existe
|
||
/// aucun budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à
|
||
/// allouer ».
|
||
/// - `mechanism` absent = la charge n'a pas publié de charge utile de mécanisme ce
|
||
/// cycle-là.
|
||
///
|
||
/// D'où l'usage systématique de types nullables plutôt que de valeurs par défaut.
|
||
///
|
||
/// ## Codes, jamais des phrases
|
||
///
|
||
/// `decision.code` et `faultCode` sont des **codes à catalogue fermé** accompagnés de
|
||
/// paramètres numériques. La box ne compose aucune phrase : la locale nymea est par
|
||
/// connexion, et `NymeaEnergyJsonHandler` est un `JsonHandler` sans `pluginId`, donc
|
||
/// structurellement hors du catalogue de traduction. Le rendu est à la charge de l'app
|
||
/// (`services/telemetry_text.dart`).
|
||
library;
|
||
|
||
/// Seuil de fraîcheur — au-delà, l'instantané est présenté comme **ancien**.
|
||
///
|
||
/// Calibré sur le battement de cœur du plugin : `LoadTelemetryChanged` est émise à
|
||
/// chaque changement significatif **et** périodiquement toutes les 60 s même sans
|
||
/// changement, et le `timestamp` avance d'un cycle par minute (vérifié sur `.75` :
|
||
/// 12:37 → 12:38 → 12:39). Un seuil ≤ 60 s crierait donc « données anciennes » sur une
|
||
/// installation parfaitement vivante, au premier battement un peu tardif.
|
||
///
|
||
/// Trois cycles laissent passer un battement manqué et un aller-retour réseau lent, sans
|
||
/// laisser un arbitre réellement figé passer pour une installation stable plus de trois
|
||
/// minutes.
|
||
const Duration kTelemetryStaleAfter = Duration(seconds: 180);
|
||
|
||
/// Décomposition du budget de surplus du cycle. Absent en mode dégradé.
|
||
class TelemetryBudget {
|
||
/// Budget entrant du waterfall non-EV : net signé − réservation EV.
|
||
final double surplusW;
|
||
|
||
/// Puissance EV commandée ce cycle, pas encore vue au compteur.
|
||
final double evReservedW;
|
||
|
||
/// Recrédits anti-clignotement rendus aux charges avant arrondi.
|
||
final double recreditedW;
|
||
|
||
/// Somme des allocations du cycle.
|
||
final double allocatedW;
|
||
|
||
/// Résidu de fin de cascade.
|
||
final double remainingW;
|
||
|
||
const TelemetryBudget({
|
||
required this.surplusW,
|
||
required this.evReservedW,
|
||
required this.recreditedW,
|
||
required this.allocatedW,
|
||
required this.remainingW,
|
||
});
|
||
|
||
factory TelemetryBudget.fromJson(Map<String, dynamic> m) => TelemetryBudget(
|
||
surplusW: _d(m['surplusW']),
|
||
evReservedW: _d(m['evReservedW']),
|
||
recreditedW: _d(m['recreditedW']),
|
||
allocatedW: _d(m['allocatedW']),
|
||
remainingW: _d(m['remainingW']),
|
||
);
|
||
|
||
/// Identité que le plugin déclare vérifiable :
|
||
/// `surplusW + recreditedW − allocatedW == remainingW`.
|
||
///
|
||
/// `recreditedW` n'est pas décoratif : le waterfall rend à chaque charge sa propre
|
||
/// consommation de début de cycle avant d'arrondir. Sans ce terme, la somme serait
|
||
/// fausse dès qu'une charge consomme déjà — c'est-à-dire dans le cas normal.
|
||
///
|
||
/// Tolérance 1 W : les valeurs voyagent en `Double`.
|
||
bool get identityHolds =>
|
||
(surplusW + recreditedW - allocatedW - remainingW).abs() < 1.0;
|
||
}
|
||
|
||
/// Verrou actif sur une charge. Absent quand aucun verrou ne mord.
|
||
class LoadLock {
|
||
/// `minOn` · `minOff` · `minStateHold`.
|
||
final String kind;
|
||
|
||
/// Secondes restantes **au moment du cycle publié** — jamais décomptées côté app.
|
||
final int remainingS;
|
||
|
||
const LoadLock({required this.kind, required this.remainingS});
|
||
|
||
factory LoadLock.fromJson(Map<String, dynamic> m) => LoadLock(
|
||
kind: m['kind'] as String? ?? '',
|
||
remainingS: (m['remainingS'] as num?)?.toInt() ?? 0,
|
||
);
|
||
}
|
||
|
||
/// Union discriminée par `kind` — chaque variante porte sa charge utile, et seulement la
|
||
/// sienne. Les clés sont toutes optionnelles au schéma (règle LM-302 du plugin) : une
|
||
/// variante ajoutée par une box plus récente ne doit pas faire échouer la lecture.
|
||
class LoadMechanism {
|
||
final Map<String, dynamic> raw;
|
||
|
||
/// `relay` · `variable` · `sgReady` · `evcharger` — ce dernier émis depuis `+etm22`.
|
||
final String kind;
|
||
|
||
const LoadMechanism(this.raw, this.kind);
|
||
|
||
factory LoadMechanism.fromJson(Map<String, dynamic> m) =>
|
||
LoadMechanism(m, m['kind'] as String? ?? '');
|
||
|
||
bool get isRelay => kind == 'relay';
|
||
bool get isVariable => kind == 'variable';
|
||
bool get isSgReady => kind == 'sgReady';
|
||
bool get isEvCharger => kind == 'evcharger';
|
||
|
||
// relay
|
||
double? get stageW => _dn(raw['stageW']);
|
||
double? get maxStageW => _dn(raw['maxStageW']);
|
||
|
||
/// Plancher de modulation — la consigne non nulle la plus basse que la charge accepte.
|
||
///
|
||
/// **Publié en télémétrie avant d'être configurable** : le champ de configuration
|
||
/// correspondant n'est pas encore au schéma de `SetLoadConfig`. On le LIT donc, sans
|
||
/// prétendre le régler — cf. le bloc « Plancher » de l'écran de mécanisme, grisé tant
|
||
/// que le champ d'écriture n'existe pas.
|
||
double? get minPowerW => _dn(raw['minPowerW']);
|
||
|
||
/// Paliers atteignables, **publiés par la box** depuis `+etm16`.
|
||
///
|
||
/// Une seule fonction moteur (`RelayRouter::deriveStages`) sert la construction du
|
||
/// routeur, la comparaison matérielle et cette publication : il n'y a donc plus de
|
||
/// combinatoire à refaire côté app, ni de divergence possible.
|
||
///
|
||
/// **Elle est en télémétrie, pas dans `GetLoadConfig`** — précisément pour qu'elle ne
|
||
/// parte jamais dans une écriture : ce sont les relais qui se déclarent, les paliers
|
||
/// s'en déduisent.
|
||
///
|
||
/// `null` sur une box antérieure, ou sur un mécanisme qui n'en produit pas.
|
||
List<int>? get stagesW {
|
||
final v = raw['stagesW'];
|
||
if (v is! List) return null;
|
||
return [for (final x in v) if (x is num) x.toInt()];
|
||
}
|
||
|
||
// variable
|
||
double? get setpointW => _dn(raw['setpointW']);
|
||
double? get percent => _dn(raw['percent']);
|
||
double? get maxPowerW => _dn(raw['maxPowerW']);
|
||
|
||
// sgReady — `estimatedPowerW` est une ESTIMATION déclarée, jamais un engagement.
|
||
int? get state => (raw['state'] as num?)?.toInt();
|
||
double? get estimatedPowerW => _dn(raw['estimatedPowerW']);
|
||
|
||
// ── evcharger (3g) — l'ÉTAT RÉEL de la borne ────────────────────────────────
|
||
//
|
||
// Ces quatre champs viennent du Thing, pas de l'arbitre. C'est la distinction qui
|
||
// compte tant que 3g-2 n'est pas livré : `adjustEvChargers()` re-décide le surplus
|
||
// APRÈS le waterfall et passe outre le refus de l'arbitre. Relevé sur `.75`, deux
|
||
// lignes du même cycle — l'arbitre publie `BELOW_MIN_POWER` et « 0 W », le proxy
|
||
// commande `ON` à 6 A une milliseconde plus tard.
|
||
//
|
||
// Donc : pour une borne, [LoadTelemetryEntry.allocatedW] et son motif sont une
|
||
// INTENTION, ces champs-ci sont ce que la borne fait. Les deux peuvent se contredire,
|
||
// et c'est l'écran qui aurait l'air faux. Cf. [LoadTelemetryEntry.allocationIsCommand].
|
||
bool? get chargingEnabled => raw['chargingEnabled'] as bool?;
|
||
bool? get pluggedIn => raw['pluggedIn'] as bool?;
|
||
double? get currentA => _dn(raw['currentA']);
|
||
int? get phaseCount => (raw['phaseCount'] as num?)?.toInt();
|
||
}
|
||
|
||
/// Motif de décision : un code du catalogue fermé, et ses paramètres numériques.
|
||
///
|
||
/// `params` ne contient que des nombres et des booléens ; le libellé de la charge n'y
|
||
/// figure jamais — le client le résout via `GetLoadConfig`.
|
||
class LoadDecision {
|
||
final String code;
|
||
final Map<String, dynamic> params;
|
||
|
||
const LoadDecision({required this.code, required this.params});
|
||
|
||
factory LoadDecision.fromJson(Map<String, dynamic>? m) {
|
||
if (m == null) return const LoadDecision(code: '', params: {});
|
||
final p = m['params'];
|
||
return LoadDecision(
|
||
code: m['code'] as String? ?? '',
|
||
params: p is Map ? Map<String, dynamic>.from(p) : const {},
|
||
);
|
||
}
|
||
|
||
double? numParam(String key) => _dn(params[key]);
|
||
bool? boolParam(String key) => params[key] as bool?;
|
||
}
|
||
|
||
/// État runtime d'**une** charge arbitrée par le waterfall.
|
||
class LoadTelemetryEntry {
|
||
final Map<String, dynamic> raw;
|
||
final String loadId;
|
||
|
||
/// Ce que le waterfall a **alloué** — pas une mesure de compteur.
|
||
final double allocatedW;
|
||
|
||
/// Faux = charge en défaut : aucune commande n'est émise, et le verrou ne se lève pas
|
||
/// tout seul (`ClearLoadFault` est une action délibérée d'exploitant).
|
||
final bool available;
|
||
|
||
/// Absent quand la charge est saine.
|
||
final String? faultCode;
|
||
|
||
/// **Qui paie** cette allocation : `"surplus"` ou `"grid"`. Nouveau en `+etm22`.
|
||
///
|
||
/// Il devient nécessaire du seul fait que les bornes sont entrées dans `loads[]` : deux
|
||
/// charges peuvent y figurer pour des raisons différentes.
|
||
///
|
||
/// - `"surplus"` — la cascade PV. L'allocation est comptée dans `budget.allocatedW`.
|
||
/// - `"grid"` — le proxy (échéance de départ, tarif dynamique) qui **soutire au
|
||
/// réseau**. Elle vit dans `budget.evReservedW`, PAS dans `budget.allocatedW`.
|
||
///
|
||
/// **Sommer les `allocatedW` sans filtrer donne un écart avec `budget.allocatedW` que
|
||
/// rien ne permet d'interpréter** : défaut du moteur, ou borne servie au réseau ? Cf.
|
||
/// [LoadTelemetry.surplusAllocatedW].
|
||
///
|
||
/// `null` = **omis**, jamais « zéro financement » : en mode dégradé il n'y a pas de
|
||
/// plan, donc pas de financement. Une box antérieure à `+etm22` ne le publie pas non
|
||
/// plus — d'où le repli explicite de [isSurplusFunded].
|
||
final String? funding;
|
||
|
||
/// Puissance **MESURÉE** par le compteur rattaché à cette charge. `null` quand aucun
|
||
/// compteur ne l'est.
|
||
///
|
||
/// Elle ne remplace pas [allocatedW] et ne s'y substitue jamais : l'une est ce que le
|
||
/// waterfall a **décidé**, l'autre ce qui **passe réellement**. Les afficher côte à côte
|
||
/// est tout l'intérêt du rattachement — commandé 3 000 W, mesuré 0 W, la charge ne fait
|
||
/// pas ce qu'on lui demande, et c'est la seule chose que la mesure autorise à conclure.
|
||
///
|
||
/// **`null` n'est pas 0.** Une charge sans compteur ne publie rien, et afficher un zéro
|
||
/// à la place ferait passer une absence de mesure pour une absence de consommation.
|
||
final double? measuredW;
|
||
|
||
final LoadLock? lock;
|
||
final LoadMechanism? mechanism;
|
||
final LoadDecision decision;
|
||
|
||
const LoadTelemetryEntry({
|
||
required this.raw,
|
||
required this.loadId,
|
||
required this.allocatedW,
|
||
required this.available,
|
||
required this.faultCode,
|
||
this.measuredW,
|
||
this.funding,
|
||
required this.lock,
|
||
required this.mechanism,
|
||
required this.decision,
|
||
});
|
||
|
||
factory LoadTelemetryEntry.fromJson(Map<String, dynamic> m) {
|
||
final lock = m['lock'];
|
||
final mech = m['mechanism'];
|
||
final fault = m['faultCode'];
|
||
return LoadTelemetryEntry(
|
||
raw: m,
|
||
loadId: m['loadId'] as String? ?? '',
|
||
allocatedW: _d(m['allocatedW']),
|
||
// Absent au schéma ne peut pas arriver (champ requis) ; en cas de box exotique,
|
||
// « disponible » est le repli qui n'invente pas un défaut.
|
||
available: m['available'] as bool? ?? true,
|
||
faultCode: (fault is String && fault.isNotEmpty) ? fault : null,
|
||
measuredW: _dn(m['measuredW']),
|
||
funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty)
|
||
? m['funding'] as String
|
||
: null,
|
||
lock: lock is Map ? LoadLock.fromJson(Map<String, dynamic>.from(lock)) : null,
|
||
mechanism: mech is Map
|
||
? LoadMechanism.fromJson(Map<String, dynamic>.from(mech))
|
||
: null,
|
||
decision: LoadDecision.fromJson(
|
||
m['decision'] is Map ? Map<String, dynamic>.from(m['decision'] as Map) : null),
|
||
);
|
||
}
|
||
|
||
bool get inFault => !available;
|
||
|
||
/// Vrai quand l'allocation est **financée par le surplus** — donc comptée dans
|
||
/// `budget.allocatedW`.
|
||
///
|
||
/// `funding` absent (mode dégradé, ou box antérieure à `+etm22`) est traité comme
|
||
/// surplus : c'est le seul financement qui existait avant ce champ, et le compter
|
||
/// laisse l'identité vérifiable sur une box ancienne au lieu de la faire échouer.
|
||
bool get isSurplusFunded => funding != 'grid';
|
||
|
||
/// **Faux pour une borne : l'allocation publiée est une INTENTION, pas un ordre.**
|
||
///
|
||
/// `adjustEvChargers()` re-décide le surplus après le waterfall et passe outre le refus
|
||
/// de l'arbitre — relevé sur `.75`, l'arbitre publie « 0 W / `BELOW_MIN_POWER` » et le
|
||
/// proxy commande `ON` à 6 A dans le même cycle. Pour une borne, [allocatedW] et
|
||
/// [decision] sont donc **la décision de l'arbitre**, et l'état réel se lit dans
|
||
/// `mechanism.chargingEnabled` / `mechanism.currentA`, qui viennent du Thing.
|
||
///
|
||
/// Écrire « la borne ne charge pas parce que le budget est sous le plancher » à partir
|
||
/// du seul motif est faux aujourd'hui, et c'est l'écran qui aurait l'air faux.
|
||
///
|
||
/// La réserve **ne vaut pas** pour l'ECS, la PAC et les autres charges pilotées : là,
|
||
/// l'allocation publiée **est** la commande.
|
||
///
|
||
/// Elle tombe avec le lot plugin 3g-2 (« l'arbitre commande seul »). Le jour où il est
|
||
/// déployé, ce getter rend `true` partout et les branches qui le lisent disparaissent.
|
||
bool get allocationIsCommand => mechanism?.isEvCharger != true;
|
||
}
|
||
|
||
/// Instantané complet de l'arbitrage.
|
||
class LoadTelemetry {
|
||
final Map<String, dynamic> raw;
|
||
|
||
/// `true` = watchdog L2 en repli (compteur muet > 90 s, planification suspendue).
|
||
final bool degradedMode;
|
||
|
||
/// Fin du **dernier cycle d'arbitrage**. `null` = aucun cycle depuis le démarrage du
|
||
/// service : c'est la preuve de fraîcheur, et son absence est une information.
|
||
final DateTime? timestamp;
|
||
|
||
/// `null` en mode dégradé — il n'existe alors aucun budget pour ce cycle.
|
||
final TelemetryBudget? budget;
|
||
|
||
/// Les charges arbitrées, **bornes de recharge comprises depuis `+etm22`**.
|
||
///
|
||
/// Jusque-là `buildTelemetry()` sautait les bornes : elles étaient arbitrées, leurs
|
||
/// décisions allaient au journal du plugin, et l'écran ne pouvait pas les voir. Deux
|
||
/// conséquences pour un client :
|
||
///
|
||
/// - une borne peut être là au titre du surplus **ou** au titre du réseau (échéance,
|
||
/// tarif dynamique) — d'où [LoadTelemetryEntry.funding] ;
|
||
/// - pour une borne, l'allocation publiée n'est **pas** un ordre tant que 3g-2 n'est pas
|
||
/// livré — d'où [LoadTelemetryEntry.allocationIsCommand].
|
||
///
|
||
/// Depuis le lot plugin B-bis, tout `loadId` publié ici a une entrée dans
|
||
/// `GetLoadConfig` — l'inverse n'est pas vrai : une charge configurée absente d'ici est
|
||
/// **désactivée**, jamais perdue.
|
||
final List<LoadTelemetryEntry> loads;
|
||
|
||
const LoadTelemetry({
|
||
required this.raw,
|
||
required this.degradedMode,
|
||
required this.timestamp,
|
||
required this.budget,
|
||
required this.loads,
|
||
});
|
||
|
||
factory LoadTelemetry.fromJson(Map<String, dynamic> m) {
|
||
final ts = m['timestamp'];
|
||
final budget = m['budget'];
|
||
final loads = m['loads'];
|
||
return LoadTelemetry(
|
||
raw: m,
|
||
degradedMode: m['degradedMode'] as bool? ?? false,
|
||
timestamp: (ts is String && ts.isNotEmpty) ? DateTime.tryParse(ts) : null,
|
||
budget: budget is Map
|
||
? TelemetryBudget.fromJson(Map<String, dynamic>.from(budget))
|
||
: null,
|
||
loads: loads is List
|
||
? [
|
||
for (final l in loads.whereType<Map>())
|
||
LoadTelemetryEntry.fromJson(Map<String, dynamic>.from(l)),
|
||
]
|
||
: const [],
|
||
);
|
||
}
|
||
|
||
/// `true` tant qu'aucun cycle d'arbitrage n'a eu lieu depuis le démarrage du service.
|
||
bool get hasNeverRun => timestamp == null;
|
||
|
||
/// Somme des allocations **financées par le surplus**.
|
||
///
|
||
/// C'est le seul terme comparable à `budget.allocatedW` : une borne servie au réseau
|
||
/// est comptée dans `budget.evReservedW`, et l'inclure ici creuserait un écart que rien
|
||
/// ne permettrait d'interpréter — défaut du moteur, ou borne au réseau ?
|
||
double get surplusAllocatedW {
|
||
var total = 0.0;
|
||
for (final e in loads) {
|
||
if (e.isSurplusFunded) total += e.allocatedW;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/// L'identité que le plugin déclare vérifiable depuis `+etm22` :
|
||
/// **somme des `allocatedW` financés au surplus == `budget.allocatedW`**.
|
||
///
|
||
/// `null` en mode dégradé — sans budget, il n'y a rien à réconcilier, et rendre `false`
|
||
/// se lirait « le moteur se contredit » là où il n'a simplement pas planifié.
|
||
///
|
||
/// Tolérance 1 W : les valeurs voyagent en `Double`.
|
||
bool? get surplusIdentityHolds {
|
||
final b = budget;
|
||
if (b == null) return null;
|
||
return (surplusAllocatedW - b.allocatedW).abs() < 1.0;
|
||
}
|
||
|
||
LoadTelemetryEntry? entryFor(String loadId) {
|
||
for (final e in loads) {
|
||
if (e.loadId == loadId) return e;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Âge du dernier cycle publié, mesuré contre l'horloge locale.
|
||
///
|
||
/// `null` si aucun cycle n'a eu lieu. **Négatif ramené à zéro** : l'horloge de la box
|
||
/// et celle du téléphone ne sont pas synchronisées, et un âge négatif se lirait comme
|
||
/// une donnée du futur.
|
||
Duration? ageAt(DateTime now) {
|
||
final ts = timestamp;
|
||
if (ts == null) return null;
|
||
final d = now.toUtc().difference(ts.toUtc());
|
||
return d.isNegative ? Duration.zero : d;
|
||
}
|
||
}
|
||
|
||
double _d(Object? v) => (v as num?)?.toDouble() ?? 0.0;
|
||
double? _dn(Object? v) => (v as num?)?.toDouble();
|