Cet article fait partie de la série Assistant vocal sur Raspberry Pi.
L’assistant répond bien, mais il ne sait pas quel temps il fait dehors. On branche Open-Meteo, une API météo gratuite et sans clé. Tant qu’à y être, on swap aussi Ollama pour Claude API : une seule ligne dans appsettings.json.
Le code complet de cet article est disponible sur GitHub.
Partie 1 : Météo en temps réel avec Open-Meteo
Open-Meteo est une API météo open-source, gratuite et sans inscription. On appelle l’API au démarrage de l’assistant pour obtenir les conditions actuelles de Blainville, puis on les injecte dans le system prompt.
Open-Meteo → température, conditions → injection dans SystemPrompt → LLM répond avec contexte météo
Étape 1.1 : Créer WeatherService
Crée Services/WeatherService.cs :
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace AudioAssistant.Services;
public interface IWeatherService
{
Task<string> GetCurrentWeatherAsync(CancellationToken cancellationToken);
}
public class WeatherService : IWeatherService
{
private readonly HttpClient _http;
private readonly AssistantOptions _options;
private readonly ILogger<WeatherService> _logger;
public WeatherService(HttpClient http, IOptions<AssistantOptions> options, ILogger<WeatherService> logger)
{
_http = http;
_options = options.Value;
_logger = logger;
}
public async Task<string> GetCurrentWeatherAsync(CancellationToken cancellationToken)
{
try
{
var url = $"https://api.open-meteo.com/v1/forecast" +
$"?latitude={_options.WeatherLatitude}" +
$"&longitude={_options.WeatherLongitude}" +
$"¤t=temperature_2m,apparent_temperature,precipitation,weathercode,windspeed_10m" +
$"&timezone=America%2FToronto" +
$"&forecast_days=1";
var response = await _http.GetFromJsonAsync<OpenMeteoResponse>(url, cancellationToken);
if (response?.Current == null)
return "Météo non disponible.";
var description = GetWeatherDescription(response.Current.Weathercode);
var summary = $"{description}, {response.Current.Temperature2m:F1}°C " +
$"(ressenti {response.Current.ApparentTemperature:F1}°C), " +
$"vent {response.Current.Windspeed10m:F0} km/h";
if (response.Current.Precipitation > 0)
summary += $", précipitations {response.Current.Precipitation:F1} mm";
_logger.LogInformation("Météo Blainville : {Summary}", summary);
return summary;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Impossible d'obtenir la météo.");
return "Météo non disponible.";
}
}
private static string GetWeatherDescription(int code) => code switch
{
0 => "Ciel dégagé",
1 => "Principalement dégagé",
2 => "Partiellement nuageux",
3 => "Couvert",
45 or 48 => "Brouillard",
51 or 53 or 55 => "Bruine",
61 or 63 or 65 => "Pluie",
71 or 73 or 75 => "Neige",
77 => "Grains de neige",
80 or 81 or 82 => "Averses de pluie",
85 or 86 => "Averses de neige",
95 => "Orage",
96 or 99 => "Orage avec grêle",
_ => "Conditions variables"
};
}
// JsonPropertyName requis : Open-Meteo retourne du snake_case que .NET ne mappe pas automatiquement
internal record OpenMeteoResponse(
[property: JsonPropertyName("current")] OpenMeteoCurrent Current);
internal record OpenMeteoCurrent(
[property: JsonPropertyName("temperature_2m")] double Temperature2m,
[property: JsonPropertyName("apparent_temperature")] double ApparentTemperature,
[property: JsonPropertyName("precipitation")] double Precipitation,
[property: JsonPropertyName("weathercode")] int Weathercode,
[property: JsonPropertyName("windspeed_10m")] double Windspeed10m);
Si vous voyez des valeurs à
0.0°Cpartout, c’est un problème de désérialisation : vérifiez que les attributs[JsonPropertyName]sont bien présents sur les DTOs. C’est le piège classique avec Open-Meteo.
Étape 1.2 : Mettre à jour AssistantOptions
Ajoutez les coordonnées GPS et les nouvelles options :
namespace AudioAssistant;
public class AssistantOptions
{
public int GpioButtonPin { get; set; } = 17;
public string AudioDevice { get; set; } = "hw:3,0";
public int RecordingDurationSeconds { get; set; } = 15;
public int SilenceDurationMs { get; set; } = 1500;
public string SilenceThreshold { get; set; } = "-40dB";
public string WhisperModel { get; set; } = "ggml-base.bin";
public string PiperBinary { get; set; } = "/home/gabriel/piper/piper/piper";
public string PiperVoice { get; set; } = "/home/gabriel/piper-voices/fr_FR-siwis-low.onnx";
public string AudioOutputDevice { get; set; } = "hw:3,0";
public string OllamaBaseUrl { get; set; } = "http://pi-cerveau.local:11434";
public string OllamaModel { get; set; } = "llama3.2:1b";
public string SystemPrompt { get; set; } = "";
public int MaxConversationTurns { get; set; } = 10;
// Météo
public double WeatherLatitude { get; set; } = 45.67;
public double WeatherLongitude { get; set; } = -73.88;
// LLM Provider
public string LlmProvider { get; set; } = "ollama"; // "ollama" ou "claude"
public string ClaudeApiKey { get; set; } = "";
public string ClaudeModel { get; set; } = "claude-sonnet-4-6";
}
Les coordonnées
45.67, -73.88correspondent à Blainville, QC. Ajustez selon votre emplacement.
Étape 1.3 : Mettre à jour ContextService
Le ContextService reçoit maintenant le IWeatherService et injecte la météo dans le system prompt via InitializeAsync :
using Microsoft.Extensions.Options;
namespace AudioAssistant.Services;
public interface IContextService
{
Task InitializeAsync(CancellationToken cancellationToken);
List<ConversationMessage> AddUserMessage(string userInput);
void AddAssistantMessage(string response);
void Reset();
}
public class ContextService : IContextService
{
private readonly AssistantOptions _options;
private readonly IWeatherService _weather;
private readonly List<ConversationMessage> _history = new();
private readonly ILogger<ContextService> _logger;
public ContextService(
IOptions<AssistantOptions> options,
IWeatherService weather,
ILogger<ContextService> logger)
{
_options = options.Value;
_weather = weather;
_logger = logger;
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
var meteo = await _weather.GetCurrentWeatherAsync(cancellationToken);
var systemPromptAvecMeteo = _options.SystemPrompt +
$"\n\nMétéo actuelle à Blainville : {meteo}";
_history.Clear();
_history.Add(new ConversationMessage("system", systemPromptAvecMeteo));
_logger.LogInformation("Contexte initialisé avec météo : {Meteo}", meteo);
}
public List<ConversationMessage> AddUserMessage(string userInput)
{
_history.Add(new ConversationMessage("user", userInput));
_logger.LogInformation("Historique : {Count} messages", _history.Count);
return _history;
}
public void AddAssistantMessage(string response)
{
_history.Add(new ConversationMessage("assistant", response));
}
public void Reset()
{
_history.Clear();
_logger.LogInformation("Historique réinitialisé.");
}
}
Étape 1.4 : Mettre à jour Worker.cs
Appelez InitializeAsync avant la boucle principale :
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Assistant démarré. Initialisation du contexte...");
await _context.InitializeAsync(stoppingToken);
_logger.LogInformation("Appuie sur le bouton pour parler.");
while (!stoppingToken.IsCancellationRequested)
{
_gpio.WaitForButtonPress(stoppingToken);
if (stoppingToken.IsCancellationRequested) break;
try
{
var audioFile = await _recorder.RecordAsync(stoppingToken);
var texte = await _transcription.TranscribeAsync(audioFile, stoppingToken);
if (string.IsNullOrWhiteSpace(texte))
{
await _speech.SpeakAsync("Je n'ai pas bien entendu. Peux-tu répéter?", stoppingToken);
}
else
{
var history = _context.AddUserMessage(texte);
var reponse = await _llm.ChatAsync(history, stoppingToken);
_context.AddAssistantMessage(reponse);
await _speech.SpeakAsync(reponse, stoppingToken);
}
if (File.Exists(audioFile))
File.Delete(audioFile);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
_logger.LogError(ex, "Erreur dans le pipeline");
await _speech.SpeakAsync("Une erreur s'est produite.", stoppingToken);
}
}
}
Test météo
Arrêtez le service systemd avant de tester. Sinon les deux instances entrent en conflit sur le périphérique audio :
sudo systemctl stop assistant
dotnet run
Vous devriez voir dans les logs :
info: Météo Blainville : Principalement dégagé, 8.3°C (ressenti 6.1°C), vent 14 km/h
info: Contexte initialisé avec météo : Principalement dégagé, 8.3°C...
Demandez à l’assistant : “Comment je devrais m’habiller ce matin?” La réponse devrait tenir compte de la météo réelle.
Partie 2 : Swapper entre Ollama et Claude API
Pourquoi swapper ?
Ollama est parfait pour un usage quotidien : local, gratuit, privé. Mais llama3.2:1b a des lacunes notables en français québécois. Un exemple réel obtenu durant le développement, quand on lui a demandé comment s’habiller par temps frais :
“La crinière devra s’abuffer et les baskets te serviront bien pour les balades à pied.”
Voilà les limites du modèle 1B. Le modèle 3B est meilleur, mais trop lent sur Pi. Claude s’en sort nettement mieux en français québécois. L’interface ILlmService qu’on a mise en place à l’article #4 rend le swap trivial.
appsettings.json: "LlmProvider": "claude"
↓
Program.cs injecte ClaudeService au lieu de OllamaService
↓
Worker.cs ne change pas — il parle toujours à ILlmService
Étape 2.1 : Ajouter le package Anthropic
dotnet add package Anthropic.SDK
Étape 2.2 : Créer ClaudeService
Crée Services/ClaudeService.cs :
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Options;
namespace AudioAssistant.Services;
public class ClaudeService : ILlmService
{
private readonly AssistantOptions _options;
private readonly ILogger<ClaudeService> _logger;
public ClaudeService(IOptions<AssistantOptions> options, ILogger<ClaudeService> logger)
{
_options = options.Value;
_logger = logger;
}
public async Task<string> ChatAsync(List<ConversationMessage> history, CancellationToken cancellationToken)
{
_logger.LogInformation("Envoi à Claude ({Count} messages)...", history.Count);
var client = new AnthropicClient(_options.ClaudeApiKey);
var systemPrompt = history
.FirstOrDefault(m => m.Role == "system")?.Content ?? "";
var messages = history
.Where(m => m.Role != "system")
.Select(m => new Message
{
Role = m.Role == "user" ? RoleType.User : RoleType.Assistant,
Content = new List<ContentBase> { new TextContent { Text = m.Content } }
})
.ToList();
var request = new MessageParameters
{
Model = _options.ClaudeModel,
MaxTokens = 512,
System = new List<SystemMessage> { new SystemMessage(systemPrompt) },
Messages = messages
};
var response = await client.Messages.GetClaudeMessageAsync(request, cancellationToken);
var text = response.Content.OfType<TextContent>().FirstOrDefault()?.Text?.Trim()
?? "Je n'ai pas de réponse.";
_logger.LogInformation("Réponse de Claude : \"{Text}\"", text);
return text;
}
}
Étape 2.3 : Mettre à jour Program.cs
using AudioAssistant;
using AudioAssistant.Services;
using Microsoft.Extensions.Options;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<AssistantOptions>(
builder.Configuration.GetSection("Assistant"));
builder.Services.AddSingleton<IGpioService, GpioService>();
builder.Services.AddSingleton<IAudioRecorderService, AudioRecorderService>();
builder.Services.AddSingleton<ITranscriptionService, WhisperTranscriptionService>();
builder.Services.AddSingleton<ISpeechService, PiperSpeechService>();
builder.Services.AddSingleton<IContextService, ContextService>();
builder.Services.AddHttpClient<IWeatherService, WeatherService>();
// Enregistrer les deux services LLM
builder.Services.AddHttpClient<OllamaService>(client =>
{
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddTransient<ClaudeService>();
// Factory qui choisit selon LlmProvider dans appsettings
builder.Services.AddTransient<ILlmService>(sp =>
{
var options = sp.GetRequiredService<IOptions<AssistantOptions>>().Value;
return options.LlmProvider.ToLower() switch
{
"claude" => (ILlmService)sp.GetRequiredService<ClaudeService>(),
_ => sp.GetRequiredService<OllamaService>()
};
});
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
Étape 2.4 : Mettre à jour appsettings.json
{
"Assistant": {
"GpioButtonPin": 17,
"AudioDevice": "hw:3,0",
"RecordingDurationSeconds": 15,
"SilenceDurationMs": 1500,
"SilenceThreshold": "-40dB",
"WhisperModel": "ggml-base.bin",
"PiperBinary": "/home/gabriel/piper/piper/piper",
"PiperVoice": "/home/gabriel/piper-voices/fr_FR-siwis-low.onnx",
"AudioOutputDevice": "hw:3,0",
"OllamaBaseUrl": "http://pi-cerveau.local:11434",
"OllamaModel": "llama3.2:1b",
"MaxConversationTurns": 10,
"WeatherLatitude": 45.67,
"WeatherLongitude": -73.88,
"LlmProvider": "ollama",
"ClaudeApiKey": "sk-ant-...",
"ClaudeModel": "claude-sonnet-4-6",
"SystemPrompt": "Tu es un assistant vocal personnel qui s'appelle Alex. Tu aides la famille Mongeon qui habite à Blainville, Québec, Canada. Tu réponds toujours en français québécois, de façon naturelle et chaleureuse. Tu es concis : tes réponses font 1 à 3 phrases maximum, car elles seront lues à voix haute."
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
Ne jamais committer
ClaudeApiKeydans Git. Utilisez plutôt une variable d’environnement dans le service systemd :sudo systemctl edit assistant[Service] Environment="Assistant__ClaudeApiKey=sk-ant-..."
Tester le swap
Pour passer à Claude, changez "LlmProvider": "ollama" pour "LlmProvider": "claude" dans appsettings.json, puis republier et redémarrer :
cd ~/projects/AudioAssistant
dotnet publish -c Release -r linux-arm64 --self-contained false -o ~/assistant-publish
sudo systemctl restart assistant
journalctl -u assistant -f
Dans les logs, vous devriez voir Envoi à Claude au lieu de Envoi au LLM.
La différence de qualité avec Claude se voit dès la première réponse en français. Open-Meteo : gratuit, sans inscription, sans clé. Ce genre d’API, ça ne court pas les rues.
Le code complet de cet article est disponible sur GitHub.
Articles de la série
- Setup des deux Raspberry Pi
- Worker Service .NET 10 et pipeline audio
- Intégration Ollama et contexte maison
- Mémoire, détection de silence et systemd
- Météo en temps réel et swap Claude API (cet article)
- Function Calling : enseigner des outils à l’assistant
- Bilan, leçons apprises et perspectives v2
Dans l’article #6, on pousse plus loin l’idée de la factory LLM en ajoutant du function calling : l’assistant peut appeler des outils définis en code, pas juste répondre à des questions.
Cet article a été rédigé avec l’aide de l’IA et révisé par moi.