From 17de240ceec9e0f81a4c0e4574d3250df31dd0a1 Mon Sep 17 00:00:00 2001 From: Ray Date: Wed, 10 Sep 2025 12:57:02 -0400 Subject: [PATCH] support multiday for local provider; make each day's data midnight to midnight, so it's consistent with all other providers; make indentations consistent --- src/errors.ts | 2 +- src/routes/weatherProviders/AccuWeather.ts | 2 +- src/routes/weatherProviders/Apple.ts | 96 +++++++++---------- src/routes/weatherProviders/DWD.ts | 10 +- src/routes/weatherProviders/OWM.ts | 4 +- src/routes/weatherProviders/OpenMeteo.ts | 6 +- src/routes/weatherProviders/PirateWeather.ts | 50 +++++----- .../weatherProviders/WeatherProvider.ts | 24 ++--- src/routes/weatherProviders/local.ts | 74 +++++++++++++- src/server.ts | 36 +++---- 10 files changed, 187 insertions(+), 117 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index f76bbc9..9c2271c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -83,7 +83,7 @@ export function makeCodedError( err: any ): CodedError { if ( err instanceof CodedError ) { return err; } else { - console.error("Unexpected error:", err); + console.error("Unexpected error:", err); return new CodedError( ErrorCode.UnexpectedError ); } } diff --git a/src/routes/weatherProviders/AccuWeather.ts b/src/routes/weatherProviders/AccuWeather.ts index 1f13c38..5132888 100644 --- a/src/routes/weatherProviders/AccuWeather.ts +++ b/src/routes/weatherProviders/AccuWeather.ts @@ -47,7 +47,7 @@ export default class AccuWeatherWeatherProvider extends WeatherProvider { const cloudCoverInfo: CloudCoverInfo[] = historicData.map( ( hour ): CloudCoverInfo => { //return empty interval if measurement does not exist - const time = fromUnixTime( hour.EpochTime, {in: tz(getTZ(coordinates))} ); + const time = fromUnixTime( hour.EpochTime, {in: tz(getTZ(coordinates))} ); if(hour.CloudCover === undefined ){ return { startTime: time, diff --git a/src/routes/weatherProviders/Apple.ts b/src/routes/weatherProviders/Apple.ts index 33f98c4..73f02b9 100644 --- a/src/routes/weatherProviders/Apple.ts +++ b/src/routes/weatherProviders/Apple.ts @@ -36,38 +36,38 @@ interface Metadata { } interface CurrentWeather { - name: string, - metadata: Metadata, - asOf: string; // Required; ISO 8601 date-time - cloudCover?: number; // Optional; 0 to 1 - conditionCode: string; // Required; enumeration of weather condition - daylight?: boolean; // Optional; indicates daylight - humidity: number; // Required; 0 to 1 - precipitationIntensity: number; // Required; in mm/h - pressure: number; // Required; in millibars - pressureTrend: PressureTrend; // Required; direction of pressure change - temperature: number; // Required; in °C - temperatureApparent: number; // Required; feels-like temperature in °C - temperatureDewPoint: number; // Required; in °C - uvIndex: number; // Required; UV radiation level - visibility: number; // Required; in meters - windDirection?: number; // Optional; in degrees - windGust?: number; // Optional; max wind gust speed in km/h - windSpeed: number; // Required; in km/h + name: string, + metadata: Metadata, + asOf: string; // Required; ISO 8601 date-time + cloudCover?: number; // Optional; 0 to 1 + conditionCode: string; // Required; enumeration of weather condition + daylight?: boolean; // Optional; indicates daylight + humidity: number; // Required; 0 to 1 + precipitationIntensity: number; // Required; in mm/h + pressure: number; // Required; in millibars + pressureTrend: PressureTrend; // Required; direction of pressure change + temperature: number; // Required; in °C + temperatureApparent: number; // Required; feels-like temperature in °C + temperatureDewPoint: number; // Required; in °C + uvIndex: number; // Required; UV radiation level + visibility: number; // Required; in meters + windDirection?: number; // Optional; in degrees + windGust?: number; // Optional; max wind gust speed in km/h + windSpeed: number; // Required; in km/h } interface DayPartForecast { - cloudCover: number; // Required; 0 to 1 - conditionCode: string; // Required; enumeration of weather condition - forecastEnd: string; // Required; ISO 8601 date-time - forecastStart: string; // Required; ISO 8601 date-time - humidity: number; // Required; 0 to 1 - precipitationAmount: number; // Required; in millimeters - precipitationChance: number; // Required; as a percentage - precipitationType: PrecipitationType; // Required - snowfallAmount: number; // Required; in millimeters - windDirection?: number; // Optional; in degrees - windSpeed: number; // Required; in km/h + cloudCover: number; // Required; 0 to 1 + conditionCode: string; // Required; enumeration of weather condition + forecastEnd: string; // Required; ISO 8601 date-time + forecastStart: string; // Required; ISO 8601 date-time + humidity: number; // Required; 0 to 1 + precipitationAmount: number; // Required; in millimeters + precipitationChance: number; // Required; as a percentage + precipitationType: PrecipitationType; // Required + snowfallAmount: number; // Required; in millimeters + windDirection?: number; // Optional; in degrees + windSpeed: number; // Required; in km/h } interface DailyForecastData { @@ -99,9 +99,9 @@ interface DailyForecastData { } interface DailyForecast { - name: string, - metadata: Metadata, - days: DailyForecastData[]; + name: string, + metadata: Metadata, + days: DailyForecastData[]; } interface HourWeatherConditions { @@ -127,9 +127,9 @@ interface HourWeatherConditions { } interface HourlyForecast { - name: string, - metadata: Metadata, - hours: HourWeatherConditions[]; + name: string, + metadata: Metadata, + hours: HourWeatherConditions[]; } interface ForecastMinute { @@ -147,16 +147,16 @@ interface ForecastPeriodSummary { } interface NextHourForecast { - name: string, - metadata: Metadata, - forecastEnd?: string; // ISO 8601 date-time - forecastStart?: string; // ISO 8601 date-time - minutes: ForecastMinute[]; // Required; array of forecast minutes - summary: ForecastPeriodSummary[]; // Required; array of forecast summaries + name: string, + metadata: Metadata, + forecastEnd?: string; // ISO 8601 date-time + forecastStart?: string; // ISO 8601 date-time + minutes: ForecastMinute[]; // Required; array of forecast minutes + summary: ForecastPeriodSummary[]; // Required; array of forecast summaries } interface WeatherAlertSummary { - areaId?: string; // Official designation of the affected area + areaId?: string; // Official designation of the affected area areaName?: string; // Human-readable name of the affected area certainty: Certainty; // Required; likelihood of the event countryCode: string; // Required; ISO country code @@ -175,9 +175,9 @@ interface WeatherAlertSummary { } interface WeatherAlertCollection { - name: string, - metadata: Metadata, - alerts: WeatherAlertSummary[]; + name: string, + metadata: Metadata, + alerts: WeatherAlertSummary[]; } interface AppleWeather { @@ -225,7 +225,7 @@ export default class AppleWeatherProvider extends WeatherProvider { ): Promise { const currentDay = startOfDay(localTime(coordinates)); - const tz = getTZ(coordinates); + const tz = getTZ(coordinates); const startTimestamp = new Date(+subDays(currentDay, 10)).toISOString(); const endTimestamp = new Date(+currentDay).toISOString(); @@ -280,7 +280,7 @@ export default class AppleWeatherProvider extends WeatherProvider { const cloudCoverInfo: CloudCoverInfo[] = daysInHours[i].map( (hour): CloudCoverInfo => { - const startTime = new TZDate(hour.forecastStart, tz); + const startTime = new TZDate(hour.forecastStart, tz); return { startTime, @@ -347,7 +347,7 @@ export default class AppleWeatherProvider extends WeatherProvider { coordinates: GeoCoordinates, pws: PWS | undefined ): Promise { - const tz = getTZ(coordinates); + const tz = getTZ(coordinates); const forecastUrl = `https://weatherkit.apple.com/api/v1/weather/en/${coordinates[0]}/${coordinates[1]}?dataSets=currentWeather,forecastDaily&timezone=${tz}`; diff --git a/src/routes/weatherProviders/DWD.ts b/src/routes/weatherProviders/DWD.ts index e7da02e..c84fec1 100644 --- a/src/routes/weatherProviders/DWD.ts +++ b/src/routes/weatherProviders/DWD.ts @@ -13,11 +13,11 @@ export default class DWDWeatherProvider extends WeatherProvider { } protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { - const tz = getTZ(coordinates); + const tz = getTZ(coordinates); const currentDay = startOfDay(localTime(coordinates)); - const startTimestamp = subDays(currentDay, 7).toISOString(); - const endTimestamp = currentDay.toISOString(); + const startTimestamp = subDays(currentDay, 7).toISOString(); + const endTimestamp = currentDay.toISOString(); const historicUrl = `https://api.brightsky.dev/weather?lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&date=${ startTimestamp }&last_date=${ endTimestamp }&tz=${tz}` @@ -52,7 +52,7 @@ export default class DWDWeatherProvider extends WeatherProvider { for(let i = 0; i < daysInHours.length; i++){ const cloudCoverInfo: CloudCoverInfo[] = daysInHours[i].map( ( hour ): CloudCoverInfo => { - const startTime = new TZDate(hour.timestamp, tz); + const startTime = new TZDate(hour.timestamp, tz); const result : CloudCoverInfo = { startTime, endTime: addHours(startTime, 1), @@ -151,7 +151,7 @@ export default class DWDWeatherProvider extends WeatherProvider { forecast: [], }; - const local = localTime(coordinates); + const local = localTime(coordinates); for ( let day = 0; day < 7; day++ ) { diff --git a/src/routes/weatherProviders/OWM.ts b/src/routes/weatherProviders/OWM.ts index ca9343f..01713e1 100644 --- a/src/routes/weatherProviders/OWM.ts +++ b/src/routes/weatherProviders/OWM.ts @@ -20,7 +20,7 @@ export default class OWMWeatherProvider extends WeatherProvider { const localKey = keyToUse(this.API_KEY, pws); //Get previous date by using UTC - const yesterday = subDays(startOfDay(localTime(coordinates)), 1); + const yesterday = subDays(startOfDay(localTime(coordinates)), 1); const yesterdayUrl = `https://api.openweathermap.org/data/3.0/onecall/day_summary?units=imperial&appid=${ localKey }&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&date=${format(yesterday, "yyyy-MM-dd")}&tz=${format(yesterday, "xxx")}`; @@ -41,7 +41,7 @@ export default class OWMWeatherProvider extends WeatherProvider { let clouds = (new Array(24)).fill(historicData.cloud_cover.afternoon); const cloudCoverInfo: CloudCoverInfo[] = clouds.map( ( sample, i ): CloudCoverInfo => { - const start = addHours(yesterday, i); + const start = addHours(yesterday, i); if( sample === undefined ) { return { startTime: start, diff --git a/src/routes/weatherProviders/OpenMeteo.ts b/src/routes/weatherProviders/OpenMeteo.ts index 6164c59..eeda65a 100755 --- a/src/routes/weatherProviders/OpenMeteo.ts +++ b/src/routes/weatherProviders/OpenMeteo.ts @@ -16,10 +16,10 @@ export default class OpenMeteoWeatherProvider extends WeatherProvider { protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { const tz = getTZ(coordinates); - const currentDay = startOfDay(localTime(coordinates)); + const currentDay = startOfDay(localTime(coordinates)); - const startTimestamp = format(subDays(currentDay, 7), "yyyy-MM-dd"); - const endTimestamp = format(currentDay, "yyyy-MM-dd"); + const startTimestamp = format(subDays(currentDay, 7), "yyyy-MM-dd"); + const endTimestamp = format(currentDay, "yyyy-MM-dd"); const historicUrl = `https://api.open-meteo.com/v1/forecast?latitude=${ coordinates[ 0 ] }&longitude=${ coordinates[ 1 ] }&hourly=temperature_2m,relativehumidity_2m,precipitation,direct_radiation,windspeed_10m&temperature_unit=fahrenheit&windspeed_unit=mph&precipitation_unit=inch&start_date=${startTimestamp}&end_date=${endTimestamp}&timezone=${tz}&timeformat=unixtime`; diff --git a/src/routes/weatherProviders/PirateWeather.ts b/src/routes/weatherProviders/PirateWeather.ts index 9d88c68..d1d6219 100644 --- a/src/routes/weatherProviders/PirateWeather.ts +++ b/src/routes/weatherProviders/PirateWeather.ts @@ -16,7 +16,7 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { // The Unix timestamp of 24 hours ago. - const yesterday = subDays(startOfDay(localTime(coordinates)), 1); + const yesterday = subDays(startOfDay(localTime(coordinates)), 1); const localKey = keyToUse(this.API_KEY, pws); @@ -49,7 +49,7 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { samples = samples.slice(0,24); const cloudCoverInfo: CloudCoverInfo[] = samples.map( ( hour ): CloudCoverInfo => { - const startTime = fromUnixTime(hour.time); + const startTime = fromUnixTime(hour.time); return { startTime, endTime: addDays(startTime, 1), @@ -68,7 +68,7 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { */ temp += hour.temperature; - const currentHumidity = hour.humidity || this.humidityFromDewPoint(hour.temperature, hour.dewPoint); + const currentHumidity = hour.humidity || this.humidityFromDewPoint(hour.temperature, hour.dewPoint); humidity += currentHumidity; // This field may be missing from the response if it is snowing. precip += hour.precipAccumulation || 0; @@ -173,7 +173,7 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { } } - private celsiusToFahrenheit(celsius: number): number { + private celsiusToFahrenheit(celsius: number): number { return (celsius * 9) / 5 + 32; } @@ -185,30 +185,30 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { return kph * 0.621371; } - //https://www.npl.co.uk/resources/q-a/dew-point-and-relative-humidity - private eLn(temperature: number, a: number, b: number): number { - return Math.log(611.2) + ((a * temperature) / (b + temperature)); - } + //https://www.npl.co.uk/resources/q-a/dew-point-and-relative-humidity + private eLn(temperature: number, a: number, b: number): number { + return Math.log(611.2) + ((a * temperature) / (b + temperature)); + } - private eWaterLn(temperature: number): number { - return this.eLn(temperature, 17.62, 243.12); - } - private eIceLn(temperature: number): number { - return this.eLn(temperature, 22.46, 272.62); - } + private eWaterLn(temperature: number): number { + return this.eLn(temperature, 17.62, 243.12); + } + private eIceLn(temperature: number): number { + return this.eLn(temperature, 22.46, 272.62); + } - private humidityFromDewPoint(temperature: number, dewPoint: number): number { - if (isNaN(temperature)) return temperature; - if (isNaN(dewPoint)) return dewPoint; + private humidityFromDewPoint(temperature: number, dewPoint: number): number { + if (isNaN(temperature)) return temperature; + if (isNaN(dewPoint)) return dewPoint; - let eFn: (temp: number) => number; + let eFn: (temp: number) => number; - if (temperature > 0) { - eFn = (temp: number) => this.eWaterLn(temp); - } else { - eFn = (temp: number) => this.eIceLn(temp); - } + if (temperature > 0) { + eFn = (temp: number) => this.eWaterLn(temp); + } else { + eFn = (temp: number) => this.eIceLn(temp); + } - return 100 * Math.exp(eFn(dewPoint) - eFn(temperature)); - } + return 100 * Math.exp(eFn(dewPoint) - eFn(temperature)); + } } diff --git a/src/routes/weatherProviders/WeatherProvider.ts b/src/routes/weatherProviders/WeatherProvider.ts index a7102c1..5840873 100644 --- a/src/routes/weatherProviders/WeatherProvider.ts +++ b/src/routes/weatherProviders/WeatherProvider.ts @@ -66,22 +66,22 @@ export class WeatherProvider { return pws?.id || `${coordinates[0]};s${coordinates[1]}` } - /** - * Internal command to get the weather data from an API, will be cached when anything outside calls it - * @param coordinates Coordinates of requested data - * @param pws PWS data which includes the apikey - * @returns Returns weather data (should not be mutated) - */ + /** + * Internal command to get the weather data from an API, will be cached when anything outside calls it + * @param coordinates Coordinates of requested data + * @param pws PWS data which includes the apikey + * @returns Returns weather data (should not be mutated) + */ protected async getWeatherDataInternal(coordinates: GeoCoordinates, pws: PWS | undefined): Promise { throw "Selected WeatherProvider does not support getWeatherData"; } - /** - * Internal command to get the watering data from an API, will be cached when anything outside calls it - * @param coordinates Coordinates of requested data - * @param pws PWS data which includes the apikey - * @returns Returns watering data array in reverse chronological order (array should not be mutated) - */ + /** + * Internal command to get the watering data from an API, will be cached when anything outside calls it + * @param coordinates Coordinates of requested data + * @param pws PWS data which includes the apikey + * @returns Returns watering data array in reverse chronological order (array should not be mutated) + */ protected async getWateringDataInternal(coordinates: GeoCoordinates, pws: PWS | undefined): Promise { throw new CodedError( ErrorCode.UnsupportedAdjustmentMethod ); } diff --git a/src/routes/weatherProviders/local.ts b/src/routes/weatherProviders/local.ts index af67f32..32f2b3b 100644 --- a/src/routes/weatherProviders/local.ts +++ b/src/routes/weatherProviders/local.ts @@ -1,5 +1,7 @@ import express from "express"; import fs from "fs"; +import { startOfDay, subDays, getUnixTime } from "date-fns"; +import { localTime } from "../weather"; import { GeoCoordinates, WeatherData, WateringData, PWS } from "../../types"; import { WeatherProvider } from "./WeatherProvider"; @@ -10,6 +12,8 @@ var queue: Array = [], lastRainEpoch = 0, lastRainCount: number; +const LOCAL_OBSERVATION_DAYS = 7; + function getMeasurement(req: express.Request, key: string): number { let value: number; @@ -70,6 +74,72 @@ export default class LocalWeatherProvider extends WeatherProvider { } protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { + // 1. Trim queue to 7 days (if not already trimmed) + queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < LOCAL_OBSERVATION_DAYS*24*60*60); + if ( queue.length == 0 || queue[0].timestamp - queue[queue.length-1].timestamp < 23*60*60) { + console.error( "There is insufficient data to support watering calculation from local PWS." ); + throw new CodedError( ErrorCode.InsufficientWeatherData ); + } + + // 2. Determine day boundaries + const currentDay = startOfDay(localTime(coordinates)); // today 00:00 local + const endTime = getUnixTime(currentDay); + const startTime = getUnixTime(subDays(currentDay, 7)); + const filteredData = queue.filter(obs => obs.timestamp >= startTime && obs.timestamp < endTime); + const data: WateringData[] = []; + + // 3. Loop over each day from yesterday back to 7 days ago + let dayEnd = currentDay; + for (let i = 0; i < 7; i++) { + let dayStart = subDays(dayEnd, 1); + // collect observations for [dayStart, dayEnd) + const dayObs = filteredData.filter(obs => obs.timestamp >= getUnixTime(dayStart) && obs.timestamp < getUnixTime(dayEnd)); + if (dayObs.length === 0) { + if (i === 0) { + console.error( "There is insufficient data to support watering calculation from local PWS." ); + throw new CodedError( ErrorCode.InsufficientWeatherData ); + } + break; // stop if we hit a gap or ran out of data + } + // 4. Calculate daily averages/totals + let cTemp=0, cHumidity=0, cPrecip=0, cSolar=0, cWind=0; + const avgTemp = dayObs.reduce((sum, obs) => !isNaN(obs.temp) && ++cTemp ? sum + obs.temp : sum, 0) / cTemp; + const avgHum = dayObs.reduce((sum, obs) => !isNaN(obs.humidity) && ++cHumidity ? sum + obs.humidity : sum, 0) / cHumidity; + const totalPrecip = dayObs.reduce((sum, obs) => !isNaN(obs.precip) && ++cPrecip ? sum + obs.precip : sum, 0); + const minTemp = dayObs.reduce((min, obs) => (min > obs.temp ? obs.temp : min), Infinity); + const maxTemp = dayObs.reduce((max, obs) => (max < obs.temp ? obs.temp : max), -Infinity); + const minHum = dayObs.reduce((min, obs) => (min > obs.humidity ? obs.humidity : min), Infinity); + const maxHum = dayObs.reduce((max, obs) => (max < obs.humidity ? obs.humidity : max), -Infinity); + const avgSolar= dayObs.reduce((sum, obs) => !isNaN(obs.solarRadiation) && ++cSolar ? sum + obs.solarRadiation : sum, 0) / cSolar; + const avgWind = dayObs.reduce((sum, obs) => !isNaN(obs.windSpeed) && ++cWind ? sum + obs.windSpeed : sum, 0) / cWind; + // 5. Verify all metrics present + if (!(cTemp && cHumidity && cPrecip) + || [minTemp, minHum, -maxTemp, -maxHum].includes(Infinity) + || !(cSolar && cWind && cPrecip)) { + if (i === 0) { + console.error( "There is insufficient data to support watering calculation from local PWS." ); + throw new CodedError( ErrorCode.InsufficientWeatherData ); + } + break; + } + // 6. Create WateringData for this day + data.push({ + weatherProvider: "local", + periodStartTime: Math.floor(getUnixTime(dayStart)), // start of the day (epoch) + temp: avgTemp, + humidity: avgHum, + precip: totalPrecip, + minTemp: minTemp, + maxTemp: maxTemp, + minHumidity: minHum, + maxHumidity: maxHum, + solarRadiation: avgSolar, + windSpeed: avgWind + }); + dayEnd = dayStart; // move to previous day + } + return data; + queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); @@ -106,7 +176,7 @@ export default class LocalWeatherProvider extends WeatherProvider { } function saveQueue() { - queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); + queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < (LOCAL_OBSERVATION_DAYS+1)*24*60*60 ); try { fs.writeFileSync( "observations.json" , JSON.stringify( queue ), "utf8" ); } catch ( err ) { @@ -118,7 +188,7 @@ if ( process.env.WEATHER_PROVIDER === "local" && process.env.LOCAL_PERSISTENCE ) if ( fs.existsSync( "observations.json" ) ) { try { queue = JSON.parse( fs.readFileSync( "observations.json", "utf8" ) ); - queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); + queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < (LOCAL_OBSERVATION_DAYS+1)*24*60*60 ); } catch ( err ) { console.error( "Error reading historical observations from local storage.", err ); queue = []; diff --git a/src/server.ts b/src/server.ts index cdb0f48..e4285b0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,24 +12,24 @@ import { pinoHttp } from "pino-http"; import { pino, LevelWithSilent } from "pino"; function getLogLevel(): LevelWithSilent { - switch (process.env.LOG_LEVEL) { - case "trace": - return "trace"; - case "debug": - return "debug"; - case "info": - return "info"; - case "warn": - return "warn"; - case "error": - return "error"; - case "fatal": - return "fatal"; - case "silent": - return "silent"; - default: - return "info"; - } +switch (process.env.LOG_LEVEL) { + case "trace": + return "trace"; + case "debug": + return "debug"; + case "info": + return "info"; + case "warn": + return "warn"; + case "error": + return "error"; + case "fatal": + return "fatal"; + case "silent": + return "silent"; + default: + return "info"; +} } const logger = pino({ level: getLogLevel() });