Skip to content

Commit 4e62350

Browse files
authored
Add costs computing feature (#58)
1 parent 0c374c5 commit 4e62350

12 files changed

+413
-61
lines changed

CONTRIBUTING.md

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
- Click Terminal > Run Task > Start Home Assistant
99
- Open [localhost:7123](http://localhost:7123)
1010
- Install the add-on and configure it
11+
- To access the add-on logs, open the terminal in VSCode, and run `docker logs addon_local_linky -f`
12+
- Click the "rebuild" button in the add-on configuration to rebuild the add-on
1113
- Enjoy!
1214

1315
## Building locally

README.md

+88-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Pour utiliser cet add-on, il vous faut :
4040

4141
## Configuration
4242

43-
Une fois l'add-on installé, rendez-vous dans l'onglet _Configuration_.
43+
Une fois l'add-on installé, rendez-vous dans l'onglet _Configuration_ puis dans l'encadré `meters`
4444

4545
La configuration YAML de base comporte 2 compteurs :
4646

@@ -93,6 +93,7 @@ Pour visualiser les données de **HA Linky** dans vos tableaux de bord d'énergi
9393
- Cliquez [ici](https://my.home-assistant.io/redirect/config_energy/), ou ouvrez le menu _Paramètres_ / _Settings_, puis _Tableaux de bord_ / _Dashboards_, puis _Énergie_ / _Energy_
9494
- Dans la section _Réseau électrique_ / _Electricity grid_, cliquez sur _Ajouter une consommation_ / _Add consumption_
9595
- Choisissez la statistique correspondant au `name` que vous avez choisi à l'étape de configuration
96+
- Si vous avez configuré une tarification, cliquez sur _Utiliser une entité de suivi des coûts totaux_, et choisissez la statistique équivalente dont le nom finit par _(costs)_
9697
- Cliquez sur _Enregistrer_ / _Save_
9798

9899
### Bon à savoir
@@ -125,6 +126,90 @@ La démarche à suivre est la suivante :
125126
- Repassez l'action du compteur à `sync` et redémarrez l'add-on
126127
- Si un fichier CSV correspondant à votre PRM est trouvé, HA Linky l'utilisera pour initialiser les données au lieu d'appeler l'API.
127128

129+
### Calcul des coûts
130+
131+
À partir de la version **1.5.0**, vous pouvez fournir une configuration de tarification pour que HA Linky calcule le coût de votre consommation.
132+
133+
La configuration des tarifs est optionnelle, et s'écrit dans l'encadré `costs` de l'onglet _Configuration_, sous forme de liste de tarifs
134+
135+
Chaque item de la liste peut recevoir les paramètres suivants :
136+
137+
| Paramètre | Description | Optionnel |
138+
| ------------ | --------------------------------------------------------------------------------------- | --------- |
139+
| `price` | Coût du kWh en € | **Non** |
140+
| `prm` | Numéro de PRM en consommation. Par défaut, tous les PRMs en consommation sont concernés | Oui |
141+
| `after` | Heure à partir de laquelle ce tarif est valable, au format _"HH:00"_ | Oui |
142+
| `before` | Heure à partir de laquelle ce tarif n'est plus valable, au format _"HH:00"_ | Oui |
143+
| `weekday` | Jours de la semaine pour lesquels ce tarif est valabe (voir exemple ci-dessous) | Oui |
144+
| `start_date` | Date à partir de laquelle ce tarif est valable, au format _"YYYY-MM-DD"_ | Oui |
145+
| `end_date` | Date à partir de laquelle ce tarif n'est plus valable, au format _"YYYY-MM-DD"_ | Oui |
146+
147+
#### Exemples
148+
149+
Configuration la plus simple : `0,23 € / kWh` quelle que soit la date ou l'heure
150+
151+
```yaml
152+
- price: 0.23
153+
```
154+
155+
Configuration HP/HC : `0,21 € / kWh` de 22h à 6h et de 12h à 14h, et `0,25 €` / kWh le reste du temps.
156+
157+
**N.B :** Il faut configurer séparément la période minuit - 6h et la période 22h - minuit
158+
159+
```yaml
160+
- price: 0.21
161+
before: '06:00'
162+
- price: 0.25
163+
after: '06:00'
164+
before: '12:00'
165+
- price: 0.21
166+
after: '12:00'
167+
before: '14:00'
168+
- price: 0.25
169+
after: '14:00'
170+
before: '22:00'
171+
- price: 0.21
172+
after: '22:00'
173+
```
174+
175+
Configuration par jour de la semaine : `0,24 € / kWh` la semaine, `0,22 € / kWh` le week-end
176+
177+
```yaml
178+
- price: 0.24
179+
weekday:
180+
- mon
181+
- tue
182+
- wed
183+
- thu
184+
- fri
185+
- price: 0.22
186+
weekday:
187+
- sat
188+
- sun
189+
```
190+
191+
Tarif qui évolue au cours du temps : `0,21 € / kWh` jusqu'au 30 juin inclus, `0,22 € / kWh` en juillet et août, puis `0,23 € / kWh` à partir du 1 septembre
192+
193+
```yaml
194+
- price: 0.21
195+
end_date: '2024-07-01'
196+
- price: 0.22
197+
start_date: '2024-07-01'
198+
end_date: '2024-09-01'
199+
- price: 0.23
200+
start_date: '2024-09-01'
201+
```
202+
203+
#### Notes concernant le calcul des coûts
204+
205+
- Vous pouvez combiner **tous** les paramètres (horaires, jours de la semaine, dates, prm), pour personnaliser au maximum le calcul des coûts
206+
- L'ajout des coûts au tableau de bord Énergie s'effectue en choisissant _Utiliser une entité de suivi des coûts totaux_ dans la fenêtre de configuration de la consommation
207+
- Le calcul des coûts est effectué en même temps que la consommation est importée dans Home Assistant. Il faudra faire une remise à zéro si vous souhaitez recalculer le coût des consommations déjà importées.
208+
- La configuration des horaires ne fonctionne que pour les heures piles, autrement dit, les minutes différentes de `:00` n'auront aucun effet
209+
- Si plusieurs items de la liste sont valides au même moment (chevauchement d'horaires ou de dates par exemple), HA Linky choisira l'item le plus haut placé dans la liste
210+
- Assurez-vous d'entourer les heures et les dates par des guillemets doubles `"` pour être certain que celles-ci soient bien interprétées par HA Linky
211+
- Vous pouvez vérifier le coût calculé d'une heure en particulier en vous rendant dans _Outils de développement_, onglet _Statistiques_, puis en cliquant sur l'icône la plus à droite de la ligne qui vous intéresse (flèche montante)
212+
128213
## Installation standalone
129214

130215
Si votre installation de Home Assistant ne vous permet pas d'accéder au système d'add-ons, il est également possible de lancer HA Linky en utilisant Docker
@@ -162,7 +247,8 @@ Créez ensuite un fichier nommé `options.json`, au format suivant, puis suivez
162247
"action": "sync",
163248
"production": true
164249
}
165-
]
250+
],
251+
"costs": []
166252
}
167253
```
168254

config.yaml

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Linky
22
description: Sync Energy dashboards with your Linky smart meter
3-
version: 1.4.0
3+
version: 1.5.0
44
slug: linky
55
init: false
66
url: https://github.com/bokub/ha-linky
@@ -27,10 +27,20 @@ options:
2727
name: 'Linky production'
2828
action: 'sync'
2929
production: true
30+
costs: []
3031
schema:
3132
meters:
3233
- prm: str?
3334
token: str?
3435
name: str?
3536
action: list(sync|reset)
3637
production: bool?
38+
costs:
39+
- price: float
40+
prm: str?
41+
after: str?
42+
before: str?
43+
weekday:
44+
- str?
45+
start_date: str?
46+
end_date: str?

src/config.test.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,19 @@ describe('getUserConfig', () => {
1616
"meters": [
1717
{ "prm": "123", "token": "ccc", "name": "Conso", "action": "sync" },
1818
{ "prm": "123", "token": "ppp", "name": "Prod", "action": "reset", "production": true }
19-
]
19+
],
20+
"costs": [{ "price": 0.1, "start_date": "2024-07-01", "prm": "123" }]
2021
}`);
2122
expect(getUserConfig()).toEqual({
2223
meters: [
23-
{ action: 'sync', name: 'Conso', prm: '123', production: false, token: 'ccc' },
24+
{
25+
action: 'sync',
26+
name: 'Conso',
27+
prm: '123',
28+
production: false,
29+
token: 'ccc',
30+
costs: [{ price: 0.1, start_date: '2024-07-01' }],
31+
},
2432
{ action: 'reset', name: 'Prod', prm: '123', production: true, token: 'ppp' },
2533
],
2634
});

src/config.ts

+41-3
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@ export type MeterConfig = {
66
name: string;
77
action: 'sync' | 'reset';
88
production: boolean;
9+
costs?: CostConfig[];
10+
};
11+
12+
export type CostConfig = {
13+
price: number;
14+
after?: string;
15+
before?: string;
16+
weekday?: Array<'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'>;
17+
start_date?: string;
18+
end_date?: string;
919
};
1020

1121
export type UserConfig = { meters: MeterConfig[] };
1222

1323
export function getUserConfig(): UserConfig {
14-
let parsed: { meters?: any[] } = {};
24+
let parsed: { meters?: any[]; costs?: any } = {};
1525

1626
try {
1727
parsed = JSON.parse(readFileSync('/data/options.json', 'utf8'));
@@ -24,13 +34,41 @@ export function getUserConfig(): UserConfig {
2434
if (parsed.meters && Array.isArray(parsed.meters) && parsed.meters.length > 0) {
2535
for (const meter of parsed.meters) {
2636
if (meter.prm && meter.token) {
27-
result.meters.push({
37+
const resultMeter: MeterConfig = {
2838
prm: meter.prm.toString(),
2939
token: meter.token,
3040
name: meter.name || 'Linky',
3141
action: meter.action === 'reset' ? 'reset' : 'sync',
3242
production: meter.production === true,
33-
});
43+
};
44+
if (!resultMeter.production && Array.isArray(parsed.costs)) {
45+
const prmCostConfigs = parsed.costs.filter((cost) => !cost.prm || cost.prm === meter.prm);
46+
if (prmCostConfigs.length > 0) {
47+
resultMeter.costs = [];
48+
for (const cost of prmCostConfigs) {
49+
if (cost.price && typeof cost.price === 'number') {
50+
const resultCost: CostConfig = { price: cost.price };
51+
if (cost.after && typeof cost.after === 'string') {
52+
resultCost.after = cost.after;
53+
}
54+
if (cost.before && typeof cost.before === 'string') {
55+
resultCost.before = cost.before;
56+
}
57+
if (cost.weekday && Array.isArray(cost.weekday)) {
58+
resultCost.weekday = cost.weekday;
59+
}
60+
if (cost.start_date && typeof cost.start_date === 'string') {
61+
resultCost.start_date = cost.start_date;
62+
}
63+
if (cost.end_date && typeof cost.end_date === 'string') {
64+
resultCost.end_date = cost.end_date;
65+
}
66+
resultMeter.costs.push(resultCost);
67+
}
68+
}
69+
}
70+
}
71+
result.meters.push(resultMeter);
3472
}
3573
}
3674
}

src/cost.test.ts

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { computeCosts } from './cost.js';
3+
4+
describe('Cost computer', () => {
5+
it('Should take start and end dates in account', () => {
6+
const result = computeCosts(
7+
[
8+
{ start: '2024-01-15T00:00:00+01:00', state: 1000, sum: 0 },
9+
{ start: '2024-01-16T00:00:00+01:00', state: 2000, sum: 0 },
10+
{ start: '2024-01-17T00:00:00+01:00', state: 3000, sum: 0 },
11+
{ start: '2024-01-18T00:00:00+01:00', state: 4000, sum: 0 },
12+
{ start: '2024-01-19T00:00:00+01:00', state: 5000, sum: 0 },
13+
{ start: '2024-01-20T00:00:00+01:00', state: 6000, sum: 0 },
14+
],
15+
[
16+
{ price: 0.1, end_date: '2024-01-16' },
17+
{ price: 1, start_date: '2024-01-17', end_date: '2024-01-19' },
18+
{ price: 10, start_date: '2024-01-19' },
19+
],
20+
);
21+
22+
expect(result).toEqual([
23+
{ start: '2024-01-15T00:00:00+01:00', state: 0.1, sum: 0.1 },
24+
{ start: '2024-01-17T00:00:00+01:00', state: 3, sum: 3 + 0.1 },
25+
{ start: '2024-01-18T00:00:00+01:00', state: 4, sum: 4 + 3 + 0.1 },
26+
{ start: '2024-01-19T00:00:00+01:00', state: 50, sum: 50 + 4 + 3 + 0.1 },
27+
{ start: '2024-01-20T00:00:00+01:00', state: 60, sum: 60 + 50 + 4 + 3 + 0.1 },
28+
]);
29+
});
30+
31+
it('Should take weekday in account', () => {
32+
const result = computeCosts(
33+
[
34+
{ start: '2024-01-01T00:00:00+01:00', state: 1000, sum: 0 },
35+
{ start: '2024-01-02T00:00:00+01:00', state: 2000, sum: 0 },
36+
{ start: '2024-01-03T00:00:00+01:00', state: 3000, sum: 0 },
37+
{ start: '2024-01-04T00:00:00+01:00', state: 4000, sum: 0 },
38+
{ start: '2024-01-05T00:00:00+01:00', state: 5000, sum: 0 },
39+
],
40+
[
41+
{ price: 2, weekday: ['mon', 'sun'] },
42+
{ price: 10, weekday: ['wed', 'thu'] },
43+
],
44+
);
45+
46+
expect(result).toEqual([
47+
{ start: '2024-01-01T00:00:00+01:00', state: 2, sum: 2 },
48+
{ start: '2024-01-03T00:00:00+01:00', state: 30, sum: 30 + 2 },
49+
{ start: '2024-01-04T00:00:00+01:00', state: 40, sum: 40 + 30 + 2 },
50+
]);
51+
});
52+
53+
it('Should take time in account', () => {
54+
const result = computeCosts(
55+
[
56+
{ start: '2024-01-01T00:00:00+01:00', state: 500, sum: 0 },
57+
{ start: '2024-01-01T01:00:00+01:00', state: 1000, sum: 0 },
58+
{ start: '2024-01-01T02:00:00+01:00', state: 2000, sum: 0 },
59+
{ start: '2024-01-01T03:00:00+01:00', state: 3000, sum: 0 },
60+
{ start: '2024-01-01T04:00:00+01:00', state: 4000, sum: 0 },
61+
{ start: '2024-01-01T05:00:00+01:00', state: 5000, sum: 0 },
62+
],
63+
[
64+
{ price: 0.1, before: '01:00' },
65+
{ price: 1, after: '01:00', before: '03:00' },
66+
{ price: 10, after: '03:00', before: '04:00' },
67+
{ price: 100, after: '05:00' },
68+
],
69+
);
70+
71+
expect(result).toEqual([
72+
{ start: '2024-01-01T00:00:00+01:00', state: 0.05, sum: 0.05 },
73+
{ start: '2024-01-01T01:00:00+01:00', state: 1, sum: 1 + 0.05 },
74+
{ start: '2024-01-01T02:00:00+01:00', state: 2, sum: 2 + 1 + 0.05 },
75+
{ start: '2024-01-01T03:00:00+01:00', state: 30, sum: 30 + 2 + 1 + 0.05 },
76+
{ start: '2024-01-01T05:00:00+01:00', state: 500, sum: 500 + 30 + 2 + 1 + 0.05 },
77+
]);
78+
});
79+
});

src/cost.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { StatisticDataPoint } from './format.js';
2+
import { CostConfig } from './config.js';
3+
import dayjs from 'dayjs';
4+
import { info } from './log.js';
5+
6+
export function computeCosts(energy: StatisticDataPoint[], costConfigs: CostConfig[]): StatisticDataPoint[] {
7+
const result: StatisticDataPoint[] = [];
8+
9+
for (const point of energy) {
10+
const matchingCostConfig = findMatchingCostConfig(point, costConfigs);
11+
if (matchingCostConfig) {
12+
const cost = Math.round(matchingCostConfig.price * point.state) / 1000;
13+
result.push({
14+
start: point.start,
15+
state: cost,
16+
sum: Math.round(1000 * ((result.length === 0 ? 0 : result[result.length - 1].sum) + cost)) / 1000,
17+
});
18+
}
19+
}
20+
21+
if (result.length > 0) {
22+
const intervalFrom = dayjs(result[0].start).format('DD/MM/YYYY');
23+
const intervalTo = dayjs(result[result.length - 1].start).format('DD/MM/YYYY');
24+
info(`Successfully computed the cost of ${result.length} data points from ${intervalFrom} to ${intervalTo}`);
25+
}
26+
27+
return result;
28+
}
29+
30+
function findMatchingCostConfig(point: StatisticDataPoint, configs: CostConfig[]): CostConfig {
31+
return configs.find((config) => {
32+
if (!config.price || typeof config.price !== 'number') {
33+
return false;
34+
}
35+
const pointStart = dayjs(point.start);
36+
if (config.start_date) {
37+
const configStartDate = dayjs(config.start_date);
38+
if (pointStart.isBefore(configStartDate)) {
39+
return false;
40+
}
41+
}
42+
if (config.end_date) {
43+
const configEndDate = dayjs(config.end_date);
44+
if (pointStart.isAfter(configEndDate) || pointStart.isSame(configEndDate)) {
45+
return false;
46+
}
47+
}
48+
49+
if (config.weekday && config.weekday.length > 0) {
50+
const weekday = pointStart.format('ddd').toLowerCase();
51+
if (!(config.weekday as string[]).includes(weekday)) {
52+
return false;
53+
}
54+
}
55+
56+
if (config.after) {
57+
const afterHour = +config.after.split(':')[0];
58+
if (isNaN(afterHour) || pointStart.hour() < afterHour) {
59+
return false;
60+
}
61+
}
62+
63+
if (config.before) {
64+
const beforeHour = +config.before.split(':')[0];
65+
if (isNaN(beforeHour) || pointStart.hour() >= beforeHour) {
66+
return false;
67+
}
68+
}
69+
70+
return true;
71+
});
72+
}

0 commit comments

Comments
 (0)