Skip to content

Commit 05b7982

Browse files
Add Telegram commands for Easee charger usage reports with error handling (#23)
* Initial plan * Add Easee API integration for charging sessions and Telegram commands for monthly reports Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Fix date formatting and calculations based on code review feedback Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Polish report formatting and extract magic number to constant Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Fix charger ID type from string to int to match Easee API response Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Fix charging sessions API endpoint URL to use correct Easee API format Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Revert to path-based URL parameters for charging sessions API endpoint Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Update charging sessions endpoint URL to use /sessions/ instead of /total/ Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Add error handling and support for nested charger API structure - Update chargers API to handle Site -> Circuit -> Charger hierarchy - Add ChargerInfo model with ID and Name - Add ChargingResult wrapper for error handling - Return error messages to users instead of empty results - Use charger names in logs and error messages Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Change charger ID from int to string to support alphanumeric IDs Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Fix JSON property name for user ID from userId to authUser Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Change user ID type from string to int to match Easee API response Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Make authUser property nullable with default value of 0 Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> * Remove sessionEnergyDetails sub-object and read actualDurationSeconds directly Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thomasneuberger <23504477+thomasneuberger@users.noreply.github.com>
1 parent fbcc4fe commit 05b7982

15 files changed

Lines changed: 563 additions & 0 deletions

TgHomeBot.Charging.Contract/IChargingConnector.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using TgHomeBot.Charging.Contract.Models;
2+
13
namespace TgHomeBot.Charging.Contract;
24

35
/// <summary>
@@ -26,4 +28,29 @@ public interface IChargingConnector
2628
/// </summary>
2729
/// <returns>True if authenticated, false otherwise</returns>
2830
bool IsAuthenticated { get; }
31+
32+
/// <summary>
33+
/// Gets all charger IDs available to the authenticated user
34+
/// </summary>
35+
/// <param name="cancellationToken">Cancellation token</param>
36+
/// <returns>Result containing list of charger IDs or error message</returns>
37+
Task<ChargingResult<IReadOnlyList<string>>> GetChargerIdsAsync(CancellationToken cancellationToken = default);
38+
39+
/// <summary>
40+
/// Gets charging sessions for a specific charger within a date range
41+
/// </summary>
42+
/// <param name="chargerId">ID of the charger</param>
43+
/// <param name="chargerName">Name of the charger for logging purposes</param>
44+
/// <param name="from">Start date</param>
45+
/// <param name="to">End date</param>
46+
/// <param name="cancellationToken">Cancellation token</param>
47+
/// <returns>Result containing list of charging sessions or error message</returns>
48+
Task<ChargingResult<IReadOnlyList<ChargingSession>>> GetChargingSessionsAsync(string chargerId, string chargerName, DateTime from, DateTime to, CancellationToken cancellationToken = default);
49+
50+
/// <summary>
51+
/// Gets all chargers with their information (ID and name)
52+
/// </summary>
53+
/// <param name="cancellationToken">Cancellation token</param>
54+
/// <returns>Result containing list of chargers or error message</returns>
55+
Task<ChargingResult<IReadOnlyList<ChargerInfo>>> GetChargersAsync(CancellationToken cancellationToken = default);
2956
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace TgHomeBot.Charging.Contract.Models;
2+
3+
/// <summary>
4+
/// Information about a charger
5+
/// </summary>
6+
public class ChargerInfo
7+
{
8+
public required string Id { get; set; }
9+
public required string Name { get; set; }
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace TgHomeBot.Charging.Contract.Models;
2+
3+
/// <summary>
4+
/// Result wrapper for API operations that can fail
5+
/// </summary>
6+
public class ChargingResult<T>
7+
{
8+
public bool Success { get; set; }
9+
public T? Data { get; set; }
10+
public string? ErrorMessage { get; set; }
11+
12+
public static ChargingResult<T> Ok(T data) => new() { Success = true, Data = data };
13+
public static ChargingResult<T> Error(string message) => new() { Success = false, ErrorMessage = message };
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace TgHomeBot.Charging.Contract.Models;
2+
3+
/// <summary>
4+
/// Charging session information
5+
/// </summary>
6+
public class ChargingSession
7+
{
8+
public required string UserId { get; set; }
9+
public required DateTime CarConnected { get; set; }
10+
public DateTime? CarDisconnected { get; set; }
11+
public required double KiloWattHours { get; set; }
12+
public int? ActualDurationSeconds { get; set; }
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using MediatR;
2+
using TgHomeBot.Charging.Contract.Models;
3+
4+
namespace TgHomeBot.Charging.Contract.Requests;
5+
6+
/// <summary>
7+
/// Request to get charging sessions for all chargers within a date range
8+
/// </summary>
9+
public class GetChargingSessionsRequest(DateTime from, DateTime to) : IRequest<ChargingResult<IReadOnlyList<ChargingSession>>>
10+
{
11+
public DateTime From => from;
12+
public DateTime To => to;
13+
}

TgHomeBot.Charging.Easee/Bootstrap.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
using MediatR;
12
using Microsoft.Extensions.Configuration;
23
using Microsoft.Extensions.DependencyInjection;
34
using TgHomeBot.Charging.Contract;
5+
using TgHomeBot.Charging.Contract.Models;
6+
using TgHomeBot.Charging.Contract.Requests;
7+
using TgHomeBot.Charging.Easee.RequestHandlers;
48

59
namespace TgHomeBot.Charging.Easee;
610

@@ -12,6 +16,8 @@ public static IServiceCollection AddEasee(this IServiceCollection services, ICon
1216

1317
services.AddSingleton<IChargingConnector, EaseeConnector>();
1418

19+
services.AddTransient<IRequestHandler<GetChargingSessionsRequest, ChargingResult<IReadOnlyList<ChargingSession>>>, GetChargingSessionsRequestHandler>();
20+
1521
return services;
1622
}
1723
}

TgHomeBot.Charging.Easee/EaseeConnector.cs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
using System.Globalization;
2+
using System.Net.Http.Headers;
13
using System.Net.Http.Json;
24
using System.Text.Json;
35
using Microsoft.Extensions.Logging;
46
using Microsoft.Extensions.Options;
57
using TgHomeBot.Charging.Contract;
8+
using TgHomeBot.Charging.Contract.Models;
69
using TgHomeBot.Charging.Easee.Models;
710
using TgHomeBot.Common.Contract;
811

@@ -230,4 +233,146 @@ private void SaveTokenToFile()
230233
_logger.LogError(ex, "Error saving token to file");
231234
}
232235
}
236+
237+
public async Task<ChargingResult<IReadOnlyList<ChargerInfo>>> GetChargersAsync(CancellationToken cancellationToken = default)
238+
{
239+
try
240+
{
241+
await EnsureAuthenticatedAsync(cancellationToken);
242+
243+
_logger.LogInformation("Fetching chargers from Easee API");
244+
245+
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/accounts/chargers");
246+
AddAuthorizationHeader(request);
247+
248+
var response = await _httpClient.SendAsync(request, cancellationToken);
249+
250+
if (!response.IsSuccessStatusCode)
251+
{
252+
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
253+
_logger.LogError("Failed to get chargers with status {StatusCode}: {ErrorContent}",
254+
response.StatusCode, errorContent);
255+
return ChargingResult<IReadOnlyList<ChargerInfo>>.Error($"Fehler beim Abrufen der Ladestationen (HTTP {response.StatusCode})");
256+
}
257+
258+
var sites = await response.Content.ReadFromJsonAsync<List<EaseeSite>>(cancellationToken);
259+
260+
if (sites == null)
261+
{
262+
_logger.LogError("Failed to deserialize chargers response");
263+
return ChargingResult<IReadOnlyList<ChargerInfo>>.Error("Fehler beim Verarbeiten der Ladestationen-Daten");
264+
}
265+
266+
var chargers = sites
267+
.SelectMany(s => s.Circuits)
268+
.SelectMany(c => c.Chargers)
269+
.Select(ch => new ChargerInfo
270+
{
271+
Id = ch.Id,
272+
Name = ch.Name
273+
})
274+
.ToList();
275+
276+
_logger.LogInformation("Successfully fetched {Count} chargers", chargers.Count);
277+
return ChargingResult<IReadOnlyList<ChargerInfo>>.Ok(chargers);
278+
}
279+
catch (Exception ex)
280+
{
281+
_logger.LogError(ex, "Error fetching chargers");
282+
return ChargingResult<IReadOnlyList<ChargerInfo>>.Error($"Fehler beim Abrufen der Ladestationen: {ex.Message}");
283+
}
284+
}
285+
286+
public async Task<ChargingResult<IReadOnlyList<string>>> GetChargerIdsAsync(CancellationToken cancellationToken = default)
287+
{
288+
var result = await GetChargersAsync(cancellationToken);
289+
if (!result.Success)
290+
{
291+
return ChargingResult<IReadOnlyList<string>>.Error(result.ErrorMessage!);
292+
}
293+
294+
var ids = result.Data!.Select(c => c.Id).ToList();
295+
return ChargingResult<IReadOnlyList<string>>.Ok(ids);
296+
}
297+
298+
public async Task<ChargingResult<IReadOnlyList<ChargingSession>>> GetChargingSessionsAsync(string chargerId, string chargerName, DateTime from, DateTime to, CancellationToken cancellationToken = default)
299+
{
300+
try
301+
{
302+
await EnsureAuthenticatedAsync(cancellationToken);
303+
304+
_logger.LogInformation("Fetching charging sessions for charger {ChargerName} ({ChargerId}) from {From} to {To}",
305+
chargerName, chargerId, from, to);
306+
307+
var fromStr = from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
308+
var toStr = to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
309+
var url = $"/api/sessions/charger/{chargerId}/sessions/{fromStr}/{toStr}";
310+
311+
using var request = new HttpRequestMessage(HttpMethod.Get, url);
312+
AddAuthorizationHeader(request);
313+
314+
var response = await _httpClient.SendAsync(request, cancellationToken);
315+
316+
if (!response.IsSuccessStatusCode)
317+
{
318+
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
319+
_logger.LogError("Failed to get charging sessions for {ChargerName} with status {StatusCode}: {ErrorContent}",
320+
chargerName, response.StatusCode, errorContent);
321+
return ChargingResult<IReadOnlyList<ChargingSession>>.Error($"Fehler beim Abrufen der Ladevorgänge für {chargerName} (HTTP {response.StatusCode})");
322+
}
323+
324+
var sessions = await response.Content.ReadFromJsonAsync<List<EaseeChargingSession>>(cancellationToken);
325+
326+
if (sessions == null)
327+
{
328+
_logger.LogError("Failed to deserialize charging sessions response for {ChargerName}", chargerName);
329+
return ChargingResult<IReadOnlyList<ChargingSession>>.Error($"Fehler beim Verarbeiten der Ladevorgänge-Daten für {chargerName}");
330+
}
331+
332+
_logger.LogInformation("Successfully fetched {Count} charging sessions for charger {ChargerName}",
333+
sessions.Count, chargerName);
334+
335+
var chargingSessions = sessions.Select(s => new ChargingSession
336+
{
337+
UserId = s.UserId.ToString(),
338+
CarConnected = s.CarConnected,
339+
CarDisconnected = s.CarDisconnected,
340+
KiloWattHours = s.KiloWattHours,
341+
ActualDurationSeconds = s.ActualDurationSeconds
342+
}).ToList();
343+
344+
return ChargingResult<IReadOnlyList<ChargingSession>>.Ok(chargingSessions);
345+
}
346+
catch (Exception ex)
347+
{
348+
_logger.LogError(ex, "Error fetching charging sessions for charger {ChargerName}", chargerName);
349+
return ChargingResult<IReadOnlyList<ChargingSession>>.Error($"Fehler beim Abrufen der Ladevorgänge für {chargerName}: {ex.Message}");
350+
}
351+
}
352+
353+
private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken)
354+
{
355+
if (!IsAuthenticated)
356+
{
357+
var refreshed = await RefreshTokenAsync(cancellationToken);
358+
if (!refreshed)
359+
{
360+
throw new InvalidOperationException("Not authenticated with Easee API. Please authenticate first.");
361+
}
362+
}
363+
}
364+
365+
private void AddAuthorizationHeader(HttpRequestMessage request)
366+
{
367+
string? accessToken;
368+
lock (_lock)
369+
{
370+
accessToken = _tokenData?.AccessToken;
371+
}
372+
373+
if (accessToken != null)
374+
{
375+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
376+
}
377+
}
233378
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace TgHomeBot.Charging.Easee.Models;
4+
5+
/// <summary>
6+
/// Easee charger information
7+
/// </summary>
8+
internal class EaseeCharger
9+
{
10+
[JsonPropertyName("id")]
11+
public required string Id { get; set; }
12+
13+
[JsonPropertyName("name")]
14+
public required string Name { get; set; }
15+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace TgHomeBot.Charging.Easee.Models;
4+
5+
/// <summary>
6+
/// Easee charging session information
7+
/// </summary>
8+
internal class EaseeChargingSession
9+
{
10+
[JsonPropertyName("id")]
11+
public required int Id { get; set; }
12+
13+
[JsonPropertyName("carConnected")]
14+
public required DateTime CarConnected { get; set; }
15+
16+
[JsonPropertyName("carDisconnected")]
17+
public DateTime? CarDisconnected { get; set; }
18+
19+
[JsonPropertyName("kiloWattHours")]
20+
public required double KiloWattHours { get; set; }
21+
22+
[JsonPropertyName("authUser")]
23+
public int UserId { get; set; } = 0;
24+
25+
[JsonPropertyName("actualDurationSeconds")]
26+
public int? ActualDurationSeconds { get; set; }
27+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace TgHomeBot.Charging.Easee.Models;
4+
5+
/// <summary>
6+
/// Easee site information containing circuits
7+
/// </summary>
8+
internal class EaseeSite
9+
{
10+
[JsonPropertyName("circuits")]
11+
public required List<EaseeCircuit> Circuits { get; set; }
12+
}
13+
14+
/// <summary>
15+
/// Easee circuit information containing chargers
16+
/// </summary>
17+
internal class EaseeCircuit
18+
{
19+
[JsonPropertyName("chargers")]
20+
public required List<EaseeCharger> Chargers { get; set; }
21+
}

0 commit comments

Comments
 (0)