import { Hono } from "hono" import { validator } from "hono/validator" import { z } from "zod" import { fetchWithCache, kv } from "./util.ts" const SensorsSchema = z.object({ sensorData: z.array( z.discriminatedUnion("sensorType", [ z.object({ timestamp: z.string(), sensorType: z.literal("location"), data: z.object({ altitude: z.number(), speed: z.number(), horizontalAccuracy: z.number(), verticalAccuracy: z.number(), longitude: z.number(), course: z.number(), floor: z.number().nullable(), latitude: z.number(), }), }), z.object({ timestamp: z.string(), sensorType: z.literal("gyroscope"), data: z.object({ x: z.number(), y: z.number(), z: z.number(), }), }), z.object({ timestamp: z.string(), sensorType: z.literal("battery"), data: z.object({ batteryState: z.string(), batteryLevel: z.number(), }), }), z.object({ timestamp: z.string(), sensorType: z.literal("altitude"), data: z.object({ altitude: z.number(), }), }), z.object({ timestamp: z.string(), sensorType: z.literal("heading"), data: z.object({ x: z.number(), y: z.number(), z: z.number(), trueHeading: z.number(), headingAccuracy: z.number(), magneticHeading: z.number(), }), }), ]), ), isSuccess: z.boolean(), deviceId: z.string(), timestamp: z.coerce.date(), deviceName: z.string(), }) const ReverseGeocodingSchema = z.object({ type: z.literal("FeatureCollection"), features: z.array( z.object({ type: z.literal("Feature"), properties: z.object({ datasource: z.object({ sourcename: z.string(), attribution: z.string(), license: z.string(), url: z.string(), }).optional(), name: z.string().optional(), country: z.string(), country_code: z.string(), state: z.string().optional(), city: z.string().optional(), postcode: z.string().optional(), district: z.string().optional(), suburb: z.string().optional(), street: z.string().optional(), housenumber: z.string().optional(), iso3166_2: z.string().optional(), lon: z.number(), lat: z.number(), state_code: z.string().optional(), distance: z.number().optional(), result_type: z.string().optional(), formatted: z.string(), address_line1: z.string().optional(), address_line2: z.string().optional(), category: z.string().optional(), timezone: z.object({ name: z.string(), offset_STD: z.string(), offset_STD_seconds: z.number(), offset_DST: z.string(), offset_DST_seconds: z.number(), abbreviation_STD: z.string(), abbreviation_DST: z.string(), }).optional(), plus_code: z.string().optional(), rank: z.object({ importance: z.number(), popularity: z.number(), }).optional(), place_id: z.string().optional(), }), geometry: z.object({ type: z.literal("Point"), coordinates: z.tuple([z.number(), z.number()]), }), bbox: z.array(z.number()).optional(), }), ), query: z.object({ lat: z.number(), lon: z.number(), plus_code: z.string().optional(), }).optional(), }) type Sensors = z.infer type Country = { country: string country_code: string } const sensors = new Hono() .get("/country", async (c) => { c.header("Access-Control-Allow-Origin", "*") // Served cached data if available const cached = await kv.get(["country"]) if (cached.value) { console.info("Serving cached country data") return c.json(cached.value) } // Fetch sensors data from KV const data = await kv.get(["sensors", "latest"]) if (!data.value) { return c.text("No data found", 404) } // Extract location from sensors data const location = data.value.sensorData.find((sensor) => sensor.sensorType === "location" )?.data if (!location) { return c.text("No location data found", 404) } // Extract geocode from location data const geocode = await fetchWithCache( `https://api.geoapify.com/v1/geocode/reverse?lat=${location.latitude}&lon=${location.longitude}&apiKey=${ Deno.env.get("GEOAPIFY_API_KEY") }`, ) if (!geocode.success) { console.error(geocode.error) return c.text("Geoapify API error", 404) } const country = ReverseGeocodingSchema.safeParse(geocode.value) if (!country.success) { console.error(country.error) return c.text("Invalid country data", 400) } const letterA = "a".codePointAt(0) as number //biome-ignore format: breaks emoji const regionalIndicatorA = "🇦".codePointAt(0) as number const toRegionalIndicator = (char: string) => String.fromCodePoint( char.codePointAt(0) as number - letterA + regionalIndicatorA, ) const emoji = country.data.features[0].properties.country_code .split("") .map((char) => toRegionalIndicator(char)) .join("") const ret = { country: country.data.features[0].properties.country, country_code: country.data.features[0].properties.country_code, country_flag: emoji, } // Cache country data to KV store (expiry set for 2 hours) await kv.set(["country"], ret satisfies Country, { expireIn: 60 * 60 * 2 }) return c.json(ret) }) .get("/get", async (c) => { const data = await kv.get(["sensors", "latest"]) if (!data.value) { return c.text("No data found", 404) } return c.json(data.value) }) .post( "/set", validator("json", (value, c) => { const parsed = SensorsSchema.safeParse(value) if (!parsed.success) { return c.text("Invalid!", 401) } return parsed.data }), async (c) => { const body = c.req.valid("json") console.log(`Receiving sensor data at ${body.timestamp.toISOString()}`) // save data to kv store await kv.set(["sensors", body.timestamp.toISOString()], body) await kv.set(["sensors", "latest"], body) return c.json(body) }, ) export { sensors }