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 fd575f83..46572c24 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' @@ -337,6 +338,13 @@ export default function DeviceDetailBox() { : t('unknown') } /> + {typeof data.device.height === 'number' ? ( + + ) : null} (null) @@ -20,10 +33,11 @@ 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 [marker, setMarker] = useState<{ latitude: number | string @@ -35,93 +49,135 @@ 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 elevation = useTerrainElevation({ + latitude: markerLocation?.latitude, + longitude: markerLocation?.longitude, + }) + const parsedHeightAboveGround = + deviceLocationInputSchema.shape.heightAboveGround.safeParse( + savedHeightAboveGround, ) + + 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 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}
@@ -174,7 +228,76 @@ export function LocationStep() { /> {errors.longitude?.message ? (

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

+ ) : null} +
+ +
+ + +

+ {t('height_info_text')} +

+ + {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 57b386c2..cdd8b623 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, @@ -31,34 +33,23 @@ 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, + sensorSelectionSchema, +} from '~/lib/new-device-form' +import { + deviceLocationInputSchema, + type DeviceLocationInput, +} from '~/lib/location' 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, + deviceLocationInputSchema, + deviceSelectionSchema, + sensorSelectionSchema, advancedSchema, ]) @@ -74,21 +65,21 @@ export const Stepper = defineStepper([ id: 'location', label: 'location', infoKey: 'location_info_text', - schema: locationSchema, + schema: deviceLocationInputSchema, 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, }, { @@ -107,13 +98,13 @@ 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 FormData = | GeneralInfoData - | LocationData + | DeviceLocationInput | DeviceData | SensorData | AdvancedData @@ -135,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, @@ -173,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 ( @@ -196,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' @@ -262,7 +287,7 @@ export default function NewDeviceStepper() { +
+ ) : !heightInputReady ? ( +
+
+ +
+ + {t('calculating_height_above_ground')} + +
+ ) : elevation.status === 'loading' ? ( +
+
+ +
+ + {t('fetching_elevation')} + +
+ ) : elevation.status === 'error' ? ( +
+

{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} +
+ + {locationErrors.heightAboveGround ? ( +

+ {t(locationErrors.heightAboveGround)} +

+ ) : null} + + {locationErrors.elevation ? ( +

+ {t(locationErrors.elevation)}

) : null}
diff --git a/app/routes/device.new.tsx b/app/routes/device.new.tsx index 0ecc0878..5e0620d3 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 { calculateHeightAboveSeaLevel } from '~/lib/elevation' +import { newDeviceSubmissionSchema } from '~/lib/new-device-form' +import { + ElevationLookupError, + getTerrainElevation, +} from '~/services/elevation-service.server' + +export type NewDeviceActionData = { + ok: false + error: + | 'invalid_device_form' + | 'elevation_required_error' + | 'device_creation_failed' +} export async function loader({ request }: Route.LoaderArgs) { const user = await getUser(request) @@ -18,59 +32,112 @@ export async function loader({ request }: Route.LoaderArgs) { } 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 }, + ) + } - if (!userId) { - throw new Error('User is not authenticated.') - } + const parsedSubmission = newDeviceSubmissionSchema.safeParse(submittedData) + + if (!parsedSubmission.success) { + return responseData( + { ok: false, error: 'invalid_device_form' }, + { status: 400 }, + ) + } - 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, - }), + 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 } = submission.location + let terrainElevation + + try { + terrainElevation = await getTerrainElevation(latitude, longitude) + } catch (error) { + console.error( + 'Could not calculate device height above sea level:', + error instanceof ElevationLookupError ? error.code : error, + ) + + return responseData( + { ok: false, error: 'elevation_required_error' }, + { status: 503 }, + ) + } + + const finalHeight = calculateHeightAboveSeaLevel( + terrainElevation.elevation, + heightAboveGround, + ) + + 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, + height: finalHeight, } + 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..29dda7d7 --- /dev/null +++ b/app/routes/resources.elevation.ts @@ -0,0 +1,51 @@ +import { data } from 'react-router' +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' + +export async function loader({ request }: Route.LoaderArgs) { + const userId = await getUserId(request) + if (!userId) throw new Response('Unauthorized', { status: 401 }) + + const url = new URL(request.url) + const parsed = locationCoordinatesSchema.safeParse({ + latitude: url.searchParams.get('latitude'), + longitude: url.searchParams.get('longitude'), + }) + + if (!parsed.success) { + return data( + { ok: false, error: 'invalid_location' }, + { status: 400 }, + ) + } + + try { + const result = await getTerrainElevation( + parsed.data.latitude, + parsed.data.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 }, + ) + } +} diff --git a/app/services/device-service.server.ts b/app/services/device-service.server.ts index 86c2a038..bb92ceac 100644 --- a/app/services/device-service.server.ts +++ b/app/services/device-service.server.ts @@ -29,6 +29,7 @@ export const CreateDeviceServiceSchema = z tags: z.array(z.string()).optional().default([]), latitude: z.number(), longitude: z.number(), + height: z.number().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..12428803 --- /dev/null +++ b/app/services/elevation-service.server.ts @@ -0,0 +1,290 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { + calculateHeightAboveSeaLevel, + 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 })) +} + +export async function calculateDeviceHeightAboveSeaLevel( + latitude: number, + longitude: number, + heightAboveGround: number, +) { + const terrainElevation = await getTerrainElevation(latitude, longitude) + + return calculateHeightAboveSeaLevel( + terrainElevation.elevation, + heightAboveGround, + ) +} 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..be09e59b 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. Wenn das Feld leer bleibt, wird Bodenniveau angenommen; negative Werte stehen für Installationen unter der Oberfläche. Gespeichert wird die berechnete Höhe über dem Meeresspiegel.", + "calculating_height_above_ground": "Höhe über dem Boden wird aus der gespeicherten Höhe berechnet...", + "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 Oberflächenhöhe konnte nicht abgerufen werden. Änderungen werden erst gespeichert, wenn sie verfügbar ist.", + "elevation_save_error": "Die Oberflächenhöhe ist nicht verfügbar. Der Standort wurde daher nicht 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..db448f6b 100644 --- a/public/locales/de/newdevice.json +++ b/public/locales/de/newdevice.json @@ -111,15 +111,35 @@ "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 in openSenseMap gespeicherte Höhe 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_text": "Höhe relativ zur geschätzten Bodenoberfläche in Metern. Wenn das Feld leer bleibt, wird Bodenniveau angenommen; negative Werte stehen für Installationen unter der Oberfläche. Gespeichert wird die berechnete Höhe über dem Meeresspiegel.", + "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_unavailable": "Für diesen Standort ist keine Oberflächenhöhe verfügbar.", + "elevation_error": "Die Oberflächenhöhe konnte nicht abgerufen werden.", + "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.", + "elevation_required_error": "Die Oberflächenhöhe konnte nicht abgerufen werden. Daher wurde keine möglicherweise falsche Höhe gespeichert. Bitte versuche es erneut.", + "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..c17f2d28 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 to assume that the device is at ground level; negative values represent below-ground installations. The calculated height above sea level is stored.", + "calculating_height_above_ground": "Calculating height above ground from the stored height...", + "terrain_elevation": "Estimated surface elevation", + "final_height": "Calculated height above sea level", + "fetching_elevation": "Fetching estimated surface elevation...", + "elevation_error": "The surface elevation could not be retrieved. Changes will not be saved until it is available.", + "elevation_save_error": "The surface elevation is unavailable, so the location was not 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..3e617c7d 100644 --- a/public/locales/en/newdevice.json +++ b/public/locales/en/newdevice.json @@ -111,15 +111,35 @@ "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 height stored by openSenseMap.", + "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_text": "Height relative to the estimated ground surface in meters. Leave blank to assume that the device is at ground level; negative values represent below-ground installations. The calculated height above sea level is stored.", + "terrain_elevation": "Estimated surface elevation", + "final_height": "Calculated height above sea level", + "fetching_elevation": "Fetching estimated surface elevation...", + "elevation_unavailable": "No surface elevation is available for this location.", + "elevation_error": "The surface elevation could not be retrieved.", + "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.", + "elevation_required_error": "The surface elevation could not be retrieved, so no potentially incorrect height was saved. Please try again.", + "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..2a31e1ac 100644 --- a/tests/lib/transform-to-api-format.spec.ts +++ b/tests/lib/transform-to-api-format.spec.ts @@ -21,6 +21,7 @@ describe('transformDeviceToApiFormat', () => { model: 'custom', latitude: 37.7749, longitude: -122.4194, + height: 18.25, useAuth: true, public: false, status: 'active', @@ -63,6 +64,7 @@ describe('transformDeviceToApiFormat', () => { model: 'custom', latitude: 37.7749, longitude: -122.4194, + height: 18.25, useAuth: true, public: false, status: 'active', @@ -72,7 +74,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 +82,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 +171,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 +183,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 +191,30 @@ describe('transformDeviceToApiFormat', () => { ]) }) + test('preserves zero height in both location coordinate formats', () => { + const result = transformDeviceToApiFormat({ + ...mockDevice, + height: 0, + } 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 height is %s', + (height) => { + const result = transformDeviceToApiFormat({ + ...mockDevice, + 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 +256,7 @@ describe('transformDeviceToApiFormat', () => { expect(result.model).toBe(mockDevice.model) expect(result.latitude).toBe(mockDevice.latitude) expect(result.longitude).toBe(mockDevice.longitude) + expect(result.height).toBe(mockDevice.height) 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..49a8f123 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' @@ -12,6 +16,22 @@ import { } from '~/routes/api.boxes.$deviceId' import { registerUser } from '~/services/user-service.server' +const TEST_TERRAIN_ELEVATION = vi.hoisted(() => 250) + +vi.mock('~/services/elevation-service.server', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + calculateDeviceHeightAboveSeaLevel: async ( + _latitude: number, + _longitude: number, + heightAboveGround: number, + ) => TEST_TERRAIN_ELEVATION + heightAboveGround, + } +}) + const DEVICE_TEST_USER = generateTestUserCredentials() const generateMinimalDevice = ( @@ -53,8 +73,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 +147,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 +168,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 +178,14 @@ 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.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 +197,7 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { coordinates: [ update_payload.location.lng, update_payload.location.lat, + expectedHeight, ], timestamp: expect.any(String), }, @@ -177,7 +205,14 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { ]) }) - it('should allow to update the device via PUT with array as grouptags', async () => { + it('should preserve height when PUT omits it', async () => { + await updateDeviceLocation({ + id: queryableDevice.id, + latitude: queryableDevice.latitude, + longitude: queryableDevice.longitude, + height: 32.5, + }) + const update_payload = { name: 'neuername', exposure: 'outdoor', @@ -213,13 +248,16 @@ describe('openSenseMap API Routes: /boxes/:deviceId', () => { expect(data.grouptag).toEqual(update_payload.grouptag) expect(data.description).toBe(update_payload.description) + expect(data.height).toBe(32.5) expect(data.currentLocation.coordinates).toEqual([ update_payload.location.lng, update_payload.location.lat, + 32.5, ]) expect(data.loc[0].geometry.coordinates).toEqual([ update_payload.location.lng, update_payload.location.lat, + 32.5, ]) //TODO: this fails, check if we actually need timestamps in images @@ -228,6 +266,54 @@ 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.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.currentLocation.coordinates).toEqual([ + 13.405, + 52.52, + TEST_TERRAIN_ELEVATION, + ]) + }) + 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..c9a68ff8 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' @@ -8,6 +9,22 @@ import { createToken } from '~/lib/jwt' import { loader, action } from '~/routes/api.boxes' import { registerUser } from '~/services/user-service.server' +const TEST_TERRAIN_ELEVATION = vi.hoisted(() => 250) + +vi.mock('~/services/elevation-service.server', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + calculateDeviceHeightAboveSeaLevel: async ( + _latitude: number, + _longitude: number, + heightAboveGround: number, + ) => TEST_TERRAIN_ELEVATION + heightAboveGround, + } +}) + const BOXES_TEST_USER = generateTestUserCredentials() const generateMinimalDevice = ( location: number[] | {} = [123, 12, 34], @@ -103,6 +120,46 @@ 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, + height: -12.5, + 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.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 +368,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 +564,8 @@ 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.currentLocation.coordinates).toEqual([7.5, 51.9]) }) it('should reject creation without authentication', async () => { @@ -655,7 +716,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 +734,25 @@ 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.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 +764,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 +786,17 @@ 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.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)