UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

573 lines (479 loc) 15.7 kB
/** * Weather Provider Plugin Example for Signal K * * Demonstrates: * - Weather Provider API integration (Signal K's official weather API) * - Provides observations, forecasts, and warnings via /signalk/v2/api/weather * - Fetches real weather data from OpenWeatherMap API * * This is different from the Resource Provider pattern - Weather Provider * integrates with Signal K's specialized weather API at: * GET /signalk/v2/api/weather/observations?lat=...&lon=... * GET /signalk/v2/api/weather/forecasts/daily?lat=...&lon=... * GET /signalk/v2/api/weather/forecasts/point?lat=...&lon=... * GET /signalk/v2/api/weather/warnings?lat=...&lon=... */ import { Plugin, emit, setStatus, setError, debug, createSimpleDelta } from '@signalk/assemblyscript-plugin-sdk/assembly' import { hasNetworkCapability } from '@signalk/assemblyscript-plugin-sdk/assembly/network' import { fetchSync } from 'as-fetch/sync' // ===== Weather Provider FFI ===== // Declare the host-provided function for registering as a weather provider @external("env", "sk_register_weather_provider") declare function sk_register_weather_provider( namePtr: usize, nameLen: usize ): i32 /** * Register this plugin as a weather provider * @param providerName - Display name for this provider (e.g., "OpenWeatherMap") * @returns true if registration succeeded */ function registerWeatherProvider(providerName: string): bool { const nameBytes = String.UTF8.encode(providerName) const result = sk_register_weather_provider( changetype<usize>(nameBytes), nameBytes.byteLength ) return result === 1 } // ===== Weather Data Types ===== /** * Weather observation/forecast data structure * Aligned with Signal K Weather API specification */ class WeatherData { date: string = '' type: string = 'observation' // 'observation' | 'daily' | 'point' description: string = '' // Outside conditions temperature: f64 = 0.0 // Kelvin minTemperature: f64 = 0.0 // Kelvin (for daily forecasts) maxTemperature: f64 = 0.0 // Kelvin (for daily forecasts) feelsLikeTemperature: f64 = 0.0 // Kelvin humidity: f64 = 0.0 // Ratio (0-1) pressure: f64 = 0.0 // Pascals dewPointTemperature: f64 = 0.0 // Kelvin cloudCover: f64 = 0.0 // Ratio (0-1) uvIndex: f64 = 0.0 // Wind windSpeedTrue: f64 = 0.0 // m/s windDirectionTrue: f64 = 0.0 // Radians windGust: f64 = 0.0 // m/s // Precipitation precipitationVolume: f64 = 0.0 // mm toJSON(): string { let json = '{"date":"' + this.date + '"' json += ',"type":"' + this.type + '"' if (this.description.length > 0) { json += ',"description":"' + this.description + '"' } json += ',"outside":{' json += '"temperature":' + this.temperature.toString() if (this.type === 'daily') { json += ',"minTemperature":' + this.minTemperature.toString() json += ',"maxTemperature":' + this.maxTemperature.toString() } if (this.feelsLikeTemperature > 0) { json += ',"feelsLikeTemperature":' + this.feelsLikeTemperature.toString() } json += ',"relativeHumidity":' + this.humidity.toString() json += ',"pressure":' + this.pressure.toString() json += ',"cloudCover":' + this.cloudCover.toString() json += '}' json += ',"wind":{' json += '"speedTrue":' + this.windSpeedTrue.toString() json += ',"directionTrue":' + this.windDirectionTrue.toString() if (this.windGust > 0) { json += ',"gust":' + this.windGust.toString() } json += '}' json += '}' return json } } /** * Weather warning data structure */ class WeatherWarning { startTime: string = '' endTime: string = '' details: string = '' source: string = 'OpenWeatherMap' type: string = 'Warning' toJSON(): string { return ( '{"startTime":"' + this.startTime + '"' + ',"endTime":"' + this.endTime + '"' + ',"details":"' + this.details + '"' + ',"source":"' + this.source + '"' + ',"type":"' + this.type + '"}' ) } } // ===== Configuration ===== class WeatherConfig { apiKey: string = '' defaultLatitude: f64 = 60.1699 // Helsinki defaultLongitude: f64 = 24.9384 } // ===== Global State ===== let config: WeatherConfig = new WeatherConfig() let lastObservation: WeatherData | null = null // ===== JSON Parsing Helpers ===== function extractNumber(json: string, key: string): f64 { const match = json.indexOf('"' + key + '":') if (match < 0) return 0.0 const start = match + key.length + 3 let end = start while ( end < json.length && ((json.charCodeAt(end) >= 48 && json.charCodeAt(end) <= 57) || json.charCodeAt(end) === 46 || json.charCodeAt(end) === 45) ) { end++ } if (end > start) { return parseFloat(json.substring(start, end)) } return 0.0 } function extractString(json: string, key: string): string { const match = json.indexOf('"' + key + '":"') if (match < 0) return '' const start = match + key.length + 4 const end = json.indexOf('"', start) if (end > start) { return json.substring(start, end) } return '' } // ===== Weather API Functions ===== function fetchCurrentWeather(lat: f64, lon: f64): WeatherData | null { const url = 'https://api.openweathermap.org/data/2.5/weather?lat=' + lat.toString() + '&lon=' + lon.toString() + '&appid=' + config.apiKey debug('Fetching current weather from: ' + url) const response = fetchSync(url) if (!response.ok) { debug('Weather fetch failed: HTTP ' + response.status.toString()) return null } const json = response.text() debug('Weather response: ' + json.substring(0, 200)) const data = new WeatherData() data.date = '' // Server will provide timestamp data.type = 'observation' // Parse main weather data data.temperature = extractNumber(json, 'temp') data.feelsLikeTemperature = extractNumber(json, 'feels_like') data.humidity = extractNumber(json, 'humidity') / 100.0 data.pressure = extractNumber(json, 'pressure') * 100.0 // hPa to Pa // Parse wind data.windSpeedTrue = extractNumber(json, 'speed') const windDeg = extractNumber(json, 'deg') data.windDirectionTrue = (windDeg * 3.14159265359) / 180.0 // Parse clouds data.cloudCover = extractNumber(json, 'all') / 100.0 // Parse description data.description = extractString(json, 'description') return data } function fetchForecast( lat: f64, lon: f64, forecastType: string ): WeatherData[] { // OpenWeatherMap One Call API would be better for forecasts // For this example, we'll use the basic forecast API const url = 'https://api.openweathermap.org/data/2.5/forecast?lat=' + lat.toString() + '&lon=' + lon.toString() + '&appid=' + config.apiKey debug('Fetching forecast from: ' + url) const response = fetchSync(url) if (!response.ok) { debug('Forecast fetch failed: HTTP ' + response.status.toString()) return [] } const json = response.text() // Parse forecast list - simplified parsing // In production, use proper JSON parsing const forecasts: WeatherData[] = [] // For this example, create mock forecast data based on current conditions // A real implementation would parse the OpenWeatherMap response const data = new WeatherData() data.date = '' // Server will provide timestamp data.type = forecastType data.temperature = extractNumber(json, 'temp') data.humidity = extractNumber(json, 'humidity') / 100.0 data.pressure = extractNumber(json, 'pressure') * 100.0 data.windSpeedTrue = extractNumber(json, 'speed') const windDeg = extractNumber(json, 'deg') data.windDirectionTrue = (windDeg * 3.14159265359) / 180.0 data.description = extractString(json, 'description') if (forecastType === 'daily') { data.minTemperature = data.temperature - 5.0 data.maxTemperature = data.temperature + 5.0 } forecasts.push(data) return forecasts } // ===== Plugin Class ===== class WeatherProviderPlugin extends Plugin { // Note: Plugin ID is derived from package.json name name(): string { return 'Weather Provider Plugin (Example)' } start(configJson: string): i32 { debug('Weather Provider plugin starting...') // Check network capability if (!hasNetworkCapability()) { setError('Network capability not granted') return 1 } // Parse configuration if (configJson.length > 2) { // Extract API key const apiKeyMatch = configJson.indexOf('"apiKey"') if (apiKeyMatch >= 0) { const colonPos = configJson.indexOf(':', apiKeyMatch) const quoteStart = configJson.indexOf('"', colonPos) if (quoteStart >= 0) { const keyStart = quoteStart + 1 const keyEnd = configJson.indexOf('"', keyStart) if (keyEnd > keyStart) { config.apiKey = configJson.substring(keyStart, keyEnd) } } } // Extract default latitude const latMatch = configJson.indexOf('"defaultLatitude"') if (latMatch >= 0) { config.defaultLatitude = extractNumber(configJson, 'defaultLatitude') } // Extract default longitude const lonMatch = configJson.indexOf('"defaultLongitude"') if (lonMatch >= 0) { config.defaultLongitude = extractNumber(configJson, 'defaultLongitude') } } // Validate configuration if (config.apiKey.length === 0) { setError( 'No API key configured - get one from https://openweathermap.org/api' ) return 1 } // Register as a weather provider debug('Registering as weather provider...') if (registerWeatherProvider('OpenWeatherMap WASM')) { debug('Successfully registered as weather provider') } else { debug('Warning: Failed to register as weather provider') setError( 'Failed to register as weather provider - capability may not be granted' ) return 1 } // Fetch initial weather data lastObservation = fetchCurrentWeather( config.defaultLatitude, config.defaultLongitude ) if (lastObservation !== null) { // Also emit as deltas for real-time display const obs = lastObservation as WeatherData emit( createSimpleDelta( 'environment.outside.temperature', obs.temperature.toString() ) ) emit( createSimpleDelta( 'environment.outside.humidity', obs.humidity.toString() ) ) emit( createSimpleDelta( 'environment.outside.pressure', obs.pressure.toString() ) ) } setStatus('Weather provider running - data from OpenWeatherMap') return 0 } stop(): i32 { debug('Weather Provider plugin stopped') setStatus('Stopped') return 0 } schema(): string { return `{ "type": "object", "required": ["apiKey"], "properties": { "apiKey": { "type": "string", "title": "OpenWeatherMap API Key", "description": "Get your free API key from https://openweathermap.org/api" }, "defaultLatitude": { "type": "number", "title": "Default Latitude", "description": "Default latitude when not specified in request", "default": 60.1699 }, "defaultLongitude": { "type": "number", "title": "Default Longitude", "description": "Default longitude when not specified in request", "default": 24.9384 } } }` } } // ===== Plugin Instance & Exports ===== const plugin = new WeatherProviderPlugin() // Note: plugin_id() is no longer required - ID is derived from package.json name export function plugin_name(): string { return plugin.name() } export function plugin_schema(): string { return plugin.schema() } export function plugin_start(configPtr: usize, configLen: usize): i32 { const len = i32(configLen) const configBytes = new Uint8Array(len) for (let i: i32 = 0; i < len; i++) { configBytes[i] = load<u8>(configPtr + <usize>i) } const configJson = String.UTF8.decode(configBytes.buffer) return plugin.start(configJson) } export function plugin_stop(): i32 { return plugin.stop() } // ===== Weather Provider Handler Exports ===== // These are called by the Signal K server when requests come to /signalk/v2/api/weather /** * Request structure for weather queries */ class WeatherRequest { latitude: f64 = 0.0 longitude: f64 = 0.0 type: string = '' maxCount: i32 = 10 static parse(json: string): WeatherRequest { const req = new WeatherRequest() // Parse position const posMatch = json.indexOf('"position"') if (posMatch >= 0) { req.latitude = extractNumber(json, 'latitude') req.longitude = extractNumber(json, 'longitude') } // Parse type req.type = extractString(json, 'type') // Parse options const countMatch = json.indexOf('"maxCount"') if (countMatch >= 0) { req.maxCount = i32(extractNumber(json, 'maxCount')) } return req } } /** * Get weather observations for a location * Called for: GET /signalk/v2/api/weather/observations?lat=...&lon=... * * @param requestJson - JSON with { "position": { "latitude": ..., "longitude": ... }, "options": {...} } * @returns JSON array of observation data */ export function weather_get_observations(requestJson: string): string { debug('weather_get_observations called: ' + requestJson) const req = WeatherRequest.parse(requestJson) // Use provided position or default const lat = req.latitude !== 0.0 ? req.latitude : config.defaultLatitude const lon = req.longitude !== 0.0 ? req.longitude : config.defaultLongitude // Fetch current weather as observation const observation = fetchCurrentWeather(lat, lon) if (observation === null) { return '[]' } // Cache it lastObservation = observation // Return as array return '[' + observation.toJSON() + ']' } /** * Get weather forecasts for a location * Called for: GET /signalk/v2/api/weather/forecasts/daily?lat=...&lon=... * GET /signalk/v2/api/weather/forecasts/point?lat=...&lon=... * * @param requestJson - JSON with position, type ('daily'|'point'), and options * @returns JSON array of forecast data */ export function weather_get_forecasts(requestJson: string): string { debug('weather_get_forecasts called: ' + requestJson) const req = WeatherRequest.parse(requestJson) // Use provided position or default const lat = req.latitude !== 0.0 ? req.latitude : config.defaultLatitude const lon = req.longitude !== 0.0 ? req.longitude : config.defaultLongitude // Determine forecast type const forecastType = req.type.length > 0 ? req.type : 'point' // Fetch forecasts const forecasts = fetchForecast(lat, lon, forecastType) if (forecasts.length === 0) { return '[]' } // Build JSON array let result = '[' for (let i = 0; i < forecasts.length; i++) { if (i > 0) result += ',' result += forecasts[i].toJSON() } result += ']' return result } /** * Get weather warnings for a location * Called for: GET /signalk/v2/api/weather/warnings?lat=...&lon=... * * @param requestJson - JSON with position * @returns JSON array of warning data */ export function weather_get_warnings(requestJson: string): string { debug('weather_get_warnings called: ' + requestJson) // OpenWeatherMap's free tier doesn't include weather alerts // Return empty array - real implementation would use One Call API // which requires a paid subscription for alerts return '[]' }