diff --git a/.gitignore b/.gitignore index bb438a2..eea0033 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ workspace/* !workspace/*.md !workspace/bourse-config.json +# code du concierge véhicule (aucun secret : lit les tokens depuis des fichiers ignorés) +!workspace/concierge-*.js !workspace/cron/ workspace/cron/* !workspace/cron/*.md diff --git a/workspace/concierge-detection.js b/workspace/concierge-detection.js new file mode 100644 index 0000000..efcef97 --- /dev/null +++ b/workspace/concierge-detection.js @@ -0,0 +1,195 @@ +// concierge-detection.js — Concierge véhicule Airtria, ÉTAGE DÉTECTION (lecture seule) +// ----------------------------------------------------------------------------- +// Rôle : à partir de PostgreSQL (rôle airtria_ro, SELECT seul), détecter les +// branchements et les sessions de charge, regrouper les sessions par branchement +// via le journal `evenement`, et décider pour chaque groupe : « invité auto » ou +// « poser la question ». AUCUNE écriture, nulle part. Aucun envoi WhatsApp ici. +// +// Ce module est la base réutilisée par le concierge live ; le mode --calibration +// se contente d'imprimer le regroupement pour le valider contre la vérité terrain +// (semaine 08-12/09 : structure attendue 2-3-2-1-1-1, 5 questions, Megane auto). +// +// Design complet et raisons : voir airtria-concierge-vehicule-SPEC.md +// ----------------------------------------------------------------------------- +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { Client } = require('pg'); // dépendance à ajouter : npm install pg + +// --- Paramètres ------------------------------------------------------------- +const PG = { + host: process.env.PGHOST || '10.10.10.22', + port: Number(process.env.PGPORT || 5433), + database: process.env.PGDATABASE || 'evcs', + user: process.env.PGUSER || 'airtria_ro', + // mot de passe lu depuis un fichier protégé, jamais en dur ni en argument + password: readSecret(process.env.PG_PASS_FILE || + path.join(__dirname, '.pg-airtria')), +}; + +// Règle invité (voir SPEC, sens unique) : conclusif SEULEMENT au-dessus du seuil. +const CONSIGNE_MIN_A = 20; // en dessous : aucune information → demander +const P_INVITE_W = 4200; // VW/JEEP ~3,6-3,8 kW ; Megane 6,36 kW à 28 A + +function readSecret(file) { + try { + return fs.readFileSync(file, 'utf8').trim(); + } catch (e) { + console.error(`[concierge] Mot de passe introuvable (${file}). ` + + `Dépose-le en lecture seule (chmod 600) avant de lancer.`); + process.exit(2); + } +} + +// --- Requêtes (lecture seule) ---------------------------------------------- +// Transitions branché/débranché : le FAIT qui donne la frontière des groupes. +// On ordonne sur ts, on borne les intervalles sur horodatage_ha (vérité physique +// même après un rattrapage post-coupure). `entite` n'existe que sur les transitions. +const SQL_TRANSITIONS = ` + SELECT extract(epoch FROM ts) AS ts_epoch, + extract(epoch FROM (detail->>'horodatage_ha')::timestamptz) AS ha_epoch, + detail->>'apres' AS etat + FROM evenement + WHERE detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' + AND ts >= $1 AND ts < $2 + ORDER BY ts`; + +// Sessions de charge. `debut` renvoyé AUSSI en texte VERBATIM (ms + offset) : +// c'est la clef de jointure exacte que le helper devra porter (voir SPEC). +const SQL_SESSIONS = ` + SELECT debut::text AS debut_text, + extract(epoch FROM debut) AS debut_epoch, + extract(epoch FROM debranche_a) AS debranche_epoch, + vehicule, vehicule_sur, + p_max_w, consigne_max_a, courant_max_a, + energie_compteur_kwh, energie_kwh, terminee + FROM session_charge + WHERE debut >= $1 AND debut < $2 + ORDER BY debut`; + +// --- Logique de regroupement ----------------------------------------------- +// Un branchement = [on ... off suivant]. Défensif face au flapping du capteur : +// un 'on' déjà ouvert est ignoré, un 'off' sans ouverture est ignoré. +function buildPlugins(transitions) { + const plugins = []; + let open = null; + for (const t of transitions) { + const at = t.ha_epoch != null ? Number(t.ha_epoch) : Number(t.ts_epoch); + if (t.etat === 'on') { + if (open === null) open = { on: at, off: null }; + } else if (t.etat === 'off') { + if (open !== null) { open.off = at; plugins.push(open); open = null; } + } + } + if (open !== null) plugins.push(open); // branchement encore en cours + return plugins; +} + +function isGuest(s) { + return s.consigne_max_a != null && s.p_max_w != null && + Number(s.consigne_max_a) >= CONSIGNE_MIN_A && Number(s.p_max_w) > P_INVITE_W; +} + +// Associe chaque session à son branchement. Une session hors de tout intervalle +// connu = frontière absente (coupure du service) → INCERTAINE : on la traitera +// seule (question par session), jamais sur une frontière reconstruite. +// Tolérance sur la borne BASSE : la session qui ouvre un branchement est écrite +// quelques dizaines de ms AVANT que l'événement 'on' soit journalisé (même cycle, +// session d'abord — observé 62 ms le 09/09). Une borne stricte l'exclurait. 2 s +// ne peut pas happer le branchement précédent (aucun écart off→on observé < 2 min). +const ASSIGN_TOL_S = 2; +function assign(sessions, plugins) { + const groups = plugins.map(p => ({ plugin: p, sessions: [] })); + const uncertain = []; + for (const s of sessions) { + const d = Number(s.debut_epoch); + const g = groups.find(g => + d >= g.plugin.on - ASSIGN_TOL_S && (g.plugin.off === null || d <= g.plugin.off)); + if (g) g.sessions.push(s); else uncertain.push(s); + } + return { groups: groups.filter(g => g.sessions.length > 0), uncertain }; +} + +// Décision par groupe : tous invités → auto (pas de question) ; sinon 1 question. +// (Le test « sélecteur périmé » est fait en live via HA ; en calibration, le +// sélecteur était figé toute la semaine, donc tout non-invité → question.) +function decide(group) { + const allGuest = group.sessions.every(isGuest); + return allGuest ? { action: 'auto-invite' } : { action: 'question' }; +} + +// --- Programme -------------------------------------------------------------- +async function main() { + // ⚠️ Une fenêtre d'analyse doit COMMENCER SUR UN 'on', jamais au milieu d'un + // branchement : sinon le premier événement est un 'off' orphelin et la session + // ouverte avant la fenêtre tombe hors intervalle (classée « incertaine » à tort). + // En production ce n'est pas un souci (on suit le flux) ; seulement au rejeu. + // Calibration : 08/09 19:00 = juste avant le 'on' de 19:39 (6 branchements complets). + const since = process.env.SINCE || '2026-09-08T19:00:00+02:00'; + const until = process.env.UNTIL || '2026-09-13T00:00:00+02:00'; + + const client = new Client(PG); + await client.connect(); + try { + const tr = await client.query(SQL_TRANSITIONS, [since, until]); + const se = await client.query(SQL_SESSIONS, [since, until]); + const plugins = buildPlugins(tr.rows); + const { groups, uncertain } = assign(se.rows, plugins); + + let questions = 0; + let mixedGroups = 0; + console.log(`\n=== Calibration ${since} → ${until} ===`); + console.log(`Branchements détectés : ${plugins.length} | ` + + `sessions : ${se.rows.length} | transitions : ${tr.rows.length}`); + const shape = []; + for (const g of groups) { + const d = decide(g); + if (d.action === 'question') questions++; + shape.push(g.sessions.length); + // Invariant : un seul câble → toutes les sessions d'un branchement portent + // la même étiquette. Un groupe à étiquettes mixtes = frontière fausse ici + // OU ligne fausse en base ; les deux méritent une alerte (contrôle continu). + const labels = [...new Set(g.sessions.map(s => s.vehicule).filter(Boolean))]; + const mixed = labels.length > 1; + if (mixed) mixedGroups++; + const on = new Date(g.plugin.on * 1000).toISOString(); + const off = g.plugin.off ? new Date(g.plugin.off * 1000).toISOString() : 'EN COURS'; + console.log(`\n[branchement ${on} → ${off}] ${d.action}` + + (mixed ? ` 🚨 ÉTIQUETTES MIXTES: ${labels.join(', ')}` : '')); + for (const s of g.sessions) { + const div = (s.energie_compteur_kwh != null && s.energie_kwh != null && + Math.abs(Number(s.energie_compteur_kwh) - Number(s.energie_kwh)) > 0.5) + ? ' ⚠️ écart compteur/intégré' : ''; + console.log(` ${s.debut_text} p_max=${s.p_max_w}W ` + + `consigne=${s.consigne_max_a}A compteur=${s.energie_compteur_kwh}kWh` + + ` [étiquette base: ${s.vehicule}/${s.vehicule_sur}]` + + `${isGuest(s) ? ' → INVITÉ (règle puissance)' : ''}${div}`); + } + } + if (uncertain.length) { + console.log(`\n[INCERTAIN — hors frontière connue → question par session]`); + for (const s of uncertain) { + questions++; + console.log(` ${s.debut_text} (aucun branchement encadrant)`); + } + } + console.log(`\n--- Résumé ---`); + console.log(`Structure des groupes : ${shape.join('-')} (attendu 2-3-2-1-1-1)`); + console.log(`Questions à poser : ${questions} (attendu 5)`); + console.log(`Groupes à étiquettes mixtes : ${mixedGroups} (attendu 0)`); + } finally { + await client.end(); + } +} + +function makeClient() { return new Client(PG); } + +if (require.main === module) { + main().catch(e => { console.error('[concierge]', e.message); process.exit(1); }); +} + +module.exports = { + buildPlugins, assign, isGuest, decide, makeClient, + SQL_TRANSITIONS, SQL_SESSIONS, CONSIGNE_MIN_A, P_INVITE_W, +}; diff --git a/workspace/concierge-live.js b/workspace/concierge-live.js new file mode 100644 index 0000000..8b69df9 --- /dev/null +++ b/workspace/concierge-live.js @@ -0,0 +1,156 @@ +// concierge-live.js — Concierge véhicule Airtria, ÉTAGE QUESTION/RÉPONSE (live). +// ----------------------------------------------------------------------------- +// Deux sous-commandes, appelées par l'agent : +// detect → cron 10 min : repère un nouveau branchement, décide +// (invité auto | question | rien), imprime la question WhatsApp +// ou rien. Écrit l'état en attente. Publie le helper si invité. +// answer "" → réception d'une réponse WhatsApp : reconnaît le véhicule, +// publie le helper pour les sessions en attente, confirme. +// Tout le déterministe est ici ; l'agent ne fait que router. Voir SPEC. +// ----------------------------------------------------------------------------- +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + buildPlugins, assign, isGuest, makeClient, SQL_TRANSITIONS, SQL_SESSIONS, +} = require('./concierge-detection.js'); +const { publishLabel } = require('./concierge-write.js'); + +const STATE = path.join(__dirname, 'concierge-state.json'); // progression +const PENDING = path.join(__dirname, 'concierge-pending.json'); // question ouverte +const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; +const SELECTOR = 'input_select.victron_evcs_vehicle'; // lecture seule (HA) +const WINDOW_DAYS = 5; + +const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; +const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2)); +const haToken = () => fs.readFileSync( + process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); + +async function haState(entity) { + const r = await fetch(`${HA_BASE}/api/states/${entity}`, + { headers: { Authorization: `Bearer ${haToken()}` } }); + if (!r.ok) throw new Error(`HA ${entity} HTTP ${r.status}`); + return r.json(); // { state, last_changed, attributes:{options?} } +} + +// Changements de VALEUR du sélecteur, lus dans evenement (robuste : le détecteur +// saute les lectures `unavailable`, contrairement au last_changed HA qu'un blip MQTT +// remet à zéro). genre='intervention', entite=input_select.victron_evcs_vehicle. +const SQL_SELECTOR_CHANGES = ` + SELECT extract(epoch FROM ts) AS ts_epoch, + extract(epoch FROM (detail->>'horodatage_ha')::timestamptz) AS ha_epoch + FROM evenement + WHERE genre = 'intervention' + AND detail->>'entite' = 'input_select.victron_evcs_vehicle' + AND ts >= $1 AND ts < $2 + ORDER BY ts`; + +async function loadWindow() { + const until = new Date().toISOString(); + const since = new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString(); + const c = makeClient(); + await c.connect(); + try { + // Requêtes séquentielles : node-postgres n'aime pas des query() concurrentes + // sur un même client (avertissement de dépréciation, retrait prévu en pg@9). + const tr = await c.query(SQL_TRANSITIONS, [since, until]); + const se = await c.query(SQL_SESSIONS, [since, until]); + const sc = await c.query(SQL_SELECTOR_CHANGES, [since, until]); + const plugins = buildPlugins(tr.rows); + const { groups } = assign(se.rows, plugins); + const selChanges = sc.rows.map(r => Number(r.ha_epoch != null ? r.ha_epoch : r.ts_epoch)); + return { + groups: groups.slice().sort((a, b) => a.plugin.on - b.plugin.on), + selChanges, + }; + } finally { await c.end(); } +} + +// Fraîcheur du sélecteur (règle soutenue par les données, EVCS 2026-09-13) : FRAIS +// seulement si un changement de sélecteur tombe dans [début − 10 min, début + 30 min]. +// Un changement plus tardif (en cours de branchement) ne peut pas étiqueter CE +// branchement (câble non bougé) → producteur des groupes mixtes → DEMANDER. Pas de +// changement → DEMANDER. Absence d'événement ne prouve pas tout (mémoire détecteur +// en-processus, redémarrages), mais dans le doute on demande = le bon côté. +const FRESH_BEFORE_S = 600, FRESH_AFTER_S = 1800; +function selectorFreshAt(onEpoch, selChanges) { + return selChanges.some(c => c >= onEpoch - FRESH_BEFORE_S && c <= onEpoch + FRESH_AFTER_S); +} + +const hhmm = (epoch) => new Date(epoch * 1000) + .toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); + +async function detect() { + const { groups, selChanges } = await loadWindow(); + if (groups.length === 0) return ''; + const latest = groups[groups.length - 1]; + const state = loadJSON(STATE, null); + + // Bootstrap : au 1er lancement, prendre acte de l'historique sans harceler. + if (!state) { saveJSON(STATE, { lastOn: latest.plugin.on }); return ''; } + if (latest.plugin.on <= state.lastOn) return ''; // rien de nouveau + + // Invité conclusif (règle puissance) → publier, aucune question. + // Label INVITE (majuscules, sans accent) = convention de la colonne (VW/JEEP/ + // MEGANE). La règle de puissance ne sait PAS quelle voiture c'est → jamais MEGANE. + if (latest.sessions.every(isGuest)) { + await publishLabel(latest.sessions.map(s => s.debut_text), 'INVITE', 'regle-puissance'); + saveJSON(STATE, { lastOn: latest.plugin.on }); + return ''; + } + + // Fraîcheur du sélecteur via evenement (fenêtre serrée autour du début), jamais + // via last_changed (qu'un blip unavailable remet à zéro). + const fresh = selectorFreshAt(latest.plugin.on, selChanges); + saveJSON(STATE, { lastOn: latest.plugin.on }); // traité : pas de re-question + if (fresh) return ''; // sélecteur réglé au branchement → confiance, pas de question + + const ref = 'V-' + Math.random().toString(36).slice(2, 5); + saveJSON(PENDING, { + ref, sessions: latest.sessions.map(s => s.debut_text), + askedAt: new Date().toISOString(), pluginOn: latest.plugin.on, + }); + return `🔌 Charge démarrée à ${hhmm(latest.plugin.on)}. ` + + `Quel véhicule est branché ? (VW / JEEP / invité) [${ref}]`; +} + +// Labels en MAJUSCULES sans accent (convention de la colonne : VW/JEEP/MEGANE). +// Le propriétaire peut NOMMER l'invité (MEGANE) ; sinon INVITE générique. +// ZERO (moto) : accepté par WhatsApp bien que le sélecteur HA ne l'expose pas +// encore — le propriétaire ajoutera l'option quand la moto sera en service. +function parseLabel(text, options) { + const t = (text || '').toLowerCase(); + for (const o of options) if (t.includes(o.toLowerCase())) return o; // VW/JEEP (options HA) + if (/m[ée]gane/.test(t)) return 'MEGANE'; + if (/moto|z[ée]ro/.test(t)) return 'ZERO'; + if (/invit|pr[eê]t|ami|emprunt/.test(t)) return 'INVITE'; + return null; +} + +async function answer(text) { + const pending = loadJSON(PENDING, null); + if (!pending) return ''; + let options = ['VW', 'JEEP']; + try { + const sel = await haState(SELECTOR); + if (Array.isArray(sel.attributes?.options)) options = sel.attributes.options; + } catch { /* défaut VW/JEEP */ } + const label = parseLabel(text, options); + if (!label) return ''; // non reconnu : l'agent pourra redemander, on ne publie rien + await publishLabel(pending.sessions, label, 'whatsapp'); + fs.rmSync(PENDING, { force: true }); + const when = new Date(pending.pluginOn * 1000) + .toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }); + return `✅ Noté : ${label} pour la charge du ${when}. Merci !`; +} + +const cmd = process.argv[2]; +(async () => { + let out = ''; + if (cmd === 'detect') out = await detect(); + else if (cmd === 'answer') out = await answer(process.argv.slice(3).join(' ')); + else { console.error('usage: concierge-live.js detect | answer ""'); process.exit(2); } + if (out) console.log(out); +})().catch(e => { console.error('[concierge-live]', e.message); process.exit(1); }); diff --git a/workspace/concierge-write.js b/workspace/concierge-write.js new file mode 100644 index 0000000..ec52a62 --- /dev/null +++ b/workspace/concierge-write.js @@ -0,0 +1,61 @@ +// concierge-write.js — Concierge véhicule Airtria, ÉTAGE ÉCRITURE (canal HA restreint) +// ----------------------------------------------------------------------------- +// SEULE écriture HA d'Airtria. La restriction à cette entité est CONVENTIONNELLE +// (le token HA peut techniquement tout écrire) : elle ne tient que par ce code. +// Donc, invariants tenus ici et vérifiés par concierge-write.test.js : +// - l'entité écrite est une CONSTANTE EN DUR, jamais interpolée ni construite ; +// - une seule écriture, via input_text.set_value, aucun actionneur ; +// - relecture obligatoire après écriture (détecte un rejet silencieux HA). +// Voir airtria-concierge-vehicule-SPEC.md. +// ----------------------------------------------------------------------------- +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// ⚠️ Constante unique. Ne JAMAIS la rendre dynamique ni en ajouter d'autres. +const HELPER_ENTITY = 'input_text.airtria_vehicle_label'; +const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; +const MAX_LEN = 255; // le helper DOIT être déclaré max:255 côté HA (défaut = 100) + +function haToken() { + return fs.readFileSync( + process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); +} + +// Publie le tuple {sessions[], label, confirmed_at, source} pour un branchement. +// sessions = clefs `debut` VERBATIM (ms comprises). Le service EVCS joint sur ±1 s. +async function publishLabel(sessions, label, source = 'whatsapp') { + if (!Array.isArray(sessions) || sessions.length === 0) { + throw new Error('sessions doit être une liste non vide'); + } + if (!label || typeof label !== 'string') throw new Error('label invalide'); + const payload = JSON.stringify({ + sessions, label, confirmed_at: new Date().toISOString(), source, + }); + if (payload.length > MAX_LEN) { + throw new Error(`payload ${payload.length} > ${MAX_LEN} : scinder le groupe ` + + `en deux publications espacées de > 90 s`); + } + const token = haToken(); + const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; + + // Écriture : service input_text.set_value sur la CONSTANTE en dur. + const res = await fetch(`${HA_BASE}/api/services/input_text/set_value`, { + method: 'POST', headers, + body: JSON.stringify({ entity_id: HELPER_ENTITY, value: payload }), + }); + if (!res.ok) throw new Error(`set_value HTTP ${res.status}`); + + // Relecture OBLIGATOIRE : seul moyen de voir un rejet silencieux (max trop court). + const back = await fetch(`${HA_BASE}/api/states/${HELPER_ENTITY}`, { headers }); + if (!back.ok) throw new Error(`relecture HTTP ${back.status}`); + const state = (await back.json()).state; + if (state !== payload) { + throw new Error('relecture != écrit — rejet silencieux probable ' + + '(vérifier max:255 sur le helper)'); + } + return { entity: HELPER_ENTITY, payload }; +} + +module.exports = { publishLabel, HELPER_ENTITY, MAX_LEN }; diff --git a/workspace/concierge-write.test.js b/workspace/concierge-write.test.js new file mode 100644 index 0000000..d861849 --- /dev/null +++ b/workspace/concierge-write.test.js @@ -0,0 +1,57 @@ +// concierge-write.test.js — Test de PORTÉE D'ÉCRITURE (garde-fou du canal HA). +// Doit ÉCHOUER si une écriture peut sortir de la constante airtria_vehicle_label. +// À faire tourner à CHAQUE modification, pas une seule fois (cf. EVCS advisor). +// Pur statique + require : aucun réseau, aucun token, aucune écriture. +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const SRC = path.join(__dirname, 'concierge-write.js'); +const src = fs.readFileSync(SRC, 'utf8'); +const mod = require('./concierge-write.js'); + +let ko = 0; +const check = (name, fn) => { + try { fn(); console.log(` ok ${name}`); } + catch (e) { ko++; console.log(` FAIL ${name} : ${e.message}`); } +}; + +console.log('=== Test de portée d\'écriture concierge-write.js ==='); + +// 1. La constante est exactement l'entité attendue. +check('constante = input_text.airtria_vehicle_label', () => + assert.strictEqual(mod.HELPER_ENTITY, 'input_text.airtria_vehicle_label')); + +// 2. entity_id n'est JAMAIS accolé à autre chose que la constante +// (pas de littéral d'entité, pas d'interpolation, pas de concaténation). +check('entity_id uniquement = HELPER_ENTITY', () => { + const occ = [...src.matchAll(/entity_id\s*:\s*([^,}\n]+)/g)].map(m => m[1].trim()); + assert(occ.length > 0, 'aucun entity_id trouvé'); + for (const v of occ) assert.strictEqual(v, 'HELPER_ENTITY', + `entity_id dynamique ou littéral interdit : ${v}`); +}); + +// 3. Aucune autre entité HA en dur (aucun autre "domaine.entite") que la constante. +check('aucune entité HA littérale hors la constante', () => { + const decl = "'input_text.airtria_vehicle_label'"; + const lits = [...src.matchAll(/['"`]([a-z_]+\.[a-z0-9_]+)['"`]/g)].map(m => m[0]); + for (const l of lits) assert.strictEqual(l, decl, + `littéral d'entité interdit : ${l}`); +}); + +// 4. Un SEUL appel de service, et c'est input_text/set_value (aucun actionneur). +check('seul service = input_text/set_value', () => { + const services = [...src.matchAll(/\/api\/services\/([a-z_]+\/[a-z_]+)/g)] + .map(m => m[1]); + assert.deepStrictEqual(services, ['input_text/set_value'], + `services inattendus : ${services.join(', ') || '(aucun)'}`); +}); + +// 5. La relecture existe (garde-fou rejet silencieux). +check('relecture présente (/api/states/)', () => + assert(/\/api\/states\//.test(src), 'pas de relecture après écriture')); + +console.log(ko === 0 ? '\nTOUT VERT' : `\n${ko} test(s) EN ÉCHEC`); +process.exit(ko === 0 ? 0 : 1);