diff --git a/.env.example b/.env.example index 9cbea049..9fcad6cc 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ REFRESH_TOKEN_VALIDITY_MS=604800000 # 1 week NOMINATIM_SEARCH_API="https://nominatim.openstreetmap.org/search" +OPENTOPO_DATA_API_URL="https://api.opentopodata.org/v1" +# OpenTopoData returns the first non-null result from this ordered list. +OPENTOPO_DATA_DATASET="eudem25m,mapzen" +OPENTOPO_DATA_MIN_INTERVAL_MS="1100" + OSEM_GITHUB_URL="https://github.com/openSenseMap/frontend" OSEM_API_URL="https://api.opensensemap.org/" DIRECTUS_URL="https://coelho.opensensemap.org" diff --git a/README.md b/README.md index ae8481de..5b293d6a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ running as a public beta at Screenshot OSeM - ## Project setup If you do need to set the project up locally yourself, feel free to follow these @@ -24,10 +23,13 @@ instructions: You can configure the API endpoint using the following environmental variables: -| ENV | Default value | -| ------------ | -------------------------------------------------------- | -| OSEM_API_URL | https://api.testing.opensensemap.org | -| DATABASE_URL | `postgresql://postgres:postgres@localhost:5432/postgres` | +| ENV | Default value | +| ----------------------------- | -------------------------------------------------------- | +| OSEM_API_URL | https://api.testing.opensensemap.org | +| DATABASE_URL | `postgresql://postgres:postgres@localhost:5432/postgres` | +| OPENTOPO_DATA_API_URL | `https://api.opentopodata.org/v1` | +| OPENTOPO_DATA_DATASET | `eudem25m,mapzen` | +| OPENTOPO_DATA_MIN_INTERVAL_MS | `1100` | You can create a copy of `.env.example`, rename it to `.env` and set the values. To run a local development version, you only need to adjust the `OSEM_API_URL` @@ -163,11 +165,10 @@ flexibility to adjust the outputs to the needs of the respective use case. ##### Documenting an API Route -API route documentation is generated from route-local `zod-openapi` -definitions. Each API route can export an `openapi` object that describes the -route's OpenAPI path item. Request bodies, response bodies, path parameters, -query parameters, and headers should be described with Zod schemas wherever -possible. +API route documentation is generated from route-local `zod-openapi` definitions. +Each API route can export an `openapi` object that describes the route's OpenAPI +path item. Request bodies, response bodies, path parameters, query parameters, +and headers should be described with Zod schemas wherever possible. The main benefit of this approach is that schemas can be shared between validation and documentation. This keeps the OpenAPI documentation closer to the diff --git a/app/components/device-detail/device-detail-box.tsx b/app/components/device-detail/device-detail-box.tsx index d8e665c2..ea454fc2 100644 --- a/app/components/device-detail/device-detail-box.tsx +++ b/app/components/device-detail/device-detail-box.tsx @@ -14,6 +14,7 @@ import { CalendarPlus, Hash, LandPlot, + Mountain, Image as ImageIcon, } from 'lucide-react' import { useEffect, useRef, useState } from 'react' @@ -75,6 +76,7 @@ import { type SensorWithLatestMeasurement } from '~/db/schema' import { getArchiveLink } from '~/lib/archive-link' import { type loader } from '~/routes/explore.$deviceId' import { dateDiffToNowInWords } from '~/lib/date' +import { calculateHeightAboveSeaLevel } from '~/lib/elevation' export interface MeasurementProps { sensorId: string @@ -186,6 +188,11 @@ export default function DeviceDetailBox() { if (!data.device) return null + const heightAboveSeaLevel = calculateHeightAboveSeaLevel( + data.device.terrainElevation, + data.device.heightAboveGround, + ) + return ( <> {open && ( @@ -332,6 +339,13 @@ export default function DeviceDetailBox() { : t('unknown') } /> + {heightAboveSeaLevel !== null ? ( + + ) : null} (null) @@ -20,10 +43,12 @@ export function LocationStep() { setValue, watch, formState: { errors }, - } = useFormContext() + } = useFormContext() const { t } = useTranslation('newdevice') const savedLatitude = watch('latitude') const savedLongitude = watch('longitude') + const savedHeightAboveGround = watch('heightAboveGround') + const elevationLookupConsent = watch('elevationLookupConsent') === true const [marker, setMarker] = useState<{ latitude: number | string @@ -35,93 +60,154 @@ export function LocationStep() { useEffect(() => { if (savedLatitude !== undefined && savedLongitude !== undefined) { - setMarker({ - latitude: savedLatitude, - longitude: savedLongitude, - }) + setMarker({ latitude: savedLatitude, longitude: savedLongitude }) } }, [savedLatitude, savedLongitude]) - const handleLatitudeChange = (e: React.ChangeEvent) => { - const value = e.target.value.trim() - const parsedValue = parseFloat(value) + const markerLocation = useMemo(() => { + if (marker.latitude === '' || marker.longitude === '') return null - setMarker((prev) => ({ - ...prev, - latitude: value === '' || isNaN(parsedValue) ? '' : parsedValue, - })) + const candidate = { + latitude: Number(marker.latitude), + longitude: Number(marker.longitude), + } - setValue( - 'latitude', - value === '' || isNaN(parsedValue) ? undefined : parsedValue, + return isValidLocation(candidate) ? candidate : null + }, [marker.latitude, marker.longitude]) + + const parsedHeightAboveGround = + deviceLocationInputSchema.shape.heightAboveGround.safeParse( + savedHeightAboveGround, ) + const shouldResolveElevation = + elevationLookupConsent && + markerLocation !== null && + parsedHeightAboveGround.success && + parsedHeightAboveGround.data !== undefined + const elevation = useTerrainElevation({ + latitude: shouldResolveElevation ? markerLocation.latitude : undefined, + longitude: shouldResolveElevation ? markerLocation.longitude : undefined, + }) + + const finalHeight = + elevation.result && parsedHeightAboveGround.success + ? calculateHeightAboveSeaLevel( + elevation.result.elevation, + parsedHeightAboveGround.data, + ) + : null + + const handleLatitudeChange = (event: React.ChangeEvent) => { + const value = event.target.value.trim() + const parsedValue = Number(value) + const latitude = + value === '' || !Number.isFinite(parsedValue) ? '' : parsedValue + + setMarker((current) => ({ ...current, latitude })) + setValue('latitude', latitude === '' ? undefined : latitude, { + shouldDirty: true, + shouldValidate: true, + }) } - const handleLongitudeChange = (e: React.ChangeEvent) => { - const value = e.target.value.trim() - const parsedValue = parseFloat(value) + const handleLongitudeChange = ( + event: React.ChangeEvent, + ) => { + const value = event.target.value.trim() + const parsedValue = Number(value) + const longitude = + value === '' || !Number.isFinite(parsedValue) ? '' : parsedValue + + setMarker((current) => ({ ...current, longitude })) + setValue('longitude', longitude === '' ? undefined : longitude, { + shouldDirty: true, + shouldValidate: true, + }) + } - setMarker((prev) => ({ - ...prev, - longitude: value === '' || isNaN(parsedValue) ? '' : parsedValue, - })) + const handleHeightChange = (event: React.ChangeEvent) => { + const value = event.target.value.trim() + const parsedValue = Number(value) setValue( - 'longitude', - value === '' || isNaN(parsedValue) ? undefined : parsedValue, + 'heightAboveGround', + value === '' || !Number.isFinite(parsedValue) ? undefined : parsedValue, + { shouldDirty: true, shouldValidate: true }, ) } - const onMarkerDrag = useCallback( - (event: MarkerDragEvent) => { - const { lng, lat } = event.lngLat + const handleElevationConsentChange = (checked: boolean | 'indeterminate') => { + const consentGranted = checked === true + setValue('elevationLookupConsent', consentGranted, { + shouldDirty: true, + shouldValidate: true, + }) + + if (!consentGranted) { + void withdrawElevationConsent().catch((error) => { + console.warn('Could not withdraw elevation lookup consent:', error) + }) + } + } + + const updateMarker = useCallback( + (longitude: number, latitude: number) => { + const roundedLatitude = Math.round(latitude * 1_000_000) / 1_000_000 + const roundedLongitude = Math.round(longitude * 1_000_000) / 1_000_000 + setMarker({ - latitude: Math.round(lat * 1000000) / 1000000, - longitude: Math.round(lng * 1000000) / 1000000, + latitude: roundedLatitude, + longitude: roundedLongitude, + }) + setValue('latitude', roundedLatitude, { + shouldDirty: true, + shouldValidate: true, + }) + setValue('longitude', roundedLongitude, { + shouldDirty: true, + shouldValidate: true, }) - setValue('latitude', lat) - setValue('longitude', lng) }, [setValue], ) + const onMarkerDragEnd = useCallback( + (event: MarkerDragEvent) => { + updateMarker(event.lngLat.lng, event.lngLat.lat) + }, + [updateMarker], + ) + const onMapClick = useCallback( - (event: any) => { - const { lng, lat } = event.lngLat - setMarker({ - latitude: Math.round(lat * 1000000) / 1000000, - longitude: Math.round(lng * 1000000) / 1000000, - }) - setValue('latitude', lat) - setValue('longitude', lng) + (event: { lngLat: { lng: number; lat: number } }) => { + updateMarker(event.lngLat.lng, event.lngLat.lat) }, - [setValue], + [updateMarker], ) + const displayHeightValue = savedHeightAboveGround?.toString() ?? '' + return (
- {isValidLocation({ - latitude: Number(marker.latitude), - longitude: Number(marker.longitude), - }) && ( + {markerLocation ? ( - )} + ) : null}
-
-
+
+
{errors.latitude?.message ? (

- {String(errors.latitude.message)} + {t(String(errors.latitude.message))}

) : null}
-
+
{errors.longitude?.message ? (

- {String(errors.longitude.message)} + {t(String(errors.longitude.message))} +

+ ) : null} +
+ +
+
+ + + + + + {t('height_info_text')} + + +
+ +

+ {t('height_info_text')} +

+ +
+ +
+ + + + + + {t('elevation_consent_required')} + + +
+
+ + {elevation.status === 'loading' ? ( +
+
+ +
+ + {t('fetching_elevation')} + +
+ ) : elevation.status === 'error' ? ( +
+

+ {elevation.error === 'unavailable' + ? t('elevation_unavailable') + : t('elevation_error')} +

+ +
+ ) : elevation.result ? ( +
+
+ {t('terrain_elevation')}:{' '} + {Math.round(elevation.result.elevation)} m +
+ {finalHeight !== null ? ( +
+ {t('final_height')}: {Math.round(finalHeight)} m +
+ ) : null} +
+ {t('elevation_source')}:{' '} + {elevation.result.attribution ?? elevation.result.dataset} + {elevation.result.datum ? ` (${elevation.result.datum})` : ''} +
+
+ ) : null} + + {errors.heightAboveGround?.message ? ( +

+ {t(String(errors.heightAboveGround.message))}

) : null}
diff --git a/app/components/device/new/new-device-stepper.tsx b/app/components/device/new/new-device-stepper.tsx index 9aea44cc..ca621c10 100644 --- a/app/components/device/new/new-device-stepper.tsx +++ b/app/components/device/new/new-device-stepper.tsx @@ -4,17 +4,19 @@ import { Info, Slash } from 'lucide-react' import { type MouseEvent, useEffect, useState } from 'react' import { type FieldErrors, FormProvider, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { Form, useLoaderData, useSubmit, useNavigation } from 'react-router' +import { + Form, + useActionData, + useLoaderData, + useNavigation, + useSubmit, +} from 'react-router' import { z } from 'zod' import { AdvancedStep } from './advanced-info' import { DeviceSelectionStep } from './device-info' import { GeneralInfoStep } from './general-info' import { LocationStep } from './location-info' -import { - customDeviceSchemaUploadSchema, - sensorSchema, - SensorSelectionStep, -} from './sensors-info' +import { SensorSelectionStep } from './sensors-info' import { SummaryInfo } from './summary-info' import { Breadcrumb, @@ -30,34 +32,20 @@ import { TooltipTrigger, } from '~/components/ui/tooltip' import { useToast } from '~/components/ui/use-toast' -import { DeviceModelEnum } from '~/db/schema/enum' -import { type loader } from '~/routes/device.new' -import { locationSchema, type LocationData } from '~/lib/location' +import { type action, type loader } from '~/routes/device.new' +import { + advancedSchema, + deviceSelectionSchema, + newDeviceLocationSubmissionSchema, + sensorSelectionSchema, +} from '~/lib/new-device-form' import { generalInfoSchema, type GeneralInfoData } from '~/lib/device-general' -const deviceSchema = z.object({ - model: z.enum(DeviceModelEnum.enumValues, { - error: () => 'Please select a device.', - }), -}) - -// selectedSensors can be an array of sensors -const sensorsSchema = z.object({ - selectedSensors: z - .array(sensorSchema) - .min(1, 'Please select at least one sensor'), - deviceSchema: customDeviceSchemaUploadSchema, - deviceSchemaVersionId: z.string().optional(), - deviceSchemaRegistrySelection: z.any().optional(), -}) - -const advancedSchema = z.record(z.string(), z.any()) - const formSchema = z.union([ generalInfoSchema, - locationSchema, - deviceSchema, - sensorsSchema, + newDeviceLocationSubmissionSchema, + deviceSelectionSchema, + sensorSelectionSchema, advancedSchema, ]) @@ -73,21 +61,21 @@ export const Stepper = defineStepper([ id: 'location', label: 'location', infoKey: 'location_info_text', - schema: locationSchema, + schema: newDeviceLocationSubmissionSchema, index: 1, }, { id: 'device-selection', label: 'device_selection', infoKey: 'device_selection_info_text', - schema: deviceSchema, + schema: deviceSelectionSchema, index: 2, }, { id: 'sensor-selection', label: 'sensor_selection', infoKey: 'sensor_selection_info_text', - schema: sensorsSchema, + schema: sensorSelectionSchema, index: 3, }, { @@ -106,9 +94,10 @@ export const Stepper = defineStepper([ }, ]) -type DeviceData = z.infer -type SensorData = z.infer +type DeviceData = z.infer +type SensorData = z.infer type AdvancedData = z.infer +type LocationData = z.infer type FormData = | GeneralInfoData @@ -118,12 +107,15 @@ type FormData = | AdvancedData export default function NewDeviceStepper() { - const { integrations } = useLoaderData() + const { integrations, hasElevationConsent } = useLoaderData() const submit = useSubmit() const [formData, setFormData] = useState>({}) const stepper = Stepper.useStepper() const form = useForm({ mode: 'onTouched', + defaultValues: { + elevationLookupConsent: hasElevationConsent, + }, resolver: zodResolver< z.input, any, @@ -134,12 +126,23 @@ export default function NewDeviceStepper() { const { t } = useTranslation('newdevice') const [isFirst, setIsFirst] = useState(false) const navigation = useNavigation() + const actionData = useActionData() const isSubmitting = navigation.state !== 'idle' useEffect(() => { setIsFirst(stepper.isFirst) }, [stepper.isFirst]) + useEffect(() => { + if (!actionData || actionData.ok) return + + toast({ + title: t('device_creation_error'), + description: t(actionData.error), + variant: 'destructive', + }) + }, [actionData, t, toast]) + const onSubmit = (data: FormData) => { const updatedData = { ...formData, @@ -172,13 +175,26 @@ export default function NewDeviceStepper() { if (message) { toast({ title: 'Form Error', - description: message, + description: t(message), variant: 'destructive', duration: 2000, }) } } + const onBack = () => { + const parsed = stepper.current.schema.safeParse(form.getValues()) + + if (parsed.success) { + setFormData((current) => ({ + ...current, + [stepper.current.id]: parsed.data, + })) + } + + stepper.prev() + } + return ( @@ -195,7 +211,17 @@ export default function NewDeviceStepper() {
stepper.goTo(step.id)} + onClick={() => { + if (stepper.current.id === step.id) return + + void form.handleSubmit((data) => { + setFormData((current) => ({ + ...current, + [stepper.current.id]: data, + })) + stepper.goTo(step.id) + }, onError)() + }} className={` ${ stepper.index === step.index ? 'text-foreground font-bold' @@ -257,7 +283,7 @@ export default function NewDeviceStepper() { +
+ ) : elevation.result ? ( +
+
+ {t('terrain_elevation')}:{' '} + {Math.round(elevation.result.elevation)} m +
+ {finalHeight !== null ? ( +
+ {t('final_height')}: {Math.round(finalHeight)} m +
+ ) : null} +
+ {t('elevation_source')}:{' '} + {elevation.result.attribution ?? + elevation.result.dataset} + {elevation.result.datum + ? ` (${elevation.result.datum})` + : ''} +
+
+ ) : null} +
+ + {locationErrors.heightAboveGround ? ( +

+ {t(locationErrors.heightAboveGround)}

) : null}
diff --git a/app/routes/device.new.tsx b/app/routes/device.new.tsx index aef025bd..74f8e3c0 100644 --- a/app/routes/device.new.tsx +++ b/app/routes/device.new.tsx @@ -1,4 +1,4 @@ -import { redirect } from 'react-router' +import { data as responseData, redirect } from 'react-router' import { type Route } from './+types/device.new' import ValidationStepperForm from '~/components/device/new/new-device-stepper' import { NavBar } from '~/components/nav-bar' @@ -6,6 +6,20 @@ import { getIntegrations } from '~/db/models/integration.server' import { createDevice } from '~/services/device-service.server' import { createDeviceIntegrations } from '~/services/integration-service.server' import { getUser, getUserId } from '~/services/session-service.server' +import { newDeviceSubmissionSchema } from '~/lib/new-device-form' +import { + ElevationLookupError, + getTerrainElevation, +} from '~/services/elevation-service.server' +import { + applyElevationConsentChoice, + hasCurrentElevationConsent, +} from '~/db/models/elevation-consent.server' + +export type NewDeviceActionData = { + ok: false + error: 'invalid_device_form' | 'device_creation_failed' +} export async function loader({ request }: Route.LoaderArgs) { const user = await getUser(request) @@ -13,64 +27,120 @@ export async function loader({ request }: Route.LoaderArgs) { return redirect('/explore/login') } const integrations = await getIntegrations() + const hasElevationConsent = await hasCurrentElevationConsent(user.id) - return { integrations } + return { integrations, hasElevationConsent } } export async function action({ request }: Route.ActionArgs) { + const userId = await getUserId(request) + + if (!userId) return redirect('/explore/login') + const formData = await request.formData() - const rawData = formData.get('formData') as string + const rawData = formData.get('formData') + + if (typeof rawData !== 'string') { + return responseData( + { ok: false, error: 'invalid_device_form' }, + { status: 400 }, + ) + } + + let submittedData: unknown try { - const userId = await getUserId(request) + submittedData = JSON.parse(rawData) as unknown + } catch { + return responseData( + { ok: false, error: 'invalid_device_form' }, + { status: 400 }, + ) + } + + const parsedSubmission = newDeviceSubmissionSchema.safeParse(submittedData) - if (!userId) { - throw new Error('User is not authenticated.') + if (!parsedSubmission.success) { + return responseData( + { ok: false, error: 'invalid_device_form' }, + { status: 400 }, + ) + } + + const submission = parsedSubmission.data + const generalInfo = submission['general-info'] + const { model } = submission['device-selection'] + const sensorSelection = submission['sensor-selection'] + const selectedSensors = sensorSelection.selectedSensors + const { latitude, longitude, heightAboveGround, elevationLookupConsent } = + submission.location + let terrainElevation: number | null = null + let terrainElevationDataset: string | null = null + const mayLookupElevation = await applyElevationConsentChoice( + userId, + elevationLookupConsent, + ) + + if (heightAboveGround !== undefined && mayLookupElevation) { + try { + const elevationResult = await getTerrainElevation(latitude, longitude) + terrainElevation = elevationResult.elevation + terrainElevationDataset = elevationResult.dataset + } catch (error) { + console.warn( + 'Could not calculate device height above sea level:', + error instanceof ElevationLookupError ? error.code : error, + ) } + } - const data = JSON.parse(rawData) - const advanced = data.advanced - - const selectedSensors = data['sensor-selection'].selectedSensors - - const devicePayload = { - name: data['general-info'].name.trim(), - description: data['general-info'].description?.trim() || null, - exposure: data['general-info'].exposure, - expiresAt: data['general-info'].temporaryExpirationDate, - tags: - data['general-info'].tags?.map((tag: { value: string }) => tag.value) || - [], - latitude: data.location.latitude, - longitude: data.location.longitude, - - ...(data['device-selection'].model !== 'custom' && { - model: data['device-selection'].model, - - sensorTemplates: selectedSensors.map((sensor: any) => sensor.id), - }), - - ...(data['device-selection'].model === 'custom' && { - model: data['device-selection'].model, - sensors: selectedSensors.map((sensor: any) => ({ - title: sensor.title, - sensorType: sensor.sensorType, - unit: sensor.unit, - icon: sensor.icon, - })), - deviceSchema: data['sensor-selection'].deviceSchema, - deviceSchemaVersionId: data['sensor-selection'].deviceSchemaVersionId, - }), + try { + const commonDevicePayload = { + name: generalInfo.name, + description: generalInfo.description?.trim() || null, + exposure: generalInfo.exposure, + expiresAt: generalInfo.temporaryExpirationDate?.toISOString(), + tags: generalInfo.tags?.map((tag) => tag.value) ?? [], + latitude, + longitude, + heightAboveGround: heightAboveGround ?? null, + terrainElevation, + terrainElevationDataset, } + const devicePayload = + model === 'custom' + ? { + ...commonDevicePayload, + model, + sensors: selectedSensors.map((sensor) => ({ + title: sensor.title, + sensorType: sensor.sensorType, + unit: sensor.unit, + icon: sensor.icon, + })), + deviceSchema: sensorSelection.deviceSchema, + deviceSchemaVersionId: sensorSelection.deviceSchemaVersionId, + } + : { + ...commonDevicePayload, + model, + sensorTemplates: selectedSensors.flatMap((sensor) => + sensor.id ? [sensor.id] : [], + ), + } + const newDevice = await createDevice(userId, devicePayload) - await createDeviceIntegrations(newDevice.id, advanced) + await createDeviceIntegrations(newDevice.id, submission.advanced) return redirect('/profile/me') } catch (error) { console.error('Error creating device:', error) - return redirect('/profile/me') + return responseData( + { ok: false, error: 'device_creation_failed' }, + { status: 500 }, + ) } } diff --git a/app/routes/resources.elevation.ts b/app/routes/resources.elevation.ts new file mode 100644 index 00000000..8119c714 --- /dev/null +++ b/app/routes/resources.elevation.ts @@ -0,0 +1,80 @@ +import { data } from 'react-router' +import { z } from 'zod' +import { type Route } from './+types/resources.elevation' +import { type ElevationResourceResponse } from '~/lib/elevation' +import { locationCoordinatesSchema } from '~/lib/location' +import { + ElevationLookupError, + getTerrainElevation, +} from '~/services/elevation-service.server' +import { getUserId } from '~/services/session-service.server' +import { + grantCurrentElevationConsent, + withdrawElevationConsent, +} from '~/db/models/elevation-consent.server' + +const elevationLookupRequestSchema = locationCoordinatesSchema.extend({ + consent: z.literal(true), +}) + +async function lookupElevation(latitude: number, longitude: number) { + try { + const result = await getTerrainElevation(latitude, longitude) + + return data( + { ok: true, result }, + { + headers: { + 'Cache-Control': 'private, max-age=300', + }, + }, + ) + } catch (error) { + const code = + error instanceof ElevationLookupError ? error.code : 'upstream_error' + + return data( + { ok: false, error: code }, + { status: code === 'unavailable' ? 404 : 503 }, + ) + } +} + +export async function action({ request }: Route.ActionArgs) { + const userId = await getUserId(request) + if (!userId) throw new Response('Unauthorized', { status: 401 }) + + let body: unknown + + try { + body = await request.json() + } catch { + return data( + { ok: false, error: 'invalid_location' }, + { status: 400 }, + ) + } + + if ( + typeof body === 'object' && + body !== null && + 'consent' in body && + body.consent === false + ) { + await withdrawElevationConsent(userId) + return new Response(null, { status: 204 }) + } + + const parsed = elevationLookupRequestSchema.safeParse(body) + + if (!parsed.success) { + return data( + { ok: false, error: 'invalid_location' }, + { status: 400 }, + ) + } + + await grantCurrentElevationConsent(userId) + + return lookupElevation(parsed.data.latitude, parsed.data.longitude) +} diff --git a/app/services/device-service.server.ts b/app/services/device-service.server.ts index 86c2a038..2c76afb6 100644 --- a/app/services/device-service.server.ts +++ b/app/services/device-service.server.ts @@ -29,6 +29,9 @@ export const CreateDeviceServiceSchema = z tags: z.array(z.string()).optional().default([]), latitude: z.number(), longitude: z.number(), + heightAboveGround: z.number().optional().nullable(), + terrainElevation: z.number().optional().nullable(), + terrainElevationDataset: z.string().optional().nullable(), model: z .enum([ 'homeV2Lora', diff --git a/app/services/elevation-service.server.ts b/app/services/elevation-service.server.ts new file mode 100644 index 00000000..4ce20aaa --- /dev/null +++ b/app/services/elevation-service.server.ts @@ -0,0 +1,276 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { + type ElevationLookupErrorCode, + type TerrainElevationResult, +} from '~/lib/elevation' +import { isValidLocation } from '~/lib/location' + +const DEFAULT_API_URL = 'https://api.opentopodata.org/v1' +const DEFAULT_DATASETS = 'eudem25m,mapzen' +const DEFAULT_TIMEOUT_MS = 5_000 +const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1_000 // 1 day +const DEFAULT_MIN_REQUEST_INTERVAL_MS = 1_100 +const MAX_CACHE_ENTRIES = 5_000 +const MAX_QUEUED_REQUESTS = 5 + +const responseSchema = z.object({ + status: z.string(), + error: z.string().optional(), + results: z + .array( + z.object({ + elevation: z.number().finite().nullable(), + dataset: z.string(), + location: z.object({ + lat: z.number().finite(), + lng: z.number().finite(), + }), + }), + ) + .optional(), +}) + +type CacheEntry = { + result: TerrainElevationResult + expiresAt: number +} + +const cache = new Map() +const inFlight = new Map>() + +let requestQueue: Promise = Promise.resolve() +let nextRequestAt = 0 +let queuedRequestCount = 0 + +export class ElevationLookupError extends Error { + constructor( + public readonly code: ElevationLookupErrorCode, + message: string, + ) { + super(message) + this.name = 'ElevationLookupError' + } +} + +function parsePositiveInteger(value: string | undefined, fallback: number) { + const parsed = Number(value) + + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback +} + +function coordinateCacheKey(latitude: number, longitude: number) { + return `${latitude.toFixed(5)},${longitude.toFixed(5)}` // meter-level precision +} + +function datasetMetadata(dataset: string) { + if (dataset.startsWith('eudem')) { + return { + datum: 'EVRS2000', + attribution: 'OpenTopoData / EU-DEM / Copernicus', + } + } + + if (dataset.startsWith('srtm')) { + return { + datum: 'EGM96', + attribution: 'OpenTopoData / NASA SRTM', + } + } + + if (dataset === 'mapzen') { + return { + datum: 'EGM96', + attribution: 'OpenTopoData / Mapzen terrain data', + } + } + + return { datum: null, attribution: null } +} + +function pruneCache(now: number) { + for (const [key, entry] of cache) { + if (entry.expiresAt <= now) cache.delete(key) + } + + while (cache.size >= MAX_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value + if (typeof oldestKey !== 'string') break + cache.delete(oldestKey) + } +} + +async function withRateLimit(operation: () => Promise): Promise { + if (queuedRequestCount >= MAX_QUEUED_REQUESTS) { + throw new ElevationLookupError( + 'rate_limited', + 'The elevation lookup queue is full.', + ) + } + + queuedRequestCount += 1 + + let releaseQueue!: () => void + const previousRequest = requestQueue + requestQueue = new Promise((resolve) => { + releaseQueue = resolve + }) + let queueReleased = false + + try { + await previousRequest + + const waitMs = Math.max(0, nextRequestAt - Date.now()) + if (waitMs > 0) await delay(waitMs) + + const minIntervalMs = parsePositiveInteger( + process.env.OPENTOPO_DATA_MIN_INTERVAL_MS, + DEFAULT_MIN_REQUEST_INTERVAL_MS, + ) + nextRequestAt = Date.now() + minIntervalMs + queuedRequestCount -= 1 + releaseQueue() + queueReleased = true + + return await operation() + } finally { + if (!queueReleased) { + queuedRequestCount -= 1 + releaseQueue() + } + } +} + +async function requestElevation( + latitude: number, + longitude: number, +): Promise { + if ( + process.env.NODE_ENV === 'production' && + !process.env.OPENTOPO_DATA_API_URL + ) { + throw new ElevationLookupError( + 'upstream_error', + 'OPENTOPO_DATA_API_URL must be configured.', + ) + } + + const apiUrl = (process.env.OPENTOPO_DATA_API_URL ?? DEFAULT_API_URL).replace( + /\/$/, + '', + ) + const dataset = process.env.OPENTOPO_DATA_DATASET ?? DEFAULT_DATASETS + const datasetPath = dataset.split(',').map(encodeURIComponent).join(',') + const url = new URL(`${apiUrl}/${datasetPath}`) + url.searchParams.set('locations', `${latitude},${longitude}`) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) + + try { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }) + + if (response.status === 429) { + throw new ElevationLookupError( + 'rate_limited', + 'OpenTopoData rate limit reached.', + ) + } + + if (!response.ok) { + throw new ElevationLookupError( + 'upstream_error', + `OpenTopoData responded with HTTP ${response.status}.`, + ) + } + + const parsed = responseSchema.safeParse(await response.json()) + + if (!parsed.success || parsed.data.status !== 'OK') { + throw new ElevationLookupError( + 'invalid_response', + 'OpenTopoData returned an invalid response.', + ) + } + + const firstResult = parsed.data.results?.[0] + + if (!firstResult || firstResult.elevation === null) { + throw new ElevationLookupError( + 'unavailable', + 'No elevation is available for this location.', + ) + } + + return { + elevation: firstResult.elevation, + dataset: firstResult.dataset, + ...datasetMetadata(firstResult.dataset), + latitude, + longitude, + } + } catch (error) { + if (error instanceof ElevationLookupError) throw error + + if (controller.signal.aborted) { + throw new ElevationLookupError( + 'timeout', + 'OpenTopoData did not respond in time.', + ) + } + + throw new ElevationLookupError( + 'upstream_error', + 'OpenTopoData could not be reached.', + ) + } finally { + clearTimeout(timeout) + } +} + +export function getTerrainElevation( + latitude: number, + longitude: number, +): Promise { + if (!isValidLocation({ latitude, longitude })) { + return Promise.reject( + new ElevationLookupError( + 'invalid_location', + 'Latitude or longitude is invalid.', + ), + ) + } + + const key = coordinateCacheKey(latitude, longitude) + const now = Date.now() + const cached = cache.get(key) + + if (cached && cached.expiresAt > now) { + return Promise.resolve({ ...cached.result, latitude, longitude }) + } + + const pending = inFlight.get(key) + if (pending) { + return pending.then((result) => ({ ...result, latitude, longitude })) + } + + pruneCache(now) + + const request = withRateLimit(() => requestElevation(latitude, longitude)) + .then((result) => { + cache.set(key, { + result, + expiresAt: Date.now() + DEFAULT_CACHE_TTL_MS, + }) + + return result + }) + .finally(() => inFlight.delete(key)) + + inFlight.set(key, request) + + return request.then((result) => ({ ...result, latitude, longitude })) +} diff --git a/public/locales/de/device-detail-box.json b/public/locales/de/device-detail-box.json index d10bb808..63f0b462 100644 --- a/public/locales/de/device-detail-box.json +++ b/public/locales/de/device-detail-box.json @@ -21,6 +21,7 @@ "mobile": "Mobil" }, "unknown": "Unbekannt", + "height_above_sea_level": "Höhe über dem Meeresspiegel", "sensor_model": "Sensormodell", "last_updated": "Zuletzt aktualisiert", "created_at": "Erstellt am", diff --git a/public/locales/de/edit-device-general.json b/public/locales/de/edit-device-general.json index 200eb50a..54318e08 100644 --- a/public/locales/de/edit-device-general.json +++ b/public/locales/de/edit-device-general.json @@ -24,5 +24,26 @@ "unsaved_changes": "Ungesicherte Änderungen", "longitude": "Längengrad", "latitude": "Breitengrad", + "latitude_required": "Der Breitengrad ist erforderlich.", + "latitude_invalid": "Der Breitengrad muss eine gültige Zahl sein.", + "latitude_out_of_range": "Der Breitengrad muss zwischen -90 und 90 liegen.", + "longitude_required": "Der Längengrad ist erforderlich.", + "longitude_invalid": "Der Längengrad muss eine gültige Zahl sein.", + "longitude_out_of_range": "Der Längengrad muss zwischen -180 und 180 liegen.", + "height_above_ground_invalid": "Die Höhe über dem Boden muss eine gültige endliche Zahl sein.", + "height": "Höhe", + "height_above_ground": "Höhe über dem Boden", + "optional": "optional", + "enter_height": "Höhe über dem Meeresspiegel eingeben (m)", + "enter_height_above_ground": "Höhe über dem Boden eingeben (m)", + "height_info_text": "Höhe relativ zur geschätzten Bodenoberfläche in Metern. Lasse das Feld leer, wenn keine Gerätehöhe gespeichert werden soll. Wenn verfügbar, wird aus der geschätzten Geländehöhe die Höhe über dem Meeresspiegel berechnet.", + "elevation_lookup_consent": "Ich willige ein, dass die Koordinaten des Geräts an OpenTopoData übermittelt werden, um die Höhe über dem Meeresspiegel zu ermitteln. Weitere Informationen enthält die Datenschutzerklärung.", + "elevation_consent_required": "Ohne Einwilligung wird die Höhe über dem Boden weiterhin gespeichert, die Höhe über dem Meeresspiegel kann jedoch nicht berechnet werden.", + "terrain_elevation": "Geschätzte Oberflächenhöhe", + "final_height": "Berechnete Höhe über dem Meeresspiegel", + "fetching_elevation": "Geschätzte Oberflächenhöhe wird abgerufen...", + "elevation_error": "Die Höhe über dem Meeresspiegel kann derzeit nicht berechnet werden. Der Standort und die eingegebene Höhe über dem Boden werden trotzdem gespeichert.", + "retry_elevation": "Höhenabfrage erneut versuchen", + "elevation_source": "Quelle der Höhendaten", "reset_to_original_location": "Zurücksetzen auf ursprünglichen Standort" } diff --git a/public/locales/de/newdevice.json b/public/locales/de/newdevice.json index ba624034..4f7f1345 100644 --- a/public/locales/de/newdevice.json +++ b/public/locales/de/newdevice.json @@ -111,15 +111,39 @@ "mqtt_connect_options_info": "Eine json-kodierte Zeichenkette mit Optionen, die an den MQTT-Client übergeben werden", "loading": "Lade", "location": "Standort", - "location_info_text": "Wähle den Standort des Geräts aus, indem du auf die Karte klickst oder die Breiten- und Längengradkoordinaten manuell eingibst. Ziehe den Marker auf der Karte, um den Standort bei Bedarf anzupassen.", - "location_text": "Klicke in die Karte, um einen Standort für dein Gerät auszuwählen. Du kannst auch Koordinaten manuell eingeben oder die Geosuche benutzen.", + "location_info_text": "Wähle den Standort des Geräts auf der Karte oder gib Breiten- und Längengrad manuell ein. Optional kannst du die Höhe des Geräts über dem Boden angeben. Eine geschätzte Oberflächenhöhe wird addiert, um die Gerätehöhe über dem Meeresspiegel zu berechnen.", + "location_text": "Klicke auf die Karte, um einen Standort für dein Gerät auszuwählen, oder gib die Koordinaten manuell ein.", "search_placeholder": "Suche", "latitude": "Breitengrad", "longitude": "Längengrad", + "latitude_required": "Der Breitengrad ist erforderlich.", + "latitude_invalid": "Der Breitengrad muss eine gültige Zahl sein.", + "latitude_out_of_range": "Der Breitengrad muss zwischen -90 und 90 liegen.", + "longitude_required": "Der Längengrad ist erforderlich.", + "longitude_invalid": "Der Längengrad muss eine gültige Zahl sein.", + "longitude_out_of_range": "Der Längengrad muss zwischen -180 und 180 liegen.", + "height_above_ground_invalid": "Die Höhe über dem Boden muss eine gültige endliche Zahl sein.", "enter latitude": "Breitengrad eingeben (-90 bis 90)", "enter longitude": "Längengrad eingeben (-180 bis 180)", + "enter height above ground": "Höhe über dem Boden eingeben (m)", "height": "Höhe", - "height_info_text": "Höhe über dem Meeresspiegel des von Dir gewählten Standorts in Metern. Wenn Du dein Gerät deutlich über der Höhe des Erdbodens aufgestellt hast (z.B. hohes Gebäude), sollten Du diese Höhe zu der abgeleiteten Höhe hinzuaddieren. Die Höhe ist vor allem dann wichtig, wenn Du einen Sensor angeschlossen hast, der den Luftdruck misst, um diese Messungen vergleichbar zu machen.", + "height_above_ground": "Höhe über dem Boden", + "height_info_label": "Weitere Informationen zur Höhe über dem Boden", + "height_info_text": "Höhe relativ zur geschätzten Bodenoberfläche in Metern. Lasse das Feld leer, wenn keine Gerätehöhe gespeichert werden soll. Wenn verfügbar, wird aus der geschätzten Geländehöhe die Höhe über dem Meeresspiegel berechnet.", + "elevation_lookup_consent": "Ich willige ein, dass die Koordinaten des Geräts an OpenTopoData übermittelt werden, um die Höhe über dem Meeresspiegel zu ermitteln. Weitere Informationen enthält die Datenschutzerklärung.", + "elevation_consent_info_label": "Was ohne Einwilligung zur Höhenabfrage geschieht", + "elevation_consent_required": "Ohne Einwilligung wird die Höhe über dem Boden weiterhin gespeichert, die Höhe über dem Meeresspiegel kann jedoch nicht berechnet werden.", + "terrain_elevation": "Geschätzte Oberflächenhöhe", + "final_height": "Berechnete Höhe über dem Meeresspiegel", + "fetching_elevation": "Geschätzte Oberflächenhöhe wird abgerufen...", + "height_not_set": "Nicht angegeben", + "elevation_unavailable": "Die Höhe über dem Meeresspiegel kann derzeit nicht berechnet werden. Die eingegebene Höhe über dem Boden wird trotzdem gespeichert.", + "elevation_error": "Die Oberflächenhöhe konnte nicht abgerufen werden. Die eingegebene Höhe über dem Boden wird trotzdem gespeichert.", + "retry_elevation": "Höhenabfrage erneut versuchen", + "elevation_source": "Quelle der Höhendaten", + "device_creation_error": "Gerät konnte nicht erstellt werden", + "invalid_device_form": "Die übermittelten Gerätedaten sind ungültig. Bitte prüfe das Formular.", + "device_creation_failed": "Beim Erstellen des Geräts ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.", "summary": "Dein Gerät in der Übersicht", "summary_text": "Bitte prüfe ob alle Einstellungen richtig sind.", "summary_general": "Deine allgemeinen Informationen", diff --git a/public/locales/en/device-detail-box.json b/public/locales/en/device-detail-box.json index 2c7f4fe2..64fda73b 100644 --- a/public/locales/en/device-detail-box.json +++ b/public/locales/en/device-detail-box.json @@ -21,6 +21,7 @@ "mobile": "Mobile" }, "unknown": "Unknown", + "height_above_sea_level": "Height above sea level", "sensor_model": "Sensor model", "last_updated": "Last updated", "created_at": "Created at", diff --git a/public/locales/en/edit-device-general.json b/public/locales/en/edit-device-general.json index 5a94954d..944997ac 100644 --- a/public/locales/en/edit-device-general.json +++ b/public/locales/en/edit-device-general.json @@ -24,5 +24,26 @@ "unsaved_changes": "Unsaved changes", "longitude": "Longitude", "latitude": "Latitude", + "latitude_required": "Latitude is required.", + "latitude_invalid": "Latitude must be a valid number.", + "latitude_out_of_range": "Latitude must be between -90 and 90.", + "longitude_required": "Longitude is required.", + "longitude_invalid": "Longitude must be a valid number.", + "longitude_out_of_range": "Longitude must be between -180 and 180.", + "height_above_ground_invalid": "Height above ground must be a valid finite number.", + "height": "Height", + "height_above_ground": "Height above ground", + "optional": "optional", + "enter_height": "Enter height above sea level (m)", + "enter_height_above_ground": "Enter height above ground (m)", + "height_info_text": "Height relative to the estimated ground surface in meters. Leave blank if no device height should be stored. When available, the estimated terrain elevation is used to calculate the height above sea level.", + "elevation_lookup_consent": "I consent to the transmission of the device coordinates to OpenTopoData to retrieve the height above sea level. See the privacy policy for details.", + "elevation_consent_required": "Without consent, the height above ground will still be saved, but the height above sea level cannot be calculated.", + "terrain_elevation": "Estimated surface elevation", + "final_height": "Calculated height above sea level", + "fetching_elevation": "Fetching estimated surface elevation...", + "elevation_error": "The height above sea level cannot currently be calculated. The location and entered height above ground will still be saved.", + "retry_elevation": "Retry elevation lookup", + "elevation_source": "Elevation source", "reset_to_original_location": "Reset to original location" } diff --git a/public/locales/en/newdevice.json b/public/locales/en/newdevice.json index 72d00e80..8a1ab16e 100644 --- a/public/locales/en/newdevice.json +++ b/public/locales/en/newdevice.json @@ -111,15 +111,39 @@ "mqtt_connect_options_info": "A json encoded string with options to supply to the MQTT client", "loading": "Loading", "location": "Location", - "location_info_text": "Select the device's location by clicking on the map or entering latitude and longitude coordinates manually. Drag the marker on the map to adjust the location if needed.", - "location_text": "Click on the map to choose a location for your device. You can also enter your coordinates manually or use the geosearch.", + "location_info_text": "Select the device's location by clicking on the map or entering latitude and longitude manually. You can optionally enter the device height above ground. An estimated surface elevation is added to calculate the device height above sea level.", + "location_text": "Click on the map to choose a location for your device, or enter its coordinates manually.", "search_placeholder": "Search", "latitude": "Latitude", "longitude": "Longitude", + "latitude_required": "Latitude is required.", + "latitude_invalid": "Latitude must be a valid number.", + "latitude_out_of_range": "Latitude must be between -90 and 90.", + "longitude_required": "Longitude is required.", + "longitude_invalid": "Longitude must be a valid number.", + "longitude_out_of_range": "Longitude must be between -180 and 180.", + "height_above_ground_invalid": "Height above ground must be a valid finite number.", "enter latitude": "Enter latitude (-90 to 90)", "enter longitude": "Enter longitude (-180 to 180)", + "enter height above ground": "Enter height above ground (m)", "height": "Height", - "height_info_text": "Height above sea level of your selected location in meters. If you have set up your device above ground level (e.g. high building), you should add this to the derived height. The height is espacially important if you have connected a sensor that meassures air pressure to make this meassurements compareable.", + "height_above_ground": "Height above ground", + "height_info_label": "More information about height above ground", + "height_info_text": "Height relative to the estimated ground surface in meters. Leave blank if no device height should be stored. When available, the estimated terrain elevation is used to calculate the height above sea level.", + "elevation_lookup_consent": "I consent to the transmission of the device coordinates to OpenTopoData to retrieve the height above sea level. See the privacy policy for details.", + "elevation_consent_info_label": "What happens without elevation lookup consent", + "elevation_consent_required": "Without consent, the height above ground will still be saved, but the height above sea level cannot be calculated.", + "terrain_elevation": "Estimated surface elevation", + "final_height": "Calculated height above sea level", + "fetching_elevation": "Fetching estimated surface elevation...", + "height_not_set": "Not set", + "elevation_unavailable": "The height above sea level cannot currently be calculated. The entered height above ground will still be saved.", + "elevation_error": "The surface elevation could not be retrieved. The entered height above ground will still be saved.", + "retry_elevation": "Retry elevation lookup", + "elevation_source": "Elevation source", + "device_creation_error": "Device could not be created", + "invalid_device_form": "The submitted device information is invalid. Please review the form.", + "device_creation_failed": "An unexpected error occurred while creating the device. Please try again.", "summary": "Your device summary", "summary_text": "Please check if everything is setup correctly.", "summary_general": "Your general Information", diff --git a/tests/lib/location.spec.ts b/tests/lib/location.spec.ts new file mode 100644 index 00000000..ad0c1e26 --- /dev/null +++ b/tests/lib/location.spec.ts @@ -0,0 +1,82 @@ +import { + deviceLocationInputSchema, + parseDeviceLocationInputFormData, + validateDeviceLocationInputFieldErrors, +} from '~/lib/location' + +function locationFormData(height?: string) { + const formData = new FormData() + formData.set('latitude', '51.969') + formData.set('longitude', '7.596') + + if (height !== undefined) formData.set('heightAboveGround', height) + + return formData +} + +describe('device location height validation', () => { + it.each([undefined, ''])( + 'accepts an optional blank height (%s)', + (height) => { + const result = parseDeviceLocationInputFormData(locationFormData(height)) + + expect(result.success).toBe(true) + if (!result.success) return + + expect(result.data).toEqual({ + latitude: 51.969, + longitude: 7.596, + heightAboveGround: undefined, + }) + }, + ) + + it.each([ + ['zero', '0', 0], + ['negative', '-12.5', -12.5], + ['positive', '123.75', 123.75], + ] as const)('parses a %s height', (_label, input, expected) => { + const result = parseDeviceLocationInputFormData(locationFormData(input)) + + expect(result.success).toBe(true) + if (!result.success) return + + expect(result.data.heightAboveGround).toBe(expected) + }) + + it.each(['not-a-number', 'Infinity', '-Infinity'])( + 'rejects invalid height %s', + (height) => { + const result = parseDeviceLocationInputFormData(locationFormData(height)) + + expect(result.success).toBe(false) + if (result.success) return + + expect(result.errors.heightAboveGround).toBeDefined() + }, + ) + + it('reports height errors through client-side field validation', () => { + expect( + validateDeviceLocationInputFieldErrors({ + latitude: 51.969, + longitude: 7.596, + heightAboveGround: Number.NaN, + }), + ).toHaveProperty('heightAboveGround') + }) + + it('normalizes a null height to undefined in the shared form schema', () => { + expect( + deviceLocationInputSchema.parse({ + latitude: 51.969, + longitude: 7.596, + heightAboveGround: null, + }), + ).toEqual({ + latitude: 51.969, + longitude: 7.596, + heightAboveGround: undefined, + }) + }) +}) diff --git a/tests/lib/transform-to-api-format.spec.ts b/tests/lib/transform-to-api-format.spec.ts index 5b6d18aa..a7061321 100644 --- a/tests/lib/transform-to-api-format.spec.ts +++ b/tests/lib/transform-to-api-format.spec.ts @@ -21,6 +21,9 @@ describe('transformDeviceToApiFormat', () => { model: 'custom', latitude: 37.7749, longitude: -122.4194, + heightAboveGround: 3.25, + terrainElevation: 15, + terrainElevationDataset: 'eudem25m', useAuth: true, public: false, status: 'active', @@ -63,6 +66,11 @@ describe('transformDeviceToApiFormat', () => { model: 'custom', latitude: 37.7749, longitude: -122.4194, + heightAboveGround: 3.25, + heightAboveSeaLevel: 18.25, + terrainElevation: 15, + terrainElevationDataset: 'eudem25m', + height: 18.25, useAuth: true, public: false, status: 'active', @@ -72,7 +80,7 @@ describe('transformDeviceToApiFormat', () => { userId: 'user-123', currentLocation: { type: 'Point', - coordinates: [-122.4194, 37.7749], + coordinates: [-122.4194, 37.7749, 18.25], timestamp: '2024-01-01T12:00:00.000Z', }, lastMeasurementAt: '2024-01-01T12:00:00.000Z', @@ -80,7 +88,7 @@ describe('transformDeviceToApiFormat', () => { { geometry: { type: 'Point', - coordinates: [-122.4194, 37.7749], + coordinates: [-122.4194, 37.7749, 18.25], timestamp: '2024-01-01T12:00:00.000Z', }, type: 'Feature', @@ -169,7 +177,7 @@ describe('transformDeviceToApiFormat', () => { expect(result.currentLocation).toEqual({ type: 'Point', - coordinates: [-122.4194, 37.7749], // [longitude, latitude] + coordinates: [-122.4194, 37.7749, 18.25], // [longitude, latitude, height] timestamp: '2024-01-01T12:00:00.000Z', }) }) @@ -181,7 +189,7 @@ describe('transformDeviceToApiFormat', () => { { geometry: { type: 'Point', - coordinates: [-122.4194, 37.7749], // [longitude, latitude] + coordinates: [-122.4194, 37.7749, 18.25], // [longitude, latitude, height] timestamp: '2024-01-01T12:00:00.000Z', }, type: 'Feature', @@ -189,6 +197,30 @@ describe('transformDeviceToApiFormat', () => { ]) }) + test('preserves zero height in both location coordinate formats', () => { + const result = transformDeviceToApiFormat({ + ...mockDevice, + terrainElevation: -3.25, + } as any) + + expect(result.height).toBe(0) + expect(result.currentLocation.coordinates).toEqual([-122.4194, 37.7749, 0]) + expect(result.loc[0].geometry.coordinates).toEqual([-122.4194, 37.7749, 0]) + }) + + test.each([null, undefined])( + 'omits the third coordinate when terrain elevation is %s', + (height) => { + const result = transformDeviceToApiFormat({ + ...mockDevice, + terrainElevation: height, + } as any) + + expect(result.currentLocation.coordinates).toEqual([-122.4194, 37.7749]) + expect(result.loc[0].geometry.coordinates).toEqual([-122.4194, 37.7749]) + }, + ) + test('sets correct integrations structure', () => { const result = transformDeviceToApiFormat(mockDevice as any) @@ -230,6 +262,13 @@ describe('transformDeviceToApiFormat', () => { expect(result.model).toBe(mockDevice.model) expect(result.latitude).toBe(mockDevice.latitude) expect(result.longitude).toBe(mockDevice.longitude) + expect(result.height).toBe(18.25) + expect(result.heightAboveGround).toBe(mockDevice.heightAboveGround) + expect(result.heightAboveSeaLevel).toBe(18.25) + expect(result.terrainElevation).toBe(mockDevice.terrainElevation) + expect(result.terrainElevationDataset).toBe( + mockDevice.terrainElevationDataset, + ) expect(result.useAuth).toBe(mockDevice.useAuth) expect(result.public).toBe(mockDevice.public) expect(result.status).toBe(mockDevice.status) diff --git a/tests/routes/api.boxes.$deviceId.spec.ts b/tests/routes/api.boxes.$deviceId.spec.ts index f5dffce7..663c0521 100644 --- a/tests/routes/api.boxes.$deviceId.spec.ts +++ b/tests/routes/api.boxes.$deviceId.spec.ts @@ -2,7 +2,11 @@ import { generateTestUserCredentials } from 'tests/data/generate_test_user' import invariant from 'tiny-invariant' import { type Route } from '../../.react-router/types/app/routes/+types/api.boxes.$deviceId' import { BASE_URL } from '../../vitest.setup' -import { createDevice, deleteDevice } from '~/db/models/device.server' +import { + createDevice, + deleteDevice, + updateDeviceLocation, +} from '~/db/models/device.server' import { deleteUserByEmail } from '~/db/models/user.server' import { type User, type Device } from '~/db/schema' import { createToken } from '~/lib/jwt' @@ -11,6 +15,32 @@ import { action as deviceAction, } from '~/routes/api.boxes.$deviceId' import { registerUser } from '~/services/user-service.server' +import { getTerrainElevation } from '~/services/elevation-service.server' +import { applyElevationConsentChoice } from '~/db/models/elevation-consent.server' + +const TEST_TERRAIN_ELEVATION = vi.hoisted(() => 250) +const TEST_ELEVATION_DATASET = vi.hoisted(() => 'eudem25m') + +vi.mock('~/db/models/elevation-consent.server', () => ({ + applyElevationConsentChoice: vi.fn(async () => true), +})) + +vi.mock('~/services/elevation-service.server', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + getTerrainElevation: vi.fn(async (latitude: number, longitude: number) => ({ + elevation: TEST_TERRAIN_ELEVATION, + dataset: TEST_ELEVATION_DATASET, + datum: null, + attribution: null, + latitude, + longitude, + })), + } +}) const DEVICE_TEST_USER = generateTestUserCredentials() @@ -53,8 +83,8 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { queryableDevice = await createDevice( { ...generateMinimalDevice(), - latitude: 123, - longitude: 12, + latitude: 12, + longitude: 123, tags: ['testgroup'], useAuth: false, }, @@ -127,7 +157,7 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { exposure: 'indoor', grouptag: 'testgroup', description: 'total neue beschreibung', - location: { lat: 54.2, lng: 21.1 }, + location: { lat: 54.2, lng: 21.1, height: 45.75 }, weblink: 'http://www.google.de', useAuth: true, image: @@ -148,6 +178,8 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { params: { deviceId: queryableDevice?.id }, } as Route.ActionArgs as Route.ActionArgs) const data = await response.json() + const expectedHeight = + TEST_TERRAIN_ELEVATION + update_payload.location.height expect(response.status).toBe(200) expect(data.name).toBe(update_payload.name) @@ -156,9 +188,18 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { expect(data.grouptag).toContain(update_payload.grouptag) expect(data.description).toBe(update_payload.description) expect(data.access_token).not.toBeNull() + expect(data.height).toBe(expectedHeight) + expect(data.heightAboveGround).toBe(update_payload.location.height) + expect(data.heightAboveSeaLevel).toBe(expectedHeight) + expect(data.terrainElevation).toBe(TEST_TERRAIN_ELEVATION) + expect(data.terrainElevationDataset).toBe(TEST_ELEVATION_DATASET) expect(data.currentLocation).toEqual({ type: 'Point', - coordinates: [update_payload.location.lng, update_payload.location.lat], + coordinates: [ + update_payload.location.lng, + update_payload.location.lat, + expectedHeight, + ], timestamp: expect.any(String), }) @@ -170,6 +211,7 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { coordinates: [ update_payload.location.lng, update_payload.location.lat, + expectedHeight, ], timestamp: expect.any(String), }, @@ -177,7 +219,16 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { ]) }) - it('should allow to update the device via PUT with array as grouptags', async () => { + it('should preserve above-ground height and recalculate sea-level height when coordinates change', async () => { + await updateDeviceLocation({ + id: queryableDevice.id, + latitude: queryableDevice.latitude, + longitude: queryableDevice.longitude, + heightAboveGround: 7.5, + terrainElevation: 25, + terrainElevationDataset: 'mapzen', + }) + const update_payload = { name: 'neuername', exposure: 'outdoor', @@ -213,13 +264,21 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { expect(data.grouptag).toEqual(update_payload.grouptag) expect(data.description).toBe(update_payload.description) + const expectedHeight = TEST_TERRAIN_ELEVATION + 7.5 + expect(data.heightAboveGround).toBe(7.5) + expect(data.heightAboveSeaLevel).toBe(expectedHeight) + expect(data.terrainElevation).toBe(TEST_TERRAIN_ELEVATION) + expect(data.terrainElevationDataset).toBe(TEST_ELEVATION_DATASET) + expect(data.height).toBe(expectedHeight) expect(data.currentLocation.coordinates).toEqual([ update_payload.location.lng, update_payload.location.lat, + expectedHeight, ]) expect(data.loc[0].geometry.coordinates).toEqual([ update_payload.location.lng, update_payload.location.lat, + expectedHeight, ]) //TODO: this fails, check if we actually need timestamps in images @@ -228,6 +287,124 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { // const tsMs = parseInt(ts36, 36) * 1000 // expect(Date.now() - tsMs).toBeLessThan(1000) }) + + it('should convert a zero above-ground height via PUT', async () => { + const updatePayload = { + location: { lat: 52.52, lng: 13.405, height: 0 }, + } + + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updatePayload), + }) + + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice.id }, + } as Route.ActionArgs) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.height).toBe(TEST_TERRAIN_ELEVATION) + expect(data.heightAboveGround).toBe(0) + expect(data.heightAboveSeaLevel).toBe(TEST_TERRAIN_ELEVATION) + expect(data.terrainElevation).toBe(TEST_TERRAIN_ELEVATION) + expect(data.terrainElevationDataset).toBe(TEST_ELEVATION_DATASET) + expect(data.currentLocation.coordinates).toEqual([ + 13.405, + 52.52, + TEST_TERRAIN_ELEVATION, + ]) + expect(data.loc[0].geometry.coordinates).toEqual([ + 13.405, + 52.52, + TEST_TERRAIN_ELEVATION, + ]) + + const getResponse = (await deviceLoader({ + params: { deviceId: queryableDevice.id }, + } as Route.LoaderArgs)) as Response + const persisted = await getResponse.json() + + expect(getResponse.status).toBe(200) + expect(persisted.height).toBe(TEST_TERRAIN_ELEVATION) + expect(persisted.heightAboveGround).toBe(0) + expect(persisted.heightAboveSeaLevel).toBe(TEST_TERRAIN_ELEVATION) + expect(persisted.terrainElevation).toBe(TEST_TERRAIN_ELEVATION) + expect(persisted.terrainElevationDataset).toBe(TEST_ELEVATION_DATASET) + expect(persisted.currentLocation.coordinates).toEqual([ + 13.405, + 52.52, + TEST_TERRAIN_ELEVATION, + ]) + }) + + it('should retain height above ground when elevation lookup fails', async () => { + vi.mocked(getTerrainElevation).mockRejectedValueOnce( + new Error('Elevation unavailable'), + ) + const updatePayload = { + location: { lat: 54.18, lng: 7.89, height: 5 }, + } + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updatePayload), + }) + + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice.id }, + } as Route.ActionArgs) + const responseData = await response.json() + + expect(response.status).toBe(200) + expect(responseData.heightAboveGround).toBe(5) + expect(responseData.heightAboveSeaLevel).toBeNull() + expect(responseData.terrainElevation).toBeNull() + expect(responseData.terrainElevationDataset).toBeNull() + expect(responseData.height).toBeNull() + expect(responseData.currentLocation.coordinates).toEqual([7.89, 54.18]) + }) + + it('should not request elevation without consent', async () => { + vi.mocked(applyElevationConsentChoice).mockResolvedValueOnce(false) + const elevationLookup = vi.mocked(getTerrainElevation) + elevationLookup.mockClear() + const updatePayload = { + location: { lat: 54.18, lng: 7.89, height: 5 }, + elevationLookupConsent: false, + } + const request = new Request(`${BASE_URL}/${queryableDevice.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(updatePayload), + }) + + const response = await deviceAction({ + request, + params: { deviceId: queryableDevice.id }, + } as Route.ActionArgs) + const responseData = await response.json() + + expect(response.status).toBe(200) + expect(elevationLookup).not.toHaveBeenCalled() + expect(responseData.heightAboveGround).toBe(5) + expect(responseData.heightAboveSeaLevel).toBeNull() + expect(responseData.terrainElevation).toBeNull() + expect(responseData.terrainElevationDataset).toBeNull() + }) + it('should remove image when deleteImage=true', async () => { const update_payload = { deleteImage: true, diff --git a/tests/routes/api.boxes.spec.ts b/tests/routes/api.boxes.spec.ts index b76877b4..170dde12 100644 --- a/tests/routes/api.boxes.spec.ts +++ b/tests/routes/api.boxes.spec.ts @@ -1,4 +1,5 @@ import { generateTestUserCredentials } from 'tests/data/generate_test_user' +import invariant from 'tiny-invariant' import { type Route } from '../../.react-router/types/app/routes/+types/api.boxes' import { BASE_URL } from '../../vitest.setup' import { createDevice, deleteDevice } from '~/db/models/device.server' @@ -7,6 +8,32 @@ import { type Device, type User } from '~/db/schema' import { createToken } from '~/lib/jwt' import { loader, action } from '~/routes/api.boxes' import { registerUser } from '~/services/user-service.server' +import { getTerrainElevation } from '~/services/elevation-service.server' +import { applyElevationConsentChoice } from '~/db/models/elevation-consent.server' + +const TEST_TERRAIN_ELEVATION = vi.hoisted(() => 250) +const TEST_ELEVATION_DATASET = vi.hoisted(() => 'eudem25m') + +vi.mock('~/db/models/elevation-consent.server', () => ({ + applyElevationConsentChoice: vi.fn(async () => true), +})) + +vi.mock('~/services/elevation-service.server', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + getTerrainElevation: vi.fn(async (latitude: number, longitude: number) => ({ + elevation: TEST_TERRAIN_ELEVATION, + dataset: TEST_ELEVATION_DATASET, + datum: null, + attribution: null, + latitude, + longitude, + })), + } +}) const BOXES_TEST_USER = generateTestUserCredentials() const generateMinimalDevice = ( @@ -103,6 +130,51 @@ describe('openSenseMap API Routes: /boxes', () => { expect(body.features.length).lessThanOrEqual(2) }) + it('should include a non-null height in device GeoJSON coordinates', async () => { + invariant(user, 'Test user must be registered') + + const heightDevice = await createDevice( + { + name: `GeoJSON Height Device ${Date.now()}`, + latitude: 51.969, + longitude: 7.596, + heightAboveGround: 2.5, + terrainElevation: -15, + exposure: 'outdoor', + model: 'custom', + sensors: [], + }, + user.id, + ) + createdDeviceIds.push(heightDevice.id) + + const searchParams = new URLSearchParams({ + format: 'geojson', + name: heightDevice.name, + limit: '1', + }) + const request = new Request(`${BASE_URL}?${searchParams}`, { + method: 'GET', + }) + + const response = (await loader({ + request, + } as Route.LoaderArgs)) as Response + const body = await response.json() + const feature = body.features.find( + (candidate: any) => candidate.properties.id === heightDevice.id, + ) + + expect(response.status).toBe(200) + expect(feature).toBeDefined() + expect(feature.properties.height).toBe(-12.5) + expect(feature.properties.heightAboveGround).toBeUndefined() + expect(feature.properties.terrainElevation).toBeUndefined() + expect(feature.properties.terrainElevationDataset).toBeUndefined() + expect(feature.properties.heightAboveSeaLevel).toBe(-12.5) + expect(feature.geometry.coordinates).toEqual([7.596, 51.969, -12.5]) + }) + it('should deny searching for a name if limit is greater than max value', async () => { // Arrange const request = new Request( @@ -311,7 +383,9 @@ describe('openSenseMap API Routes: /boxes', () => { expect(feature.geometry).toBeDefined() expect(feature.geometry.type).toBe('Point') expect(Array.isArray(feature.geometry.coordinates)).toBe(true) - expect(feature.geometry.coordinates).toHaveLength(2) + expect(feature.geometry.coordinates.length).toBe( + feature.properties.height === null ? 2 : 3, + ) expect(feature.geometry.coordinates[0]).toBeDefined() expect(feature.geometry.coordinates[1]).toBeDefined() expect(feature.properties).toBeDefined() @@ -505,6 +579,10 @@ describe('openSenseMap API Routes: /boxes', () => { expect(body).toHaveProperty('sensors') expect(Array.isArray(body.sensors)).toBe(true) expect(body.sensors).toHaveLength(0) + expect(body.height).toBeNull() + expect(body.heightAboveGround).toBeNull() + expect(body.heightAboveSeaLevel).toBeNull() + expect(body.currentLocation.coordinates).toEqual([7.5, 51.9]) }) it('should reject creation without authentication', async () => { @@ -655,7 +733,7 @@ describe('openSenseMap API Routes: /boxes', () => { it('should allow to set the location for a new box as array', async () => { // Arrange - const loc = [0, 0, 0] + const loc = [7.123456, 51.654321, 123.4] const requestBody = generateMinimalDevice(loc) const request = new Request(`${BASE_URL}/boxes`, { @@ -673,13 +751,29 @@ describe('openSenseMap API Routes: /boxes', () => { } as Route.ActionArgs)) as Response const responseData = await response.json() await deleteDevice({ id: responseData._id }) + const expectedHeight = TEST_TERRAIN_ELEVATION + loc[2] // Assert expect(response.status).toBe(201) expect(responseData.latitude).toBeDefined() expect(responseData.longitude).toBeDefined() - expect(responseData.latitude).toBe(loc[0]) - expect(responseData.longitude).toBe(loc[1]) + expect(responseData.latitude).toBe(loc[1]) + expect(responseData.longitude).toBe(loc[0]) + expect(responseData.height).toBe(expectedHeight) + expect(responseData.heightAboveGround).toBe(loc[2]) + expect(responseData.heightAboveSeaLevel).toBe(expectedHeight) + expect(responseData.terrainElevation).toBe(TEST_TERRAIN_ELEVATION) + expect(responseData.terrainElevationDataset).toBe(TEST_ELEVATION_DATASET) + expect(responseData.currentLocation.coordinates).toEqual([ + loc[0], + loc[1], + expectedHeight, + ]) + expect(responseData.loc[0].geometry.coordinates).toEqual([ + loc[0], + loc[1], + expectedHeight, + ]) expect(responseData.createdAt).toBeDefined() // Check that createdAt is recent (within 5 minutes) @@ -691,7 +785,7 @@ describe('openSenseMap API Routes: /boxes', () => { it('should allow to set the location for a new box as latLng object', async () => { // Arrange - const loc = { lng: 120.123456, lat: 60.654321 } + const loc = { lng: 120.123456, lat: 60.654321, height: 0 } const requestBody = generateMinimalDevice(loc) const request = new Request(BASE_URL, { @@ -713,6 +807,21 @@ describe('openSenseMap API Routes: /boxes', () => { expect(responseData.latitude).toBe(loc.lat) expect(responseData.longitude).toBeDefined() expect(responseData.longitude).toBe(loc.lng) + expect(responseData.height).toBe(TEST_TERRAIN_ELEVATION) + expect(responseData.heightAboveGround).toBe(0) + expect(responseData.heightAboveSeaLevel).toBe(TEST_TERRAIN_ELEVATION) + expect(responseData.terrainElevation).toBe(TEST_TERRAIN_ELEVATION) + expect(responseData.terrainElevationDataset).toBe(TEST_ELEVATION_DATASET) + expect(responseData.currentLocation.coordinates).toEqual([ + loc.lng, + loc.lat, + TEST_TERRAIN_ELEVATION, + ]) + expect(responseData.loc[0].geometry.coordinates).toEqual([ + loc.lng, + loc.lat, + TEST_TERRAIN_ELEVATION, + ]) expect(responseData.createdAt).toBeDefined() // Check that createdAt is recent (within 5 minutes) @@ -722,6 +831,60 @@ describe('openSenseMap API Routes: /boxes', () => { expect(diffInMs).toBeLessThan(300000) // 5 minutes in milliseconds }) + it('should retain height above ground when elevation lookup fails', async () => { + vi.mocked(getTerrainElevation).mockRejectedValueOnce( + new Error('Elevation unavailable'), + ) + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: JSON.stringify( + generateMinimalDevice({ lng: 7.6, lat: 51.9, height: 5 }), + ), + }) + + const response = (await action({ + request, + } as Route.ActionArgs)) as Response + const responseData = await response.json() + if (responseData._id) createdDeviceIds.push(responseData._id) + + expect(response.status).toBe(201) + expect(responseData.heightAboveGround).toBe(5) + expect(responseData.heightAboveSeaLevel).toBeNull() + expect(responseData.terrainElevation).toBeNull() + expect(responseData.terrainElevationDataset).toBeNull() + expect(responseData.height).toBeNull() + expect(responseData.currentLocation.coordinates).toEqual([7.6, 51.9]) + }) + + it('should not request elevation without consent', async () => { + vi.mocked(applyElevationConsentChoice).mockResolvedValueOnce(false) + const elevationLookup = vi.mocked(getTerrainElevation) + elevationLookup.mockClear() + + const request = new Request(BASE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: JSON.stringify( + generateMinimalDevice({ lng: 7.6, lat: 51.9, height: 5 }), + ), + }) + + const response = (await action({ + request, + } as Route.ActionArgs)) as Response + const responseData = await response.json() + if (responseData._id) createdDeviceIds.push(responseData._id) + + expect(response.status).toBe(201) + expect(elevationLookup).not.toHaveBeenCalled() + expect(responseData.heightAboveGround).toBe(5) + expect(responseData.heightAboveSeaLevel).toBeNull() + expect(responseData.terrainElevation).toBeNull() + expect(responseData.terrainElevationDataset).toBeNull() + }) + it('should reject a new box with invalid coords', async () => { function minimalSensebox(coords: number[]) { return {