代码到实物Hackson-Day1

我们的项目叫“风侯”,是一个可以随身携带的全天候环境检测设备。

在古代,“风候”指的就是风向、气候与时序的变化(比如观测风候)。用在随身设备上,直观地传递出“随时感知身边小气候”的意境。

设想

我们希望做一个足够小巧且时尚的环境检测设备,可以一直被当作挂件随身携带,24小时的检测你身边的环境信息,汇总数据并给出建议。

我们考虑了这些生活中值得关注的环境信息:

日照与光源健康:你每天是否晒够了太阳?办公桌上的灯光是否存在看不见的频闪伤害眼睛?

微环境空气质量:密闭的会议室或刚上车时,空气是否已经污浊(VOC浓度过高)到需要立刻通风换气?

无形的压力源:气压的突变是否是你今天感到疲惫或偏头痛的原因?你一天中有多少时间暴露在让人烦躁的高分贝噪音中?

传感器选择

INMP441

现代人经常忽略环境噪音(如地铁轰鸣、办公室底噪)。长时间暴露在70分贝以上的环境不仅会造成不可逆的听力损伤,更是导致焦虑、血压升高和易疲劳的“隐形压力源”。INMP441 是一款高精度全向麦克风。我们可以在设备端实时计算音频信号的能量值,转化为环境分贝读数。

AHT20+BMP280

单一的温度无法反映真实的“体感”。更重要的是气压:很多人是“气象敏感型体质”,气压的骤降(如暴雨前或高层电梯内)是导致偏头痛、关节酸痛和胸闷的直接元凶。AHT20 负责高精度读取温湿度,BMP280 则敏锐捕捉百帕(hPa)级别的微小气压波动。

SGP41

刚装修的房间、新买的家具,甚至是不通风的会议室和长时间关闭外循环的车厢,都会积聚大量挥发性有机化合物(VOC),导致头晕、注意力下降和呼吸道刺激。传感器内的金属氧化物(MOX)遇有害气体产生电阻变化,输出 VOC 指数。

本来还有计划安装CO2传感器的,但是调研了一圈发现CO2传感器都太贵太耗电了,于是就作罢。

GY-AS7341

现代人极度缺乏自然光照,导致维生素D合成不足、血清素降低(容易抑郁、失眠)。

许多劣质 LED 屏幕和台灯存在肉眼无法察觉的高频闪烁,这是导致视觉疲劳、眼干和视力下降的罪魁祸首。

AS7341 拥有多个光谱通道。首先,它能通过光谱特征区分“自然阳光”和“人造灯光”;其次,利用它的高频采样能力(Hz),可以捕捉灯光亮度的周期性波动,计算出频闪指数。

架构设计

因为是物联网设备,我们设计了这样一个以云为中心的架构:

设备端

硬件采用 ESP32 微控制器,使用I2C总线连接各个传感器。

有锂电池包负责供电。

设备端通过 WiFi 联网,数据通过 HTTP POST 请求,直接上传至云端的 API 接口。

正在调试…

后端服务

部署在 Cloudflare Workers 上,完全采用 Serverless架构。

在边缘计算节点解析数据,将嵌套的设备上传载荷拆解为单个传感器的独立数据行。

这个今日已经实现: alt text

Database

使用 Cloudflare D1 数据库。 采用分离的表结构进行存储:device 表用于管理设备元数据,可以用来做账号绑定和权限管理;sensor_data 表则记录拆解后带有时间戳的具体传感器数值(如温度、湿度、CO2 等)。

实现: alt text

web看板

基于 web-dashboard 的目录结构与后端的 D1 数据库设计,Fenghou 的前端看板是一个基于 React + Vite 构建,并利用 Cloudflare Pages Functions 实现全栈边缘 API 路由的高效物联网数据可视化平台。

in progress….

移动App和NFC

因为没有屏幕,就没法配网(如果不硬编码的话),刚好ESP32上有低功耗蓝牙,那只要通过蓝牙交换数据就好了。

但是没屏幕和按钮,那也没法确认蓝牙配对是不是来自物主,于是想到NFC,如果能近距离接触到这东西,那能看数据也是理所应当的。所以就想用NFC交换蓝牙令牌完成配对,那手机App里就可以配网了,也可以顺便实时查看数据。

然后都有NFC了,那为什么不顺手做个App Clip呢?于是在XCode里新建了一个带App Clip的Project,然后发现Developer Free账号没办法给App Clip签名,那也就没办法运行了,这个方案Pass。

看情况做不做,现在感觉时间来不太急。

今日Log

额,上午去其他地方开培训会了,没来,Pass。

下午2点过来,首先就是发现最开始用的ESP32-C3-SuperMini没法正常连上Wi-Fi,折腾了好久,发现可能是散热太拉了,只要板子一热WiFi就会disconnect,在研究了2小时后决定换一颗MCU,于是就换成了ESP32-S3,体积大很多,但是比较稳定。

然后就是I2C总线的问题了,不知道为什么,明明接的都是对的,但是I2C总线就是会报错,又研究了3小时,发现是那颗ESP32-S3有一个USB口一个COM口,因为之前项目是在ESP32-C3上写的,那个板子只有一个USB口,于是我打开了USB虚拟串口功能,导致成功的日志只打到了USB口上没打到COM口上。然后那个会有错误日志的原因是I2C扫描的默认设备Address没东西,所以报了个Error。

非常不幸的是,在研究这个东西的过程中,不知道什么时候,SGP41这颗传感器烧了,那我们就很遗憾的没法检测甲醛了。但是这个时候设计PCB的同学已经设计好了,所以如果那块PCB能打出来的话,会看到预留的接口。代码这边的话,I2C本来就有自动发现注册的能力,少个模块不会挂掉。

哦对了,还有个小问题,就是我们的API是在Cloudflare上的嘛,DNS也是Cloudflare托管,但是板子死活连不上,一直报错无法解析hostname。嗯,这个是四川的破网的老毛病了,三大运营商的DNS解析服务没法解析很多海外网站。我的手机电脑都有DNS over Https直接连的阿里云解析服务,但是这个板子连https连一次都费劲,DoH更是不支持。测试了一下,直接在板子上把DNS配置成1.1.1.1(Cloudflare DNS0就能用了。这个不像8.8.8.8一样被拦截了,运营商还算做人。

还遇到的一个问题是,我打算把fenghou.goudaijun.top/api分配给beckend这个Worker,fenghou.goudaijun.top/dashboard奉陪给前端,但是发现,额这个Cloudflare的URL路由功能竟然不能把DNS的A记录直接指向路由,而是必须要有个IP/负载均衡/Worker(是的一个域名必须要有确定的后端才能再做路由),无奈再次把很久之前写的老演员Hello World拿出来绑定了,所以直接访问没有特殊路由的URL会显示Hello World((

em总之就是这样,任重道远啊。不过我真觉得这东西挺实用的吧。

代码

哦,好像要贴代码,那我找几个贴一下:

OnDevice是ESP32的设备端代码

/OnDevice/src/main.cpp

ondevice_main.cpp 在新窗口打开
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#include <Arduino.h>
#include <WiFi.h>

#include "ConfigStore.h"
#include "DataAggregator.h"
#include "module/NetworkUploader.h"
#include "sensor/AudioSampler.h"
#include "sensor/I2cSensorManager.h"
#include "sensor/Types.h"

namespace {
QueueHandle_t sensorQueue;
QueueHandle_t uploadQueue;
SemaphoreHandle_t dataMutex;
SemaphoreHandle_t configMutex;

LatestData latestData;
ConfigStore configStore;
I2cSensorManager i2cSensors;
AudioSampler audioSampler;
DataAggregator dataAggregator;
NetworkUploader networkUploader;

String wifiHostname;

String normalizedHostnameFromDeviceId(const char *deviceId) {
  String hostname = "fenghou-";
  hostname += deviceId;
  hostname.toLowerCase();

  for (size_t i = 0; i < hostname.length(); i++) {
    char c = hostname.charAt(i);
    bool allowed = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
    if (!allowed) {
      hostname.setCharAt(i, '-');
    }
  }

  return hostname;
}

void onWiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
  switch (event) {
  case ARDUINO_EVENT_WIFI_STA_START:
    Serial.println("[wifi-event] STA start");
    break;
  case ARDUINO_EVENT_WIFI_STA_CONNECTED:
    Serial.println("[wifi-event] STA connected to AP");
    break;
  case ARDUINO_EVENT_WIFI_STA_GOT_IP:
    Serial.print("[wifi-event] got IP: ");
    Serial.println(WiFi.localIP());
    break;
  case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
    Serial.print("[wifi-event] STA disconnected, reason=");
    Serial.println(info.wifi_sta_disconnected.reason);
    break;
  default:
    break;
  }
}

void taskI2CSensors(void *) {
  i2cSensors.run();
}

void taskAudio(void *) {
  audioSampler.run();
}

void taskDataProcess(void *) {
  dataAggregator.run();
}

void taskNetworkUpload(void *) {
  networkUploader.run();
}
} // namespace

void setup() {
  Serial.begin(115200);
  unsigned long serialWaitStart = millis();
  while (!Serial && millis() - serialWaitStart < 3000) {
    delay(10);
  }
  delay(2000);

  sensorQueue = xQueueCreate(12, sizeof(SensorSample));
  uploadQueue = xQueueCreate(3, sizeof(LatestData));
  dataMutex = xSemaphoreCreateMutex();
  configMutex = xSemaphoreCreateMutex();
  if (!sensorQueue || !uploadQueue || !dataMutex || !configMutex) {
    Serial.println("FreeRTOS allocation failed.");
    while (true) {
      delay(1000);
    }
  }

  if (!configStore.begin(configMutex)) {
    Serial.println("Preferences init failed.");
  }
  configStore.load();

  DeviceConfig cfg;
  configStore.getCopy(&cfg);
  wifiHostname = normalizedHostnameFromDeviceId(cfg.deviceId);
  WiFi.onEvent(onWiFiEvent);
  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);
  WiFi.setHostname(wifiHostname.c_str());
  WiFi.persistent(false);

  Serial.println("Fenghou on-device monitor booting");
  Serial.printf("Config: valid=%d ssid=%s server=%s device=%s\n",
                cfg.valid,
                cfg.ssid,
                cfg.server,
                cfg.deviceId);
  Serial.print("WiFi hostname: ");
  Serial.println(wifiHostname);

  i2cSensors.begin(sensorQueue, &configStore, dataMutex, &latestData);
  audioSampler.begin(sensorQueue);
  dataAggregator.begin(sensorQueue, uploadQueue, dataMutex, &latestData, &configStore);
  networkUploader.begin(uploadQueue, &configStore);

  xTaskCreate(taskI2CSensors, "i2c", 6144, nullptr, 2, nullptr);
  xTaskCreate(taskAudio, "audio", 4096, nullptr, 3, nullptr);
  xTaskCreate(taskDataProcess, "data", 4096, nullptr, 1, nullptr);
  xTaskCreate(taskNetworkUpload, "upload", 6144, nullptr, 2, nullptr);
}

void loop() {
  vTaskDelay(pdMS_TO_TICKS(1000));
}

/OnDevice/src/sensor/I2cSensorManager.cpp

ondevice_i2c.cpp 在新窗口打开
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
#include "I2cSensorManager.h"

#include <Wire.h>
#include <math.h>

#include "../DebugLog.h"

void I2cSensorManager::begin(QueueHandle_t sampleQueue,
                             ConfigStore *configStore,
                             SemaphoreHandle_t dataMutex,
                             LatestData *latestData) {
  sampleQueue_ = sampleQueue;
  configStore_ = configStore;
  dataMutex_ = dataMutex;
  latestData_ = latestData;
}

void I2cSensorManager::run() {
  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
  Wire.setClock(I2C_CLOCK_HZ);
  Wire.setTimeOut(50);
  vTaskDelay(pdMS_TO_TICKS(100));

  scanI2cBus();

  aht20Present_ = i2cDevicePresent(AHT20_ADDR);
  bmp280Present_ = bmp280Begin();
  sgp30Present_ = i2cDevicePresent(SGP30_ADDR) && sgp30Begin();
  as7341Present_ = as7341Setup();
  st25Present_ = i2cDevicePresent(ST25_USER_ADDR);
  DEBUG_LOG("[sensor:init] sda=%d scl=%d aht20=%d bmp280=%d sgp30=%d as7341=%d st25=%d\n",
            PIN_I2C_SDA,
            PIN_I2C_SCL,
            aht20Present_ ? 1 : 0,
            bmp280Present_ ? 1 : 0,
            sgp30Present_ ? 1 : 0,
            as7341Present_ ? 1 : 0,
            st25Present_ ? 1 : 0);

  uint32_t lastAht = 0;
  uint32_t lastBmp = 0;
  uint32_t lastSgp = 0;
  uint32_t lastAs = 0;
  uint32_t lastNfc = 0;

  for (;;) {
    uint32_t now = millis();

    if (now - lastNfc >= 5000) {
      lastNfc = now;
      pollNfc(now);
    }
    if (now - lastAht >= 2000) {
      lastAht = now;
      pollAht(now);
    }
    if (now - lastBmp >= 5000) {
      lastBmp = now;
      pollBmp(now);
    }
    if (now - lastSgp >= 1000) {
      lastSgp = now;
      pollSgp(now);
    }
    if (now - lastAs >= 5000) {
      lastAs = now;
      pollAs7341(now);
    }

    vTaskDelay(pdMS_TO_TICKS(50));
  }
}

void I2cSensorManager::sendSample(const SensorSample &sample) {
  xQueueSend(sampleQueue_, &sample, QUEUE_WAIT);
}

void I2cSensorManager::pollNfc(uint32_t now) {
  if (!st25Present_) {
    return;
  }
  char raw[256];
  if (!st25ReadUserMemory(raw, sizeof(raw))) {
    return;
  }
  DEBUG_LOG("[sensor:nfc] raw=%s\n", raw);
  if (!configStore_ || !configStore_->applyPayload(raw)) {
    return;
  }

  SensorSample sample = {};
  sample.kind = KIND_CONFIG;
  sample.ms = now;
  sample.ok = true;
  sendSample(sample);
  Serial.println("NFC config updated.");
}

void I2cSensorManager::pollAht(uint32_t now) {
  SensorSample sample = {};
  sample.kind = KIND_AHT;
  sample.ms = now;
  sample.ok = aht20Present_ && aht20Read(&sample.temperatureC, &sample.humidityRh);
  DEBUG_LOG("[sensor:aht20] ok=%d temperature_c=%.2f humidity_rh=%.2f\n",
            sample.ok ? 1 : 0,
            sample.temperatureC,
            sample.humidityRh);
  sendSample(sample);
}

void I2cSensorManager::pollBmp(uint32_t now) {
  SensorSample sample = {};
  sample.kind = KIND_BMP;
  sample.ms = now;
  sample.ok = bmp280Read(&sample.temperatureC, &sample.pressureHpa, &sample.altitudeM);
  DEBUG_LOG("[sensor:bmp280] ok=%d temperature_c=%.2f pressure_hpa=%.2f altitude_m=%.1f\n",
            sample.ok ? 1 : 0,
            sample.temperatureC,
            sample.pressureHpa,
            sample.altitudeM);
  sendSample(sample);
}

void I2cSensorManager::pollSgp(uint32_t now) {
  SensorSample sample = {};
  sample.kind = KIND_AIR;
  sample.ms = now;
  sample.sgpWarmup = sgp30StartMs_ == 0 || now - sgp30StartMs_ < 15000;

  LatestData copy;
  xSemaphoreTake(dataMutex_, portMAX_DELAY);
  copy = *latestData_;
  xSemaphoreGive(dataMutex_);

  if (sgp30Present_) {
    uint32_t ah = absoluteHumidityMgM3(copy.temperatureC, copy.humidityRh);
    if (ah) {
      sgp30SetHumidity(ah);
    }
    sample.ok = sgp30Read(&sample.eco2Ppm, &sample.tvocPpb);
  }
  DEBUG_LOG("[sensor:sgp30] present=%d ok=%d eco2_ppm=%u tvoc_ppb=%u warmup=%d\n",
            sgp30Present_ ? 1 : 0,
            sample.ok ? 1 : 0,
            sample.eco2Ppm,
            sample.tvocPpb,
            sample.sgpWarmup ? 1 : 0);
  sendSample(sample);
}

void I2cSensorManager::pollAs7341(uint32_t now) {
  SensorSample sample = {};
  sample.kind = KIND_LIGHT;
  sample.ms = now;
  sample.ok = as7341Present_ && as7341Read(sample.f, &sample.clear, &sample.nir);
  if (!sample.ok && i2cDevicePresent(AS7341_ADDR)) {
    as7341Present_ = as7341Setup();
  }
  DEBUG_LOG("[sensor:as7341] ok=%d f=[%u,%u,%u,%u,%u,%u,%u,%u] clear=%u nir=%u\n",
            sample.ok ? 1 : 0,
            sample.f[0], sample.f[1], sample.f[2], sample.f[3],
            sample.f[4], sample.f[5], sample.f[6], sample.f[7],
            sample.clear,
            sample.nir);
  sendSample(sample);
}

void I2cSensorManager::scanI2cBus() {
  DEBUG_LOG("[i2c:scan] start sda=%d scl=%d clock=%lu\n",
            PIN_I2C_SDA,
            PIN_I2C_SCL,
            static_cast<unsigned long>(I2C_CLOCK_HZ));

  uint8_t found = 0;
  for (uint8_t addr = 1; addr < 0x7F; addr++) {
    Wire.beginTransmission(addr);
    uint8_t err = Wire.endTransmission();
    if (err == 0) {
      found++;
      DEBUG_LOG("[i2c:scan] found addr=0x%02X%s%s%s%s%s\n",
                addr,
                addr == AHT20_ADDR ? " AHT20" : "",
                addr == AS7341_ADDR ? " AS7341" : "",
                addr == SGP30_ADDR ? " SGP30" : "",
                (addr == BMP280_ADDR_PRIMARY || addr == BMP280_ADDR_SECONDARY) ? " BMP280" : "",
                addr == ST25_USER_ADDR ? " ST25" : "");
    } else if (err == 4) {
      DEBUG_LOG("[i2c:scan] unknown error addr=0x%02X err=%u\n", addr, err);
    }
    vTaskDelay(pdMS_TO_TICKS(1));
  }

  DEBUG_LOG("[i2c:scan] done found=%u\n", found);
}

bool I2cSensorManager::i2cWrite(uint8_t addr, const uint8_t *data, size_t len) {
  Wire.beginTransmission(addr);
  Wire.write(data, len);
  uint8_t err = Wire.endTransmission();
  if (err != 0) {
    DEBUG_LOG("[i2c:write-fail] addr=0x%02X len=%u err=%u\n",
              addr,
              static_cast<unsigned int>(len),
              err);
    return false;
  }
  return true;
}

bool I2cSensorManager::i2cWriteByte(uint8_t addr, uint8_t reg, uint8_t value) {
  uint8_t data[2] = {reg, value};
  return i2cWrite(addr, data, sizeof(data));
}

bool I2cSensorManager::i2cReadReg(uint8_t addr, uint8_t reg, uint8_t *buf, size_t len) {
  Wire.beginTransmission(addr);
  Wire.write(reg);
  uint8_t err = Wire.endTransmission(false);
  if (err != 0) {
    DEBUG_LOG("[i2c:read-reg-fail] phase=write-addr addr=0x%02X reg=0x%02X len=%u err=%u\n",
              addr,
              reg,
              static_cast<unsigned int>(len),
              err);
    return false;
  }
  size_t got = Wire.requestFrom(addr, static_cast<uint8_t>(len));
  if (got != len) {
    DEBUG_LOG("[i2c:read-reg-fail] phase=request addr=0x%02X reg=0x%02X len=%u got=%u\n",
              addr,
              reg,
              static_cast<unsigned int>(len),
              static_cast<unsigned int>(got));
    return false;
  }
  for (size_t i = 0; i < len; i++) {
    buf[i] = Wire.read();
  }
  return true;
}

bool I2cSensorManager::i2cDevicePresent(uint8_t addr) {
  Wire.beginTransmission(addr);
  uint8_t err = Wire.endTransmission();
  DEBUG_LOG("[i2c:probe] addr=0x%02X present=%d err=%u\n",
            addr,
            err == 0 ? 1 : 0,
            err);
  return err == 0;
}

uint16_t I2cSensorManager::le16(const uint8_t *p) {
  return static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1]) << 8);
}

int16_t I2cSensorManager::sle16(const uint8_t *p) {
  return static_cast<int16_t>(le16(p));
}

uint8_t I2cSensorManager::crc8Sgp30(const uint8_t *data, uint8_t len) {
  uint8_t crc = 0xFF;
  for (uint8_t i = 0; i < len; i++) {
    crc ^= data[i];
    for (uint8_t bit = 0; bit < 8; bit++) {
      crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : (crc << 1);
    }
  }
  return crc;
}

bool I2cSensorManager::sgp30Command(const uint8_t *cmd,
                                    uint8_t cmdLen,
                                    uint16_t delayMs,
                                    uint16_t *words,
                                    uint8_t wordCount) {
  if (!i2cWrite(SGP30_ADDR, cmd, cmdLen)) {
    return false;
  }
  vTaskDelay(pdMS_TO_TICKS(delayMs));
  if (wordCount == 0) {
    return true;
  }

  const uint8_t replyLen = wordCount * 3;
  uint8_t reply[18];
  if (replyLen > sizeof(reply)) {
    return false;
  }
  size_t got = Wire.requestFrom(SGP30_ADDR, replyLen);
  if (got != replyLen) {
    DEBUG_LOG("[i2c:request-fail] sensor=sgp30 addr=0x%02X len=%u got=%u\n",
              SGP30_ADDR,
              replyLen,
              static_cast<unsigned int>(got));
    return false;
  }
  for (uint8_t i = 0; i < replyLen; i++) {
    reply[i] = Wire.read();
  }
  for (uint8_t i = 0; i < wordCount; i++) {
    uint8_t *p = &reply[i * 3];
    if (crc8Sgp30(p, 2) != p[2]) {
      return false;
    }
    words[i] = (static_cast<uint16_t>(p[0]) << 8) | p[1];
  }
  return true;
}

bool I2cSensorManager::sgp30Begin() {
  uint8_t iaqInit[] = {0x20, 0x03};
  if (!sgp30Command(iaqInit, sizeof(iaqInit), 10)) {
    return false;
  }
  sgp30StartMs_ = millis();
  return true;
}

uint32_t I2cSensorManager::absoluteHumidityMgM3(float temperatureC, float humidityRh) {
  if (!isfinite(temperatureC) || !isfinite(humidityRh)) {
    return 0;
  }
  const float ah = 216.7f *
                   ((humidityRh / 100.0f) * 6.112f *
                    expf((17.62f * temperatureC) / (243.12f + temperatureC)) /
                    (273.15f + temperatureC));
  if (ah <= 0.0f) {
    return 0;
  }
  return static_cast<uint32_t>(ah * 1000.0f);
}

bool I2cSensorManager::sgp30SetHumidity(uint32_t absoluteHumidity) {
  if (absoluteHumidity > 256000) {
    return false;
  }
  uint16_t scaled = static_cast<uint16_t>(((uint64_t)absoluteHumidity * 256 * 16777) >> 24);
  uint8_t cmd[5] = {0x20, 0x61, static_cast<uint8_t>(scaled >> 8), static_cast<uint8_t>(scaled & 0xFF), 0};
  cmd[4] = crc8Sgp30(cmd + 2, 2);
  return sgp30Command(cmd, sizeof(cmd), 10);
}

bool I2cSensorManager::sgp30Read(uint16_t *eco2, uint16_t *tvoc) {
  uint8_t cmd[] = {0x20, 0x08};
  uint16_t words[2];
  if (!sgp30Command(cmd, sizeof(cmd), 12, words, 2)) {
    return false;
  }
  *eco2 = words[0];
  *tvoc = words[1];
  DEBUG_LOG("[sensor:sgp30:raw] words=%u,%u\n", words[0], words[1]);
  return true;
}

bool I2cSensorManager::aht20Read(float *temperatureC, float *humidityRh) {
  uint8_t status = 0;
  if (!i2cReadReg(AHT20_ADDR, 0x71, &status, 1)) {
    return false;
  }
  if ((status & 0x18) != 0x18) {
    uint8_t initCmd[] = {0xBE, 0x08, 0x00};
    i2cWrite(AHT20_ADDR, initCmd, sizeof(initCmd));
    vTaskDelay(pdMS_TO_TICKS(10));
  }

  uint8_t trig[] = {0xAC, 0x33, 0x00};
  if (!i2cWrite(AHT20_ADDR, trig, sizeof(trig))) {
    return false;
  }
  vTaskDelay(pdMS_TO_TICKS(80));

  uint8_t data[7];
  size_t got = Wire.requestFrom(AHT20_ADDR, static_cast<uint8_t>(7));
  if (got != 7) {
    DEBUG_LOG("[i2c:request-fail] sensor=aht20 addr=0x%02X len=7 got=%u\n",
              AHT20_ADDR,
              static_cast<unsigned int>(got));
    return false;
  }
  for (uint8_t i = 0; i < 7; i++) {
    data[i] = Wire.read();
  }
  if (data[0] & 0x80) {
    return false;
  }

  uint32_t rawHumidity = ((uint32_t)data[1] << 12) | ((uint32_t)data[2] << 4) | (data[3] >> 4);
  uint32_t rawTemperature = ((uint32_t)(data[3] & 0x0F) << 16) | ((uint32_t)data[4] << 8) | data[5];
  DEBUG_LOG("[sensor:aht20:raw] status=0x%02X bytes=%02X %02X %02X %02X %02X %02X %02X raw_h=%lu raw_t=%lu\n",
            data[0], data[0], data[1], data[2], data[3], data[4], data[5], data[6],
            static_cast<unsigned long>(rawHumidity),
            static_cast<unsigned long>(rawTemperature));
  *humidityRh = rawHumidity * 100.0f / 1048576.0f;
  *temperatureC = rawTemperature * 200.0f / 1048576.0f - 50.0f;
  return true;
}

bool I2cSensorManager::bmp280ReadCoefficients(uint8_t addr) {
  uint8_t c[24];
  if (!i2cReadReg(addr, 0x88, c, sizeof(c))) {
    return false;
  }
  bmpDigT1_ = le16(c + 0);
  bmpDigT2_ = sle16(c + 2);
  bmpDigT3_ = sle16(c + 4);
  bmpDigP1_ = le16(c + 6);
  bmpDigP2_ = sle16(c + 8);
  bmpDigP3_ = sle16(c + 10);
  bmpDigP4_ = sle16(c + 12);
  bmpDigP5_ = sle16(c + 14);
  bmpDigP6_ = sle16(c + 16);
  bmpDigP7_ = sle16(c + 18);
  bmpDigP8_ = sle16(c + 20);
  bmpDigP9_ = sle16(c + 22);
  return bmpDigP1_ != 0;
}

bool I2cSensorManager::bmp280Begin() {
  uint8_t id = 0;
  if (i2cReadReg(BMP280_ADDR_PRIMARY, 0xD0, &id, 1) && id == 0x58) {
    bmp280Addr_ = BMP280_ADDR_PRIMARY;
  } else if (i2cReadReg(BMP280_ADDR_SECONDARY, 0xD0, &id, 1) && id == 0x58) {
    bmp280Addr_ = BMP280_ADDR_SECONDARY;
  } else {
    return false;
  }
  if (!bmp280ReadCoefficients(bmp280Addr_)) {
    return false;
  }
  if (!i2cWriteByte(bmp280Addr_, 0xF4, 0x2F)) {
    return false;
  }
  return i2cWriteByte(bmp280Addr_, 0xF5, 0x90);
}

bool I2cSensorManager::bmp280Read(float *temperatureC, float *pressureHpa, float *altitudeM) {
  uint8_t d[6];
  if (!bmp280Present_ || !i2cReadReg(bmp280Addr_, 0xF7, d, sizeof(d))) {
    return false;
  }
  int32_t adcP = ((int32_t)d[0] << 12) | ((int32_t)d[1] << 4) | (d[2] >> 4);
  int32_t adcT = ((int32_t)d[3] << 12) | ((int32_t)d[4] << 4) | (d[5] >> 4);
  DEBUG_LOG("[sensor:bmp280:raw] addr=0x%02X bytes=%02X %02X %02X %02X %02X %02X adc_p=%ld adc_t=%ld\n",
            bmp280Addr_,
            d[0], d[1], d[2], d[3], d[4], d[5],
            static_cast<long>(adcP),
            static_cast<long>(adcT));

  int32_t var1 = ((((adcT >> 3) - ((int32_t)bmpDigT1_ << 1))) * ((int32_t)bmpDigT2_)) >> 11;
  int32_t var2 = (((((adcT >> 4) - ((int32_t)bmpDigT1_)) * ((adcT >> 4) - ((int32_t)bmpDigT1_))) >> 12) *
                  ((int32_t)bmpDigT3_)) >> 14;
  bmpTfine_ = var1 + var2;
  *temperatureC = ((bmpTfine_ * 5 + 128) >> 8) / 100.0f;

  int64_t pVar1 = ((int64_t)bmpTfine_) - 128000;
  int64_t pVar2 = pVar1 * pVar1 * (int64_t)bmpDigP6_;
  pVar2 = pVar2 + ((pVar1 * (int64_t)bmpDigP5_) << 17);
  pVar2 = pVar2 + (((int64_t)bmpDigP4_) << 35);
  pVar1 = ((pVar1 * pVar1 * (int64_t)bmpDigP3_) >> 8) + ((pVar1 * (int64_t)bmpDigP2_) << 12);
  pVar1 = (((((int64_t)1) << 47) + pVar1)) * ((int64_t)bmpDigP1_) >> 33;
  if (pVar1 == 0) {
    return false;
  }
  int64_t p = 1048576 - adcP;
  p = (((p << 31) - pVar2) * 3125) / pVar1;
  pVar1 = (((int64_t)bmpDigP9_) * (p >> 13) * (p >> 13)) >> 25;
  pVar2 = (((int64_t)bmpDigP8_) * p) >> 19;
  p = ((p + pVar1 + pVar2) >> 8) + (((int64_t)bmpDigP7_) << 4);
  *pressureHpa = (p / 256.0f) / 100.0f;
  *altitudeM = 44330.0f * (1.0f - powf(*pressureHpa / 1013.25f, 0.1903f));
  return true;
}

bool I2cSensorManager::as7341WaitBitClear(uint8_t reg, uint8_t bitMask, uint16_t timeoutMs) {
  uint32_t start = millis();
  uint8_t v = 0;
  do {
    if (!i2cReadReg(AS7341_ADDR, reg, &v, 1)) {
      return false;
    }
    if ((v & bitMask) == 0) {
      return true;
    }
    vTaskDelay(pdMS_TO_TICKS(2));
  } while (millis() - start < timeoutMs);
  return false;
}

bool I2cSensorManager::as7341WaitDataReady(uint16_t timeoutMs) {
  uint32_t start = millis();
  uint8_t v = 0;
  do {
    if (!i2cReadReg(AS7341_ADDR, 0xA3, &v, 1)) {
      return false;
    }
    if (v & 0x40) {
      return true;
    }
    vTaskDelay(pdMS_TO_TICKS(5));
  } while (millis() - start < timeoutMs);
  return false;
}

bool I2cSensorManager::as7341WriteSmux(const uint8_t *smux) {
  if (!i2cWriteByte(AS7341_ADDR, 0x80, 0x01)) {
    return false;
  }
  if (!i2cWriteByte(AS7341_ADDR, 0xAF, 0x10)) {
    return false;
  }
  for (uint8_t i = 0; i < 20; i++) {
    if (!i2cWriteByte(AS7341_ADDR, i, smux[i])) {
      return false;
    }
  }
  if (!i2cWriteByte(AS7341_ADDR, 0x80, 0x11)) {
    return false;
  }
  return as7341WaitBitClear(0x80, 0x10, 100);
}

bool I2cSensorManager::as7341Setup() {
  if (!i2cDevicePresent(AS7341_ADDR)) {
    return false;
  }
  return i2cWriteByte(AS7341_ADDR, 0x80, 0x01) &&
         i2cWriteByte(AS7341_ADDR, 0x81, 0x64) &&
         i2cWriteByte(AS7341_ADDR, 0xCA, 0xE7) &&
         i2cWriteByte(AS7341_ADDR, 0xCB, 0x03) &&
         i2cWriteByte(AS7341_ADDR, 0xAA, 0x09);
}

bool I2cSensorManager::as7341ReadSix(uint16_t *out) {
  if (!i2cWriteByte(AS7341_ADDR, 0x80, 0x03)) {
    return false;
  }
  if (!as7341WaitDataReady(400)) {
    i2cWriteByte(AS7341_ADDR, 0x80, 0x01);
    return false;
  }
  uint8_t d[12];
  if (!i2cReadReg(AS7341_ADDR, 0x95, d, sizeof(d))) {
    return false;
  }
  for (uint8_t i = 0; i < 6; i++) {
    out[i] = le16(d + i * 2);
  }
  i2cWriteByte(AS7341_ADDR, 0x80, 0x01);
  return true;
}

bool I2cSensorManager::as7341Read(uint16_t f[8], uint16_t *clear, uint16_t *nir) {
  static const uint8_t smuxLo[20] = {
      0x30, 0x01, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x50, 0x00,
      0x00, 0x00, 0x20, 0x04, 0x00, 0x30, 0x01, 0x50, 0x00, 0x06};
  static const uint8_t smuxHi[20] = {
      0x00, 0x00, 0x00, 0x40, 0x02, 0x00, 0x10, 0x03, 0x50, 0x10,
      0x03, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x50, 0x00, 0x06};
  uint16_t lo[6];
  uint16_t hi[6];
  if (!as7341WriteSmux(smuxLo) || !as7341ReadSix(lo)) {
    return false;
  }
  if (!as7341WriteSmux(smuxHi) || !as7341ReadSix(hi)) {
    return false;
  }
  DEBUG_LOG("[sensor:as7341:raw] lo=[%u,%u,%u,%u,%u,%u] hi=[%u,%u,%u,%u,%u,%u]\n",
            lo[0], lo[1], lo[2], lo[3], lo[4], lo[5],
            hi[0], hi[1], hi[2], hi[3], hi[4], hi[5]);
  f[0] = lo[0];
  f[1] = lo[1];
  f[2] = lo[2];
  f[3] = lo[3];
  f[4] = hi[0];
  f[5] = hi[1];
  f[6] = hi[2];
  f[7] = hi[3];
  *clear = (lo[4] + hi[4]) / 2;
  *nir = (lo[5] + hi[5]) / 2;
  return true;
}

bool I2cSensorManager::st25ReadUserMemory(char *buf, size_t len) {
  if (len == 0) {
    return false;
  }
  memset(buf, 0, len);
  Wire.beginTransmission(ST25_USER_ADDR);
  Wire.write(0x00);
  Wire.write(0x00);
  if (Wire.endTransmission(false) != 0) {
    return false;
  }
  size_t toRead = min(len - 1, static_cast<size_t>(220));
  size_t got = Wire.requestFrom(ST25_USER_ADDR, static_cast<uint8_t>(toRead));
  if (got != toRead) {
    DEBUG_LOG("[i2c:request-fail] sensor=st25 addr=0x%02X len=%u got=%u\n",
              ST25_USER_ADDR,
              static_cast<unsigned int>(toRead),
              static_cast<unsigned int>(got));
  }
  for (size_t i = 0; i < got && i < len - 1; i++) {
    char c = static_cast<char>(Wire.read());
    buf[i] = isPrintable(c) ? c : ' ';
  }
  buf[min(got, len - 1)] = 0;
  return got > 0;
}

Backend是Cloudflare Workers 构建的 API 的代码

/Backend/src/index.ts

backend.ts 在新窗口打开
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
interface Env {
  DB: D1Database;
}

type JsonObject = Record<string, unknown>;

const DEVICE_ID_MAX_LENGTH = 128;
const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
const SENSOR_NAME_MAX_LENGTH = 128;
const SENSOR_NAME_PATTERN = /^[A-Za-z0-9._:-]+$/;

interface DeviceUploadPayload {
  deviceId: string;
  sensorData: Array<{
    name: string;
    data: string;
  }>;
  recordedAt: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    try {
      const url = new URL(request.url);

      if (request.method === "OPTIONS") {
        return new Response(null, { status: 204, headers: corsHeaders() });
      }

      if ((url.pathname === "/health" || url.pathname === "/api/health") && request.method === "GET") {
        return jsonResponse({ ok: true });
      }

      if (url.pathname === "/device/upload" || url.pathname === "/api/device/upload") {
        if (request.method !== "POST") {
          return jsonError("method_not_allowed", "Use POST /api/device/upload.", 405);
        }

        return handleDeviceUpload(request, env);
      }

      return jsonError("not_found", "Route not found.", 404);
    } catch (error) {
      console.error("Unhandled request error", error);
      return jsonError("internal_error", "Internal server error.", 500);
    }
  }
} satisfies ExportedHandler<Env>;

async function handleDeviceUpload(request: Request, env: Env): Promise<Response> {
  const contentType = request.headers.get("content-type") ?? "";
  if (!contentType.toLowerCase().includes("application/json")) {
    return jsonError("unsupported_media_type", "Request body must be JSON.", 415);
  }

  const payload = await parseJsonObject(request);
  if (!payload.ok) {
    return jsonError("invalid_json", payload.error, 400);
  }

  const upload = normalizeDeviceUploadPayload(payload.value);
  if (!upload.ok) {
    return jsonError(upload.code, upload.error, 400);
  }

  console.log(
    JSON.stringify({
      event: "device_upload_received",
      device_id: upload.value.deviceId,
      time: upload.value.recordedAt,
      sensor_count: upload.value.sensorData.length,
      sensor_names: upload.value.sensorData.map((sensor) => sensor.name)
    })
  );

  const receivedAt = new Date().toISOString();

  const statements = [
    env.DB.prepare(
      `INSERT INTO device (device_id, first_seen_time, last_upload_time, upload_count)
       VALUES (?1, ?2, ?2, 1)
       ON CONFLICT(device_id) DO UPDATE SET
         last_upload_time = excluded.last_upload_time,
         upload_count = device.upload_count + 1`
    ).bind(upload.value.deviceId, receivedAt),
  ];
  const dataIds: string[] = [];

  for (const sensor of upload.value.sensorData) {
    const dataId = crypto.randomUUID();
    dataIds.push(dataId);

    statements.push(
      env.DB.prepare(
        `INSERT INTO sensor_data
           (data_id, device_id, sensor_name, data, time, upload_time)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
      ).bind(
        dataId,
        upload.value.deviceId,
        sensor.name,
        sensor.data,
        upload.value.recordedAt,
        receivedAt
      )
    );
  }

  await env.DB.batch(statements);

  console.log(
    JSON.stringify({
      event: "device_upload_stored",
      device_id: upload.value.deviceId,
      time: upload.value.recordedAt,
      upload_time: receivedAt,
      sensor_count: upload.value.sensorData.length,
      data_ids: dataIds
    })
  );

  return jsonResponse(
    {
      ok: true,
      device_id: upload.value.deviceId,
      time: upload.value.recordedAt,
      upload_time: receivedAt,
      sensor_count: upload.value.sensorData.length,
      data_ids: dataIds
    },
    201
  );
}

async function parseJsonObject(
  request: Request
): Promise<{ ok: true; value: JsonObject } | { ok: false; error: string }> {
  try {
    const value: unknown = await request.json();
    if (!isJsonObject(value)) {
      return { ok: false, error: "JSON body must be an object." };
    }

    return { ok: true, value };
  } catch {
    return { ok: false, error: "Malformed JSON body." };
  }
}

function normalizeDeviceUploadPayload(
  payload: JsonObject
): { ok: true; value: DeviceUploadPayload } | { ok: false; code: string; error: string } {
  const deviceId = normalizeDeviceId(payload.device_id);
  if (!deviceId.ok) {
    return { ok: false, code: "invalid_device_id", error: deviceId.error };
  }

  const recordedAt = normalizePayloadTime(payload.time);
  if (!recordedAt.ok) {
    return { ok: false, code: "invalid_time", error: recordedAt.error };
  }

  if (!isJsonObject(payload.sensor_data)) {
    return { ok: false, code: "invalid_sensor_data", error: "sensor_data must be a non-empty JSON object." };
  }

  const sensors = Object.entries(payload.sensor_data);
  if (sensors.length === 0) {
    return { ok: false, code: "invalid_sensor_data", error: "sensor_data must contain at least one sensor." };
  }

  const sensorData = [];
  for (const [name, value] of sensors) {
    const sensorName = normalizeSensorName(name);
    if (!sensorName.ok) {
      return { ok: false, code: "invalid_sensor_name", error: sensorName.error };
    }

    if (value === undefined) {
      return { ok: false, code: "invalid_sensor_value", error: `Sensor ${name} has an unsupported value.` };
    }

    sensorData.push(normalizeSensorReading(sensorName.value, value));
  }

  return {
    ok: true,
    value: {
      deviceId: deviceId.value,
      sensorData,
      recordedAt: recordedAt.value
    }
  };
}

function normalizeDeviceId(rawValue: unknown): { ok: true; value: string } | { ok: false; error: string } {
  if (typeof rawValue !== "string" || rawValue.trim() === "") {
    return { ok: false, error: "device_id is required and must be a non-empty string." };
  }

  const value = rawValue.trim();
  if (value.length > DEVICE_ID_MAX_LENGTH) {
    return { ok: false, error: `Device id must be ${DEVICE_ID_MAX_LENGTH} characters or fewer.` };
  }

  if (!DEVICE_ID_PATTERN.test(value)) {
    return { ok: false, error: "Device id may contain only letters, numbers, dot, underscore, colon, and dash." };
  }

  return { ok: true, value };
}

function normalizeSensorName(value: string): { ok: true; value: string } | { ok: false; error: string } {
  const normalized = value.trim();
  if (normalized === "") {
    return { ok: false, error: "Sensor name must not be empty." };
  }

  if (normalized.length > SENSOR_NAME_MAX_LENGTH) {
    return { ok: false, error: `Sensor name must be ${SENSOR_NAME_MAX_LENGTH} characters or fewer.` };
  }

  if (!SENSOR_NAME_PATTERN.test(normalized)) {
    return { ok: false, error: "Sensor name may contain only letters, numbers, dot, underscore, colon, and dash." };
  }

  return { ok: true, value: normalized };
}

function normalizePayloadTime(value: unknown): { ok: true; value: string } | { ok: false; error: string } {
  if (typeof value === "string") {
    const date = new Date(value);
    if (!Number.isNaN(date.getTime())) {
      return { ok: true, value: date.toISOString() };
    }
  }

  if (typeof value === "number" && Number.isFinite(value)) {
    const milliseconds = value < 10_000_000_000 ? value * 1000 : value;
    const date = new Date(milliseconds);
    if (!Number.isNaN(date.getTime())) {
      return { ok: true, value: date.toISOString() };
    }
  }

  return { ok: false, error: "time must be an ISO timestamp string, Unix seconds, or Unix milliseconds." };
}

function normalizeSensorReading(name: string, value: unknown): DeviceUploadPayload["sensorData"][number] {
  return {
    name,
    data: JSON.stringify(value)
  };
}

function isJsonObject(value: unknown): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function jsonResponse(body: JsonObject, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: {
      "content-type": "application/json; charset=utf-8",
      ...corsHeaders()
    }
  });
}

function jsonError(code: string, message: string, status: number): Response {
  return jsonResponse(
    {
      ok: false,
      error: {
        code,
        message
      }
    },
    status
  );
}

function corsHeaders(): HeadersInit {
  return {
    "access-control-allow-origin": "*",
    "access-control-allow-methods": "GET,POST,OPTIONS",
    "access-control-allow-headers": "content-type,x-device-id"
  };
}
本文采用 CC BY 4.0 许可协议,转载请注明出处。
最后更新于 May 30, 2026 22:39 +0800
使用 Hugo 构建
主题 StackJimmy 设计