1import { Hono } from "hono"
2import { validator } from "hono/validator"
3import { z } from "zod"
4import { fetchWithCache, kv } from "./util.ts"
5
6const SensorsSchema = z.object({
7 sensorData: z.array(
8 z.discriminatedUnion("sensorType", [
9 z.object({
10 timestamp: z.string(),
11 sensorType: z.literal("location"),
12 data: z.object({
13 altitude: z.number(),
14 speed: z.number(),
15 horizontalAccuracy: z.number(),
16 verticalAccuracy: z.number(),
17 longitude: z.number(),
18 course: z.number(),
19 floor: z.number().nullable(),
20 latitude: z.number(),
21 }),
22 }),
23 z.object({
24 timestamp: z.string(),
25 sensorType: z.literal("gyroscope"),
26 data: z.object({
27 x: z.number(),
28 y: z.number(),
29 z: z.number(),
30 }),
31 }),
32 z.object({
33 timestamp: z.string(),
34 sensorType: z.literal("battery"),
35 data: z.object({
36 batteryState: z.string(),
37 batteryLevel: z.number(),
38 }),
39 }),
40 z.object({
41 timestamp: z.string(),
42 sensorType: z.literal("altitude"),
43 data: z.object({
44 altitude: z.number(),
45 }),
46 }),
47 z.object({
48 timestamp: z.string(),
49 sensorType: z.literal("heading"),
50 data: z.object({
51 x: z.number(),
52 y: z.number(),
53 z: z.number(),
54 trueHeading: z.number(),
55 headingAccuracy: z.number(),
56 magneticHeading: z.number(),
57 }),
58 }),
59 ]),
60 ),
61 isSuccess: z.boolean(),
62 deviceId: z.string(),
63 timestamp: z.coerce.date(),
64 deviceName: z.string(),
65})
66
67const ReverseGeocodingSchema = z.object({
68 type: z.literal("FeatureCollection"),
69 features: z.array(
70 z.object({
71 type: z.literal("Feature"),
72 properties: z.object({
73 datasource: z.object({
74 sourcename: z.string(),
75 attribution: z.string(),
76 license: z.string(),
77 url: z.string(),
78 }).optional(),
79 name: z.string().optional(),
80 country: z.string(),
81 country_code: z.string(),
82 state: z.string().optional(),
83 city: z.string().optional(),
84 postcode: z.string().optional(),
85 district: z.string().optional(),
86 suburb: z.string().optional(),
87 street: z.string().optional(),
88 housenumber: z.string().optional(),
89 iso3166_2: z.string().optional(),
90 lon: z.number(),
91 lat: z.number(),
92 state_code: z.string().optional(),
93 distance: z.number().optional(),
94 result_type: z.string().optional(),
95 formatted: z.string(),
96 address_line1: z.string().optional(),
97 address_line2: z.string().optional(),
98 category: z.string().optional(),
99 timezone: z.object({
100 name: z.string(),
101 offset_STD: z.string(),
102 offset_STD_seconds: z.number(),
103 offset_DST: z.string(),
104 offset_DST_seconds: z.number(),
105 abbreviation_STD: z.string(),
106 abbreviation_DST: z.string(),
107 }).optional(),
108 plus_code: z.string().optional(),
109 rank: z.object({
110 importance: z.number(),
111 popularity: z.number(),
112 }).optional(),
113 place_id: z.string().optional(),
114 }),
115 geometry: z.object({
116 type: z.literal("Point"),
117 coordinates: z.tuple([z.number(), z.number()]),
118 }),
119 bbox: z.array(z.number()).optional(),
120 }),
121 ),
122 query: z.object({
123 lat: z.number(),
124 lon: z.number(),
125 plus_code: z.string().optional(),
126 }).optional(),
127})
128
129type Sensors = z.infer<typeof SensorsSchema>
130
131type Country = {
132 country: string
133 country_code: string
134}
135
136const sensors = new Hono()
137 .get("/country", async (c) => {
138 c.header("Access-Control-Allow-Origin", "*")
139
140 // Served cached data if available
141 const cached = await kv.get<Country>(["country"])
142 if (cached.value) {
143 console.info("Serving cached country data")
144 return c.json(cached.value)
145 }
146
147 // Fetch sensors data from KV
148 const data = await kv.get<Sensors>(["sensors", "latest"])
149 if (!data.value) {
150 return c.text("No data found", 404)
151 }
152
153 // Extract location from sensors data
154 const location = data.value.sensorData.find((sensor) =>
155 sensor.sensorType === "location"
156 )?.data
157
158 if (!location) {
159 return c.text("No location data found", 404)
160 }
161
162 // Extract geocode from location data
163 const geocode = await fetchWithCache<Country>(
164 `https://api.geoapify.com/v1/geocode/reverse?lat=${location.latitude}&lon=${location.longitude}&apiKey=${
165 Deno.env.get("GEOAPIFY_API_KEY")
166 }`,
167 )
168
169 if (!geocode.success) {
170 console.error(geocode.error)
171 return c.text("Geoapify API error", 404)
172 }
173
174 const country = ReverseGeocodingSchema.safeParse(geocode.value)
175
176 if (!country.success) {
177 console.error(country.error)
178 return c.text("Invalid country data", 400)
179 }
180
181 const letterA = "a".codePointAt(0) as number
182 //biome-ignore format: breaks emoji
183 const regionalIndicatorA = "🇦".codePointAt(0) as number
184
185 const toRegionalIndicator = (char: string) =>
186 String.fromCodePoint(
187 char.codePointAt(0) as number - letterA + regionalIndicatorA,
188 )
189
190 const emoji = country.data.features[0].properties.country_code
191 .split("")
192 .map((char) => toRegionalIndicator(char))
193 .join("")
194
195 const ret = {
196 country: country.data.features[0].properties.country,
197 country_code: country.data.features[0].properties.country_code,
198 country_flag: emoji,
199 }
200
201 // Cache country data to KV store (expiry set for 2 hours)
202 await kv.set(["country"], ret satisfies Country, { expireIn: 60 * 60 * 2 })
203
204 return c.json(ret)
205 })
206 .get("/get", async (c) => {
207 const data = await kv.get<Sensors>(["sensors", "latest"])
208 if (!data.value) {
209 return c.text("No data found", 404)
210 }
211 return c.json(data.value)
212 })
213 .post(
214 "/set",
215 validator("json", (value, c) => {
216 const parsed = SensorsSchema.safeParse(value)
217 if (!parsed.success) {
218 return c.text("Invalid!", 401)
219 }
220 return parsed.data
221 }),
222 async (c) => {
223 const body = c.req.valid("json")
224 console.log(`Receiving sensor data at ${body.timestamp.toISOString()}`)
225
226 // save data to kv store
227 await kv.set(["sensors", body.timestamp.toISOString()], body)
228 await kv.set(["sensors", "latest"], body)
229
230 return c.json(body)
231 },
232 )
233
234export { sensors }