This post is part of the Voice Assistant on Raspberry Pi series.
The assistant responds well, but it has no idea what the weather is like outside. We wire up Open-Meteo, a free, key-less weather API. And while we’re at it, we swap Ollama for the Claude API: a single line in appsettings.json.
The complete code for this article is available on GitHub.
Part 1: Real-time weather with Open-Meteo
Open-Meteo is an open-source weather API, free and without any registration. We call it at startup to get current conditions, then inject the result into the system prompt.
Open-Meteo → temperature, conditions → injected into SystemPrompt → LLM answers with weather context
Step 1.1: Create WeatherService
Create 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 "Weather unavailable.";
var description = GetWeatherDescription(response.Current.Weathercode);
var summary = $"{description}, {response.Current.Temperature2m:F1}°C " +
$"(feels like {response.Current.ApparentTemperature:F1}°C), " +
$"wind {response.Current.Windspeed10m:F0} km/h";
if (response.Current.Precipitation > 0)
summary += $", precipitation {response.Current.Precipitation:F1} mm";
_logger.LogInformation("Blainville weather: {Summary}", summary);
return summary;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to fetch weather.");
return "Weather unavailable.";
}
}
private static string GetWeatherDescription(int code) => code switch
{
0 => "Clear sky",
1 => "Mainly clear",
2 => "Partly cloudy",
3 => "Overcast",
45 or 48 => "Fog",
51 or 53 or 55 => "Drizzle",
61 or 63 or 65 => "Rain",
71 or 73 or 75 => "Snow",
77 => "Snow grains",
80 or 81 or 82 => "Rain showers",
85 or 86 => "Snow showers",
95 => "Thunderstorm",
96 or 99 => "Thunderstorm with hail",
_ => "Variable conditions"
};
}
// JsonPropertyName required: Open-Meteo returns snake_case that .NET won't map automatically
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);
If you see all-zero values, it’s a deserialization issue. Make sure the
[JsonPropertyName]attributes are on the DTOs. That’s the classic Open-Meteo gotcha with .NET.
Step 1.2: Update AssistantOptions
Add GPS coordinates and the new 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;
// Weather
public double WeatherLatitude { get; set; } = 45.67;
public double WeatherLongitude { get; set; } = -73.88;
// LLM Provider
public string LlmProvider { get; set; } = "ollama"; // "ollama" or "claude"
public string ClaudeApiKey { get; set; } = "";
public string ClaudeModel { get; set; } = "claude-sonnet-4-6";
}
The coordinates
45.67, -73.88are for Blainville, QC. Adjust for your location.
Step 1.3: Update ContextService
ContextService now takes IWeatherService and injects the weather into the 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 weather = await _weather.GetCurrentWeatherAsync(cancellationToken);
var systemPromptWithWeather = _options.SystemPrompt +
$"\n\nCurrent weather in Blainville: {weather}";
_history.Clear();
_history.Add(new ConversationMessage("system", systemPromptWithWeather));
_logger.LogInformation("Context initialized with weather: {Weather}", weather);
}
public List<ConversationMessage> AddUserMessage(string userInput)
{
_history.Add(new ConversationMessage("user", userInput));
_logger.LogInformation("History: {Count} messages", _history.Count);
return _history;
}
public void AddAssistantMessage(string response)
{
_history.Add(new ConversationMessage("assistant", response));
}
public void Reset()
{
_history.Clear();
_logger.LogInformation("History reset.");
}
}
Step 1.4: Update Worker.cs
Call InitializeAsync before the main loop:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Assistant started. Initializing context...");
await _context.InitializeAsync(stoppingToken);
_logger.LogInformation("Press the button to speak.");
while (!stoppingToken.IsCancellationRequested)
{
_gpio.WaitForButtonPress(stoppingToken);
if (stoppingToken.IsCancellationRequested) break;
try
{
var audioFile = await _recorder.RecordAsync(stoppingToken);
var text = await _transcription.TranscribeAsync(audioFile, stoppingToken);
if (string.IsNullOrWhiteSpace(text))
{
await _speech.SpeakAsync("Je n'ai pas bien entendu. Peux-tu répéter?", stoppingToken);
}
else
{
var history = _context.AddUserMessage(text);
var response = await _llm.ChatAsync(history, stoppingToken);
_context.AddAssistantMessage(response);
await _speech.SpeakAsync(response, stoppingToken);
}
if (File.Exists(audioFile))
File.Delete(audioFile);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
_logger.LogError(ex, "Error in pipeline");
await _speech.SpeakAsync("Une erreur s'est produite.", stoppingToken);
}
}
}
Test the weather integration
Stop the systemd service first; otherwise both instances will fight over the audio device:
sudo systemctl stop assistant
dotnet run
You should see in the logs:
info: Blainville weather: Mainly clear, 8.3°C (feels like 6.1°C), wind 14 km/h
info: Context initialized with weather: Mainly clear, 8.3°C...
Ask the assistant: “How should I dress this morning?” The response should reference the actual current conditions.
Part 2: Swapping to the Claude API
Why swap?
Ollama is great for everyday use: local, free, private. But llama3.2:1b has noticeable gaps in French. A real example from development, when asked how to dress for cool weather:
“La crinière devra s’abuffer et les baskets te serviront bien pour les balades à pied.”
That’s the 1B model hitting its limits. The 3B model is better but too slow on the Pi. Claude handles French significantly better. The ILlmService interface we set up in article #4 makes the swap trivial.
appsettings.json: "LlmProvider": "claude"
↓
Program.cs injects ClaudeService instead of OllamaService
↓
Worker.cs doesn't change — it still talks to ILlmService
Step 2.1: Add the Anthropic package
dotnet add package Anthropic.SDK
Step 2.2: Create ClaudeService
Create 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("Sending to 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()
?? "I don't have a response.";
_logger.LogInformation("Claude response: \"{Text}\"", text);
return text;
}
}
Step 2.3: Update 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>();
builder.Services.AddHttpClient<OllamaService>(client =>
{
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddTransient<ClaudeService>();
// Factory selects the LLM based on LlmProvider in 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();
Step 2.4: Update 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": "You are a personal voice assistant named Alex. You help the Mongeon family in Blainville, Quebec. Always respond in French, naturally and concisely. Keep answers to 1-3 sentences — they will be read aloud."
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
Never commit
ClaudeApiKeyto Git. Use a systemd environment variable instead:sudo systemctl edit assistant[Service] Environment="Assistant__ClaudeApiKey=sk-ant-..."
Testing the swap
Change "LlmProvider": "ollama" to "LlmProvider": "claude" in appsettings.json, then republish and restart:
cd ~/projects/AudioAssistant
dotnet publish -c Release -r linux-arm64 --self-contained false -o ~/assistant-publish
sudo systemctl restart assistant
journalctl -u assistant -f
You should see Sending to Claude in the logs instead of Sending to LLM.
The quality difference with Claude shows on the first response in French. Open-Meteo: free, no account, no key. That combination is harder to find than it should be.
The complete code for this article is available on GitHub.
Series articles
- Setting Up Both Raspberry Pis
- .NET 10 Worker Service and Audio Pipeline
- Ollama Integration and Home Context
- Memory, Silence Detection, and systemd
- Real-Time Weather and Swapping to the Claude API (this article)
- Function Calling: Teaching Tools to the Assistant
- Retrospective, Lessons Learned, and v2 Roadmap
In article #6, we take the LLM factory idea further by adding function calling: the assistant can invoke tools defined in code, not just answer questions.
This post was written with AI assistance and edited by me.