- nymea_models : discoveryParamTypes, isDiscoveryAddable, ThingDescriptor (+alreadyAdded), thingErrorMessage FR - nymea_service : discoverThings() (0-résultat = "aucun appareil", distinct d'erreur), addThingFromDescriptor() - thing_catalog : bouton "Découvrir" actif pour Discovery+JustAdd - thing_discovery_screen (nouveau) : form slaveAddress (sans sélecteur master) -> scan global -> descriptors (master résolu) -> o:thingId = grisé "déjà ajouté" -> AddThing - retrait d'un discoverThings mort (collision) NON VALIDÉ SUR MATÉRIEL : 3 points marqués "À REVALIDER SUR SDM RÉEL" (résolution master par paramTypeId, timeout scan, title/description). Merge bloqué jusqu'à descriptor SDM réel sur hems .75. Chaîne RPC prouvée live (GPIO : 26 descriptors ajoutables). Réf. UI_DATA_CONTRACT.md §5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
414 lines
15 KiB
Dart
414 lines
15 KiB
Dart
// ── Thing models ──────────────────────────────────────────────────────────────
|
|
|
|
class NymeaThing {
|
|
final String id;
|
|
final String name;
|
|
final String thingClassId;
|
|
final String setupStatus;
|
|
final List<Map<String, dynamic>> paramValues;
|
|
final List<NymeaStateValue> states;
|
|
|
|
const NymeaThing({
|
|
required this.id,
|
|
required this.name,
|
|
required this.thingClassId,
|
|
required this.setupStatus,
|
|
required this.paramValues,
|
|
this.states = const [],
|
|
});
|
|
|
|
factory NymeaThing.fromJson(Map<String, dynamic> j) {
|
|
return NymeaThing(
|
|
id: j['id'] as String? ?? '',
|
|
name: j['name'] as String? ?? 'Unknown',
|
|
thingClassId: j['thingClassId'] as String? ?? '',
|
|
setupStatus: j['setupStatus'] as String? ?? '',
|
|
paramValues: List<Map<String, dynamic>>.from(j['paramValues'] ?? []),
|
|
states: (j['states'] as List? ?? [])
|
|
.map((s) => NymeaStateValue.fromJson(s))
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
NymeaThing copyWith({String? name, List<NymeaStateValue>? states}) => NymeaThing(
|
|
id: id,
|
|
name: name ?? this.name,
|
|
thingClassId: thingClassId,
|
|
setupStatus: setupStatus,
|
|
paramValues: paramValues,
|
|
states: states ?? this.states,
|
|
);
|
|
|
|
dynamic settingValue(String settingTypeId) {
|
|
try {
|
|
return paramValues
|
|
.firstWhere((p) => p['paramTypeId'] == settingTypeId)['value'];
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
dynamic stateValue(String stateTypeId) {
|
|
try {
|
|
return states.firstWhere((s) => s.stateTypeId == stateTypeId).value;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
bool get isSetupComplete => setupStatus == 'ThingSetupStatusComplete';
|
|
}
|
|
|
|
class NymeaStateValue {
|
|
final String stateTypeId;
|
|
final dynamic value;
|
|
|
|
const NymeaStateValue({required this.stateTypeId, required this.value});
|
|
|
|
factory NymeaStateValue.fromJson(Map<String, dynamic> j) => NymeaStateValue(
|
|
stateTypeId: j['stateTypeId'] as String? ?? '',
|
|
value: j['value'],
|
|
);
|
|
}
|
|
|
|
// ── Résultat d'une action nymea ───────────────────────────────────────────────
|
|
class NymeaActionResult {
|
|
final bool success;
|
|
final String thingError; // "ThingErrorNoError" si OK
|
|
final String? displayMessage; // message humain optionnel du serveur
|
|
|
|
const NymeaActionResult({
|
|
required this.success,
|
|
this.thingError = 'ThingErrorNoError',
|
|
this.displayMessage,
|
|
});
|
|
|
|
/// Message d'erreur lisible. Vide si succès.
|
|
String get errorText {
|
|
if (success) return '';
|
|
if (displayMessage != null && displayMessage!.isNotEmpty) return displayMessage!;
|
|
const map = {
|
|
'ThingErrorNoError': '',
|
|
'ThingErrorNotFound': 'Appareil introuvable',
|
|
'ThingErrorInvalidParameter': 'Paramètre invalide',
|
|
'ThingErrorSetupFailed': 'Échec de configuration',
|
|
'ThingErrorHardwareNotAvailable': 'Matériel indisponible',
|
|
'ThingErrorActionTypeNotFound': 'Action introuvable',
|
|
'ThingErrorStateTypeNotFound': 'État introuvable',
|
|
'ThingErrorHardwareFailure': 'Panne matérielle',
|
|
'ThingErrorTimeout': 'Délai dépassé',
|
|
};
|
|
return map[thingError] ?? thingError.replaceAll('ThingError', '');
|
|
}
|
|
}
|
|
|
|
// ── State type definition (from GetThingClasses) ──────────────────────────────
|
|
class NymeaStateType {
|
|
final String id;
|
|
final String name;
|
|
final String displayName;
|
|
final String type; // "Double", "Bool", "Int", "String"
|
|
final String unit; // "W", "V", "A", "Percentage", ""
|
|
final dynamic defaultValue;
|
|
final bool writable;
|
|
final List<dynamic>? allowedValues; // valeurs possibles pour les états enum
|
|
final double? minValue; // borne min pour Int/Double
|
|
final double? maxValue; // borne max pour Int/Double
|
|
|
|
const NymeaStateType({
|
|
required this.id,
|
|
required this.name,
|
|
required this.displayName,
|
|
required this.type,
|
|
required this.unit,
|
|
this.defaultValue,
|
|
this.writable = false,
|
|
this.allowedValues,
|
|
this.minValue,
|
|
this.maxValue,
|
|
});
|
|
|
|
factory NymeaStateType.fromJson(Map<String, dynamic> j) => NymeaStateType(
|
|
id: j['id'] as String? ?? '',
|
|
name: j['name'] as String? ?? '',
|
|
displayName: j['displayName'] as String? ?? j['name'] as String? ?? '',
|
|
type: j['type'] as String? ?? 'String',
|
|
unit: j['unit'] as String? ?? '',
|
|
defaultValue: j['defaultValue'],
|
|
writable: j['writable'] as bool? ?? false,
|
|
allowedValues: (j['allowedValues'] as List?)?.toList()
|
|
?? (j['possibleValues'] as List?)?.toList(),
|
|
minValue: (j['minValue'] as num?)?.toDouble(),
|
|
maxValue: (j['maxValue'] as num?)?.toDouble(),
|
|
);
|
|
|
|
String formatValue(dynamic value) {
|
|
if (value == null) return '—';
|
|
switch (unit) {
|
|
case 'W': return '${_fmt(value)} W';
|
|
case 'kW': return '${_fmt(value)} kW';
|
|
case 'Wh': return '${_fmt(value)} Wh';
|
|
case 'kWh': return '${_fmt(value)} kWh';
|
|
case 'V': return '${_fmt(value)} V';
|
|
case 'A': return '${_fmt(value)} A';
|
|
case 'Percentage': return '${_fmt(value)} %';
|
|
case 'Celsius': return '${_fmt(value)} °C';
|
|
case 'Hz': return '${_fmt(value)} Hz';
|
|
case 'VoltAmpereReactive': return '${_fmt(value)} VAr';
|
|
case 'KiloVoltAmpereHour': return '${_fmt(value)} kVAh';
|
|
case 'Rpm': return '${_fmt(value)} rpm';
|
|
default:
|
|
if (type == 'Bool') return (value == true || value == 'true') ? 'Oui' : 'Non';
|
|
return value.toString();
|
|
}
|
|
}
|
|
|
|
String _fmt(dynamic value) {
|
|
if (value is double) return value.toStringAsFixed(2);
|
|
return value.toString();
|
|
}
|
|
}
|
|
|
|
// ── Action type definition ─────────────────────────────────────────────────────
|
|
class NymeaActionType {
|
|
final String id;
|
|
final String name;
|
|
final String displayName;
|
|
final List<Map<String, dynamic>> paramTypes;
|
|
|
|
const NymeaActionType({
|
|
required this.id,
|
|
required this.name,
|
|
required this.displayName,
|
|
this.paramTypes = const [],
|
|
});
|
|
|
|
factory NymeaActionType.fromJson(Map<String, dynamic> j) => NymeaActionType(
|
|
id: j['id'] as String? ?? '',
|
|
name: j['name'] as String? ?? '',
|
|
displayName: j['displayName'] as String? ?? j['name'] as String? ?? '',
|
|
paramTypes: List<Map<String, dynamic>>.from(j['paramTypes'] ?? []),
|
|
);
|
|
}
|
|
|
|
/// Fabricant (`Integrations.GetVendors` → `Vendor`). Plusieurs vendors peuvent
|
|
/// partager un `displayName` (ex. deux « ABB ») → identité = [id].
|
|
class NymeaVendor {
|
|
final String id;
|
|
final String name;
|
|
final String displayName;
|
|
|
|
const NymeaVendor({
|
|
required this.id,
|
|
required this.name,
|
|
required this.displayName,
|
|
});
|
|
|
|
factory NymeaVendor.fromJson(Map<String, dynamic> j) => NymeaVendor(
|
|
id: j['id'] as String? ?? '',
|
|
name: j['name'] as String? ?? '',
|
|
displayName: j['displayName'] as String? ?? '',
|
|
);
|
|
}
|
|
|
|
class NymeaThingClass {
|
|
final String id;
|
|
final String name;
|
|
final String displayName;
|
|
final List<String> interfaces; // ← clé pour la catégorisation
|
|
final List<NymeaStateType> stateTypes;
|
|
final List<NymeaActionType> actionTypes;
|
|
final List<Map<String, dynamic>> paramTypes;
|
|
final List<NymeaStateType> settingsTypes;
|
|
final String vendorId; // Vendor.id (filtre fabricant)
|
|
final String setupMethod; // enum SetupMethod (détail)
|
|
final List<String> createMethods; // enum CreateMethods (détail)
|
|
final List<Map<String, dynamic>> discoveryParamTypes; // params de DiscoverThings
|
|
|
|
const NymeaThingClass({
|
|
required this.id,
|
|
required this.name,
|
|
required this.displayName,
|
|
this.interfaces = const [],
|
|
this.stateTypes = const [],
|
|
this.actionTypes = const [],
|
|
this.paramTypes = const [],
|
|
this.settingsTypes = const [],
|
|
this.vendorId = '',
|
|
this.setupMethod = '',
|
|
this.createMethods = const [],
|
|
this.discoveryParamTypes = const [],
|
|
});
|
|
|
|
/// Ajoutable par **discovery direct** (lot C) : createMethod Discovery + setup
|
|
/// JustAdd (ni pairing OAuth/PushButton/UserAndPassword → lot D).
|
|
bool get isDiscoveryAddable =>
|
|
createMethods.contains('CreateMethodDiscovery') &&
|
|
setupMethod == 'SetupMethodJustAdd';
|
|
|
|
factory NymeaThingClass.fromJson(Map<String, dynamic> j) => NymeaThingClass(
|
|
id: j['id'] as String? ?? '',
|
|
name: j['name'] as String? ?? '',
|
|
displayName: j['displayName'] as String? ?? '',
|
|
interfaces: List<String>.from(j['interfaces'] ?? []),
|
|
stateTypes: (j['stateTypes'] as List? ?? [])
|
|
.map((s) => NymeaStateType.fromJson(s as Map<String, dynamic>))
|
|
.toList(),
|
|
actionTypes: (j['actionTypes'] as List? ?? [])
|
|
.map((a) => NymeaActionType.fromJson(a as Map<String, dynamic>))
|
|
.toList(),
|
|
paramTypes: List<Map<String, dynamic>>.from(j['paramTypes'] ?? []),
|
|
settingsTypes: (j['settingsTypes'] as List? ?? [])
|
|
.map((s) => NymeaStateType.fromJson(s as Map<String, dynamic>))
|
|
.toList(),
|
|
vendorId: j['vendorId'] as String? ?? '',
|
|
setupMethod: j['setupMethod'] as String? ?? '',
|
|
createMethods: List<String>.from(j['createMethods'] ?? const []),
|
|
discoveryParamTypes:
|
|
List<Map<String, dynamic>>.from(j['discoveryParamTypes'] ?? const []),
|
|
);
|
|
|
|
/// Retourne la stateType par son id
|
|
NymeaStateType? stateTypeById(String id) {
|
|
try { return stateTypes.firstWhere((s) => s.id == id); }
|
|
catch (_) { return null; }
|
|
}
|
|
|
|
/// Retourne la stateType "principale" à afficher sur la card
|
|
NymeaStateType? get primaryStateType {
|
|
// Priorité : power → energy → temperature → connected → on/off → premier
|
|
const priority = [
|
|
'currentPower', 'power', 'totalEnergyConsumed', 'totalEnergyProduced',
|
|
'temperature', 'batterylevel', 'soc', 'connected', 'on',
|
|
];
|
|
for (final p in priority) {
|
|
final found = stateTypes.where(
|
|
(s) => s.name.toLowerCase() == p.toLowerCase()
|
|
);
|
|
if (found.isNotEmpty) return found.first;
|
|
}
|
|
return stateTypes.isNotEmpty ? stateTypes.first : null;
|
|
}
|
|
}
|
|
|
|
// ── Favorite Widgets ──────────────────────────────────────────────────────────
|
|
|
|
enum FavoriteType {
|
|
pvPower,
|
|
homePower,
|
|
batterySOC,
|
|
gridPower,
|
|
rates,
|
|
evCharger,
|
|
thingState,
|
|
chart,
|
|
}
|
|
|
|
class FavoriteWidget {
|
|
final String id;
|
|
final FavoriteType type;
|
|
final String title;
|
|
final String? thingId;
|
|
final String? stateTypeId;
|
|
final Map<String, dynamic> extra;
|
|
|
|
const FavoriteWidget({
|
|
required this.id,
|
|
required this.type,
|
|
required this.title,
|
|
this.thingId,
|
|
this.stateTypeId,
|
|
this.extra = const {},
|
|
});
|
|
|
|
factory FavoriteWidget.fromJson(Map<String, dynamic> j) => FavoriteWidget(
|
|
id: j['id'] as String,
|
|
type: FavoriteType.values.firstWhere(
|
|
(t) => t.name == j['type'],
|
|
orElse: () => FavoriteType.pvPower),
|
|
title: j['title'] as String,
|
|
thingId: j['thingId'] as String?,
|
|
stateTypeId: j['stateTypeId'] as String?,
|
|
extra: Map<String, dynamic>.from(j['extra'] as Map? ?? {}),
|
|
);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'type': type.name,
|
|
'title': title,
|
|
if (thingId != null) 'thingId': thingId,
|
|
if (stateTypeId != null) 'stateTypeId': stateTypeId,
|
|
if (extra.isNotEmpty) 'extra': extra,
|
|
};
|
|
}
|
|
|
|
// ── Historique d'un état ───────────────────────────────────────────────────────
|
|
class HistoryEntry {
|
|
final DateTime timestamp;
|
|
final double value;
|
|
|
|
const HistoryEntry({required this.timestamp, required this.value});
|
|
}
|
|
|
|
/// Résultat de discovery (`Integrations.DiscoverThings` → `ThingDescriptor`).
|
|
/// `id` = thingDescriptorId à passer à `AddThing`. `params` pré-remplis par le
|
|
/// plugin. `thingId` présent ⇒ thing **déjà ajouté** (ne pas ré-ajouter).
|
|
class ThingDescriptor {
|
|
final String id;
|
|
final String title;
|
|
final String description;
|
|
final String thingClassId;
|
|
final List<Map<String, dynamic>> params;
|
|
final String? thingId;
|
|
|
|
const ThingDescriptor({
|
|
required this.id,
|
|
this.title = '',
|
|
this.description = '',
|
|
this.thingClassId = '',
|
|
this.params = const [],
|
|
this.thingId,
|
|
});
|
|
|
|
bool get alreadyAdded => thingId != null && thingId!.isNotEmpty;
|
|
|
|
factory ThingDescriptor.fromJson(Map<String, dynamic> j) => ThingDescriptor(
|
|
id: j['id'] as String? ?? '',
|
|
title: j['title'] as String? ?? '',
|
|
description: j['description'] as String? ?? '',
|
|
thingClassId: j['thingClassId'] as String? ?? '',
|
|
params: List<Map<String, dynamic>>.from(j['params'] ?? const []),
|
|
thingId: j['thingId'] as String?,
|
|
);
|
|
|
|
/// Valeur d'un param par son `paramTypeId` (pour résoudre slaveAddress/master).
|
|
dynamic paramValue(String paramTypeId) {
|
|
for (final p in params) {
|
|
if (p['paramTypeId'] == paramTypeId) return p['value'];
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Mappe un `ThingError` fil → message lisible (FR). Chaîne vide pour NoError.
|
|
String thingErrorMessage(String code) => switch (code) {
|
|
'ThingErrorNoError' => '',
|
|
'ThingErrorThingClassNotFound' => 'Type d\'appareil introuvable.',
|
|
'ThingErrorThingDescriptorNotFound' =>
|
|
'Résultat de découverte expiré — relancez la recherche.',
|
|
'ThingErrorMissingParameter' => 'Paramètre manquant.',
|
|
'ThingErrorInvalidParameter' => 'Paramètre invalide.',
|
|
'ThingErrorSetupFailed' => 'Échec de l\'installation de l\'appareil.',
|
|
'ThingErrorDuplicateUuid' => 'Cet appareil est déjà ajouté.',
|
|
'ThingErrorCreationMethodNotSupported' =>
|
|
'Ajout direct non supporté pour ce type.',
|
|
'ThingErrorSetupMethodNotSupported' =>
|
|
'Cette méthode d\'installation n\'est pas supportée.',
|
|
'ThingErrorHardwareNotAvailable' =>
|
|
'Matériel indisponible (bus/port hors-ligne ?).',
|
|
'ThingErrorHardwareFailure' => 'Défaillance matérielle.',
|
|
'ThingErrorAuthenticationFailure' => 'Échec d\'authentification.',
|
|
'ThingErrorTimeout' => 'Délai dépassé — appareil injoignable.',
|
|
_ => 'Erreur : $code',
|
|
};
|