API: allow external integrations to declare their own energy contract types

Hello everyone,

Following the discussion on weekend rates and @pierre-gilles’ response on Gladys#2999, here is the corresponding feature request.

The principle, first. Pierre-Gilles proposed keeping the core generic and allowing external integrations to declare new types of contracts, rather than stacking country-specific cases in the core. I completely agree, and looking at the code I find that it’s even more relevant than I thought: the architecture already goes in this direction, it mainly lacks opening the door.

Note that @mutmut has already opened a request on managing rates according to the day, season, and holidays. This one does not replace it: his describes the user need, this one describes the technical mechanism that would allow responding to it without overloading the core. Both are complementary.

What already exists and is going in the right direction

server/services/energy-monitoring/contracts/contracts.calculateCost.js is already a dictionary indexed by contract type, where each entry is a calculation function with a uniform signature:

(energyPricesAtConsumptionDate, consumptionDate, consumptionValue, systemTimezone, context) => cost

This is exactly an extension point. Better: the context parameter is already used to inject external data — Tempo receives edfTempoHistoricalMap, i.e., a day color calendar retrieved elsewhere. The precedent of a contract type that depends on an external data source is therefore already established.

On the storage side, hour_slots is a simple free string and subscribed_power too. An external integration could encode whatever it wants there without touching the schema.

What’s blocking

Four locks, from the hardest to the simplest:

  1. The SQL model. In server/models/energy_price.js, the contract column is a DataTypes.ENUM(...ENERGY_CONTRACT_TYPES_LIST), and day_type an ENUM(...ENERGY_PRICE_DAY_TYPES_LIST). This is the real blocker: adding a type requires a database migration, which an external integration cannot do.
  2. The hard-coded list ENERGY_CONTRACT_TYPES in server/utils/constants.js, which feeds this ENUM and server validation.
  3. The calculation dictionary is not registerable: the three handlers are hard-coded in the module.
  4. The front end, with KNOWN_CONTRACT_TYPES hard-coded in ImportPrices.jsx and the labels in contractTypes of the i18n files.

What I propose

a) Change contract and day_type from ENUM to STRING, moving validation to the application layer (against the list of registered types, core + integrations). A naming convention per integration would avoid collisions, for example <service>:<type>. This is the structuring change: without it, no external extension is possible.

b) Expose a registration API on the service, in the spirit of:

gladys.energyMonitoring.registerContractType({
  id: 'edf-zen-week-end',
  calculateCost: async (prices, date, value, timezone, context) => { ... },
});

The signature being already that of the existing handlers, the three current types could be migrated to this mechanism without changing anything to their logic — a good way to validate the API in passing.

c) Make the import screen data-driven: the list of types and the label come from the registered types, with a clean fallback when no translation exists. The current time slot selector would remain the default behavior.

d) Optional, for later: allow the integration to declare the type of price editor it needs, or even to provide its own screen.

With this, a “France energy” integration could handle Zen Week-End, Tempo, Engie / OHM / Enercoop offers, and @mutmut’s seasonal cases, without the core having to know the calendar of French holidays.

An open question

Today, the rate grids live in the community repository energy-contracts, and that’s valuable: when someone updates the EDF prices, everyone benefits. If country-specific types go into an external integration, where do the price data go? Do they stay in energy-contracts with the integration just adding the calculation logic, or do they move too?

I have a preference for the first option — keep a single repository of grids, shared, and only take out the business code — but it’s really a question, not a fixed position.

Thanks!

A quick follow-up on this request, which went unanswered: rather than waiting, I tried something with Fable 5.1: creating the PR!!! Trying to stick as closely as possible to what already exists in the core.

PR: feat(energy): energy-calendar integration type and day-type contract by guim31 · Pull Request #3099 · GladysAssistant/Gladys · GitHub

Here are some clarifications from the AI:

After rereading the code, I changed my mind compared to my first message. Having the calculation function handled by the integration, as I proposed, would have forced the core to query the integration for every 30-minute sample: tens of thousands of WebSocket commands acknowledged for a recalculation from the start, and unaudited third-party code deciding the amounts billed. That’s not reasonable.

The proposal in the PR is more modest and exactly follows the weather type schema (spec B.18):

  • A new external integration type energy-calendar. The core asks it, once per calculation and over a date range, for the day type of each day (weekday, weekend, holiday… the vocabulary is free, it’s the integration that publishes it). The response is standardized and bounded before entering the core, like the weather. No device screens, no device: a dedicated API.
  • A generic contract type day-type in the core. Its prices have a free day_type and optional hour_slots. The calculation is that of Tempo, with the day type from the calendar instead of the color: prices are filtered by day type, then by time slot; a price without a time slot applies to the entire day (it’s the « weekend » line of an EDF Zen Week-End contract). The core knows no national calendar.
  • day_type goes from a Tempo color ENUM to a validated string. Under SQLite, an ENUM is a TEXT column, so no migration, and Tempo continues to work as is.
  • On the interface side: the contract in the price editor selector, the -day-type suffix recognized by the import screen, and the type in the external integration screens. Translations in en, fr, de.
  • The spec docs/specs/external-integrations.md has a section B.19 that details all this.

What would follow, outside this repository: a handler onEnergyCalendarGetDayTypes in the SDK, adding the type to the integration-store manifest schema, a « France calendar » integration (weekends and holidays, calculated locally), and the EDF Zen Week-End grid in energy-contracts. The tariff grids would therefore remain centralized, with the integration only providing the calendar.

Server tests are green with 100% coverage on the added lines, lint, translations, and front build as well. I’m obviously open to any feedback on the splitting or naming before going further.