Driver for Tariff Flex-D Hydro-Quebec)

/**

  • Hydro-Québec Flex D – Événements de Pointe
  • Auteur: Erick (Hubitat Community)
  • Version: 2.1.1
  • Date: 2026-03-21
  • Description:
  • Driver virtuel pour Hubitat qui détecte les événements de pointe
  • du tarif Flex D d'Hydro-Québec (saison 2025-2026).
  • ═══════════════════════════════════════════════════════════
  • CONDITIONS FLEX D 2025-2026 (en vigueur 1er avril 2025)
  • ═══════════════════════════════════════════════════════════
  • PÉRIODE HIVER: 1er décembre au 31 mars
  • HEURES DE POINTE POSSIBLES:
  • • Matin : 06h00 à 10h00 (4 heures)
  • • Soir : 16h00 à 20h00 (4 heures)
  • ÉVÉNEMENTS DE POINTE (dynamiques):
  • • Maximum 30 événements par hiver
  • • Maximum 120 heures au total par hiver
  • • Maximum 2 événements par jour
  • • Durée d'un événement: 4 heures
  • • Délai minimal entre 2 événements: 6 heures
  • • Ont lieu 7 JOURS/SEMAINE (lundi au dimanche)
  • • Annoncés par HQ avant 15h la veille
  • DATES EXCLUES (aucun événement possible):
  • • 24, 25, 26 et 31 décembre
  • • 1er et 2 janvier
  • • Vendredi saint et lundi de Pâques
  •  (si dans la période hivernale)
    
  • TARIFS (1er avril 2025):
  • Hiver hors-pointe: 4,820 ¢/kWh (≤40 kWh/jour)
  •                   8,784 ¢/kWh (reste)
    
  • Événement pointe: 45,526 ¢/kWh
  • Été: 6,972 ¢/kWh (≤40 kWh/jour)
  •                   10,756 ¢/kWh (reste)
    
  • Accès réseau: 44,810 ¢/jour
  • SOURCE DE DONNÉES:
  • API Données Ouvertes Hydro-Québec
  • https://donnees.hydroquebec.com
  • Dataset: evenements-pointe (TPC-DPC pour Flex D résidentiel)
  • ATTRIBUTS EXPOSÉS:
    • peakStatus : "POINTE" | "PRÉ-POINTE" | "HORS-POINTE" | "ÉTÉ"
    • peakPeriod : "matin" | "soir" | "aucune"
    • peakSource : "api" | "horaire" | "manuel"
    • season : "hiver" | "été"
    • nextPeakStart : prochaine pointe connue (texte)
    • minutesToPeak : minutes avant prochaine pointe (-1 si en pointe)
    • currentRate : tarif actuel en ¢/kWh
    • peakEventsToday : nombre d'événements aujourd'hui
    • peakEventsTotal : total événements cet hiver
    • peakHoursTotal : total heures de pointe cet hiver
    • dayType : "normal" | "exclu" | "été"
    • lastApiCheck : dernière vérification API
    • switch : "on" = en pointe, "off" = hors-pointe
      */

metadata {
definition(
name: "Hydro-Québec Flex D Pointe",
namespace: "erick-hq",
author: "Erick",
importUrl: ""
) {
capability "Switch"
capability "Sensor"
capability "Refresh"

    attribute "peakStatus",       "string"
    attribute "peakPeriod",       "string"
    attribute "peakSource",       "string"
    attribute "season",           "string"
    attribute "nextPeakStart",    "string"
    attribute "minutesToPeak",    "number"
    attribute "currentRate",      "string"
    attribute "peakEventsToday",  "number"
    attribute "peakEventsTotal",  "number"
    attribute "peakHoursTotal",   "number"
    attribute "dayType",          "string"
    attribute "lastApiCheck",     "string"

    command "refresh"
    command "forceCheck"
    command "setPeakManual", [[name:"active", type:"ENUM", constraints:["true","false"],
                               description:"Forcer manuellement un état de pointe"]]
    command "clearManualPeak"
}

preferences {
    input name: "logEnable",
          type: "bool",
          title: "Activer les logs de débogage",
          defaultValue: true

    input name: "pollInterval",
          type: "enum",
          title: "Intervalle de vérification",
          options: [
              "1":  "Chaque minute",
              "5":  "Chaque 5 minutes",
              "10": "Chaque 10 minutes",
              "15": "Chaque 15 minutes"
          ],
          defaultValue: "5"

    input name: "apiPollMinutes",
          type: "enum",
          title: "Intervalle de vérification API HQ",
          options: [
              "15":  "Chaque 15 minutes",
              "30":  "Chaque 30 minutes",
              "60":  "Chaque heure"
          ],
          defaultValue: "30"

    input name: "preAlertMinutes",
          type: "number",
          title: "Pré-alerte (minutes avant la pointe)",
          description: "Envoyer un événement X minutes avant (0 = désactivé)",
          defaultValue: 30,
          range: "0..120"

    input name: "switchInvert",
          type: "bool",
          title: "Inverser le switch (ON = hors-pointe)",
          description: "Utile pour contrôler un appareil à couper en pointe",
          defaultValue: false

    input name: "useApi",
          type: "bool",
          title: "Utiliser l'API Données Ouvertes HQ",
          description: "Vérifie les événements réels via l'API publique d'Hydro-Québec",
          defaultValue: true

    input name: "fallbackToSchedule",
          type: "bool",
          title: "Fallback horaire si API indisponible",
          description: "Utiliser les fenêtres horaires (6h-10h / 16h-20h) si l'API ne répond pas",
          defaultValue: false

    input name: "prechauffage",
          type: "bool",
          title: "Activer la phase de préchauffage",
          description: "Signaler PRÉ-POINTE 1h avant un événement confirmé pour préchauffer",
          defaultValue: true
}

}

// ═══════════════════════════════════════════════════════════
// CONSTANTES FLEX D 2025-2026
// ═══════════════════════════════════════════════════════════

@groovy.transform.Field static final int PEAK_MORNING_START = 360 // 06:00
@groovy.transform.Field static final int PEAK_MORNING_END = 600 // 10:00
@groovy.transform.Field static final int PEAK_EVENING_START = 960 // 16:00
@groovy.transform.Field static final int PEAK_EVENING_END = 1200 // 20:00
@groovy.transform.Field static final int PEAK_DURATION_HOURS = 4
@groovy.transform.Field static final int MAX_EVENTS_PER_WINTER = 30
@groovy.transform.Field static final int MAX_HOURS_PER_WINTER = 120
@groovy.transform.Field static final int MAX_EVENTS_PER_DAY = 2
@groovy.transform.Field static final int MIN_HOURS_BETWEEN_EVENTS = 6

// Tarifs en ¢/kWh (1er avril 2025)
@groovy.transform.Field static final String RATE_WINTER_TIER1 = "4,820"
@groovy.transform.Field static final String RATE_WINTER_TIER2 = "8,784"
@groovy.transform.Field static final String RATE_PEAK_EVENT = "45,526"
@groovy.transform.Field static final String RATE_SUMMER_TIER1 = "6,972"
@groovy.transform.Field static final String RATE_SUMMER_TIER2 = "10,756"
@groovy.transform.Field static final String RATE_ACCESS_PER_DAY = "44,810"

// API Données Ouvertes HQ
@groovy.transform.Field static final String HQ_API_BASE = "https://donnees.hydroquebec.com/api/explore/v2.1/catalog/datasets/evenements-pointe/records"
@groovy.transform.Field static final String HQ_FLEX_D_OFFER = "TPC-DPC"

// ═══════════════════════════════════════════════════════════
// LIFECYCLE
// ═══════════════════════════════════════════════════════════

def installed() {
log.info "Hydro-Québec Flex D Pointe v2.0 - Installé"
state.peakEvents =
state.manualPeak = false
state.totalEventsThisWinter = 0
state.totalHoursThisWinter = 0
state.apiAvailable = true
initialize()
}

def updated() {
log.info "Hydro-Québec Flex D Pointe - Préférences mises à jour"
unschedule()
initialize()
}

def initialize() {
def interval = (settings?.pollInterval ?: "5").toInteger()

switch (interval) {
    case 1:  runEvery1Minute("checkPeakStatus");  break
    case 5:  runEvery5Minutes("checkPeakStatus"); break
    case 10: runEvery10Minutes("checkPeakStatus"); break
    case 15: runEvery15Minutes("checkPeakStatus"); break
    default: runEvery5Minutes("checkPeakStatus")
}

// Planifier les appels API
if (settings?.useApi != false) {
    def apiInterval = (settings?.apiPollMinutes ?: "30").toInteger()
    switch (apiInterval) {
        case 15: runEvery15Minutes("pollHqApi"); break
        case 30: runEvery30Minutes("pollHqApi");  break
        case 60: schedule("0 5 * * * ?", "pollHqApi"); break
        default: runEvery30Minutes("pollHqApi")
    }
}

// Vérification immédiate
if (settings?.useApi != false) {
    runIn(2, "pollHqApi")
}
runIn(5, "checkPeakStatus")

// Planifier les transitions exactes (heures de pointe possibles)
scheduleDailyTransitions()
schedule("0 0 0 * * ?", "scheduleDailyTransitions")

// Réinitialiser les compteurs au 1er décembre
schedule("0 0 0 1 12 ?", "resetWinterCounters")

if (logEnable) log.debug "Initialisé – polling: ${interval}min, API: ${settings?.useApi != false ? 'activé' : 'désactivé'}"

}

// ═══════════════════════════════════════════════════════════
// COMMANDES
// ═══════════════════════════════════════════════════════════

def on() {
if (logEnable) log.debug "Switch ON forcé manuellement"
sendEvent(name: "switch", value: "on", descriptionText: "Forcé manuellement ON")
}

def off() {
if (logEnable) log.debug "Switch OFF forcé manuellement"
sendEvent(name: "switch", value: "off", descriptionText: "Forcé manuellement OFF")
}

def refresh() {
if (settings?.useApi != false) pollHqApi()
checkPeakStatus()
}

def forceCheck() {
refresh()
}

def setPeakManual(String active) {
state.manualPeak = (active == "true")
if (logEnable) log.info "Pointe manuelle: ${state.manualPeak}"
checkPeakStatus()
}

def clearManualPeak() {
state.manualPeak = false
if (logEnable) log.info "Pointe manuelle désactivée"
checkPeakStatus()
}

def resetWinterCounters() {
state.totalEventsThisWinter = 0
state.totalHoursThisWinter = 0
state.peakEvents =
if (logEnable) log.info "Compteurs hivernaux réinitialisés"
}

// ═══════════════════════════════════════════════════════════
// API DONNÉES OUVERTES HYDRO-QUÉBEC
// ═══════════════════════════════════════════════════════════

def pollHqApi() {
if (settings?.useApi == false) return

def now = new Date()
def cal = Calendar.getInstance(TimeZone.getTimeZone("America/Montreal"))
cal.setTime(now)
def month = cal.get(Calendar.MONTH) + 1

// API seulement disponible du 1er déc au 31 mars
if (!isWinterPeriod(month, cal.get(Calendar.DAY_OF_MONTH))) {
    state.apiAvailable = false
    sendEvent(name: "lastApiCheck", value: "Hors saison (été)")
    return
}

try {
    // Construire la requête pour les événements Flex D (TPC-DPC)
    // Format réel de l'API OpenDataSoft v2.1 d'Hydro-Québec:
    //   Champs: offre, datedebut, datefin, plagehoraire, duree, secteurclient
    //   Valeurs datedebut: ISO 8601 ex. "2026-01-25T06:00:00-05:00"
    //   Valeurs offre: "TPC-DPC" (Flex D) ou "CPC-D" (crédit hivernal)
    def today = String.format("%04d-%02d-%02d",
        cal.get(Calendar.YEAR),
        cal.get(Calendar.MONTH) + 1,
        cal.get(Calendar.DAY_OF_MONTH))

    // Requête: événements Flex D résidentiels à partir d'aujourd'hui
    // On met tout dans le WHERE pour éviter les problèmes de clés dupliquées (refine)
    def whereClause = "datedebut >= '${today}' AND offre = '${HQ_FLEX_D_OFFER}'"

    def params = [
        uri: HQ_API_BASE,
        query: [
            "limit":    "20",
            "timezone": "America/Montreal",
            "order_by": "datedebut ASC",
            "where":    whereClause
        ],
        requestContentType: "application/json",
        contentType: "application/json",
        timeout: 15
    ]

    if (logEnable) log.debug "Appel API HQ: where=${whereClause}"

    httpGet(params) { resp ->
        if (resp.status == 200) {
            def data = resp.data
            processApiResponse(data, today)
            state.apiAvailable = true
            def checkTime = new Date().format("HH:mm:ss dd/MM", TimeZone.getTimeZone("America/Montreal"))
            sendEvent(name: "lastApiCheck", value: "OK - ${checkTime}")
        } else {
            log.warn "API HQ – Réponse HTTP ${resp.status}"
            state.apiAvailable = false
            sendEvent(name: "lastApiCheck", value: "Erreur HTTP ${resp.status}")
        }
    }
} catch (Exception e) {
    log.warn "API HQ – Erreur: ${e.message}"
    state.apiAvailable = false
    sendEvent(name: "lastApiCheck", value: "Erreur: ${e.message?.take(50)}")
}

}

def processApiResponse(data, String today) {
def records = data?.results ?:

if (logEnable) log.debug "API HQ: ${records.size()} enregistrement(s) trouvé(s)"

def events = []
records.each { record ->
    // Format réel API HQ OpenDataSoft v2.1:
    // {
    //   "offre": "TPC-DPC",
    //   "datedebut": "2026-01-25T06:00:00-05:00",
    //   "datefin": "2026-01-25T10:00:00-05:00",
    //   "plagehoraire": "AM",
    //   "duree": "PT04H00MS",
    //   "secteurclient": "Residentiel"
    // }
    def dateDebut    = record?.datedebut?.toString()
    def dateFin      = record?.datefin?.toString()
    def plageHoraire = record?.plagehoraire?.toString()
    def offre        = record?.offre?.toString()

    if (dateDebut && dateFin) {
        // Extraire la date (YYYY-MM-DD) et l'heure (HH:MM) depuis l'ISO 8601
        def dateStr  = dateDebut.take(10)                         // "2026-01-25"
        def startStr = dateDebut.substring(11, 16)                // "06:00"
        def endStr   = dateFin.substring(11, 16)                  // "10:00"

        events.add([
            date:    dateStr,
            start:   startStr,
            end:     endStr,
            plage:   plageHoraire ?: (startStr < "12:00" ? "AM" : "PM"),
            offre:   offre ?: HQ_FLEX_D_OFFER
        ])

        if (logEnable) log.debug "  → Événement: ${dateStr} ${startStr}-${endStr} (${plageHoraire})"
    }
}

state.peakEvents = events

// Compter les événements du jour
def todayEvents = events.findAll { it.date == today }
sendEvent(name: "peakEventsToday", value: todayEvents.size())

// Compter total de la saison
sendEvent(name: "peakEventsTotal", value: events.size())
sendEvent(name: "peakHoursTotal", value: events.size() * PEAK_DURATION_HOURS)

if (logEnable) {
    log.info "API HQ: ${events.size()} événements au total, ${todayEvents.size()} aujourd'hui"
    todayEvents.each { evt ->
        log.info "⚡ Événement de pointe AUJOURD'HUI: ${evt.start} à ${evt.end} (${evt.plage})"
    }
}

}

// ═══════════════════════════════════════════════════════════
// VÉRIFICATION PRINCIPALE
// ═══════════════════════════════════════════════════════════

def checkPeakStatus() {
def now = new Date()
def cal = Calendar.getInstance(TimeZone.getTimeZone("America/Montreal"))
cal.setTime(now)

def hour      = cal.get(Calendar.HOUR_OF_DAY)
def minute    = cal.get(Calendar.MINUTE)
def month     = cal.get(Calendar.MONTH) + 1
def day       = cal.get(Calendar.DAY_OF_MONTH)
def year      = cal.get(Calendar.YEAR)
def timeInMin = hour * 60 + minute
def today     = String.format("%04d-%02d-%02d", year, month, day)

// ── Saison ──
def isWinter = isWinterPeriod(month, day)
def seasonStr = isWinter ? "hiver" : "été"
sendEvent(name: "season", value: seasonStr)

if (!isWinter) {
    updateStatus("ÉTÉ", "aucune", "horaire", RATE_SUMMER_TIER1 + " / " + RATE_SUMMER_TIER2 + " ¢/kWh", "été")
    updateSwitch(false)
    sendEvent(name: "minutesToPeak", value: -1)
    sendEvent(name: "nextPeakStart", value: getNextWinterStart(year, month))
    return
}

// ── Date exclue? ──
def isExcluded = isExcludedDate(year, month, day)
sendEvent(name: "dayType", value: isExcluded ? "exclu" : "normal")

if (isExcluded) {
    updateStatus("HORS-POINTE", "aucune", "horaire",
                 RATE_WINTER_TIER1 + " / " + RATE_WINTER_TIER2 + " ¢/kWh (date exclue)", "normal")
    updateSwitch(false)
    calculateNextPeakInfo(timeInMin, cal, today)
    return
}

// ── Pointe manuelle? ──
if (state.manualPeak) {
    updateStatus("POINTE", "manuel", "manuel", RATE_PEAK_EVENT + " ¢/kWh (manuel)", "normal")
    updateSwitch(true)
    return
}

// ── Vérifier via API (événements réels) ──
def apiPeak = checkApiPeakNow(today, timeInMin)

if (apiPeak.isPeak) {
    updateStatus("POINTE", apiPeak.period, "api", RATE_PEAK_EVENT + " ¢/kWh", "normal")
    updateSwitch(true)
    sendEvent(name: "minutesToPeak", value: -1)
    if (logEnable) log.debug "EN POINTE (API) – ${apiPeak.period} – ${hour}h${String.format('%02d', minute)}"
    return
}

// ── Vérifier pré-chauffage (1h avant événement confirmé API) ──
if (settings?.prechauffage && apiPeak.nextEventMinutes != null) {
    if (apiPeak.nextEventMinutes > 0 && apiPeak.nextEventMinutes <= 60) {
        updateStatus("PRÉ-POINTE", apiPeak.nextPeriod ?: "à venir", "api",
                     RATE_WINTER_TIER1 + " ¢/kWh (préchauffage recommandé)", "normal")
        // Le switch reste OFF en pré-pointe (pas encore en pointe)
        updateSwitch(false)
        sendEvent(name: "minutesToPeak", value: apiPeak.nextEventMinutes)
        sendEvent(name: "nextPeakStart", value: "Dans ${apiPeak.nextEventMinutes} min (confirmé API)")
        if (logEnable) log.warn "⚠️ PRÉ-POINTE: Événement confirmé dans ${apiPeak.nextEventMinutes} min"
        return
    }
}

// ── Pré-alerte (basée sur API) ──
def preAlertMin = (settings?.preAlertMinutes ?: 0).toInteger()
if (preAlertMin > 0 && apiPeak.nextEventMinutes != null) {
    if (apiPeak.nextEventMinutes > 0 && apiPeak.nextEventMinutes <= preAlertMin) {
        sendEvent(name: "peakStatus", value: "PRÉ-ALERTE",
                  descriptionText: "Pointe dans ${apiPeak.nextEventMinutes} minutes!")
        if (logEnable) log.warn "⚡ PRÉ-ALERTE: Pointe dans ${apiPeak.nextEventMinutes} min"
    }
}

// ── Fallback horaire si API indisponible ──
if (!state.apiAvailable && settings?.fallbackToSchedule) {
    def schedulePeak = checkSchedulePeak(timeInMin)
    if (schedulePeak.isPeak) {
        updateStatus("POINTE", schedulePeak.period, "horaire",
                     RATE_PEAK_EVENT + " ¢/kWh (fallback horaire)", "normal")
        updateSwitch(true)
        if (logEnable) log.warn "EN POINTE (fallback horaire) – API indisponible"
        return
    }
}

// ── Hors pointe ──
updateStatus("HORS-POINTE", "aucune", state.apiAvailable ? "api" : "horaire",
             RATE_WINTER_TIER1 + " / " + RATE_WINTER_TIER2 + " ¢/kWh", "normal")
updateSwitch(false)
calculateNextPeakInfo(timeInMin, cal, today)

if (logEnable) log.debug "HORS-POINTE – ${hour}h${String.format('%02d', minute)} – source: ${state.apiAvailable ? 'API' : 'horaire'}"

}

// ═══════════════════════════════════════════════════════════
// VÉRIFICATION POINTE VIA API
// ═══════════════════════════════════════════════════════════

def checkApiPeakNow(String today, int timeInMin) {
def result = [isPeak: false, period: "aucune", nextEventMinutes: null, nextPeriod: null]

def events = state.peakEvents ?: []
if (events.size() == 0) return result

def todayEvents = events.findAll { it.date == today }

todayEvents.each { evt ->
    def startMin = parseTimeToMinutes(evt.start)
    def endMin   = parseTimeToMinutes(evt.end)

    if (startMin != null && endMin != null) {
        // Actuellement en pointe?
        if (timeInMin >= startMin && timeInMin < endMin) {
            result.isPeak = true
            result.period = (startMin < 720) ? "matin" : "soir"
        }
        // Prochain événement?
        else if (timeInMin < startMin) {
            def minsUntil = startMin - timeInMin
            if (result.nextEventMinutes == null || minsUntil < result.nextEventMinutes) {
                result.nextEventMinutes = minsUntil
                result.nextPeriod = (startMin < 720) ? "matin" : "soir"
            }
        }
    }
}

// Vérifier aussi les événements de demain pour le calcul nextEvent
if (result.nextEventMinutes == null) {
    def cal = Calendar.getInstance(TimeZone.getTimeZone("America/Montreal"))
    cal.add(Calendar.DAY_OF_MONTH, 1)
    def tomorrow = cal.format("yyyy-MM-dd")
    def tomorrowEvents = events.findAll { it.date == tomorrow }

    if (tomorrowEvents.size() > 0) {
        def firstTomorrow = tomorrowEvents[0]
        def startMin = parseTimeToMinutes(firstTomorrow.start)
        if (startMin != null) {
            def minsUntilMidnight = 1440 - timeInMin
            result.nextEventMinutes = minsUntilMidnight + startMin
            result.nextPeriod = (startMin < 720) ? "matin" : "soir"
        }
    }
}

return result

}

def checkSchedulePeak(int timeInMin) {
def result = [isPeak: false, period: "aucune"]

if (timeInMin >= PEAK_MORNING_START && timeInMin < PEAK_MORNING_END) {
    result.isPeak = true
    result.period = "matin (6h-10h)"
} else if (timeInMin >= PEAK_EVENING_START && timeInMin < PEAK_EVENING_END) {
    result.isPeak = true
    result.period = "soir (16h-20h)"
}

return result

}

// ═══════════════════════════════════════════════════════════
// PÉRIODE HIVERNALE
// ═══════════════════════════════════════════════════════════

def isWinterPeriod(int month, int day) {
// Hiver Flex D: 1er décembre au 31 mars
return (month == 12 || month <= 3)
}

// ═══════════════════════════════════════════════════════════
// DATES EXCLUES (Flex D 2025-2026)
// ═══════════════════════════════════════════════════════════

def isExcludedDate(int year, int month, int day) {
def excluded = getExcludedDates(year)
def dateStr = String.format("%04d-%02d-%02d", year, month, day)
return excluded.contains(dateStr)
}

def getExcludedDates(int year) {
def excluded =

// ── Dates fixes exclues ──
// 24, 25, 26 décembre
excluded.add(String.format("%04d-12-24", year))
excluded.add(String.format("%04d-12-25", year))
excluded.add(String.format("%04d-12-26", year))
// 31 décembre
excluded.add(String.format("%04d-12-31", year))
// 1er et 2 janvier (année suivante si on est en décembre, sinon année courante)
excluded.add(String.format("%04d-01-01", year))
excluded.add(String.format("%04d-01-02", year))
// Aussi l'année suivante pour décembre
excluded.add(String.format("%04d-01-01", year + 1))
excluded.add(String.format("%04d-01-02", year + 1))

// ── Vendredi saint et lundi de Pâques ──
// (seulement si dans la période hivernale, donc avant le 31 mars)
def easter = calculateEaster(year)
def calE = Calendar.getInstance()
calE.setTime(easter)

def easterMonth = calE.get(Calendar.MONTH) + 1
if (easterMonth <= 3) {
    // Vendredi saint (2 jours avant Pâques)
    def calGF = (Calendar) calE.clone()
    calGF.add(Calendar.DAY_OF_MONTH, -2)
    excluded.add(formatCalDate(calGF))

    // Lundi de Pâques (1 jour après Pâques)
    def calEM = (Calendar) calE.clone()
    calEM.add(Calendar.DAY_OF_MONTH, 1)
    excluded.add(formatCalDate(calEM))
}

return excluded

}

// ═══════════════════════════════════════════════════════════
// CALCUL DE PÂQUES
// ═══════════════════════════════════════════════════════════

def calculateEaster(int year) {
// Algorithme de Meeus/Jones/Butcher
int a = year % 19
int b = (int)(year / 100)
int c = year % 100
int d = (int)(b / 4)
int e = b % 4
int f = (int)((b + 8) / 25)
int g = (int)((b - f + 1) / 3)
int h = (19 * a + b - d - g + 15) % 30
int i = (int)(c / 4)
int k = c % 4
int l = (32 + 2 * e + 2 * i - h - k) % 7
int m = (int)((a + 11 * h + 22 * l) / 451)
int month = (int)((h + l - 7 * m + 114) / 31)
int day = ((h + l - 7 * m + 114) % 31) + 1

def cal = Calendar.getInstance()
cal.set(year, month - 1, day)
return cal.getTime()

}

// ═══════════════════════════════════════════════════════════
// PLANIFICATION DES TRANSITIONS
// ═══════════════════════════════════════════════════════════

def scheduleDailyTransitions() {
def cal = Calendar.getInstance(TimeZone.getTimeZone("America/Montreal"))
def month = cal.get(Calendar.MONTH) + 1
def day = cal.get(Calendar.DAY_OF_MONTH)
def year = cal.get(Calendar.YEAR)

if (!isWinterPeriod(month, day)) {
    if (logEnable) log.debug "Pas de transitions (période estivale)"
    return
}

if (isExcludedDate(year, month, day)) {
    if (logEnable) log.debug "Pas de transitions (date exclue)"
    return
}

// Planifier les vérifications aux heures de transition possibles
// Ces vérifications s'activeront SEULEMENT si un événement API est actif
schedule("0 0 6 * * ?",  "transitionCheck")   // Début possible matin
schedule("0 0 10 * * ?", "transitionCheck")    // Fin possible matin
schedule("0 0 16 * * ?", "transitionCheck")    // Début possible soir
schedule("0 0 20 * * ?", "transitionCheck")    // Fin possible soir

// Pré-chauffage: vérifier 1h avant les fenêtres possibles
if (settings?.prechauffage) {
    schedule("0 0 5 * * ?",  "transitionCheck")    // Pré-chauffage matin
    schedule("0 0 15 * * ?", "transitionCheck")    // Pré-chauffage soir
}

if (logEnable) log.debug "Transitions planifiées pour la journée"

}

def transitionCheck() {
if (logEnable) log.info "Vérification de transition horaire"
if (settings?.useApi != false) pollHqApi()
checkPeakStatus()
}

// ═══════════════════════════════════════════════════════════
// CALCUL PROCHAINE POINTE
// ═══════════════════════════════════════════════════════════

def calculateNextPeakInfo(int timeInMin, Calendar cal, String today) {
def events = state.peakEvents ?:

// Chercher dans les événements API
def nextEvent = null
events.each { evt ->
    def startMin = parseTimeToMinutes(evt.start)
    if (evt.date == today && startMin != null && startMin > timeInMin) {
        if (nextEvent == null) {
            nextEvent = [date: evt.date, start: evt.start, minutes: startMin - timeInMin]
        }
    } else if (evt.date > today && nextEvent == null) {
        def minsToMidnight = 1440 - timeInMin
        if (startMin != null) {
            nextEvent = [date: evt.date, start: evt.start, minutes: minsToMidnight + startMin]
        }
    }
}

if (nextEvent) {
    sendEvent(name: "nextPeakStart", value: "${nextEvent.date} à ${nextEvent.start} (confirmé)")
    sendEvent(name: "minutesToPeak", value: nextEvent.minutes)
} else {
    sendEvent(name: "nextPeakStart", value: "Aucun événement annoncé")
    sendEvent(name: "minutesToPeak", value: -1)
}

}

def getNextWinterStart(int year, int month) {
def nextYear = month < 12 ? year : year + 1
return "1er décembre ${nextYear}"
}

// ═══════════════════════════════════════════════════════════
// UTILITAIRES
// ═══════════════════════════════════════════════════════════

def updateStatus(String status, String period, String source, String rate, String dayType) {
sendEvent(name: "peakStatus", value: status, descriptionText: "Statut: ${status}")
sendEvent(name: "peakPeriod", value: period)
sendEvent(name: "peakSource", value: source)
sendEvent(name: "currentRate", value: rate)
sendEvent(name: "dayType", value: dayType)
}

def updateSwitch(boolean isPeak) {
def switchVal
if (settings?.switchInvert) {
switchVal = isPeak ? "off" : "on"
} else {
switchVal = isPeak ? "on" : "off"
}

def currentSwitch = device.currentValue("switch")
if (currentSwitch != switchVal) {
    sendEvent(name: "switch", value: switchVal,
              descriptionText: "Pointe: ${isPeak ? 'ACTIVE' : 'INACTIVE'}")
    if (logEnable) log.info "Switch → ${switchVal} (pointe: ${isPeak})"
}

}

def parseTimeToMinutes(String timeStr) {
try {
if (timeStr == null) return null
// Supporte "06:00", "06:00:00", "6:00"
def parts = timeStr.split(":")
if (parts.length >= 2) {
return parts[0].toInteger() * 60 + parts[1].toInteger()
}
} catch (Exception e) {
if (logEnable) log.warn "Erreur parsing heure '${timeStr}': ${e.message}"
}
return null
}

def formatCalDate(Calendar cal) {
return String.format("%04d-%02d-%02d",
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH) + 1,
cal.get(Calendar.DAY_OF_MONTH))
}