Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3140eae7db | ||
|
|
7b4e59957b | ||
|
|
1d69915cf3 | ||
|
|
8e2c6ffa35 | ||
|
|
53f63e0986 | ||
|
|
508419180c | ||
|
|
4f2becb99a | ||
|
|
2fa7c7fc70 | ||
|
|
1b330bdd4d | ||
|
|
5822a01ed7 | ||
|
|
b0bc1e9727 | ||
|
|
68998fa07e | ||
|
|
d5a31e5366 | ||
|
|
05ad191913 | ||
|
|
9ecbf8d8e0 |
@@ -0,0 +1,177 @@
|
||||
name: Android Build & Publish
|
||||
|
||||
# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0)
|
||||
# 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'android-*'
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
# 需要写权限: 上传 packages
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
# 固定工具链版本, 便于 actions/cache 命中
|
||||
# Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致
|
||||
VITE_API_BASE: http://bh.vps.honor3.com:3001
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: https://gitea.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
# ---------- 缓存: 命中后跳过下面的安装 ----------
|
||||
- name: Cache Android SDK & NDK
|
||||
id: cache-android
|
||||
uses: https://gitea.com/actions/cache@v4
|
||||
with:
|
||||
path: ~/android-sdk
|
||||
key: android-sdk-ndk-27.2-${{ runner.os }}
|
||||
|
||||
- name: Cache Rust toolchain
|
||||
id: cache-rust
|
||||
uses: https://gitea.com/actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
~/.rustup
|
||||
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
|
||||
key: rust-android-v1-${{ runner.os }}
|
||||
|
||||
- name: Cache Tauri cargo target
|
||||
uses: https://gitea.com/actions/cache@v4
|
||||
with:
|
||||
path: src-tauri/target
|
||||
key: tauri-target-android-${{ runner.os }}
|
||||
|
||||
# ---------- 安装工具链 (仅缓存未命中时) ----------
|
||||
- name: Install Rust + Android targets
|
||||
if: steps.cache-rust.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
. "$HOME/.cargo/env"
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
|
||||
cargo install cargo-ndk --locked
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
if: steps.cache-android.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends unzip
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip
|
||||
unzip -q /tmp/cmdline-tools.zip -d /tmp/
|
||||
mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
yes | sdkmanager --licenses >/dev/null || true
|
||||
sdkmanager --install \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0" \
|
||||
"platform-tools" \
|
||||
"ndk;27.2.12479018" \
|
||||
"cmake;3.22.1"
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# ---------- 全局 Android 环境 (对后续所有 step 生效, 无论缓存是否命中) ----------
|
||||
- name: Set Android env
|
||||
run: |
|
||||
echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_NDK_HOME=$HOME/android-sdk/ndk/27.2.12479018" >> "$GITHUB_ENV"
|
||||
echo "$HOME/android-sdk/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||
echo "$HOME/android-sdk/platform-tools" >> "$GITHUB_PATH"
|
||||
|
||||
# ---------- 构建 ----------
|
||||
- name: Init Android project
|
||||
run: |
|
||||
# tauri android init 需要 cargo (运行 cargo metadata)。
|
||||
# Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH,
|
||||
# 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。
|
||||
. "$HOME/.cargo/env"
|
||||
cd src-tauri
|
||||
npx tauri android init --ci
|
||||
|
||||
- name: Inject release signing config
|
||||
run: bash scripts/ci/android-signing.sh
|
||||
env:
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
- name: Build release APK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
|
||||
export CARGO_HOME="$HOME/.cargo"
|
||||
. "$HOME/.cargo/env"
|
||||
cd src-tauri
|
||||
npx tauri android build --apk
|
||||
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
|
||||
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
|
||||
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Locate APK
|
||||
id: apk
|
||||
run: |
|
||||
# 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK
|
||||
APK=""
|
||||
UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1)
|
||||
if [ -n "$UNIVERSAL" ]; then
|
||||
APK="$UNIVERSAL"
|
||||
else
|
||||
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1)
|
||||
fi
|
||||
if [ -z "$APK" ]; then
|
||||
echo "ERROR: 未找到已签名 APK" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$APK" >> $GITHUB_OUTPUT
|
||||
ls -lh "$APK"
|
||||
|
||||
# ---------- 上传到 Gitea Generic Package ----------
|
||||
- name: Publish APK to Gitea packages
|
||||
env:
|
||||
# 注意: 不能用 GITEA_TOKEN (内置 job token, 无组织 packages 写权限, 且命名规则禁止 GITEA_ 前缀)
|
||||
# 用一个自定义名 secret 存放有 packages 写权限的 user token
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
APK_PATH: ${{ steps.apk.outputs.path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一
|
||||
BRANCH="${GITHUB_REF_NAME#android-}"
|
||||
PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}"
|
||||
FILE_NAME="iboard-$(basename "$APK_PATH")"
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER}"
|
||||
PKG_NAME="iboard"
|
||||
# Gitea Generic Package Registry 上传端点 (PUT)
|
||||
# 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com
|
||||
URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}"
|
||||
echo "上传到: ${URL}"
|
||||
curl -sS --fail-with-body \
|
||||
-H "Authorization: token ${PACKAGE_TOKEN}" \
|
||||
--upload-file "$APK_PATH" \
|
||||
"${URL}"
|
||||
echo ""
|
||||
echo "已发布: ${URL}"
|
||||
@@ -12,6 +12,11 @@ on:
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的
|
||||
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
|
||||
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
|
||||
VITE_API_BASE: http://bh.vps.honor3.com:3001
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
@@ -51,6 +56,12 @@ jobs:
|
||||
- name: List images
|
||||
run: docker images
|
||||
|
||||
- name: Apply DB migrations
|
||||
# 仓库内的 prisma 迁移自动应用 (migrate deploy 幂等, 无新迁移时为 no-op)
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: Remove old container
|
||||
run: docker rm -vf iboard || true
|
||||
|
||||
@@ -60,10 +71,13 @@ jobs:
|
||||
- name: Run container
|
||||
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
|
||||
# OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package
|
||||
# 米家凭证 (auth.txt) 不进 git: 运维放宿主机 /app/iboard/cache, bind-mount 进容器 (MIHOME_CACHE_DIR=/app/cache)
|
||||
run: |
|
||||
docker run -d --name iboard -p 3001:3001 \
|
||||
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
|
||||
-e JWT_SECRET="${{ secrets.JWT_SECRET }}" \
|
||||
-e MIHOME_CACHE_DIR=/app/cache \
|
||||
-v /app/iboard/cache:/app/cache \
|
||||
iboard:latest
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Sync Secrets to Registry
|
||||
|
||||
# 将本仓库 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
|
||||
# 目标: service-iboard 项目 (proj-msid70ql-6xbq4w)
|
||||
# 触发: 仅手动 (workflow_dispatch)
|
||||
# 依赖 secret:
|
||||
# - REGISTRY_MCP_TOKEN : 注册中心 MCP 访问令牌 (在注册中心 🔑Token 管理 里生成)
|
||||
# - 其余 secret 与三个构建 workflow 引用的保持一致 (未设置则跳过对应键)
|
||||
# 注意: 传输为明文 http, 请保证注册中心仅内网可达; 若可访问则改用 https
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: '仅预演不写入 (true=只打印计划)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
sync-secrets:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
REGISTRY_MCP_URL: http://bh.vps.honor3.com:8008/mcp
|
||||
REGISTRY_PROJECT_ID: proj-msid70ql-6xbq4w
|
||||
REGISTRY_IDC: '1'
|
||||
REGISTRY_ENVIRONMENT: '1'
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Sync secrets via MCP
|
||||
run: |
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
node scripts/sync-registry.mjs --dry-run
|
||||
else
|
||||
node scripts/sync-registry.mjs
|
||||
fi
|
||||
env:
|
||||
REGISTRY_MCP_TOKEN: ${{ secrets.REGISTRY_MCP_TOKEN }}
|
||||
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
@@ -43,3 +43,5 @@ ota-package/*
|
||||
cache/apizero/
|
||||
# Dockerfile 持久卷挂载点 (跟 ota-package 同类)
|
||||
.cache/
|
||||
# 米家凭证缓存 (含 token, 绝不能提交)
|
||||
server/mihome/.cache/
|
||||
|
||||
@@ -48,6 +48,7 @@ COPY --from=builder /app/server ./server
|
||||
COPY --from=builder /app/generated ./generated
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/content ./content
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
# 环境监控模块设计(米家温湿度长期记录)
|
||||
|
||||
> 目标项目: iboard(Tauri + React + Express + Prisma/MariaDB)
|
||||
> 状态: 设计稿 v1.1 · 2026-09-20 · 待评审(v1.1: 视图新增 今天/月/年三档粒度 + 标题显示电量)
|
||||
|
||||
## 1. 需求与目标
|
||||
|
||||
在监控页新增「环境监控」tab,实现**多家庭**下温湿度**长期记录**:
|
||||
|
||||
1. 监控页新增一个 tab「环境监控」。
|
||||
2. 多家庭支持:每个家庭一个可折叠 group,**手风琴**式展开(同一时间只能展开一个)。
|
||||
3. 每个设备可独立修改**别名**(仅本应用展示用,不改动米家云端设备名)。
|
||||
4. 数据**非实时**(定时批量,不做推送):**每小时采样 1 次**落库小时档,每日聚合成日档。
|
||||
5. 记录保留**永久**(无清理任务)。
|
||||
6. 缺失补齐语义(用户确认):
|
||||
- **应用停机**导致的缺失 → 应用恢复后自动重新获取补齐(有统计的型号经云端聚合真实回填,
|
||||
见 §2.2B;无统计的型号保持缺口标注);
|
||||
- **设备自身原因**(离线/故障)的缺失 → 保持缺失,不伪造数据;
|
||||
- 总原则:**本地数据不少于米家可提供的数据**。
|
||||
7. 米家授权失效 → 前端显示「重新授权」按钮,点击弹出**二维码**,人工扫码后恢复。
|
||||
8. 每设备数据**默认显示当天**,粒度**小时**。
|
||||
9. 每设备数据可切换为**月**,粒度**天**。
|
||||
10. 每设备数据可切换为**年**,粒度**周**。
|
||||
11. 设备卡片**标题 = 别名 + 电量**(电量取当天缓存值,不必频繁更新)。
|
||||
|
||||
已确认的数据范围:仅**温湿度**(不含照度 / 人体存在)。
|
||||
|
||||
## 2. 已验证事实(调研结论,作为设计地基)
|
||||
|
||||
调研用的账号 `18814803393`,登录链路与数据能力已实测(脚本位于
|
||||
`C:\Users\xydkj\AppData\Local\Temp\opencode\mibridge-research\`):
|
||||
|
||||
| 项目 | 结论 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 登录方式 | 密码登录被米家拒绝(`70016`);**扫码登录可行** | `qr-once.mjs` |
|
||||
| Token 有效期 | 30 天,`DiskCache` 持久化,脚本自动加载刷新 | `.mijia-cache/` |
|
||||
| 实时读值 | `api.getDevicesProp([{did,siid,piid}])` 稳定可用,100 项请求 10 轮 100% 成功 | `env-monitor.mjs` |
|
||||
| 单轮延迟 | p50 ≈ 520ms,p95 ≈ 612ms(10 项属性/轮) | 同上 |
|
||||
| VRF 空调目标温写入 | 整数生效但**生效延迟 5~6s**(updateTime 才更新);半度 26.5 被服务端拒收(`-704220043`) | `vrf-temp-test*.mjs` |
|
||||
| 温湿度历史读取 | **不可读**:`history-data(8.1)`/`start-time(8.3)` 返回 `-704010000`;`start-synic` 动作要求参数;`/v2/home/device/prop_history` 类端点不可达 | `dev-history-probe.mjs` |
|
||||
|
||||
### 2.1 环境设备清单(共 3 台,均属家庭「黄金农场」505001017889)
|
||||
|
||||
| 家庭 | 设备 | 型号 | did | 温度 | 湿度 | 电量 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 黄金农场 | 炎米智能温湿度计 | `ym001.sensor_ht.ymwsdj` | `blt.3.1nmu8afgt0g00` | 2.1001 | 2.1002 | `3.1003` |
|
||||
| 黄金农场 | 炎米智能温湿度计 2 | `ym001.sensor_ht.ymwsdj` | `blt.3.1nmu8eunt0001` | 2.1001 | 2.1002 | `3.1003` |
|
||||
| 黄金农场 | 闹钟(青萍,含温湿) | `cgllc.clock.dove` | `blt.3.12cq3lu8glo00` | 2.1 | 2.2 | `2.3` |
|
||||
|
||||
> 第二个家庭「韩家」(586001550228) 当前无环境设备,设计需容忍空家庭。
|
||||
|
||||
### 2.2 关键约束:逐点历史不可达,但云端「聚合统计历史」可用(实测反转)
|
||||
|
||||
原结论"云端历史完全不可达"已推翻。实测分两条:
|
||||
|
||||
**A. 逐点历史(设备本地日志)云 API 不可达**
|
||||
- 炎米温湿度计:规格含 `8.1 history-data / 8.3 start-time / start-synic`,但 8.1/8.3 读取全返回
|
||||
`-704010000`(不可读),`start-synic` 动作试了 8 种参数形态全部报 `-704220025 参数个数不匹配`,
|
||||
判定为"局域网直连通道",云侧无暴露;
|
||||
- 青萍闹钟:miot 规格仅 temperature / relative-humidity / battery-level,**无任何历史属性/动作**。
|
||||
|
||||
**B. 云端聚合统计历史「可用」(%@zythum02/mijia-api 可直接打 `POST /v2/user/statistics`)**
|
||||
参数 `{ did, key(siid.piid), data_type, limit, time_start, time_end }`,用本库 `api.request()` 直连即可。
|
||||
|
||||
| data_type | 桶(Asia/Shanghai) | value 语义 | 实测(炎米 ym001.sensor_ht.ymwsdj) |
|
||||
| --- | --- | --- | --- |
|
||||
| `stat_hour_v3` | 1 小时整点 | 小时均值 | 连续入库(炎米1 有部分缺档),如 `[28.8]` |
|
||||
| `stat_day_v3` | 1 天 00:00 | 当日 [min, max] | 45 天全有,如 `[28.1, 29.2]`,与实时读值吻合 |
|
||||
| `stat_month_v3` | 1 月 | 当月 [min, max] | 回溯到 2026-01(联网注册后云端一直记账) |
|
||||
| `stat_week_v3` | 1 周 | — | 该型号返回 `-8 invalid params`(未开通周档) |
|
||||
| 回补深度 | — | — | 两台炎米均回溯到接入米家日起(实测到 2026-01-03);缺席日=设备当天无上报 |
|
||||
|
||||
- 青萍闹钟 `cgllc.clock.dove` 与 VRF `proxy.624017060.00001` 全部 data_type 报 `-8 invalid params`
|
||||
→ **云侧未为其开通统计**(青萍与其无历史服务一致)。
|
||||
- 证据:`mibridge-research/stats-probe.mjs` / `stats-probe2.json`。
|
||||
|
||||
因此:
|
||||
|
||||
- **停机缺口可补真值**:对开通统计的型号(炎米等),停机恢复后用 `stat_day_v3` 回填缺日
|
||||
[min,max]、`stat_hour_v3` 补小时均值,`origin=vendor_history`(见 §5.3);
|
||||
- 「精确到某个刻的逐点值」仍不可得(那是设备本地日志,须走局域网 BLE/网关,见 §10);
|
||||
- 未开通统计的型号(青萍)维持「当天实时采样 + 缺口标注」。
|
||||
|
||||
## 3. 总体方案
|
||||
|
||||
- 后端新增 `server/mihome/` 模块,封装与米家的认证 + 采集 + 查询;
|
||||
- 依赖新增 `@zythum02/mijia-api`(扫码登录、`getDevicesProp`、`DiskCache`),
|
||||
认证缓存文件用 `DiskCache` 存于服务端磁盘(`.cache/`),与采集进程同机;
|
||||
- **每小时**采样 1 次写小时档(`MihomeEnvHour`),**每日 03:00** (Asia/Shanghai) 聚合日档
|
||||
(均值/min/max)+ 读一次电量;服务启动时做**追赶同步**(缺小时/缺天则按序补齐),
|
||||
停机跨天的历史缺口由 `vendorHistoryFiller` 用 `/v2/user/statistics` 真实回填(有统计的型号);
|
||||
- 前端 `MonitorPage` 增加 tab,`antd-mobile Collapse`(手风琴)+ `recharts` 趋势图,
|
||||
别名用 `Dialog.Prompt` 编辑;每设备卡片标题=别名+电量,展开后 `Segmented` 切换
|
||||
**今天(时)/月(天)/年(周)** 三档视图;
|
||||
- 数据永久保存,小时档按 `(deviceId, hour)`、日档按 `(deviceId, date)` 唯一,天然防重。
|
||||
|
||||
## 4. 数据模型(Prisma)
|
||||
|
||||
```prisma
|
||||
// ===== 环境监控:米家 =====
|
||||
|
||||
// 米家授权状态(DiskCache 存凭证, 本表只存状态/错误供前端展示)
|
||||
model MihomeAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique @default("default")
|
||||
userId String @default("")
|
||||
status String @default("unknown") // ok | pending | expired
|
||||
lastError String @default("")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model MihomeHome {
|
||||
id Int @id @default(autoincrement())
|
||||
homeId String @unique
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
devices MihomeDevice[]
|
||||
}
|
||||
|
||||
model MihomeDevice {
|
||||
id Int @id @default(autoincrement())
|
||||
did String @unique
|
||||
homeId String
|
||||
home MihomeHome @relation(fields: [homeId], references: [homeId])
|
||||
model String
|
||||
name String
|
||||
alias String? // 用户别名, 仅本应用展示
|
||||
tempSiid Int
|
||||
tempPiid Int
|
||||
humSiid Int
|
||||
humPiid Int
|
||||
battSiid Int // 电池电量属性(如炎米3.1003 / 青萍2.3)
|
||||
battPiid Int
|
||||
battery Int? // 最近一次电量(每天读一次, 标题展示用)
|
||||
batteryAt DateTime? // 电量读取时间(当天缓存, 不频繁更新)
|
||||
enabled Boolean @default(true)
|
||||
lastSyncAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
samples MihomeEnvSample[]
|
||||
hours MihomeEnvHour[]
|
||||
}
|
||||
|
||||
model MihomeEnvSample {
|
||||
id Int @id @default(autoincrement())
|
||||
deviceId Int
|
||||
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
date DateTime // 采样日(Asia/Shanghai 当日 00:00 存 UTC)
|
||||
temperature Float? // 当日最后一次 live 值(或 vendor hour 均值)
|
||||
humidity Float?
|
||||
temperatureAvg Float? // 当日均值(由小时档聚合, 或 vendor_hour)
|
||||
humidityAvg Float?
|
||||
temperatureMin Float? // 当日最低
|
||||
temperatureMax Float? // 当日最高
|
||||
humidityMin Float?
|
||||
humidityMax Float?
|
||||
origin String @default("live") // live | vendor_history | backfill
|
||||
sampledAt DateTime?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([deviceId, date]) // 一天一条, 防重
|
||||
@@index([deviceId, date])
|
||||
}
|
||||
|
||||
model MihomeEnvHour {
|
||||
id Int @id @default(autoincrement())
|
||||
deviceId Int
|
||||
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
hour DateTime // Asia/Shanghai 整点(存 UTC)
|
||||
temperatureAvg Float? // 该小时均值(live 采样点聚合, 或 vendor_hour)
|
||||
humidityAvg Float?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([deviceId, hour]) // 一小时一条, 防重
|
||||
@@index([deviceId, hour])
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
- 凭证不进库,只在密钥盘(`server/mihome/.cache/`,需加入 `.gitignore`);
|
||||
- `MihomeHome` / `MihomeDevice` 随每次同步 **upsert**(以米家为准,重扫不漏不错);
|
||||
- 存储分三档:**小时档**(当天视图,逐小时)、**日档**(月视图,日均/min/max)、
|
||||
**年视图按日档现场聚合周桶**(不落库,见 §5.6);
|
||||
- 电量只存在 `MihomeDevice.battery/batteryAt`,每天读 1 次覆盖当天展示用(需求 11);
|
||||
- `MihomeEnvSample.origin`:
|
||||
- `live`——定时采样的真实值(小时档写入,日档由小时聚合);
|
||||
- `vendor_history`——经 `/v2/user/statistics` 拉回的云端聚合历史(min/max/hour 均值);
|
||||
- `backfill`——停机恢复后对**当天**的补记(仍用真实值,与 `live` 同字段)。
|
||||
|
||||
## 5. 后端设计
|
||||
|
||||
### 5.1 代码结构
|
||||
|
||||
```
|
||||
server/mihome/
|
||||
index.js # 模块入口: 初始化 MijiaAPI + DiskCache, 导出 service
|
||||
auth.js # 认证管理 + 扫码流程(后台 QRlogin 循环 + pendingQr 状态机)
|
||||
collector.js # 采集器: 每小时采样 / 每日聚合 / 追赶同步 / 电量 / 设备发现
|
||||
vendorStats.js # /v2/user/statistics 封装(按型号探测可用性, 提供 hour/day 回填)
|
||||
history.js # 查询聚合: today(时) / month(天) / year(周)
|
||||
cache.js # DiskCache 路径与清理
|
||||
server/routes/mihome.js # REST 路由 (authRequired)
|
||||
```
|
||||
|
||||
### 5.2 认证与「重新授权」
|
||||
|
||||
- 启动时 `login()` 自动尝试加载/刷新 `DiskCache` 凭证;成功则 `status=ok`。
|
||||
- 失败(30 天过期 / 风控)→ `MihomeAccount.status=expired`,前端弹授权横幅。
|
||||
- 重新授权流程(服务端驱动,前端轮询):
|
||||
|
||||
```
|
||||
POST /api/mihome/reauthorize
|
||||
└─ 若 pendingQr 空闲 => 后台启动 QRlogin 循环(每轮 120s 超时自动续二维码)
|
||||
└─ 立即返回 { state, qrImageUrl }
|
||||
|
||||
GET /api/mihome/reauthorize/status
|
||||
└─ 返回 { state: idle|waiting|scanning|ok|failed, qrImageUrl?, lastError? }
|
||||
└─ 扫码成功(qrlogin 返回) => 保存凭证 => 状态 ok => 触发一次立即同步
|
||||
|
||||
POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
|
||||
```
|
||||
|
||||
- `pendingQr` 为模块级单例,防并发(同一时间只允许一个扫码流程)。
|
||||
- 前端在弹窗内 `setInterval 3s` 轮询直到 `ok`,成功后关弹窗并刷新列表。
|
||||
|
||||
### 5.3 采集器 collector
|
||||
|
||||
1. **预检**:`whoami().login` 不为真 → 置 `expired`,本轮跳过(不产生记录)。
|
||||
2. **设备发现**:`getDevicesList()` → upsert 家庭/设备;仅保留具备
|
||||
`temperature` + `relative-humidity` 属性的型号(经 `getDeviceInfo`/miot-spec 判定,
|
||||
当前命中 `ym001.sensor_ht.ymwsdj`、`cgllc.clock.dove` 白名单);记录其 `battSiid/battPiid`。
|
||||
3. **小时采样(每小时)**:批量 `getDevicesProp` 读温度+湿度 → upsert
|
||||
`MihomeEnvHour(deviceId, hour=当前整点)`,写 `temperatureAvg/humidityAvg`(此小时第一个有效点
|
||||
即视为小时值)。每台独立 try/catch:
|
||||
- 设备离线/超时 → 该小时不写(缺口保持);
|
||||
- 网络/API 异常 → 整体捕获,本轮不误标,留待下次。
|
||||
4. **电量(每天 1 次)**:每日首轮采样顺带读 `battery-level` → 写
|
||||
`MihomeDevice.battery/batteryAt`(当天缓存,标题展示用,不频繁更新)。读不到不报错。
|
||||
5. **每日聚合(03:00 + 启动追赶)**: upsert `MihomeEnvSample(deviceId, date)`:
|
||||
- `temperatureAvg/humidityAvg` = 当日各小时均值;`temperatureMin/Max`、
|
||||
`humidityMin/Max` = 当日小时 min/max;`temperature/humidity` = 当日最后一次 live 值;
|
||||
- 当日采样小时数 < 8h 且设备在线的 → 判定硬件故障?**否**:仅当设备整天在线的阈值外仍缺,
|
||||
视为设备离线缺档(保持缺口,不伪造)。
|
||||
6. **追赶同步(补齐)**:每次轮询/同步结束后检查——对每个设备
|
||||
「缺小时档且采集入口健康」→ 按序补齐小时档;「今天就无样本」→ 立即执行每日聚合。
|
||||
服务启动时触发一轮,实现**停机恢复当天即补**。
|
||||
7. **历史补齐(vendorHistoryFiller / vendorStats.js)**:每台设备先探测一次各 data_type
|
||||
可用性(实测:炎米 ✅ / 青萍、VRF ❌`-8 invalid params`),仅对可用型号扫描近 45 天缺口:
|
||||
- 缺小时 → `stat_hour_v3` 回填 `MihomeEnvHour.temperatureAvg/humidityAvg`;
|
||||
- 缺天 → `stat_day_v3` 回填日档 min/max,再由小时档均值回填 avg;
|
||||
- 命中即写 `origin=vendor_history`;对「服务端停机跨天」缺口尤其有效。
|
||||
8. **缺口标记**:`GET /status` 计算每设备最近 30 天缺样本日期列表(`missingDays`):
|
||||
今天缺=待采;历史缺=先尝试 vendor 统计补齐,补不了则标注不可得。
|
||||
|
||||
> 补数边界(诚实策略):已开通统计的型号(炎米等)停机跨天缺口可用**云端聚合真值**补齐
|
||||
> ([min,max]/小时均值,`origin=vendor_history`);未开通的型号(青萍)保持缺口标注、不伪造。
|
||||
> 「精确逐点历史」仍不可得,只能走局域网 BLE/网关(§10 可选项 C)。
|
||||
|
||||
### 5.4 路由表(均在 `/api/mihome`,`authRequired`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果) |
|
||||
| GET | `/device/:did/history?range=today` | 当天逐小时(见 §5.6);缺省 `range=today` |
|
||||
| GET | `/device/:did/history?range=month` | 近 30 天逐日(日均 + min/max) |
|
||||
| GET | `/device/:did/history?range=year` | 近 52 周逐周(周均值 + min/max,服务端按日档聚合) |
|
||||
| PUT | `/device/:did` | `{ alias }` 改别名(可清空) |
|
||||
| POST | `/sync` | 手动触发一次采集(前端「立即同步」) |
|
||||
| POST | `/reauthorize` | 开始扫码(返回二维码 URL) |
|
||||
| GET | `/reauthorize/status` | 轮询扫码状态 |
|
||||
| POST | `/reauthorize/cancel` | 取消扫码 |
|
||||
|
||||
### 5.5 定时任务(并入 `server/cron.js`)
|
||||
|
||||
- `0 * * * *`(Asia/Shanghai):小时采样(温度+湿度,每小时整点 +5min 缓冲)。
|
||||
- `5 0,3 * * *`:每日聚合 → `battery` 读取 → vendor 补齐扫描。
|
||||
- 兼容表达式:`npm run data:mihome`(手动跑一次采集器)备用。
|
||||
|
||||
### 5.6 历史查询聚合(history.js)
|
||||
|
||||
| range | 时间窗 | 粒度 | 数据源/算法 |
|
||||
| --- | --- | --- | --- |
|
||||
| `today` | 当天 00:00→now | 小时 | `MihomeEnvHour` 当日行;未来小时返回 null |
|
||||
| `month` | 近 30 天 | 天 | `MihomeEnvSample`:`temperatureAvg/humidityAvg`(折线)+ min/max(区间带) |
|
||||
| `year` | 近 52 周 | 周 | 按日档**现场聚合**:每周 `avg`(日 avg 均值)、`min`(日 min 最小)、`max`(日 max 最大),不落库 |
|
||||
|
||||
响应统一形如 `[{ t, temperature, temperatureMin, temperatureMax, humidity, humidityMin, humidityMax }]`,
|
||||
`t` 为桶起始(Asia/Shanghai),缺数据字段为 null。
|
||||
|
||||
## 6. 前端设计
|
||||
|
||||
`src/pages/MonitorPage.jsx` 的 `tabs` 增加 `{ key: 'env', label: '环境监控' }`,
|
||||
对应 `src/pages/monitor/EnvView.jsx`。
|
||||
|
||||
### EnvView 结构
|
||||
|
||||
1. **授权横幅**(`status !== ok`):
|
||||
`米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `<img src=qrImageUrl>`
|
||||
3s 轮询 `/reauthorize/status` → 成功自动关闭并刷新。
|
||||
2. **顶部操作**:`立即同步`(POST `/sync`)。
|
||||
3. **家庭列表**:`antd-mobile <Collapse accordion>` 按家庭分组;组展开后显示该家庭
|
||||
下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。
|
||||
4. **设备卡片**(不可折叠,点击进入详情/趋势):
|
||||
- 标题:**别名(无别名则原名)+ 电量**(如 `卧室 78%`,电量用 `battery` 当天缓存值,
|
||||
不频繁请求);附带编辑别名按钮(`Dialog.Prompt` → `PUT`);
|
||||
- 概览:`温度 28.1℃ · 湿度 71%`(最新样本)+ 「缺 N 天」暗红角标;
|
||||
- 图表区:`Segmented`(antd-mobile) 切换 **今天/月/年**,默认**今天**;
|
||||
`recharts LineChart` 双线(温度/湿度)+ 可选 min/max 区间带(`Area` band):
|
||||
- `今天` → X 轴逐小时(00:00→当前),数据 `/device/:did/history?range=today`;
|
||||
- `月` → X 轴逐日(近 30 天),数据 `?range=month`(日均折线 + 日 min/max 影带);
|
||||
- `年` → X 轴逐周(近 52 周),数据 `?range=year`(周均值折线 + 周 min/max 影带);
|
||||
- 视图切换/拉数据留在卡片内部(不用全局 tab),逻辑沿用现有 `ServerDetail.jsx` /
|
||||
`DataView.jsx` 图表写法。
|
||||
5. 样式复用 `globals.css` 的 `.card`/`.monitor-*` 体系,新增少量 collapse 样式。
|
||||
|
||||
> 手风琴用 `Collapse accordion`;「不同时打开」即一次仅一个家庭展开。
|
||||
|
||||
## 7. 迁移与部署
|
||||
|
||||
1. `npm i @zythum02/mijia-api`(加入 dependencies)。
|
||||
2. `schema.prisma` 追加 5 个模型 → `npx prisma migrate dev --name add_mihome_env`。
|
||||
3. `.env` 可选 `MIHOME_CACHE_DIR`(默认 `server/mihome/.cache`);
|
||||
服务端放 `.cache/` 于 `.gitignore`。
|
||||
4. 首次部署:在服务器上人工跑一次扫码授权(`npm run data:mihome` 的初始化提示
|
||||
会输出二维码),写入服务器本地的 `.cache/`。
|
||||
5. 数据库迁移走现有 `prisma migrate deploy` 流程。
|
||||
|
||||
## 8. 风险与开放问题
|
||||
|
||||
| # | 风险/问题 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **逐点历史不可读**(设备本地日志),停机期要精确到刻的值补不上 | 已找到云端聚合通道(`/v2/user/statistics`,min/max/时均值,实测可行);按模型开通情况补齐(炎米 ✅ / 青萍 ❌);精确逐点 → 可选 §10 C |
|
||||
| 2 | token 30 天过期需人工扫码 | 需求 4 的重新授权流程已覆盖;扫码流程 120s 超时自动换码 |
|
||||
| 3 | 采样时刻固定凌晨,代表性一般 | 已改**每小时采样**,当天小时视图/日均/周均代表性充分;03:00 仅做日档聚合 |
|
||||
| 4 | 第三方 `@zythum02` 库上游变动 | 锁定版本;模块边界隔离,只暴露我们自己需要的函数 |
|
||||
| 5 | VRF/其他设备写入延迟 5~6s | 仅影响将来的控制功能,本模块只读,无影响 |
|
||||
|
||||
## 9. 实施步骤(里程碑)
|
||||
|
||||
- **M1 后端地基**:依赖 + Prisma 迁移(5 模型)+ `server/mihome`
|
||||
(认证 + collector 小时采样/每日聚合/电量 + vendorStats 补齐 + history.js 三段聚合 + 路由)+ cron。
|
||||
- **M2 前端**:`EnvView`(授权弹窗 / 手风琴家庭组 / 设备卡=别名+电量 /
|
||||
别名编辑 / Segmented 今天·月·年视图与图表 / 缺口角标)。
|
||||
- **M3 联调**:真机/Web 端到端 —— 小时采样与日聚合、停机恢复追赶与 vendor 补齐、
|
||||
扫码重授权、别名、电量、三个粒度的图表与缺口展示。
|
||||
- **M4(可选,已基本闭环)**:`/v2/user/statistics` 云端聚合历史通道已实测打通,
|
||||
直接并入 M1 的 `vendorHistoryFiller`(无需再抓包 App)。
|
||||
- **M5(可选)**:可选项 C 家侧本地采样代理(见 §10),用于「精确逐点」与分钟级采样。
|
||||
|
||||
## 10. 可选项 C:家侧本地采样代理 + 缓冲补数
|
||||
|
||||
> 背景:主线「云 API 每日采样」的停机跨天缺口现可由云端聚合统计(§2.2B)补齐到 [min,max]/小时
|
||||
> 粒度;可选项 C 是把它做到**精确逐点 / 分钟级**——家侧一个常驻采集进程(可选配合小米中枢网关),
|
||||
> 本地连续采样并缓冲,服务端恢复后拉真值回填。
|
||||
> 与主线**并行可选**,不影响 §1~§9 落地;仅在追求细粒度历史时投入。
|
||||
|
||||
### 10.1 事实依据(已调研)
|
||||
|
||||
| 事实 | 结论 |
|
||||
| --- | --- |
|
||||
| 家庭现有网关 | 无中枢/多模网关;`blt.*` 设备挂在「小爱音箱 Play 增强版」上转发,该音箱**不开放局域网 API** |
|
||||
| 局域网直连设备本身 | 需本地 BLE 适配器;青萍闹钟(CGD1)协议已逆向但**仅有实时/设置**、无历史通道;炎米 ym001 **零社区支持**,需自行逆向厂商 GATT + 推导 MiKey,把握低 |
|
||||
| 若有小米中枢网关 | 官方 HA 集成走网关**内置本地 MQTT Broker**,可本地化 WiFi+BLE 设备、断外网可用、延迟 10~50ms(社区实测 `ha_xiaomi_home`);但本地 Broker **只推实时 props,不提供历史** |
|
||||
| 概率评估 | 本地实时读取:无网关走蓝牙 ~60~85%(青萍高/炎米中);加中枢网关 ~85%。补停机期历史:三条局域网路线均**不直接提供**,只能靠「本地连续采样 + 缓冲」自建 |
|
||||
|
||||
### 10.2 方案
|
||||
|
||||
```
|
||||
家庭内常驻小机(现有 Windows 机 / 树莓派) 跑 server/mihome-agent/
|
||||
├─ 采样源(可切换): a) 云 getDevicesProp (同 §5.3, 无需新硬件)
|
||||
│ b) 加中枢网关后走本地 MQTT(断外网可用, 秒级粒度)
|
||||
│ c) 未来本地 BLE 嗅探(青萍 CGD1 优先)
|
||||
├─ 每 N 分钟采样 → 写本地缓冲 SQLite/JSONL(本地保留 180 天)
|
||||
└─ 提供 GET /samples?from&to&did 供服务端拉取
|
||||
服务端(iboard)
|
||||
└─ 停机恢复/每日同步时: 调 agent 拉缺口区间真实值 → 写 MihomeEnvSample(origin=backfill)
|
||||
(服务端主动拉 agent,agent 无需入公网,可用内网/隧道)
|
||||
```
|
||||
|
||||
### 10.3 效果与代价
|
||||
|
||||
- **效果**:停机期 agent 缓冲持续采样,恢复后以**精确逐点/分钟级**真值回填(粒度高于 §2.2B 的
|
||||
聚合统计);加中枢网关后数据链路断外网可用、秒级推送。
|
||||
- **代价**:家中常驻进程(可复用现有 Windows 开发机);可选购买中枢网关(~¥349);
|
||||
agent 与服务端的传输通道需内网/VPN/隧道。
|
||||
- **决策点**:是否投入属产品决策。当前「补齐停机缺口」已被 §2.2B 的云端统计覆盖(满足
|
||||
「数据不比米家少」);C 只对「要更细粒度」有增量价值。接入点:agent 值写入
|
||||
`MihomeEnvSample`,`origin=backfill`。
|
||||
- **本地直连协议现状**(中枢/蓝牙):官方 HA 集成走中枢内置 MQTT Broker(需 HA 或移植其
|
||||
`MipsLocalClient` 的证书鉴权+微链路,无公开协议,自有 Node agent 直连把握中低);纯 BLE
|
||||
嗅探见 §10.1 表格(青萍 CGD1 有逆向基础、炎米零资料)。
|
||||
@@ -82,3 +82,28 @@ export type ScraperCache = Prisma.ScraperCacheModel
|
||||
*
|
||||
*/
|
||||
export type OilPriceHistory = Prisma.OilPriceHistoryModel
|
||||
/**
|
||||
* Model MihomeAccount
|
||||
*
|
||||
*/
|
||||
export type MihomeAccount = Prisma.MihomeAccountModel
|
||||
/**
|
||||
* Model MihomeHome
|
||||
*
|
||||
*/
|
||||
export type MihomeHome = Prisma.MihomeHomeModel
|
||||
/**
|
||||
* Model MihomeDevice
|
||||
*
|
||||
*/
|
||||
export type MihomeDevice = Prisma.MihomeDeviceModel
|
||||
/**
|
||||
* Model MihomeEnvSample
|
||||
*
|
||||
*/
|
||||
export type MihomeEnvSample = Prisma.MihomeEnvSampleModel
|
||||
/**
|
||||
* Model MihomeEnvHour
|
||||
*
|
||||
*/
|
||||
export type MihomeEnvHour = Prisma.MihomeEnvHourModel
|
||||
|
||||
@@ -106,3 +106,28 @@ export type ScraperCache = Prisma.ScraperCacheModel
|
||||
*
|
||||
*/
|
||||
export type OilPriceHistory = Prisma.OilPriceHistoryModel
|
||||
/**
|
||||
* Model MihomeAccount
|
||||
*
|
||||
*/
|
||||
export type MihomeAccount = Prisma.MihomeAccountModel
|
||||
/**
|
||||
* Model MihomeHome
|
||||
*
|
||||
*/
|
||||
export type MihomeHome = Prisma.MihomeHomeModel
|
||||
/**
|
||||
* Model MihomeDevice
|
||||
*
|
||||
*/
|
||||
export type MihomeDevice = Prisma.MihomeDeviceModel
|
||||
/**
|
||||
* Model MihomeEnvSample
|
||||
*
|
||||
*/
|
||||
export type MihomeEnvSample = Prisma.MihomeEnvSampleModel
|
||||
/**
|
||||
* Model MihomeEnvHour
|
||||
*
|
||||
*/
|
||||
export type MihomeEnvHour = Prisma.MihomeEnvHourModel
|
||||
|
||||
@@ -306,6 +306,85 @@ export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | null
|
||||
notIn?: number[] | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | null
|
||||
notIn?: Date[] | string[] | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | null
|
||||
notIn?: number[] | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | null
|
||||
notIn?: Date[] | string[] | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type FloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | null
|
||||
notIn?: number[] | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | null
|
||||
notIn?: number[] | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[]
|
||||
@@ -550,4 +629,72 @@ export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | null
|
||||
notIn?: Date[] | string[] | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | null
|
||||
notIn?: number[] | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | null
|
||||
notIn?: number[] | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | null
|
||||
notIn?: Date[] | string[] | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | null
|
||||
notIn?: number[] | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -396,7 +396,12 @@ export const ModelName = {
|
||||
ServiceCheck: 'ServiceCheck',
|
||||
Econ_SowInventory: 'Econ_SowInventory',
|
||||
ScraperCache: 'ScraperCache',
|
||||
OilPriceHistory: 'OilPriceHistory'
|
||||
OilPriceHistory: 'OilPriceHistory',
|
||||
MihomeAccount: 'MihomeAccount',
|
||||
MihomeHome: 'MihomeHome',
|
||||
MihomeDevice: 'MihomeDevice',
|
||||
MihomeEnvSample: 'MihomeEnvSample',
|
||||
MihomeEnvHour: 'MihomeEnvHour'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -412,7 +417,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory"
|
||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -1274,6 +1279,336 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
MihomeAccount: {
|
||||
payload: Prisma.$MihomeAccountPayload<ExtArgs>
|
||||
fields: Prisma.MihomeAccountFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.MihomeAccountFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.MihomeAccountFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.MihomeAccountFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.MihomeAccountFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.MihomeAccountFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.MihomeAccountCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.MihomeAccountCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.MihomeAccountDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.MihomeAccountUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.MihomeAccountDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.MihomeAccountUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.MihomeAccountUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeAccountPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.MihomeAccountAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeAccount>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.MihomeAccountGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeAccountGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.MihomeAccountCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeAccountCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
MihomeHome: {
|
||||
payload: Prisma.$MihomeHomePayload<ExtArgs>
|
||||
fields: Prisma.MihomeHomeFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.MihomeHomeFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.MihomeHomeFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.MihomeHomeFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.MihomeHomeFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.MihomeHomeFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.MihomeHomeCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.MihomeHomeCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.MihomeHomeDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.MihomeHomeUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.MihomeHomeDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.MihomeHomeUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.MihomeHomeUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeHomePayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.MihomeHomeAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeHome>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.MihomeHomeGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeHomeGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.MihomeHomeCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeHomeCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
MihomeDevice: {
|
||||
payload: Prisma.$MihomeDevicePayload<ExtArgs>
|
||||
fields: Prisma.MihomeDeviceFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.MihomeDeviceFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.MihomeDeviceFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.MihomeDeviceFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.MihomeDeviceFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.MihomeDeviceFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.MihomeDeviceCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.MihomeDeviceCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.MihomeDeviceDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.MihomeDeviceUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.MihomeDeviceDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.MihomeDeviceUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.MihomeDeviceUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeDevicePayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.MihomeDeviceAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeDevice>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.MihomeDeviceGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeDeviceGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.MihomeDeviceCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeDeviceCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
MihomeEnvSample: {
|
||||
payload: Prisma.$MihomeEnvSamplePayload<ExtArgs>
|
||||
fields: Prisma.MihomeEnvSampleFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.MihomeEnvSampleFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.MihomeEnvSampleFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.MihomeEnvSampleFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.MihomeEnvSampleFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.MihomeEnvSampleFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.MihomeEnvSampleCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.MihomeEnvSampleCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.MihomeEnvSampleDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.MihomeEnvSampleUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.MihomeEnvSampleDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.MihomeEnvSampleUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.MihomeEnvSampleUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvSamplePayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.MihomeEnvSampleAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeEnvSample>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.MihomeEnvSampleGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeEnvSampleGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.MihomeEnvSampleCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeEnvSampleCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
MihomeEnvHour: {
|
||||
payload: Prisma.$MihomeEnvHourPayload<ExtArgs>
|
||||
fields: Prisma.MihomeEnvHourFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.MihomeEnvHourFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.MihomeEnvHourFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.MihomeEnvHourFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.MihomeEnvHourFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.MihomeEnvHourFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.MihomeEnvHourCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.MihomeEnvHourCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.MihomeEnvHourDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.MihomeEnvHourUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.MihomeEnvHourDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.MihomeEnvHourUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.MihomeEnvHourUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$MihomeEnvHourPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.MihomeEnvHourAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateMihomeEnvHour>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.MihomeEnvHourGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeEnvHourGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.MihomeEnvHourCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.MihomeEnvHourCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} & {
|
||||
other: {
|
||||
@@ -1510,6 +1845,85 @@ export const OilPriceHistoryScalarFieldEnum = {
|
||||
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeAccountScalarFieldEnum = {
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
userId: 'userId',
|
||||
status: 'status',
|
||||
lastError: 'lastError',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type MihomeAccountScalarFieldEnum = (typeof MihomeAccountScalarFieldEnum)[keyof typeof MihomeAccountScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeHomeScalarFieldEnum = {
|
||||
id: 'id',
|
||||
homeId: 'homeId',
|
||||
name: 'name',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type MihomeHomeScalarFieldEnum = (typeof MihomeHomeScalarFieldEnum)[keyof typeof MihomeHomeScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeDeviceScalarFieldEnum = {
|
||||
id: 'id',
|
||||
did: 'did',
|
||||
homeId: 'homeId',
|
||||
model: 'model',
|
||||
name: 'name',
|
||||
alias: 'alias',
|
||||
tempSiid: 'tempSiid',
|
||||
tempPiid: 'tempPiid',
|
||||
humSiid: 'humSiid',
|
||||
humPiid: 'humPiid',
|
||||
battSiid: 'battSiid',
|
||||
battPiid: 'battPiid',
|
||||
battery: 'battery',
|
||||
batteryAt: 'batteryAt',
|
||||
enabled: 'enabled',
|
||||
lastSyncAt: 'lastSyncAt',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type MihomeDeviceScalarFieldEnum = (typeof MihomeDeviceScalarFieldEnum)[keyof typeof MihomeDeviceScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeEnvSampleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
deviceId: 'deviceId',
|
||||
date: 'date',
|
||||
temperature: 'temperature',
|
||||
humidity: 'humidity',
|
||||
temperatureAvg: 'temperatureAvg',
|
||||
humidityAvg: 'humidityAvg',
|
||||
temperatureMin: 'temperatureMin',
|
||||
temperatureMax: 'temperatureMax',
|
||||
humidityMin: 'humidityMin',
|
||||
humidityMax: 'humidityMax',
|
||||
origin: 'origin',
|
||||
sampledAt: 'sampledAt',
|
||||
raw: 'raw',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type MihomeEnvSampleScalarFieldEnum = (typeof MihomeEnvSampleScalarFieldEnum)[keyof typeof MihomeEnvSampleScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeEnvHourScalarFieldEnum = {
|
||||
id: 'id',
|
||||
deviceId: 'deviceId',
|
||||
hour: 'hour',
|
||||
temperatureAvg: 'temperatureAvg',
|
||||
humidityAvg: 'humidityAvg',
|
||||
raw: 'raw',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type MihomeEnvHourScalarFieldEnum = (typeof MihomeEnvHourScalarFieldEnum)[keyof typeof MihomeEnvHourScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -1678,6 +2092,42 @@ export const OilPriceHistoryOrderByRelevanceFieldEnum = {
|
||||
export type OilPriceHistoryOrderByRelevanceFieldEnum = (typeof OilPriceHistoryOrderByRelevanceFieldEnum)[keyof typeof OilPriceHistoryOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const MihomeAccountOrderByRelevanceFieldEnum = {
|
||||
key: 'key',
|
||||
userId: 'userId',
|
||||
status: 'status',
|
||||
lastError: 'lastError'
|
||||
} as const
|
||||
|
||||
export type MihomeAccountOrderByRelevanceFieldEnum = (typeof MihomeAccountOrderByRelevanceFieldEnum)[keyof typeof MihomeAccountOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const MihomeHomeOrderByRelevanceFieldEnum = {
|
||||
homeId: 'homeId',
|
||||
name: 'name'
|
||||
} as const
|
||||
|
||||
export type MihomeHomeOrderByRelevanceFieldEnum = (typeof MihomeHomeOrderByRelevanceFieldEnum)[keyof typeof MihomeHomeOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const MihomeDeviceOrderByRelevanceFieldEnum = {
|
||||
did: 'did',
|
||||
homeId: 'homeId',
|
||||
model: 'model',
|
||||
name: 'name',
|
||||
alias: 'alias'
|
||||
} as const
|
||||
|
||||
export type MihomeDeviceOrderByRelevanceFieldEnum = (typeof MihomeDeviceOrderByRelevanceFieldEnum)[keyof typeof MihomeDeviceOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const MihomeEnvSampleOrderByRelevanceFieldEnum = {
|
||||
origin: 'origin'
|
||||
} as const
|
||||
|
||||
export type MihomeEnvSampleOrderByRelevanceFieldEnum = (typeof MihomeEnvSampleOrderByRelevanceFieldEnum)[keyof typeof MihomeEnvSampleOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Field references
|
||||
@@ -1847,6 +2297,11 @@ export type GlobalOmitConfig = {
|
||||
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
|
||||
scraperCache?: Prisma.ScraperCacheOmit
|
||||
oilPriceHistory?: Prisma.OilPriceHistoryOmit
|
||||
mihomeAccount?: Prisma.MihomeAccountOmit
|
||||
mihomeHome?: Prisma.MihomeHomeOmit
|
||||
mihomeDevice?: Prisma.MihomeDeviceOmit
|
||||
mihomeEnvSample?: Prisma.MihomeEnvSampleOmit
|
||||
mihomeEnvHour?: Prisma.MihomeEnvHourOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
||||
@@ -63,7 +63,12 @@ export const ModelName = {
|
||||
ServiceCheck: 'ServiceCheck',
|
||||
Econ_SowInventory: 'Econ_SowInventory',
|
||||
ScraperCache: 'ScraperCache',
|
||||
OilPriceHistory: 'OilPriceHistory'
|
||||
OilPriceHistory: 'OilPriceHistory',
|
||||
MihomeAccount: 'MihomeAccount',
|
||||
MihomeHome: 'MihomeHome',
|
||||
MihomeDevice: 'MihomeDevice',
|
||||
MihomeEnvSample: 'MihomeEnvSample',
|
||||
MihomeEnvHour: 'MihomeEnvHour'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -279,6 +284,85 @@ export const OilPriceHistoryScalarFieldEnum = {
|
||||
export type OilPriceHistoryScalarFieldEnum = (typeof OilPriceHistoryScalarFieldEnum)[keyof typeof OilPriceHistoryScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeAccountScalarFieldEnum = {
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
userId: 'userId',
|
||||
status: 'status',
|
||||
lastError: 'lastError',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type MihomeAccountScalarFieldEnum = (typeof MihomeAccountScalarFieldEnum)[keyof typeof MihomeAccountScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeHomeScalarFieldEnum = {
|
||||
id: 'id',
|
||||
homeId: 'homeId',
|
||||
name: 'name',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type MihomeHomeScalarFieldEnum = (typeof MihomeHomeScalarFieldEnum)[keyof typeof MihomeHomeScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeDeviceScalarFieldEnum = {
|
||||
id: 'id',
|
||||
did: 'did',
|
||||
homeId: 'homeId',
|
||||
model: 'model',
|
||||
name: 'name',
|
||||
alias: 'alias',
|
||||
tempSiid: 'tempSiid',
|
||||
tempPiid: 'tempPiid',
|
||||
humSiid: 'humSiid',
|
||||
humPiid: 'humPiid',
|
||||
battSiid: 'battSiid',
|
||||
battPiid: 'battPiid',
|
||||
battery: 'battery',
|
||||
batteryAt: 'batteryAt',
|
||||
enabled: 'enabled',
|
||||
lastSyncAt: 'lastSyncAt',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type MihomeDeviceScalarFieldEnum = (typeof MihomeDeviceScalarFieldEnum)[keyof typeof MihomeDeviceScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeEnvSampleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
deviceId: 'deviceId',
|
||||
date: 'date',
|
||||
temperature: 'temperature',
|
||||
humidity: 'humidity',
|
||||
temperatureAvg: 'temperatureAvg',
|
||||
humidityAvg: 'humidityAvg',
|
||||
temperatureMin: 'temperatureMin',
|
||||
temperatureMax: 'temperatureMax',
|
||||
humidityMin: 'humidityMin',
|
||||
humidityMax: 'humidityMax',
|
||||
origin: 'origin',
|
||||
sampledAt: 'sampledAt',
|
||||
raw: 'raw',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type MihomeEnvSampleScalarFieldEnum = (typeof MihomeEnvSampleScalarFieldEnum)[keyof typeof MihomeEnvSampleScalarFieldEnum]
|
||||
|
||||
|
||||
export const MihomeEnvHourScalarFieldEnum = {
|
||||
id: 'id',
|
||||
deviceId: 'deviceId',
|
||||
hour: 'hour',
|
||||
temperatureAvg: 'temperatureAvg',
|
||||
humidityAvg: 'humidityAvg',
|
||||
raw: 'raw',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type MihomeEnvHourScalarFieldEnum = (typeof MihomeEnvHourScalarFieldEnum)[keyof typeof MihomeEnvHourScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -446,3 +530,39 @@ export const OilPriceHistoryOrderByRelevanceFieldEnum = {
|
||||
|
||||
export type OilPriceHistoryOrderByRelevanceFieldEnum = (typeof OilPriceHistoryOrderByRelevanceFieldEnum)[keyof typeof OilPriceHistoryOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const MihomeAccountOrderByRelevanceFieldEnum = {
|
||||
key: 'key',
|
||||
userId: 'userId',
|
||||
status: 'status',
|
||||
lastError: 'lastError'
|
||||
} as const
|
||||
|
||||
export type MihomeAccountOrderByRelevanceFieldEnum = (typeof MihomeAccountOrderByRelevanceFieldEnum)[keyof typeof MihomeAccountOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const MihomeHomeOrderByRelevanceFieldEnum = {
|
||||
homeId: 'homeId',
|
||||
name: 'name'
|
||||
} as const
|
||||
|
||||
export type MihomeHomeOrderByRelevanceFieldEnum = (typeof MihomeHomeOrderByRelevanceFieldEnum)[keyof typeof MihomeHomeOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const MihomeDeviceOrderByRelevanceFieldEnum = {
|
||||
did: 'did',
|
||||
homeId: 'homeId',
|
||||
model: 'model',
|
||||
name: 'name',
|
||||
alias: 'alias'
|
||||
} as const
|
||||
|
||||
export type MihomeDeviceOrderByRelevanceFieldEnum = (typeof MihomeDeviceOrderByRelevanceFieldEnum)[keyof typeof MihomeDeviceOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const MihomeEnvSampleOrderByRelevanceFieldEnum = {
|
||||
origin: 'origin'
|
||||
} as const
|
||||
|
||||
export type MihomeEnvSampleOrderByRelevanceFieldEnum = (typeof MihomeEnvSampleOrderByRelevanceFieldEnum)[keyof typeof MihomeEnvSampleOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
@@ -21,4 +21,9 @@ export type * from './models/ServiceCheck.ts'
|
||||
export type * from './models/Econ_SowInventory.ts'
|
||||
export type * from './models/ScraperCache.ts'
|
||||
export type * from './models/OilPriceHistory.ts'
|
||||
export type * from './models/MihomeAccount.ts'
|
||||
export type * from './models/MihomeHome.ts'
|
||||
export type * from './models/MihomeDevice.ts'
|
||||
export type * from './models/MihomeEnvSample.ts'
|
||||
export type * from './models/MihomeEnvHour.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+1023
-458
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "iboard",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
@@ -13,6 +13,7 @@
|
||||
"start": "node --experimental-strip-types --no-warnings server/index.js",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:seed": "node scripts/seed.cjs",
|
||||
"registry:sync": "node scripts/sync-registry.mjs",
|
||||
"data:refresh": "node --no-warnings scripts/refresh-indicators.cjs",
|
||||
"data:discover": "node --no-warnings scripts/discover-mcp-tools.cjs",
|
||||
"icons": "node scripts/generate-icons.mjs",
|
||||
@@ -24,13 +25,16 @@
|
||||
"tauri:android:apk": "tauri android build --apk",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"data:news": "node --no-warnings scripts/refresh-news.cjs",
|
||||
"data:mihome": "node --experimental-strip-types --no-warnings scripts/mihome-cli.mjs",
|
||||
"data:mihome:login": "node --experimental-strip-types --no-warnings scripts/mihome-login.mjs",
|
||||
"publish": "bash scripts/wsl/build-apk.sh release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huolala-tech/page-spy": "^1.5.7",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@zythum02/mijia-api": "^0.1.4",
|
||||
"antd-mobile": "^5.42.3",
|
||||
"axios": "^1.15.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
@@ -51,7 +55,6 @@
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"axios": "^1.15.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"csv-parser": "^3.2.0",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `MihomeAccount` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`key` VARCHAR(191) NOT NULL DEFAULT 'default',
|
||||
`userId` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`status` VARCHAR(191) NOT NULL DEFAULT 'unknown',
|
||||
`lastError` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `MihomeAccount_key_key`(`key`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `MihomeHome` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`homeId` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `MihomeHome_homeId_key`(`homeId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `MihomeDevice` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`did` VARCHAR(191) NOT NULL,
|
||||
`homeId` VARCHAR(191) NOT NULL,
|
||||
`model` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`alias` VARCHAR(191) NULL,
|
||||
`tempSiid` INTEGER NOT NULL,
|
||||
`tempPiid` INTEGER NOT NULL,
|
||||
`humSiid` INTEGER NOT NULL,
|
||||
`humPiid` INTEGER NOT NULL,
|
||||
`battSiid` INTEGER NOT NULL,
|
||||
`battPiid` INTEGER NOT NULL,
|
||||
`battery` INTEGER NULL,
|
||||
`batteryAt` DATETIME(3) NULL,
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT true,
|
||||
`lastSyncAt` DATETIME(3) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `MihomeDevice_did_key`(`did`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `MihomeEnvSample` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`deviceId` INTEGER NOT NULL,
|
||||
`date` DATETIME(3) NOT NULL,
|
||||
`temperature` DOUBLE NULL,
|
||||
`humidity` DOUBLE NULL,
|
||||
`temperatureAvg` DOUBLE NULL,
|
||||
`humidityAvg` DOUBLE NULL,
|
||||
`temperatureMin` DOUBLE NULL,
|
||||
`temperatureMax` DOUBLE NULL,
|
||||
`humidityMin` DOUBLE NULL,
|
||||
`humidityMax` DOUBLE NULL,
|
||||
`origin` VARCHAR(191) NOT NULL DEFAULT 'live',
|
||||
`sampledAt` DATETIME(3) NULL,
|
||||
`raw` JSON NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `MihomeEnvSample_deviceId_date_idx`(`deviceId`, `date`),
|
||||
UNIQUE INDEX `MihomeEnvSample_deviceId_date_key`(`deviceId`, `date`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `MihomeEnvHour` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`deviceId` INTEGER NOT NULL,
|
||||
`hour` DATETIME(3) NOT NULL,
|
||||
`temperatureAvg` DOUBLE NULL,
|
||||
`humidityAvg` DOUBLE NULL,
|
||||
`raw` JSON NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `MihomeEnvHour_deviceId_hour_idx`(`deviceId`, `hour`),
|
||||
UNIQUE INDEX `MihomeEnvHour_deviceId_hour_key`(`deviceId`, `hour`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `MihomeDevice` ADD CONSTRAINT `MihomeDevice_homeId_fkey` FOREIGN KEY (`homeId`) REFERENCES `MihomeHome`(`homeId`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `MihomeEnvSample` ADD CONSTRAINT `MihomeEnvSample_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `MihomeDevice`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `MihomeEnvHour` ADD CONSTRAINT `MihomeEnvHour_deviceId_fkey` FOREIGN KEY (`deviceId`) REFERENCES `MihomeDevice`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -200,3 +200,85 @@ model OilPriceHistory {
|
||||
}
|
||||
|
||||
|
||||
// ==================== 环境监控:米家 ====================
|
||||
|
||||
// 米家授权状态 (DiskCache 存凭证, 本表只存状态/错误供前端展示)
|
||||
model MihomeAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique @default("default")
|
||||
userId String @default("")
|
||||
status String @default("unknown") // ok | pending | expired
|
||||
lastError String @default("")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model MihomeHome {
|
||||
id Int @id @default(autoincrement())
|
||||
homeId String @unique
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
devices MihomeDevice[]
|
||||
}
|
||||
|
||||
model MihomeDevice {
|
||||
id Int @id @default(autoincrement())
|
||||
did String @unique
|
||||
homeId String
|
||||
home MihomeHome @relation(fields: [homeId], references: [homeId])
|
||||
model String
|
||||
name String
|
||||
alias String? // 用户别名, 仅本应用展示
|
||||
tempSiid Int
|
||||
tempPiid Int
|
||||
humSiid Int
|
||||
humPiid Int
|
||||
battSiid Int // 电池电量属性 (炎米 3.1003 / 青萍 2.3)
|
||||
battPiid Int
|
||||
battery Int? // 最近一次电量 (每天读一次, 标题展示用)
|
||||
batteryAt DateTime? // 电量读取时间 (当天缓存, 不频繁更新)
|
||||
enabled Boolean @default(true)
|
||||
lastSyncAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
samples MihomeEnvSample[]
|
||||
hours MihomeEnvHour[]
|
||||
}
|
||||
|
||||
// 日档: 月视图(逐日)数据源; unique (deviceId, date) 防重
|
||||
model MihomeEnvSample {
|
||||
id Int @id @default(autoincrement())
|
||||
deviceId Int
|
||||
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
date DateTime // 采样日 (Asia/Shanghai 当日 00:00 存 UTC)
|
||||
temperature Float? // 当日最后一次实时值 (或 vendor hour 均值)
|
||||
humidity Float?
|
||||
temperatureAvg Float? // 当日均值 (由小时档聚合, 或 vendor_hour)
|
||||
humidityAvg Float?
|
||||
temperatureMin Float? // 当日最低
|
||||
temperatureMax Float? // 当日最高
|
||||
humidityMin Float?
|
||||
humidityMax Float?
|
||||
origin String @default("live") // live | vendor_history | backfill
|
||||
sampledAt DateTime?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([deviceId, date])
|
||||
@@index([deviceId, date])
|
||||
}
|
||||
|
||||
// 小时档: 当天视图(逐小时)数据源; unique (deviceId, hour) 防重
|
||||
model MihomeEnvHour {
|
||||
id Int @id @default(autoincrement())
|
||||
deviceId Int
|
||||
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
hour DateTime // Asia/Shanghai 整点 (存 UTC)
|
||||
temperatureAvg Float? // 该小时均值 (live 采样点, 或 vendor_hour)
|
||||
humidityAvg Float?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([deviceId, hour])
|
||||
@@index([deviceId, hour])
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# iBoard
|
||||
用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||
|
||||
## 版本管理 (V0.1)
|
||||
|
||||
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.1.0`)。
|
||||
发版时只改这一处,以下位置必须保持一致(改版本时一并核对):
|
||||
|
||||
| 文件 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
|
||||
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) |
|
||||
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
|
||||
|
||||
说明:
|
||||
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。
|
||||
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
|
||||
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
|
||||
|
||||
- 首页主要是放经济文章
|
||||
- 数据主要是放各种经济数据和图表。
|
||||
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI 专用: 为 Tauri Android release 构建注入 release 签名配置
|
||||
# 在 `tauri android init` 之后、`tauri android build` 之前运行。
|
||||
#
|
||||
# 依赖 (由调用方注入环境变量, 不要写死密钥到脚本/仓库):
|
||||
# ANDROID_KEYSTORE_B64 - base64 编码的 keystore 文件内容 (CI secret)
|
||||
# ANDROID_KEY_ALIAS - 密钥别名 (CI secret/variable)
|
||||
# ANDROID_KEY_PASSWORD - store & key 密码 (CI secret; 本脚本两者用同一个)
|
||||
#
|
||||
# 作用:
|
||||
# 1. 把 base64 密钥解码写到 $RUNNER_TEMP/iboard-release.keystore
|
||||
# 2. 生成 gen/android/keystore.properties (Tauri 签名约定读取此文件)
|
||||
# 3. 修改 gen/android/app/build.gradle.kts, 添加 signingConfigs.release
|
||||
# 并让 buildTypes.release 使用它, 产出已签名 APK
|
||||
set -euo pipefail
|
||||
|
||||
APP_GRADLE="src-tauri/gen/android/app/build.gradle.kts"
|
||||
|
||||
echo "[signing] 注入 Android release 签名配置..."
|
||||
|
||||
# --- 1. 解码 keystore ---
|
||||
if [ -z "${ANDROID_KEYSTORE_B64:-}" ]; then
|
||||
echo "[signing] ERROR: ANDROID_KEYSTORE_B64 未设置, 无法签名 release APK" >&2
|
||||
exit 1
|
||||
fi
|
||||
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS 未设置}"
|
||||
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD 未设置}"
|
||||
|
||||
KEYSTORE_FILE="${RUNNER_TEMP:-/tmp}/iboard-release.keystore"
|
||||
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "$KEYSTORE_FILE"
|
||||
|
||||
# --- 2. 写 keystore.properties (Tauri 官方约定的签名配置文件位置) ---
|
||||
# 注意: 不能写进 git, 这是运行时生成物
|
||||
KEYSTORE_PROPERTIES="src-tauri/gen/android/keystore.properties"
|
||||
cat > "$KEYSTORE_PROPERTIES" <<EOF
|
||||
keyAlias=${ANDROID_KEY_ALIAS}
|
||||
password=${ANDROID_KEY_PASSWORD}
|
||||
storeFile=${KEYSTORE_FILE}
|
||||
EOF
|
||||
|
||||
# --- 3. 修改 build.gradle.kts 注入 signingConfigs ---
|
||||
if [ ! -f "$APP_GRADLE" ]; then
|
||||
echo "[signing] ERROR: 找不到 $APP_GRADLE (请先运行 tauri android init)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 幂等: 已注入则跳过
|
||||
if grep -q 'create("release")' "$APP_GRADLE" 2>/dev/null; then
|
||||
echo "[signing] build.gradle.kts 已有 release signingConfigs, 跳过注入"
|
||||
else
|
||||
# 在 android { 块内 buildTypes { 之前插入 signingConfigs 块
|
||||
# 用 Python 处理 Kotlin DSL, 对模板结构做最小侵入
|
||||
python3 - "$APP_GRADLE" <<'PYEOF'
|
||||
import sys, re
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path, encoding='utf-8').read()
|
||||
|
||||
# Gradle Kotlin DSL 默认 import java.util.* (Properties 短名可用), 但没有 java.io.*
|
||||
# 因此顶部需补 import java.io.FileInputStream, 注入块内全部用短名
|
||||
if 'import java.io.FileInputStream' not in src:
|
||||
# 在第一个 import 之后插入; 若无 import 则插到文件顶部
|
||||
imp = re.search(r'^import .*$', src, flags=re.M)
|
||||
new_import = 'import java.io.FileInputStream\n'
|
||||
if imp:
|
||||
src = src[:imp.end()] + '\n' + new_import + src[imp.end():]
|
||||
else:
|
||||
src = new_import + src
|
||||
|
||||
# 定位 android { 块内的 buildTypes { (注意 Kotlin 嵌套花括号)
|
||||
m = re.search(r'(\n[ \t]*)(buildTypes\s*\{)', src)
|
||||
if not m:
|
||||
print('[signing] 未找到 buildTypes 块, 中止', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
indent = m.group(1) # 含换行, e.g. "\n "
|
||||
inner_indent = indent + ' '
|
||||
|
||||
signing_block = (
|
||||
f'{inner_indent}signingConfigs {{\n'
|
||||
f'{inner_indent} create("release") {{\n'
|
||||
f'{inner_indent} val keystorePropertiesFile = rootProject.file("keystore.properties")\n'
|
||||
f'{inner_indent} val keystoreProperties = Properties()\n'
|
||||
f'{inner_indent} if (keystorePropertiesFile.exists()) {{\n'
|
||||
f'{inner_indent} keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n'
|
||||
f'{inner_indent} }}\n'
|
||||
f'{inner_indent} keyAlias = keystoreProperties["keyAlias"] as? String ?: ""\n'
|
||||
f'{inner_indent} keyPassword = keystoreProperties["password"] as? String ?: ""\n'
|
||||
f'{inner_indent} storeFile = file(keystoreProperties["storeFile"] as? String ?: "")\n'
|
||||
f'{inner_indent} storePassword = keystoreProperties["password"] as? String ?: ""\n'
|
||||
f'{inner_indent} }}\n'
|
||||
f'{inner_indent}}}\n'
|
||||
)
|
||||
|
||||
# 在 buildTypes { 前插入 signingConfigs 块
|
||||
src = src.replace(m.group(0), signing_block + m.group(0), 1)
|
||||
|
||||
# 让 buildTypes 里的 release 使用该 signingConfig
|
||||
rel = re.search(r'(\n[ \t]*)(getByName\("release"\)\s*\{)', src)
|
||||
if not rel:
|
||||
rel = re.search(r'(\n[ \t]*)(release\s*\{)', src)
|
||||
if rel:
|
||||
rel_indent = rel.group(1) + ' '
|
||||
inject = rel_indent + 'signingConfig = signingConfigs.getByName("release")\n'
|
||||
src = src.replace(rel.group(0), rel.group(0) + inject, 1)
|
||||
|
||||
open(path, 'w', encoding='utf-8').write(src)
|
||||
print('[signing] build.gradle.kts 已注入 import + signingConfigs + release signingConfig')
|
||||
PYEOF
|
||||
fi
|
||||
|
||||
# --- 4. 放行明文 HTTP (release APK 默认拦截 http://, 而服务器仅支持 HTTP) ---
|
||||
# Tauri 模板默认 debug=true, release=false。Android 9+ 会拦截 release 的所有 http:// 明文请求
|
||||
# 导致 Failed to fetch。这里把 release 的 usesCleartextTraffic 改为 "true"。
|
||||
# 幂等: 第二次运行时模板已被改为 "true", 替换无 false 可改, 也不会重复注入。
|
||||
if [ ! -f "$APP_GRADLE" ]; then
|
||||
echo "[signing] ERROR: 找不到 $APP_GRADLE (明文放行跳过)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$APP_GRADLE" <<'PYEOF'
|
||||
import sys, re
|
||||
|
||||
path = sys.argv[1]
|
||||
src = open(path, encoding='utf-8').read()
|
||||
|
||||
# 1) 把显式的 usesCleartextTraffic = "false" 全部改为 "true"
|
||||
new = re.sub(
|
||||
r'(manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*)"false"',
|
||||
r'\1"true"',
|
||||
src,
|
||||
)
|
||||
|
||||
# 2) 若模板根本没有 usesCleartextTraffic 配置, 则在 release 构建块内注入一行 true
|
||||
if 'usesCleartextTraffic' not in src:
|
||||
m = re.search(r'^([ \t]*)getByName\("release"\)\s*\{', new, flags=re.M)
|
||||
if not m:
|
||||
m = re.search(r'^([ \t]*)release\s*\{', new, flags=re.M)
|
||||
if m:
|
||||
inner = m.group(1) + ' '
|
||||
inject = f'\n{inner}manifestPlaceholders["usesCleartextTraffic"] = "true"'
|
||||
new = new.replace(m.group(0), m.group(0) + inject, 1)
|
||||
else:
|
||||
print('[signing] 警告: 未找到 release 构建块, 明文放行注入跳过', file=sys.stderr)
|
||||
|
||||
open(path, 'w', encoding='utf-8').write(new)
|
||||
print('[signing] usesCleartextTraffic 已设为 true (release 放行明文 HTTP)')
|
||||
PYEOF
|
||||
|
||||
echo "[signing] 完成: keystore.properties 已写, build.gradle.kts 已注入"
|
||||
@@ -0,0 +1,29 @@
|
||||
// 手动跑一次米家采集 (备用): npm run data:mihome
|
||||
// 带文件日志, 便于定位卡点
|
||||
import fs from 'node:fs'
|
||||
|
||||
const LOG = 'D:\\workbench\\iboard\\server\\mihome\\.cache\\cli.log'
|
||||
const log = (m) => fs.appendFileSync(LOG, new Date().toISOString() + ' ' + m + '\n')
|
||||
process.on('uncaughtException', (e) => { log('uncaughtException: ' + (e.stack || e)); process.exit(1) })
|
||||
process.on('unhandledRejection', (e) => { log('unhandledRejection: ' + (e && e.stack ? e.stack : String(e))); process.exit(1) })
|
||||
|
||||
log('start')
|
||||
const m = await import('../server/mihome/index.js')
|
||||
log('imported; calling dailyMaintenance')
|
||||
try {
|
||||
const r = await m.dailyMaintenance()
|
||||
log('done: ' + JSON.stringify(r))
|
||||
console.log('[data:mihome] 完成:', JSON.stringify(r))
|
||||
} catch (e) {
|
||||
if (e instanceof m.MihomeAuthError) {
|
||||
log('auth error: ' + e.message)
|
||||
console.log('[data:mihome] 米家授权不可用: ' + e.message)
|
||||
console.log('[data:mihome] 请运行 npm run data:mihome:login 扫码授权')
|
||||
process.exit(2)
|
||||
}
|
||||
log('failed: ' + (e && e.stack ? e.stack : String(e)))
|
||||
console.error('[data:mihome] 失败:', e.message)
|
||||
process.exit(1)
|
||||
}
|
||||
log('exit(0)')
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,38 @@
|
||||
// 手动扫码授权: npm run data:mihome:login
|
||||
// 启动后台 QRlogin, 轮询状态; 出二维码后打印地址, 扫码成功后自动结束.
|
||||
import { startQrLogin, getQrState, checkAuth } from '../server/mihome/client.js'
|
||||
|
||||
const auth = await checkAuth(true)
|
||||
if (auth.status === 'ok') {
|
||||
console.log('[米家] 已授权有效, 无需重扫 (本地 .cache 凭证可用)')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await startQrLogin()
|
||||
const started = Date.now()
|
||||
console.log('[米家] 等待扫码... (120s 超时)')
|
||||
for (;;) {
|
||||
const s = getQrState()
|
||||
if (s.qrImageUrl && s.state === 'waiting') {
|
||||
console.log('[米家] 二维码(需米家 App 扫):')
|
||||
console.log(' image: ' + s.qrImageUrl)
|
||||
}
|
||||
if (s.state === 'failed') {
|
||||
console.log('[米家] 扫码失败: ' + (s.lastError || '未知'))
|
||||
process.exit(2)
|
||||
}
|
||||
if (s.state === 'idle' && s.running === false && !s.qrImageUrl) {
|
||||
// 启动即失败(可能直接完成/或没权限)
|
||||
console.log('[米家] 流程结束(无二维码产生), 请检查日志')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!s.running && s.qrImageUrl) {
|
||||
console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/')
|
||||
process.exit(0)
|
||||
}
|
||||
if (Date.now() - started > 150000) {
|
||||
console.log('[米家] 超时, 已停止; 可重跑.')
|
||||
process.exit(3)
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
// 将 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
|
||||
// 传输层: MCP streamable HTTP (POST /mcp), 认证 Bearer <REGISTRY_MCP_TOKEN>
|
||||
// 详见 workflow: .gitea/workflows/sync-registry.yml
|
||||
//
|
||||
// 用法:
|
||||
// REGISTRY_MCP_URL=http://bh.vps.honor3.com:8008/mcp \
|
||||
// REGISTRY_MCP_TOKEN=<MCP 访问令牌> \
|
||||
// REGISTRY_PROJECT_ID=proj-msid70ql-6xbq4w \
|
||||
// node scripts/sync-registry.mjs [--dry-run]
|
||||
//
|
||||
// 安全: 本脚本不会把任何 secret 的值打印到日志, 只输出 键名 + 结果。
|
||||
// 注意: 当前通过明文 http 传输, 请确保注册中心仅在内网可达 (或改用 https)。
|
||||
|
||||
const MCP_URL = process.env.REGISTRY_MCP_URL || 'http://bh.vps.honor3.com:8008/mcp'
|
||||
const MCP_TOKEN = process.env.REGISTRY_MCP_TOKEN || ''
|
||||
const PROJECT_ID = process.env.REGISTRY_PROJECT_ID || 'proj-msid70ql-6xbq4w'
|
||||
const IDC = process.env.REGISTRY_IDC || '1'
|
||||
const ENV = process.env.REGISTRY_ENVIRONMENT || '1'
|
||||
const DRY_RUN = process.argv.includes('--dry-run')
|
||||
|
||||
// CI 环境变量 -> 注册中心配置键 (key 支持 / 嵌套, 本项目分组映射为 path => group/key)
|
||||
const SECRET_MAP = [
|
||||
{ env: 'MINISIGN_PRIVATE_KEY', key: 'minisign.privateKey', group: 'secret', type: 'string', description: 'minisign 签名 OTA 升级包的私钥' },
|
||||
{ env: 'DATABASE_URL', key: 'database.url', group: 'secret', type: 'string', description: 'MySQL 连接串' },
|
||||
{ env: 'JWT_SECRET', key: 'jwt.secret', group: 'secret', type: 'string', description: 'JWT 签名密钥' },
|
||||
{ env: 'ANDROID_KEYSTORE_B64', key: 'android.keystore.b64', group: 'secret', type: 'string', description: 'Android release keystore (base64)' },
|
||||
{ env: 'ANDROID_KEY_ALIAS', key: 'android.key.alias', group: 'secret', type: 'string', description: 'Android keystore 别名' },
|
||||
{ env: 'ANDROID_KEY_PASSWORD', key: 'android.key.password', group: 'secret', type: 'string', description: 'Android keystore 密码' },
|
||||
{ env: 'PACKAGE_TOKEN', key: 'package.token', group: 'secret', type: 'string', description: 'Gitea Generic Package 写权限 token' },
|
||||
]
|
||||
|
||||
if (!MCP_TOKEN) {
|
||||
console.error('缺少 REGISTRY_MCP_TOKEN (MCP 访问令牌)')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let sessionId = null
|
||||
let nextId = 0
|
||||
|
||||
function parseSse(text) {
|
||||
let data = ''
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (line.startsWith('data:')) data += line.slice(5).trim()
|
||||
}
|
||||
if (!data) throw new Error(`MCP 响应没有 data 字段: ${text.slice(0, 200)}`)
|
||||
const obj = JSON.parse(data)
|
||||
if (obj.error) {
|
||||
const e = new Error(`${obj.error.code}: ${obj.error.message}`)
|
||||
e.mcpError = obj.error
|
||||
throw e
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
async function rpc(method, params) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
}
|
||||
if (MCP_TOKEN) headers.Authorization = `Bearer ${MCP_TOKEN}`
|
||||
if (sessionId) headers['mcp-session-id'] = sessionId
|
||||
|
||||
const res = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: ++nextId, method, params }),
|
||||
})
|
||||
const sid = res.headers.get('mcp-session-id')
|
||||
if (sid) sessionId = sid
|
||||
const text = await res.text()
|
||||
return parseSse(text)
|
||||
}
|
||||
|
||||
async function callTool(name, args) {
|
||||
const payload = await rpc('tools/call', { name, arguments: args })
|
||||
if (payload.result?.isError) {
|
||||
throw new Error(`工具 ${name} 执行失败: ${JSON.stringify(payload.result.content || {})}`)
|
||||
}
|
||||
const sc = payload.result?.structuredContent
|
||||
if (sc !== undefined && sc !== null) return sc
|
||||
const content = payload.result?.content || []
|
||||
const text = content.map((c) => c.text || '').join('\n').trim()
|
||||
if (text) {
|
||||
try { return JSON.parse(text) } catch { return { text } }
|
||||
}
|
||||
return payload.result || {}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`== 同步 CI secrets -> 注册中心 (project=${PROJECT_ID})${DRY_RUN ? ' [dry-run]' : ''}`)
|
||||
|
||||
// 1) initialize 拿 mcp-session-id (rpc 内部已缓存 header)
|
||||
await rpc('initialize', {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'iboard-ci', version: '1.0.0' },
|
||||
})
|
||||
|
||||
// 2) 记录当前模式, 非 edit 则临时切到 edit (写 value 需要), 结束后恢复
|
||||
const mode = (await callTool('get_mode', {})).mode
|
||||
console.log(`当前模式: ${mode}`)
|
||||
const needEdit = mode !== 'edit'
|
||||
if (needEdit) {
|
||||
if (DRY_RUN) {
|
||||
console.log('(dry-run) 待切换到 edit 模式')
|
||||
} else {
|
||||
await callTool('set_mode', { mode: 'edit' })
|
||||
console.log('已临时切换为 edit 模式')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 3) 拉取现有配置, 键为 group/idc/environment/key
|
||||
const list = await callTool('list_configs', { projectId: PROJECT_ID })
|
||||
const configs = Array.isArray(list.configs) ? list.configs : list
|
||||
const existing = new Map()
|
||||
for (const c of configs || []) {
|
||||
if (!c || !c.key) continue
|
||||
existing.set(`${c.idc || ''}|${c.environment || ''}|${c.group || ''}|${c.key}`, c)
|
||||
}
|
||||
|
||||
// 4) 逐个 upsert
|
||||
let updated = 0
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
for (const { env, key, group, type, description } of SECRET_MAP) {
|
||||
const value = process.env[env]
|
||||
if (value === undefined || value === '') {
|
||||
console.log(`- [skip] ${group}/${key} <- 环境变量 ${env} 未设置`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const found = existing.get(`${IDC}|${ENV}|${group}|${key}`)
|
||||
const target = `${group}/${key}`
|
||||
if (found) {
|
||||
if (DRY_RUN) {
|
||||
console.log(`- [dry-run] update ${target} (cfg ${found.id}) <- ${env}`)
|
||||
} else {
|
||||
await callTool('update_config', { projectId: PROJECT_ID, configId: found.id, value })
|
||||
console.log(`- [update] ${target} (cfg ${found.id}) <- ${env}`)
|
||||
}
|
||||
updated++
|
||||
} else {
|
||||
if (DRY_RUN) {
|
||||
console.log(`- [dry-run] create ${target} <- ${env}`)
|
||||
} else {
|
||||
await callTool('create_config', {
|
||||
projectId: PROJECT_ID,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
description,
|
||||
idc: IDC,
|
||||
environment: ENV,
|
||||
group,
|
||||
})
|
||||
console.log(`- [create] ${target} <- ${env}`)
|
||||
}
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n== 完成: ${DRY_RUN ? 'dry-run' : '写入'} ${updated} 更新 / ${created} 新建 / ${skipped} 跳过 (值不回显)`)
|
||||
} finally {
|
||||
// 5) 恢复原模式 (本次未实际切模式时无操作)
|
||||
if (needEdit && !DRY_RUN) {
|
||||
await callTool('set_mode', { mode })
|
||||
console.log(`已恢复模式: ${mode}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n同步失败: ${err.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
+19
-4
@@ -1,11 +1,25 @@
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
import { insertCronAlert } from '../scripts/lib/cron-alert.js'
|
||||
import { ApizeroError, oil, history } from './apizero/index.js'
|
||||
|
||||
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
|
||||
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
|
||||
async function importNewsFetcher() {
|
||||
const mod = await import('../scripts/fetchers/news.fetcher.js')
|
||||
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
|
||||
}
|
||||
|
||||
async function alertError(taskName, message) {
|
||||
try {
|
||||
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
|
||||
await insertCronAlert(taskName, message)
|
||||
} catch (e) {
|
||||
console.error('[cron] 报警写入失败(忽略):', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||
@@ -20,6 +34,7 @@ function scheduleNews() {
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
console.log('[cron] 6:00 触发')
|
||||
try {
|
||||
const { fetchNewsList, persistNews } = await importNewsFetcher()
|
||||
const list = await fetchNewsList()
|
||||
const r = await persistNews(list)
|
||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||
@@ -30,7 +45,7 @@ function scheduleNews() {
|
||||
} finally { await conn.end() }
|
||||
} catch (e) {
|
||||
console.error('[cron] 任务失败:', e.message)
|
||||
await insertCronAlert('新闻拉取+报警检测', e.message)
|
||||
await alertError('新闻拉取+报警检测', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
|
||||
@@ -69,7 +84,7 @@ function scheduleOilHistory() {
|
||||
} else {
|
||||
console.error('[cron] 油价历史任务失败:', e.message)
|
||||
}
|
||||
await insertCronAlert('浙江油价历史抓取', e.message)
|
||||
await alertError('浙江油价历史抓取', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
|
||||
|
||||
@@ -11,6 +11,7 @@ import articlesRouter from './routes/articles.js'
|
||||
import otaRouter from './routes/ota.js'
|
||||
import apizeroRouter from './routes/apizero.js'
|
||||
import monitorsRouter from './routes/monitors.js'
|
||||
import mihomeRouter from './routes/mihome.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
const DIST_DIR = path.join(process.cwd(), 'dist')
|
||||
@@ -34,6 +35,7 @@ app.use('/api', alertsRouter)
|
||||
app.use('/api', articlesRouter)
|
||||
app.use('/api', apizeroRouter)
|
||||
app.use('/api', monitorsRouter)
|
||||
app.use('/api/mihome', mihomeRouter)
|
||||
|
||||
// 静态资源 (Vite build 产物)
|
||||
if (HAS_DIST) {
|
||||
@@ -56,6 +58,9 @@ app.use((err, _req, res, _next) => {
|
||||
// 启动定时任务: 每天凌晨 3 点拉取新闻
|
||||
import('./cron.js').catch((e) => console.error('[cron] 加载失败:', e.message))
|
||||
|
||||
// 米家环境监控定时调度 (独立注册, 不与 cron.js 绑定, 见 server/mihome/scheduler.js)
|
||||
import('./mihome/scheduler.js').catch((e) => console.error('[cron] 米家调度注册失败:', e.message))
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[iboard-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[iboard-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// 米家凭证缓存目录: 认证 token 只落本地磁盘, 不进库不进 git
|
||||
// 环境变量:
|
||||
// MIHOME_CACHE_DIR 覆盖 (默认 <cwd>/server/mihome/.cache)
|
||||
import path from 'node:path'
|
||||
|
||||
export function cacheDir() {
|
||||
return process.env.MIHOME_CACHE_DIR
|
||||
? path.resolve(process.env.MIHOME_CACHE_DIR)
|
||||
: path.resolve(process.cwd(), 'server', 'mihome', '.cache')
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// 米家客户端单例 + 认证状态 + 扫码流程状态机
|
||||
import { MijiaAPI } from '@zythum02/mijia-api'
|
||||
import { DiskCache } from '@zythum02/mijia-api/cache/disk-cache'
|
||||
import { prisma } from '../db.js'
|
||||
import { cacheDir } from './cache.js'
|
||||
|
||||
// 未授权/已过期 (模块内部错误, 路由层做 4xx 映射)
|
||||
export class MihomeAuthError extends Error {
|
||||
constructor(message = '米家未授权或已过期') {
|
||||
super(message)
|
||||
this.name = 'MihomeAuthError'
|
||||
}
|
||||
}
|
||||
|
||||
let _api = null
|
||||
|
||||
// 惰性单例: 首次调用时才构造并加载磁盘凭证
|
||||
export function getApi() {
|
||||
if (!_api) _api = new MijiaAPI(new DiskCache(cacheDir()))
|
||||
return _api
|
||||
}
|
||||
|
||||
// 认证状态内存缓存 (5 分钟 TTL, /status 不每次打米家账号服务)
|
||||
let authCache = { status: 'unknown', lastError: '', checkedAt: 0 }
|
||||
|
||||
function persistAccount(status, lastError) {
|
||||
return prisma.mihomeAccount
|
||||
.upsert({
|
||||
where: { key: 'default' },
|
||||
create: { key: 'default', status, lastError: lastError || '' },
|
||||
update: { status, lastError: lastError || '' },
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('[mihome] 写入授权状态失败(忽略):', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
// 校验米家是否可用: 有凭证 + 能刷新 token (刷新会自动处理 30 天过期)
|
||||
// force=true 忽略 TTL 强查
|
||||
export async function checkAuth(force = false) {
|
||||
const now = Date.now()
|
||||
if (!force && now - authCache.checkedAt < 5 * 60 * 1000) return authCache
|
||||
const api = getApi()
|
||||
let status = 'expired'
|
||||
let lastError = ''
|
||||
try {
|
||||
if (!api.available) throw new Error('无授权凭证')
|
||||
await api.refreshToken()
|
||||
status = 'ok'
|
||||
} catch (e) {
|
||||
status = 'expired'
|
||||
lastError = e?.message || String(e)
|
||||
}
|
||||
authCache = { status, lastError, checkedAt: now }
|
||||
await persistAccount(status, lastError)
|
||||
return authCache
|
||||
}
|
||||
|
||||
// ==================== 扫码重授权流程 (模块级单例) ====================
|
||||
// 状态: idle | waiting(已出二维码等扫码) | failed
|
||||
// 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB
|
||||
const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
|
||||
|
||||
export function getQrState() {
|
||||
return { ...qrState }
|
||||
}
|
||||
|
||||
export function getQrImageUrl() {
|
||||
return qrState.qrImageUrl || ''
|
||||
}
|
||||
|
||||
export async function startQrLogin() {
|
||||
if (qrState.running) return getQrState()
|
||||
const api = getApi()
|
||||
qrState.running = true
|
||||
qrState.canceled = false
|
||||
qrState.lastError = ''
|
||||
qrState.qrImageUrl = ''
|
||||
qrState.startedAt = Date.now()
|
||||
;(async () => {
|
||||
try {
|
||||
await api.QRlogin(async (info) => {
|
||||
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
|
||||
qrState.state = 'waiting'
|
||||
})
|
||||
if (qrState.canceled) return
|
||||
qrState.state = 'idle'
|
||||
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
|
||||
await persistAccount('ok', '')
|
||||
} catch (e) {
|
||||
if (qrState.canceled) {
|
||||
qrState.state = 'idle'
|
||||
} else {
|
||||
qrState.state = 'failed'
|
||||
qrState.lastError = e?.message || String(e)
|
||||
}
|
||||
} finally {
|
||||
qrState.running = false
|
||||
}
|
||||
})()
|
||||
return getQrState()
|
||||
}
|
||||
|
||||
export function cancelQrLogin() {
|
||||
qrState.canceled = true
|
||||
if (qrState.running) {
|
||||
// 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果
|
||||
} else if (qrState.state !== 'failed') {
|
||||
qrState.state = 'idle'
|
||||
}
|
||||
return getQrState()
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// 采集器: 家庭/设备发现 + 每小时采样 + 电量 + 日聚合 + vendor 补齐编排
|
||||
import { prisma } from '../db.js'
|
||||
import { checkAuth, getApi, MihomeAuthError } from './client.js'
|
||||
import { getSpec } from './specs.js'
|
||||
import { dayStart, hourStart, addDays } from './time.js'
|
||||
import * as vendorStats from './vendorStats.js'
|
||||
|
||||
const BACKFILL_DAYS = 45 // 停机/首次接入时, 云端统计补齐窗口
|
||||
|
||||
const round2 = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : null)
|
||||
|
||||
// 互斥锁: 同一时刻只允许一个同步在跑 (cron 与手动/启动触发并发时合并)
|
||||
let runLock = null
|
||||
|
||||
function withLock(fn) {
|
||||
if (runLock) return runLock
|
||||
runLock = Promise.resolve()
|
||||
.then(fn)
|
||||
.finally(() => {
|
||||
runLock = null
|
||||
})
|
||||
return runLock
|
||||
}
|
||||
|
||||
// ==================== 设备发现 ====================
|
||||
|
||||
async function syncHomes() {
|
||||
const api = getApi()
|
||||
const homes = await api.getHomesList()
|
||||
for (const h of homes) {
|
||||
const homeId = String(h.id)
|
||||
await prisma.mihomeHome.upsert({
|
||||
where: { homeId },
|
||||
create: { homeId, name: h.name || homeId },
|
||||
update: { name: h.name || homeId },
|
||||
})
|
||||
}
|
||||
return homes.length
|
||||
}
|
||||
|
||||
async function syncDevices() {
|
||||
const api = getApi()
|
||||
const list = await api.getDevicesList()
|
||||
let kept = 0
|
||||
for (const d of list) {
|
||||
const spec = getSpec(d.model)
|
||||
if (!spec) continue // 非环境设备白名单, 跳过
|
||||
const homeId = String(d.home_id || '')
|
||||
if (!homeId) continue
|
||||
const data = {
|
||||
homeId,
|
||||
model: d.model,
|
||||
name: String(d.name || d.did),
|
||||
tempSiid: spec.temp[0],
|
||||
tempPiid: spec.temp[1],
|
||||
humSiid: spec.hum[0],
|
||||
humPiid: spec.hum[1],
|
||||
battSiid: spec.batt[0],
|
||||
battPiid: spec.batt[1],
|
||||
}
|
||||
await prisma.mihomeDevice.upsert({
|
||||
where: { did: d.did },
|
||||
create: { did: d.did, ...data },
|
||||
update: data,
|
||||
})
|
||||
kept++
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
async function listEnabledDevices() {
|
||||
return prisma.mihomeDevice.findMany({ where: { enabled: true } })
|
||||
}
|
||||
|
||||
// ==================== 采样 ====================
|
||||
|
||||
// 每小时采样: 批量读温湿度 -> upsert 小时档
|
||||
async function sampleHour(devices) {
|
||||
const api = getApi()
|
||||
const bucket = hourStart()
|
||||
const props = []
|
||||
for (const d of devices) {
|
||||
props.push({ did: d.did, siid: d.tempSiid, piid: d.tempPiid })
|
||||
props.push({ did: d.did, siid: d.humSiid, piid: d.humPiid })
|
||||
}
|
||||
if (props.length === 0) return 0
|
||||
|
||||
const results = []
|
||||
try {
|
||||
const res = await api.getDevicesProp(props)
|
||||
results.push(...(Array.isArray(res) ? res : [res]))
|
||||
} catch (e) {
|
||||
// 整体失败: 不写入任何小时档, 缺档留给整点补采/vendor 补齐, 本次直接抛给上层记录
|
||||
throw e
|
||||
}
|
||||
|
||||
// did -> { temp, hum }
|
||||
const byDevice = new Map(devices.map((d) => [d.did, d]))
|
||||
const perDevice = new Map()
|
||||
for (const r of results) {
|
||||
if (!r || !r.did) continue
|
||||
const ok = Number(r.code) === 0 && r.value !== undefined && r.value !== null
|
||||
if (!ok) continue
|
||||
const d = byDevice.get(r.did)
|
||||
if (!d) continue
|
||||
const cur = perDevice.get(r.did) || {}
|
||||
if (r.siid === d.tempSiid && r.piid === d.tempPiid) {
|
||||
cur.temperatureAvg = round2(r.value)
|
||||
} else if (r.siid === d.humSiid && r.piid === d.humPiid) {
|
||||
cur.humidityAvg = round2(r.value)
|
||||
}
|
||||
perDevice.set(r.did, cur)
|
||||
}
|
||||
|
||||
let written = 0
|
||||
for (const d of devices) {
|
||||
const v = perDevice.get(d.did)
|
||||
if (!v || (v.temperatureAvg === undefined && v.humidityAvg === undefined)) continue
|
||||
await prisma.mihomeEnvHour.upsert({
|
||||
where: { deviceId_hour: { deviceId: d.id, hour: bucket } },
|
||||
create: {
|
||||
deviceId: d.id,
|
||||
hour: bucket,
|
||||
temperatureAvg: v.temperatureAvg ?? null,
|
||||
humidityAvg: v.humidityAvg ?? null,
|
||||
raw: null, // 自采
|
||||
},
|
||||
update: {
|
||||
temperatureAvg: v.temperatureAvg ?? null,
|
||||
humidityAvg: v.humidityAvg ?? null,
|
||||
raw: null,
|
||||
},
|
||||
})
|
||||
await prisma.mihomeDevice.update({
|
||||
where: { id: d.id },
|
||||
data: { lastSyncAt: new Date() },
|
||||
})
|
||||
written++
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// 电量: 每天读一次 (batteryAt 不在今天就刷新), 标题展示用
|
||||
async function refreshBattery(devices) {
|
||||
const api = getApi()
|
||||
const today = dayStart()
|
||||
for (const d of devices) {
|
||||
const isFresh = d.batteryAt && d.batteryAt.getTime() >= today.getTime()
|
||||
if (isFresh) continue
|
||||
try {
|
||||
const res = await api.getDevicesProp({ did: d.did, siid: d.battSiid, piid: d.battPiid })
|
||||
if (res && Number(res.code) === 0 && typeof res.value === 'number') {
|
||||
await prisma.mihomeDevice.update({
|
||||
where: { id: d.id },
|
||||
data: { battery: Math.round(res.value), batteryAt: new Date() },
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// 电量读不到不报错, 下次再试
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 日聚合 ====================
|
||||
|
||||
// 按日档重算: 读该上海日小时内档 -> avg/min/max + 最后一次值
|
||||
export async function aggregateDay(device, dayDate) {
|
||||
const start = dayStart(dayDate.getTime())
|
||||
const end = addDays(start, 1)
|
||||
const hours = await prisma.mihomeEnvHour.findMany({
|
||||
where: { deviceId: device.id, hour: { gte: start, lt: end } },
|
||||
orderBy: { hour: 'asc' },
|
||||
})
|
||||
if (hours.length === 0) return false
|
||||
const temps = hours.filter((h) => h.temperatureAvg != null).map((h) => h.temperatureAvg)
|
||||
const hums = hours.filter((h) => h.humidityAvg != null).map((h) => h.humidityAvg)
|
||||
const last = hours[hours.length - 1]
|
||||
const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null)
|
||||
const allVendor = hours.every((h) => h.raw && h.raw.vendor === true)
|
||||
await prisma.mihomeEnvSample.upsert({
|
||||
where: { deviceId_date: { deviceId: device.id, date: start } },
|
||||
create: {
|
||||
deviceId: device.id,
|
||||
date: start,
|
||||
temperature: temps.length ? temps[temps.length - 1] : null,
|
||||
humidity: hums.length ? hums[hums.length - 1] : null,
|
||||
temperatureAvg: round2(avg(temps)),
|
||||
humidityAvg: round2(avg(hums)),
|
||||
temperatureMin: round2(temps.length ? Math.min(...temps) : null),
|
||||
temperatureMax: round2(temps.length ? Math.max(...temps) : null),
|
||||
humidityMin: round2(hums.length ? Math.min(...hums) : null),
|
||||
humidityMax: round2(hums.length ? Math.max(...hums) : null),
|
||||
origin: allVendor ? 'vendor_history' : 'live',
|
||||
sampledAt: last.hour,
|
||||
raw: { bucketCount: hours.length },
|
||||
},
|
||||
update: {
|
||||
temperature: temps.length ? temps[temps.length - 1] : null,
|
||||
humidity: hums.length ? hums[hums.length - 1] : null,
|
||||
temperatureAvg: round2(avg(temps)),
|
||||
humidityAvg: round2(avg(hums)),
|
||||
temperatureMin: round2(temps.length ? Math.min(...temps) : null),
|
||||
temperatureMax: round2(temps.length ? Math.max(...temps) : null),
|
||||
humidityMin: round2(hums.length ? Math.min(...hums) : null),
|
||||
humidityMax: round2(hums.length ? Math.max(...hums) : null),
|
||||
origin: allVendor ? 'vendor_history' : 'live',
|
||||
sampledAt: last.hour,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// 补聚合缺口日: [since, today) 有小时档却没有日档的日子
|
||||
export async function aggregateGapDays(device, since) {
|
||||
const today = dayStart()
|
||||
const existing = await prisma.mihomeEnvSample.findMany({
|
||||
where: { deviceId: device.id, date: { gte: since, lt: today } },
|
||||
select: { date: true },
|
||||
})
|
||||
const have = new Set(existing.map((s) => s.date.getTime()))
|
||||
let agg = 0
|
||||
for (let t = since.getTime(); t < today.getTime(); t += 86400000) {
|
||||
if (!have.has(t)) {
|
||||
const ok = await aggregateDay(device, new Date(t))
|
||||
if (ok) agg++
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// ==================== 对外入口 ====================
|
||||
|
||||
// 每次同步: 发现 + 当前小时采样 + 今天/昨天聚合 + 电量 (轻量, 每小时跑)
|
||||
// 注意: 单独不包 withLock, 由 syncNow/dailyMaintenance 统一加锁, 避免嵌套自锁
|
||||
async function syncLocked() {
|
||||
const auth = await checkAuth()
|
||||
if (auth.status !== 'ok') throw new MihomeAuthError(auth.lastError || '米家未授权或已过期')
|
||||
const began = Date.now()
|
||||
const homes = await syncHomes()
|
||||
const devices = await syncDevices()
|
||||
const kept = await listEnabledDevices()
|
||||
const sampled = await sampleHour(kept)
|
||||
await refreshBattery(kept)
|
||||
// 今天 + 昨天都聚合 (保证 00 点边界不丢)
|
||||
const nowMs = Date.now()
|
||||
const today = dayStart(nowMs)
|
||||
let agg = 0
|
||||
for (const d of kept) {
|
||||
if (await aggregateDay(d, today)) agg++
|
||||
const y = addDays(today, -1)
|
||||
if (await aggregateDay(d, y)) agg++
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
homes,
|
||||
devices,
|
||||
sampled,
|
||||
aggregated: agg,
|
||||
ms: Date.now() - began,
|
||||
}
|
||||
}
|
||||
|
||||
export function syncNow() {
|
||||
return withLock(syncLocked)
|
||||
}
|
||||
|
||||
// 每日维护: 轻量同步 + vendor 补齐历史缺口 (45 天窗口) + 缺口日聚合
|
||||
export function dailyMaintenance() {
|
||||
return withLock(async () => {
|
||||
const r = await syncLocked()
|
||||
const auth = await checkAuth()
|
||||
if (auth.status !== 'ok') throw new MihomeAuthError(auth.lastError || '米家未授权或已过期')
|
||||
const since = addDays(dayStart(), -BACKFILL_DAYS)
|
||||
const devices = await listEnabledDevices()
|
||||
let hFilled = 0
|
||||
let dFilled = 0
|
||||
let agg = 0
|
||||
for (const d of devices) {
|
||||
hFilled += await vendorStats.fillHourGaps(d, since)
|
||||
dFilled += await vendorStats.fillDayGaps(d, since)
|
||||
agg += await aggregateGapDays(d, since)
|
||||
}
|
||||
return { ...r, vendorHours: hFilled, vendorDays: dFilled, gapAggregated: agg }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 历史查询聚合: today(时) / month(天) / year(周)
|
||||
// 响应统一: [{ t(桶起始 ms, Asia/Shanghai), temperature, temperatureAvg, temperatureMin, temperatureMax,
|
||||
// humidity, humidityAvg, humidityMin, humidityMax, sampleCount?, missing? }]
|
||||
import { prisma } from '../db.js'
|
||||
import { dayStart, hourStart, monthStart, yearStart, weekStart, addDays, addHours, cnDateStr } from './time.js'
|
||||
|
||||
// 设备是否存在 (路由层校验)
|
||||
export async function getDevice(did) {
|
||||
return prisma.mihomeDevice.findUnique({ where: { did } })
|
||||
}
|
||||
|
||||
// ---------- 当天逐小时 ----------
|
||||
export async function todayBuckets(device) {
|
||||
const start = dayStart()
|
||||
const now = hourStart()
|
||||
const rows = await prisma.mihomeEnvHour.findMany({
|
||||
where: { deviceId: device.id, hour: { gte: start, lt: addDays(start, 1) } },
|
||||
orderBy: { hour: 'asc' },
|
||||
})
|
||||
const byHour = new Map(rows.map((r) => [r.hour.getTime(), r]))
|
||||
const out = []
|
||||
for (let t = start.getTime(); t <= now.getTime(); t += 3600000) {
|
||||
const r = byHour.get(t)
|
||||
out.push({
|
||||
t,
|
||||
temperature: r?.temperatureAvg ?? null,
|
||||
humidity: r?.humidityAvg ?? null,
|
||||
sampleCount: r ? 1 : 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 当前月逐日 ----------
|
||||
export async function monthBuckets(device) {
|
||||
const start = monthStart()
|
||||
const today = dayStart()
|
||||
const end = addDays(today, 1)
|
||||
const rows = await prisma.mihomeEnvSample.findMany({
|
||||
where: { deviceId: device.id, date: { gte: start, lt: end } },
|
||||
orderBy: { date: 'asc' },
|
||||
})
|
||||
const byDay = new Map(rows.map((r) => [r.date.getTime(), r]))
|
||||
const out = []
|
||||
for (let t = start.getTime(); t < end.getTime(); t += 86400000) {
|
||||
const r = byDay.get(t)
|
||||
out.push(r
|
||||
? {
|
||||
t,
|
||||
temperature: r.temperature,
|
||||
temperatureAvg: r.temperatureAvg,
|
||||
temperatureMin: r.temperatureMin,
|
||||
temperatureMax: r.temperatureMax,
|
||||
humidity: r.humidity,
|
||||
humidityAvg: r.humidityAvg,
|
||||
humidityMin: r.humidityMin,
|
||||
humidityMax: r.humidityMax,
|
||||
origin: r.origin,
|
||||
sampleCount: 1,
|
||||
}
|
||||
: { t, sampleCount: 0, missing: true })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 当前年逐周 (按日档现场聚合, 不落库) ----------
|
||||
export async function yearBuckets(device) {
|
||||
const start = yearStart()
|
||||
const today = dayStart()
|
||||
const end = addDays(today, 1)
|
||||
const rows = await prisma.mihomeEnvSample.findMany({
|
||||
where: { deviceId: device.id, date: { gte: start, lt: end } },
|
||||
orderBy: { date: 'asc' },
|
||||
})
|
||||
// 日 -> 周 (上海周一为一周起点)
|
||||
const weekOf = (dayStartMs) => weekStart(new Date(dayStartMs)).getTime()
|
||||
const weeks = new Map() // weekStartMs -> { tempA: [], tempMin: [], tempMax: [], humA: [], humMin: [], humMax: [], days: [] }
|
||||
for (const r of rows) {
|
||||
const k = weekOf(r.date.getTime())
|
||||
if (!weeks.has(k)) {
|
||||
weeks.set(k, { tempA: [], tempMin: [], tempMax: [], humA: [], humMin: [], humMax: [], days: [] })
|
||||
}
|
||||
const w = weeks.get(k)
|
||||
if (r.temperatureAvg != null) w.tempA.push(r.temperatureAvg)
|
||||
if (r.temperatureMin != null) w.tempMin.push(r.temperatureMin)
|
||||
if (r.temperatureMax != null) w.tempMax.push(r.temperatureMax)
|
||||
if (r.humidityAvg != null) w.humA.push(r.humidityAvg)
|
||||
if (r.humidityMin != null) w.humMin.push(r.humidityMin)
|
||||
if (r.humidityMax != null) w.humMax.push(r.humidityMax)
|
||||
w.days.push(r.date)
|
||||
}
|
||||
const avg = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null)
|
||||
const out = []
|
||||
for (const [k, w] of [...weeks.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
// 补齐周内缺的天数 (统计无数据的日算缺失)
|
||||
const wkStart = new Date(k)
|
||||
const missing = addDays(wkStart, 7)
|
||||
const haveMap = new Set(w.days.map((d) => d.getTime()))
|
||||
let missingDays = 0
|
||||
for (let t = k; t < missing.getTime(); t += 86400000) {
|
||||
// 只统计当年/到今天的日
|
||||
if (t >= start.getTime() && t < end.getTime() && !haveMap.has(t)) missingDays++
|
||||
}
|
||||
out.push({
|
||||
t: k,
|
||||
label: cnDateStr(wkStart).slice(5) + '~' + cnDateStr(addDays(wkStart, 6)).slice(5),
|
||||
temperatureAvg: avg(w.tempA),
|
||||
temperatureMin: w.tempMin.length ? Math.min(...w.tempMin) : null,
|
||||
temperatureMax: w.tempMax.length ? Math.max(...w.tempMax) : null,
|
||||
humidityAvg: avg(w.humA),
|
||||
humidityMin: w.humMin.length ? Math.min(...w.humMin) : null,
|
||||
humidityMax: w.humMax.length ? Math.max(...w.humMax) : null,
|
||||
sampleCount: w.days.length,
|
||||
missingDays,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 设备概览 (状态页用): 最新样本 + 近 30 天缺日列表 ----------
|
||||
export async function deviceOverview(device) {
|
||||
const latest = await prisma.mihomeEnvSample.findFirst({
|
||||
where: { deviceId: device.id },
|
||||
orderBy: { date: 'desc' },
|
||||
})
|
||||
// 近 30 天 (含今天) 缺少样本的上海日
|
||||
const start = addDays(dayStart(), -29)
|
||||
const end = addDays(dayStart(), 1)
|
||||
const have = await prisma.mihomeEnvSample.findMany({
|
||||
where: { deviceId: device.id, date: { gte: start, lt: end } },
|
||||
select: { date: true },
|
||||
})
|
||||
const haveSet = new Set(have.map((h) => h.date.getTime()))
|
||||
const missingDays = []
|
||||
for (let t = start.getTime(); t < end.getTime(); t += 86400000) {
|
||||
if (!haveSet.has(t)) missingDays.push(cnDateStr(new Date(t)))
|
||||
}
|
||||
return {
|
||||
latest,
|
||||
missingDays,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// server/mihome 模块入口: 汇总导出采集/认证/查询, 并提供 /status 聚合
|
||||
import { prisma } from '../db.js'
|
||||
import * as vendorStats from './vendorStats.js'
|
||||
import * as history from './history.js'
|
||||
import * as auth from './client.js'
|
||||
|
||||
export { checkAuth, startQrLogin, cancelQrLogin, getQrState, getQrImageUrl, MihomeAuthError } from './client.js'
|
||||
export { syncNow, dailyMaintenance } from './collector.js'
|
||||
export { getDevice, todayBuckets, monthBuckets, yearBuckets, deviceOverview } from './history.js'
|
||||
export { getSpec, ENV_MODELS } from './specs.js'
|
||||
|
||||
// GET /api/mihome/status 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日)
|
||||
export async function getStatus() {
|
||||
const authState = await auth.checkAuth()
|
||||
const [homes, devices, account] = await Promise.all([
|
||||
prisma.mihomeHome.findMany({ orderBy: { id: 'asc' } }),
|
||||
prisma.mihomeDevice.findMany({ orderBy: { id: 'asc' } }),
|
||||
prisma.mihomeAccount.findUnique({ where: { key: 'default' } }),
|
||||
])
|
||||
|
||||
const overviews = await Promise.all(
|
||||
devices.map(async (d) => {
|
||||
const ov = await history.deviceOverview(d)
|
||||
return {
|
||||
did: d.did,
|
||||
latest: ov.latest,
|
||||
missingDays: ov.missingDays,
|
||||
}
|
||||
})
|
||||
)
|
||||
const perDevice = new Map(overviews.map((o) => [o.did, o]))
|
||||
|
||||
// 统计可用性 (探测过的型号才有值, 未探测 undefined)
|
||||
const capability = {}
|
||||
for (const d of devices) {
|
||||
const v = vendorStats.capabilityOf(d.model)
|
||||
if (v !== undefined) capability[d.model] = v
|
||||
}
|
||||
|
||||
const homeList = homes.map((h) => ({
|
||||
homeId: h.homeId,
|
||||
name: h.name,
|
||||
devices: devices
|
||||
.filter((d) => d.homeId === h.homeId)
|
||||
.map((d) => ({
|
||||
did: d.did,
|
||||
model: d.model,
|
||||
name: d.name,
|
||||
alias: d.alias,
|
||||
title: d.alias || d.name,
|
||||
battery: d.battery,
|
||||
batteryAt: d.batteryAt,
|
||||
lastSyncAt: d.lastSyncAt,
|
||||
latest: perDevice.get(d.did)?.latest ?? null,
|
||||
missingDays: perDevice.get(d.did)?.missingDays ?? [],
|
||||
})),
|
||||
}))
|
||||
|
||||
return {
|
||||
auth: {
|
||||
status: account?.status || authState.status,
|
||||
lastError: account?.lastError || authState.lastError,
|
||||
checkedAt: authState.checkedAt,
|
||||
},
|
||||
capability,
|
||||
homes: homeList,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 米家环境监控定时调度
|
||||
// 独立于 server/cron.js 注册: 容器镜像不含 scripts/, 旧 cron.js 顶层 import 脚本模块会整体加载失败,
|
||||
// 故米家节拍不依赖它, 由 server/index.js 单独加载本模块, 确保采样/聚合在部署环境一定生效。
|
||||
import cron from 'node-cron'
|
||||
import * as mihome from './index.js'
|
||||
|
||||
export function registerMihomeScheduler() {
|
||||
cron.schedule('5 * * * *', async () => {
|
||||
console.log('[cron] 米家小时采样触发 @ 上海 xx:05')
|
||||
try {
|
||||
const r = await mihome.syncNow()
|
||||
console.log('[cron] 米家采样完成: 家庭=' + r.homes + ' 设备=' + r.devices + ' 采样=' + r.sampled + ' 聚合=' + r.aggregated + ' (' + r.ms + 'ms)')
|
||||
} catch (e) {
|
||||
console.error('[cron] 米家小时采样失败:', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每小时 xx:05 米家温湿度采样')
|
||||
|
||||
cron.schedule('5 0,3 * * *', async () => {
|
||||
console.log('[cron] 米家每日维护触发 (00:05 / 03:05)')
|
||||
try {
|
||||
const r = await mihome.dailyMaintenance()
|
||||
console.log('[cron] 米家聚合完成: 采样=' + r.sampled + ' 聚合=' + r.aggregated
|
||||
+ ' vendor小时=' + r.vendorHours + ' vendor天=' + r.vendorDays + ' 缺口聚合=' + r.gapAggregated)
|
||||
} catch (e) {
|
||||
console.error('[cron] 米家每日维护失败:', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 00:05/03:05 米家日聚合 + vendor 补齐')
|
||||
|
||||
// 启动追赶: 服务重启/停机恢复后立即补一次 (不打断 5 分钟内的整点采样)
|
||||
setTimeout(() => {
|
||||
mihome.dailyMaintenance().catch((e) => console.error('[cron] 米家启动追赶失败:', e.message))
|
||||
}, 8000)
|
||||
console.log('[cron] 已注册: 启动后 8s 执行米家追赶同步')
|
||||
}
|
||||
|
||||
registerMihomeScheduler()
|
||||
@@ -0,0 +1,26 @@
|
||||
// 环境设备白名单: 型号 -> 属性定义 (siid.piid), 均为调研阶段实测确认
|
||||
// - temp/hum: 采集用; batt: 电量 (每天读一次, 标题展示)
|
||||
// - vendorStats: 是否已实测该型号支持 /v2/user/statistics 云端聚合历史
|
||||
export const ENV_MODELS = {
|
||||
// 炎米智能温湿度计: 实测 stat_hour_v3/stat_day_v3 可用, 回溯到接入米家日
|
||||
'ym001.sensor_ht.ymwsdj': {
|
||||
temp: [2, 1001],
|
||||
hum: [2, 1002],
|
||||
batt: [3, 1003],
|
||||
vendorStats: true,
|
||||
},
|
||||
// 青萍蓝牙闹钟: 云端统计返回 -8 invalid params (未开通), 只走实时采样
|
||||
'cgllc.clock.dove': {
|
||||
temp: [2, 1],
|
||||
hum: [2, 2],
|
||||
batt: [2, 3],
|
||||
vendorStats: false,
|
||||
},
|
||||
}
|
||||
|
||||
export function getSpec(model) {
|
||||
return ENV_MODELS[model] || null
|
||||
}
|
||||
|
||||
export const XIAOMI_APP_UA =
|
||||
'APP/com.xiaomi.mihome APPV/6.0.89 Channel/MI-COM-BD-00059-00 OSVersion/MIUI-12.0.1 Android/28'
|
||||
@@ -0,0 +1,78 @@
|
||||
// Asia/Shanghai 时区工具 (中国无夏令时, 固定 +08:00)
|
||||
// 桶语义: 小时档/日档桶的 \"边界时刻\" 都按上海时区对齐, 存储统一存 UTC (Date)
|
||||
const OFFSET_MS = 8 * 3600 * 1000
|
||||
|
||||
// 把时刻按上海时区拆成 \"假装 UTC 的本地部件\"
|
||||
function cnParts(ts) {
|
||||
const d = new Date(ts + OFFSET_MS)
|
||||
return {
|
||||
y: d.getUTCFullYear(),
|
||||
mo: d.getUTCMonth() + 1,
|
||||
da: d.getUTCDate(),
|
||||
h: d.getUTCHours(),
|
||||
min: d.getUTCMinutes(),
|
||||
sec: d.getUTCSeconds(),
|
||||
dow: d.getUTCDay(), // 0=Sun
|
||||
}
|
||||
}
|
||||
|
||||
// 上海当日 00:00 (返回对应 UTC 时刻的 Date)
|
||||
export function dayStart(ts = Date.now()) {
|
||||
const p = cnParts(ts)
|
||||
return new Date(Date.UTC(p.y, p.mo - 1, p.da) - OFFSET_MS)
|
||||
}
|
||||
|
||||
// 上海整点 (返回对应 UTC 时刻的 Date)
|
||||
export function hourStart(ts = Date.now()) {
|
||||
const p = cnParts(ts)
|
||||
return new Date(Date.UTC(p.y, p.mo - 1, p.da, p.h) - OFFSET_MS)
|
||||
}
|
||||
|
||||
// 上海当周起始 (周一 00:00)
|
||||
export function weekStart(date) {
|
||||
const dow = cnParts(date.getTime()).dow
|
||||
const diff = dow === 0 ? 6 : dow - 1 // 距周一的天数
|
||||
return addDays(dayStart(date.getTime()), -diff)
|
||||
}
|
||||
|
||||
export function addDays(date, n) {
|
||||
return new Date(date.getTime() + n * 86400000)
|
||||
}
|
||||
|
||||
// 上海当月 1 号 00:00
|
||||
export function monthStart(ts = Date.now()) {
|
||||
const p = cnParts(ts)
|
||||
return new Date(Date.UTC(p.y, p.mo - 1, 1) - OFFSET_MS)
|
||||
}
|
||||
|
||||
// 上海当年 1 月 1 日 00:00
|
||||
export function yearStart(ts = Date.now()) {
|
||||
const p = cnParts(ts)
|
||||
return new Date(Date.UTC(p.y, 0, 1) - OFFSET_MS)
|
||||
}
|
||||
|
||||
export function addHours(date, n) {
|
||||
return new Date(date.getTime() + n * 3600000)
|
||||
}
|
||||
|
||||
// 上海本地 \"YYYY-MM-DD\"
|
||||
export function cnDateStr(date) {
|
||||
const p = cnParts(date.getTime())
|
||||
return `${p.y}-${String(p.mo).padStart(2, '0')}-${String(p.da).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 上海本地 \"HH:mm\"
|
||||
export function cnTimeStr(date) {
|
||||
const p = cnParts(date.getTime())
|
||||
return `${String(p.h).padStart(2, '0')}:${String(p.min).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 上海本地 \"YYYY-MM-DD HH:mm\" (用于日志)
|
||||
export function cnFullStr(date) {
|
||||
return `${cnDateStr(date)} ${cnTimeStr(date)}`
|
||||
}
|
||||
|
||||
// 判断两个日期是否同一上海日
|
||||
export function sameCnDay(a, b) {
|
||||
return dayStart(a.getTime()).getTime() === dayStart(b.getTime()).getTime()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// /v2/user/statistics 云端聚合历史: 探测可用性 + 小时/日缺口回填
|
||||
// 实测: 炎米 ym001 可用 (stat_hour_v3/stat_day_v3/stat_month_v3); 青萍/VRF 返回 -8 invalid params
|
||||
import { prisma } from '../db.js'
|
||||
import { getApi } from './client.js'
|
||||
import { dayStart, hourStart, addDays, addHours } from './time.js'
|
||||
import { getSpec } from './specs.js'
|
||||
|
||||
// 型号 -> 是否支持统计 (探测一次后缓存; undefined=未探测)
|
||||
const capabilityCache = new Map()
|
||||
|
||||
export function capabilityOf(model) {
|
||||
if (capabilityCache.has(model)) return capabilityCache.get(model)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 用同型号任一设备探测该型号统计可用性
|
||||
async function ensureCapability(device) {
|
||||
if (capabilityCache.has(device.model)) return capabilityCache.get(device.model)
|
||||
let supported = false
|
||||
try {
|
||||
const api = getApi()
|
||||
const res = await api.getStatistics({
|
||||
did: device.did,
|
||||
key: `${device.tempSiid}.${device.tempPiid}`,
|
||||
data_type: 'stat_day_v3',
|
||||
limit: 1,
|
||||
time_start: Math.floor(Date.now() / 1000) - 86400,
|
||||
time_end: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
supported = Array.isArray(res)
|
||||
} catch (e) {
|
||||
supported = false
|
||||
}
|
||||
capabilityCache.set(device.model, supported)
|
||||
console.log(`[mihome] 统计探测 ${device.model}: ${supported ? '可用' : '不可用'}`)
|
||||
return supported
|
||||
}
|
||||
|
||||
// 解析 "[28.8]" / "[28.1,29.200001]" -> [28.8, 29.2]
|
||||
function parseStatValue(value) {
|
||||
if (typeof value !== 'string') return []
|
||||
const m = value.match(/\[([^\]]*)\]/)
|
||||
if (!m) return []
|
||||
return m[1]
|
||||
.split(',')
|
||||
.map((s) => parseFloat(s.trim()))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
}
|
||||
|
||||
// 拉某设备某属性的小时均值序列 (stat_hour_v3), 默认带排序
|
||||
async function fetchStatHours(device, key, fromTs, toTs) {
|
||||
const api = getApi()
|
||||
const res = await api.getStatistics({
|
||||
did: device.did,
|
||||
key,
|
||||
data_type: 'stat_hour_v3',
|
||||
limit: 2000,
|
||||
time_start: fromTs,
|
||||
time_end: toTs,
|
||||
})
|
||||
if (!Array.isArray(res)) return []
|
||||
return res
|
||||
.filter((r) => r && r.time)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
.map((r) => ({ time: new Date(r.time * 1000), values: parseStatValue(r.value) }))
|
||||
}
|
||||
|
||||
// 回填小时缺口: 查 [since, now) 内缺失的整点桶, 从云端补齐 (origin 标记 raw.vendor=true)
|
||||
// 返回新增小时桶数量
|
||||
export async function fillHourGaps(device, since) {
|
||||
if (!(await ensureCapability(device))) return 0
|
||||
const now = hourStart()
|
||||
if (since >= now) return 0
|
||||
|
||||
const existing = await prisma.mihomeEnvHour.findMany({
|
||||
where: {
|
||||
deviceId: device.id,
|
||||
hour: { gte: since, lte: addHours(now, -1) },
|
||||
},
|
||||
select: { hour: true },
|
||||
})
|
||||
const have = new Set(existing.map((h) => h.hour.getTime()))
|
||||
|
||||
// 实际缺失桶数
|
||||
let missingCount = 0
|
||||
for (let t = since.getTime(); t <= addHours(now, -1).getTime(); t += 3600000) {
|
||||
if (!have.has(t)) missingCount++
|
||||
}
|
||||
if (missingCount === 0) return 0
|
||||
|
||||
const fromTs = Math.floor(since.getTime() / 1000)
|
||||
const toTs = Math.floor(now.getTime() / 1000)
|
||||
let filled = 0
|
||||
|
||||
for (const [siid, piid, col, num] of [
|
||||
[device.tempSiid, device.tempPiid, 'temperatureAvg', 0],
|
||||
[device.humSiid, device.humPiid, 'humidityAvg', 1],
|
||||
]) {
|
||||
const rows = await fetchStatHours(device, `${siid}.${piid}`, fromTs, toTs)
|
||||
for (const r of rows) {
|
||||
const bucket = r.time.getTime()
|
||||
if (r.values.length <= num || have.has(bucket) || bucket > now.getTime()) continue
|
||||
// 只补严格已过去的整点
|
||||
have.add(bucket)
|
||||
try {
|
||||
await prisma.mihomeEnvHour.upsert({
|
||||
where: { deviceId_hour: { deviceId: device.id, hour: r.time } },
|
||||
create: { deviceId: device.id, hour: r.time, [col]: r.values[num], raw: { vendor: true } },
|
||||
update: { [col]: r.values[num], raw: { vendor: true } },
|
||||
})
|
||||
filled++
|
||||
} catch (e) {
|
||||
if (e?.code !== 'P2002') throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filled > 0) console.log(`[mihome] vendor 补齐小时档 ${device.did}: +${filled}`)
|
||||
return filled
|
||||
}
|
||||
|
||||
// 回填日缺口: 对仍无日档的日子, 若云端有日 [min,max] 则补一条 vendor_history
|
||||
export async function fillDayGaps(device, since) {
|
||||
if (!(await ensureCapability(device))) return 0
|
||||
const today = dayStart()
|
||||
const existingSamples = await prisma.mihomeEnvSample.findMany({
|
||||
where: { deviceId: device.id, date: { gte: since, lt: today } },
|
||||
select: { date: true },
|
||||
})
|
||||
const haveDay = new Set(existingSamples.map((s) => s.date.getTime()))
|
||||
|
||||
const fromTs = Math.floor(since.getTime() / 1000)
|
||||
const toTs = Math.floor(Date.now() / 1000)
|
||||
let filled = 0
|
||||
|
||||
for (const [siid, piid] of [
|
||||
[device.tempSiid, device.tempPiid],
|
||||
[device.humSiid, device.humPiid],
|
||||
]) {
|
||||
const api = getApi()
|
||||
const res = await api.getStatistics({
|
||||
did: device.did,
|
||||
key: `${siid}.${piid}`,
|
||||
data_type: 'stat_day_v3',
|
||||
limit: 120,
|
||||
time_start: fromTs,
|
||||
time_end: toTs,
|
||||
})
|
||||
if (!Array.isArray(res)) continue
|
||||
const isTemp = siid === device.tempSiid && piid === device.tempPiid
|
||||
for (const r of res) {
|
||||
if (!r || !r.time) continue
|
||||
const day = new Date(r.time * 1000)
|
||||
const vals = parseStatValue(r.value)
|
||||
const bucket = dayStart(day.getTime()).getTime()
|
||||
if (haveDay.has(bucket) || bucket >= today.getTime() || vals.length < 2) continue
|
||||
haveDay.add(bucket)
|
||||
const data = isTemp
|
||||
? { temperatureMin: vals[0], temperatureMax: vals[1] }
|
||||
: { humidityMin: vals[0], humidityMax: vals[1] }
|
||||
try {
|
||||
await prisma.mihomeEnvSample.upsert({
|
||||
where: { deviceId_date: { deviceId: device.id, date: dayStart(day.getTime()) } },
|
||||
create: {
|
||||
deviceId: device.id,
|
||||
date: dayStart(day.getTime()),
|
||||
origin: 'vendor_history',
|
||||
...data,
|
||||
},
|
||||
update: { origin: 'vendor_history', ...data },
|
||||
})
|
||||
filled++
|
||||
} catch (e) {
|
||||
if (e?.code !== 'P2002') throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filled > 0) console.log(`[mihome] vendor 补齐日档 ${device.did}: +${filled}`)
|
||||
return filled
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 环境监控 REST 路由: /api/mihome/* (authRequired)
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
import {
|
||||
getStatus,
|
||||
getDevice,
|
||||
todayBuckets,
|
||||
monthBuckets,
|
||||
yearBuckets,
|
||||
syncNow,
|
||||
startQrLogin,
|
||||
cancelQrLogin,
|
||||
getQrState,
|
||||
getQrImageUrl,
|
||||
MihomeAuthError,
|
||||
} from '../mihome/index.js'
|
||||
import { XIAOMI_APP_UA } from '../mihome/specs.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 统一错误边界: 米家授权错误映射为 401, 其余走 500
|
||||
function guard(handler) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
return await handler(req, res, next)
|
||||
} catch (e) {
|
||||
if (e instanceof MihomeAuthError) return res.status(401).json({ error: e.message })
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数)
|
||||
router.get('/status', guard(async (_req, res) => {
|
||||
res.json(await getStatus())
|
||||
}))
|
||||
|
||||
// 历史: range = today(默认) | month | year
|
||||
router.get('/device/:did/history', guard(async (req, res) => {
|
||||
const device = await getDevice(req.params.did)
|
||||
if (!device) return res.status(404).json({ error: '设备不存在' })
|
||||
const range = req.query.range || 'today'
|
||||
let buckets
|
||||
if (range === 'month') buckets = await monthBuckets(device)
|
||||
else if (range === 'year') buckets = await yearBuckets(device)
|
||||
else buckets = await todayBuckets(device)
|
||||
res.json({ did: device.did, range: range === 'today' ? 'today' : range, buckets })
|
||||
}))
|
||||
|
||||
// 改别名 (可清空); alias 最长 24 字符
|
||||
router.put('/device/:did', guard(async (req, res) => {
|
||||
const device = await getDevice(req.params.did)
|
||||
if (!device) return res.status(404).json({ error: '设备不存在' })
|
||||
const alias = typeof req.body?.alias === 'string' ? req.body.alias.trim().slice(0, 24) : ''
|
||||
const updated = await prisma.mihomeDevice.update({ where: { id: device.id }, data: { alias: alias || null } })
|
||||
res.json({ did: updated.did, alias: updated.alias || '' })
|
||||
}))
|
||||
|
||||
// 立即同步一次 (小时采样 + 日聚合), 首页「立即同步」
|
||||
router.post('/sync', guard(async (_req, res) => {
|
||||
res.json(await syncNow())
|
||||
}))
|
||||
|
||||
// 重授权: 启动扫码 (幂等; 已在跑则返回当前状态)
|
||||
router.post('/reauthorize', guard(async (_req, res) => {
|
||||
res.json(await startQrLogin())
|
||||
}))
|
||||
|
||||
// 扫码状态轮询
|
||||
router.get('/reauthorize/status', guard(async (_req, res) => {
|
||||
res.json(getQrState())
|
||||
}))
|
||||
|
||||
// 取消扫码
|
||||
router.post('/reauthorize/cancel', guard(async (_req, res) => {
|
||||
res.json(cancelQrLogin())
|
||||
}))
|
||||
|
||||
// 代理二维码图片 (带小米 UA, 避免被 CDN 拒)
|
||||
router.get('/reauthorize/qr-image', guard(async (_req, res) => {
|
||||
const url = getQrImageUrl()
|
||||
if (!url) return res.status(404).json({ error: '暂无二维码' })
|
||||
const r = await fetch(url, { headers: { 'User-Agent': XIAOMI_APP_UA } })
|
||||
if (!r.ok) throw new Error('二维码拉取失败 ' + r.status)
|
||||
const buf = Buffer.from(await r.arrayBuffer())
|
||||
res.set('Content-Type', r.headers.get('content-type') || 'image/png')
|
||||
res.set('Cache-Control', 'no-store')
|
||||
res.send(buf)
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,39 @@
|
||||
// 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端)
|
||||
import { api, getApiBase } from './client.js'
|
||||
|
||||
// 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性
|
||||
export async function getMihomeStatus() {
|
||||
return api('/mihome/status')
|
||||
}
|
||||
|
||||
// 历史: range = today | month | year
|
||||
export async function getMihomeHistory(did, range) {
|
||||
return api('/mihome/device/' + encodeURIComponent(did) + '/history?range=' + encodeURIComponent(range))
|
||||
}
|
||||
|
||||
// 改别名 (alias 传空串 = 清空)
|
||||
export async function setMihomeAlias(did, alias) {
|
||||
return api('/mihome/device/' + encodeURIComponent(did), { method: 'PUT', body: { alias } })
|
||||
}
|
||||
|
||||
// 立即同步
|
||||
export async function syncMihome() {
|
||||
return api('/mihome/sync', { method: 'POST' })
|
||||
}
|
||||
|
||||
// 重授权
|
||||
export async function startMihomeAuth() {
|
||||
return api('/mihome/reauthorize', { method: 'POST' })
|
||||
}
|
||||
export async function getMihomeAuthStatus() {
|
||||
return api('/mihome/reauthorize/status')
|
||||
}
|
||||
export async function cancelMihomeAuth() {
|
||||
return api('/mihome/reauthorize/cancel', { method: 'POST' })
|
||||
}
|
||||
|
||||
// 二维码图片地址 (服务端带小米 UA 代理, 避免 CDN 拒连)
|
||||
export function mihomeQrImageUrl() {
|
||||
const base = getApiBase() // 同源时为空串
|
||||
return base + '/api/mihome/reauthorize/qr-image'
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
import { getApiBase, getToken } from './client.js'
|
||||
import { APP_VERSION, BUILD_ID } from '../version.js'
|
||||
|
||||
// 判断是否在 Tauri WebView 内 (与 src/debug/pagespy.js 同样的判定)
|
||||
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
|
||||
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
// 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// 仅在 Tauri WebView 内启用 PageSpy, 用于生产环境远程调试.
|
||||
// web 模式 (Vite dev / 浏览器) 通过检测 window.__TAURI_INTERNALS__ 直接跳过,
|
||||
// 配合 dynamic import 让 page-spy 这个 chunk 在 web 模式下完全不下载.
|
||||
// VITE_PAGESPY_API 留空可关闭连接 (例如本地调试不想连远端 PageSpy 服务时).
|
||||
// 加载失败不阻塞应用启动, 仅 console.warn 提示.
|
||||
|
||||
const PAGESPY_API = import.meta.env.VITE_PAGESPY_API || 'http://bh.vps.honor3.com:6752'
|
||||
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
if (inTauri && PAGESPY_API) {
|
||||
// 动态 import: 仅在 Tauri 内才下载 page-spy chunk
|
||||
import('@huolala-tech/page-spy')
|
||||
.then(({ default: PageSpy }) => {
|
||||
// PageSpy 默认会 hook fetch / XMLHttpRequest, 自动抓取网络请求和错误
|
||||
const instance = new PageSpy({ api: PAGESPY_API })
|
||||
window.__IBOARD_PAGESPY__ = instance
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[PageSpy] init failed:', err)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
// 远程调试: 仅在 Tauri WebView 内启用, web 模式自动跳过
|
||||
import './debug/pagespy.js'
|
||||
|
||||
// 移动端 UI 框架: 全局样式 + 主题 (antd-mobile 5)
|
||||
import 'antd-mobile/es/global'
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>首页</NavBar>
|
||||
<NavBar back={null} />
|
||||
<div className="home-content">
|
||||
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { NavBar } from 'antd-mobile'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import DataView from './macro/DataView.jsx'
|
||||
import StrategyView from './macro/StrategyView.jsx'
|
||||
@@ -64,7 +65,8 @@ export default function MacroPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null} />
|
||||
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function MePage() {
|
||||
|
||||
return (
|
||||
<div className="me-page">
|
||||
<NavBar back={null} className="me-navbar">我的</NavBar>
|
||||
<NavBar back={null} className="me-navbar" />
|
||||
<div className="me-profile">
|
||||
<Avatar
|
||||
className="me-avatar"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NavBar, Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
|
||||
import EnvView from './monitor/EnvView.jsx'
|
||||
|
||||
// 监控页: 两个横向分类
|
||||
// - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序
|
||||
@@ -41,10 +42,11 @@ export default function MonitorPage() {
|
||||
const tabs = [
|
||||
{ key: 'servers', label: '主机监控', render: () => <ServerView /> },
|
||||
{ key: 'services', label: '服务监控', render: () => <ServiceView /> },
|
||||
{ key: 'env', label: '环境监控', render: () => <EnvView /> },
|
||||
]
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>监控</NavBar>
|
||||
<NavBar back={null} />
|
||||
<div className="monitor-content">
|
||||
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// 环境监控: 米家温湿度长期记录
|
||||
// - 顶部: 授权横幅(重新授权弹窗) + 立即同步
|
||||
// - 家庭: Collapse 手风琴, 一次只展开一个家庭
|
||||
// - 设备卡片: 标题=别名+电量, 概览+缺N天, Segmented 今天/月/年 图表
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Collapse, Popup, Dialog, Toast, Empty, DotLoading, Segmented, Button, Card, Input,
|
||||
} from 'antd-mobile'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import {
|
||||
getMihomeStatus, getMihomeHistory, setMihomeAlias, syncMihome,
|
||||
startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, mihomeQrImageUrl,
|
||||
} from '../../api/mihome.js'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: '今天', value: 'today' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '年', value: 'year' },
|
||||
]
|
||||
|
||||
const round1 = (v) => (v === null || v === undefined ? null : Math.round(Number(v) * 10) / 10)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
export default function EnvView() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
const [qrOpen, setQrOpen] = useState(false)
|
||||
|
||||
const load = useCallback((silent) => {
|
||||
if (!silent) setErr('')
|
||||
getMihomeStatus()
|
||||
.then((d) => { setStatus(d); setErr('') })
|
||||
.catch((e) => { if (!silent) { setErr(e.message); if (!status) setStatus({ auth: { status: 'expired' }, homes: [] }) } })
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
load(false)
|
||||
const timer = setInterval(() => load(true), 60000)
|
||||
return () => clearInterval(timer)
|
||||
}, [load])
|
||||
useRefreshOnVisible(() => load(true))
|
||||
|
||||
const doSync = async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await syncMihome()
|
||||
Toast.show({ icon: 'success', content: '已同步' })
|
||||
setRefreshTick((x) => x + 1)
|
||||
load(true)
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const authOk = status?.auth?.status === 'ok'
|
||||
const homes = status?.homes || []
|
||||
|
||||
return (
|
||||
<div className="env-view">
|
||||
{!authOk && status && (
|
||||
<div className="env-auth-banner">
|
||||
<span>米家授权{status.auth.status === 'expired' ? '已过期' : '待授权'},部分历史待补齐</span>
|
||||
<span className="env-auth-btn" onClick={() => setQrOpen(true)}>重新授权</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="monitor-tip env-toolbar">
|
||||
<span>每小时采样 · 数据永久保留</span>
|
||||
<Button size="mini" color="primary" fill="outline" loading={syncing} onClick={doSync}>立即同步</Button>
|
||||
</div>
|
||||
{!status && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
{err && (
|
||||
<div className="error-banner">
|
||||
{err}
|
||||
<span className="retry-link" onClick={() => load(false)}>重试</span>
|
||||
</div>
|
||||
)}
|
||||
{status && homes.length === 0 && authOk && <Empty description="暂无米家环境设备" />}
|
||||
{status && homes.length > 0 && (
|
||||
<div className="env-homes">
|
||||
<Collapse accordion defaultActiveKey={homes[0].homeId}>
|
||||
{homes.map((h) => (
|
||||
<Collapse.Panel
|
||||
key={h.homeId}
|
||||
title={
|
||||
<span className="env-home-title">
|
||||
{h.name}
|
||||
<span className="env-home-count">{h.devices.length ? h.devices.length + ' 台设备' : '无环境设备'}</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{h.devices.length === 0 ? (
|
||||
<Empty description="该家庭暂无环境设备" />
|
||||
) : (
|
||||
<div className="env-devices">
|
||||
{h.devices.map((d) => (
|
||||
<DeviceCard key={d.did} device={d} refreshTick={refreshTick} onChanged={() => load(true)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuthPopup open={qrOpen} onClose={() => setQrOpen(false)} onOk={() => { setQrOpen(false); setRefreshTick((x) => x + 1); load(true) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 设备卡片: 标题=别名+电量 / 概览+缺N天 / 今天·月·年图表
|
||||
function DeviceCard({ device, refreshTick, onChanged }) {
|
||||
const [range, setRange] = useState('today')
|
||||
const [buckets, setBuckets] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cerr, setCerr] = useState('')
|
||||
|
||||
const load = useCallback((r) => {
|
||||
setLoading(true); setCerr('')
|
||||
getMihomeHistory(device.did, r)
|
||||
.then((d) => { setBuckets(d.buckets || []); setCerr('') })
|
||||
.catch((e) => setCerr(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [device.did])
|
||||
|
||||
useEffect(() => { load(range) }, [range, refreshTick, load])
|
||||
|
||||
const missing = device.missingDays?.length || 0
|
||||
const latest = device.latest
|
||||
const title = device.title || device.name
|
||||
const batt = device.battery != null ? device.battery + '%' : null
|
||||
|
||||
const editAlias = () => {
|
||||
const input = { current: null }
|
||||
const onOk = async () => {
|
||||
const val = (input.current && input.current.nativeElement) ? input.current.nativeElement.value : ''
|
||||
try {
|
||||
await setMihomeAlias(device.did, val.trim())
|
||||
Toast.show({ icon: 'success', content: '已保存' })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
let v = device.alias || device.name
|
||||
Dialog.show({
|
||||
title: '编辑别名',
|
||||
content: <Input ref={(el) => { input.current = el }} defaultValue={v} placeholder="别名(仅本应用展示)" />,
|
||||
closeOnAction: true,
|
||||
actions: [
|
||||
{ key: 'cancel', text: '取消' },
|
||||
{ key: 'ok', text: '保存', onClick: () => { onOk(); } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="env-device-card">
|
||||
<div className="env-device-head">
|
||||
<div className="env-device-title">
|
||||
<span className="env-device-name">{title}</span>
|
||||
{batt && <span className="env-device-batt">电量 {batt}</span>}
|
||||
<span className="env-device-edit" onClick={editAlias}>改别名</span>
|
||||
</div>
|
||||
{missing > 0 && <span className="env-missing-badge">缺 {missing} 天</span>}
|
||||
</div>
|
||||
<div className="env-device-meta">
|
||||
{latest ? (
|
||||
<>
|
||||
<span>温度 {latest.temperature != null ? round1(latest.temperature) + '℃' : '—'}
|
||||
{latest.temperatureMin != null && <em> {round1(latest.temperatureMin)}~{round1(latest.temperatureMax)}℃</em>}</span>
|
||||
<span>湿度 {latest.humidity != null ? round1(latest.humidity) + '%' : '—'}
|
||||
{latest.humidityMin != null && <em> {round1(latest.humidityMin)}~{round1(latest.humidityMax)}%</em>}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="env-muted">暂无样本(今天 {pad(new Date().getHours())}:00 起每小时采样)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="env-range">
|
||||
<Segmented
|
||||
options={RANGE_OPTIONS}
|
||||
value={range}
|
||||
onChange={(v) => setRange(v)}
|
||||
/>
|
||||
</div>
|
||||
{cerr && <div className="env-chart-err">{cerr}</div>}
|
||||
{!cerr && loading && buckets === null && <div className="env-chart-loading"><DotLoading /></div>}
|
||||
{!cerr && buckets !== null && <EnvChart range={range} buckets={buckets} />}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 图表: 温度/湿度双轴; 月/年加 min/max 淡虚线
|
||||
function EnvChart({ range, buckets }) {
|
||||
if (!buckets.length) return <div className="env-chart-empty">该视图暂无数据</div>
|
||||
const rows = buckets.map((b) => {
|
||||
let x
|
||||
let temp = round1(b.temperatureAvg ?? b.temperature)
|
||||
let hum = round1(b.humidityAvg ?? b.humidity)
|
||||
if (range === 'today') {
|
||||
const d = new Date(b.t)
|
||||
x = pad(d.getHours()) + ':00'
|
||||
} else if (range === 'month') {
|
||||
const d = new Date(b.t)
|
||||
x = (d.getMonth() + 1) + '/' + d.getDate()
|
||||
} else {
|
||||
x = b.label || ''
|
||||
}
|
||||
return {
|
||||
x,
|
||||
temp,
|
||||
hum,
|
||||
tempMin: round1(b.temperatureMin),
|
||||
tempMax: round1(b.temperatureMax),
|
||||
humMin: round1(b.humidityMin),
|
||||
humMax: round1(b.humidityMax),
|
||||
sampleCount: b.sampleCount,
|
||||
}
|
||||
})
|
||||
const showBand = range === 'month' || range === 'year'
|
||||
return (
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={rows} margin={{ top: 6, right: 6, left: -6, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="x" tick={{ fontSize: 10 }} minTickGap={range === 'today' ? 30 : 20} />
|
||||
<YAxis yAxisId="temp" tick={{ fontSize: 10 }} width={36} unit="°" />
|
||||
<YAxis yAxisId="hum" orientation="right" tick={{ fontSize: 10 }} width={40} unit="%" />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Line yAxisId="temp" dataKey="temp" name="温度" stroke="#1677ff" strokeWidth={1.8} dot={false} connectNulls={false} isAnimationActive={false} />
|
||||
<Line yAxisId="hum" dataKey="hum" name="湿度" stroke="#00b578" strokeWidth={1.8} dot={false} connectNulls={false} isAnimationActive={false} />
|
||||
{showBand && (
|
||||
<>
|
||||
<Line yAxisId="temp" dataKey="tempMin" name="温/最低" stroke="#1677ff" strokeWidth={1} strokeDasharray="3 3" opacity={0.45} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="temp" dataKey="tempMax" name="温/最高" stroke="#1677ff" strokeWidth={1} strokeDasharray="3 3" opacity={0.45} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="hum" dataKey="humMin" name="湿/最低" stroke="#00b578" strokeWidth={1} strokeDasharray="3 3" opacity={0.4} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="hum" dataKey="humMax" name="湿/最高" stroke="#00b578" strokeWidth={1} strokeDasharray="3 3" opacity={0.4} dot={false} isAnimationActive={false} />
|
||||
</>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 重新授权弹窗: 服务端生成二维码, 3s 轮询直到 ok/failed
|
||||
function AuthPopup({ open, onClose, onOk }) {
|
||||
const [state, setState] = useState(null)
|
||||
const timerRef = useRef(null)
|
||||
const [imgTs, setImgTs] = useState(0)
|
||||
|
||||
const stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } }
|
||||
|
||||
const start = async () => {
|
||||
stopPoll()
|
||||
setState(null)
|
||||
try {
|
||||
await startMihomeAuth()
|
||||
} catch (e) {
|
||||
setState({ state: 'failed', lastError: e.message })
|
||||
return
|
||||
}
|
||||
const poll = () => {
|
||||
getMihomeAuthStatus()
|
||||
.then((s) => {
|
||||
setState(s)
|
||||
setImgTs(Date.now()) // 让二维码 <img> 重新拉取 (换码刷新)
|
||||
if (s.state === 'failed') stopPoll()
|
||||
if (s.state === 'idle' && s.running === false && s.qrImageUrl) {
|
||||
stopPoll()
|
||||
onOk()
|
||||
}
|
||||
})
|
||||
.catch((e) => { stopPoll(); setState({ state: 'failed', lastError: e.message }) })
|
||||
}
|
||||
poll()
|
||||
timerRef.current = setInterval(poll, 3000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) start()
|
||||
else { stopPoll(); setState(null) }
|
||||
return stopPoll
|
||||
}, [open])
|
||||
|
||||
const cancel = async () => {
|
||||
try { await cancelMihomeAuth() } catch { /* ignore */ }
|
||||
stopPoll()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const waiting = state?.state === 'waiting' && state?.qrImageUrl
|
||||
const failed = state?.state === 'failed'
|
||||
|
||||
return (
|
||||
<Popup visible={open} onMaskClick={cancel} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
|
||||
<div className="env-qr">
|
||||
<div className="env-qr-title">米家扫码授权</div>
|
||||
{waiting ? (
|
||||
<img className="env-qr-img" src={mihomeQrImageUrl() + '?ts=' + imgTs} alt="扫码授权" />
|
||||
) : (
|
||||
<div className="env-qr-wait"><DotLoading /> {failed ? '扫码流程失败' : '正在生成二维码...'}</div>
|
||||
)}
|
||||
{failed && state?.lastError && <div className="env-qr-err">{state.lastError}</div>}
|
||||
<div className="env-qr-tip">打开米家 App → 我的 → 扫码登录,扫码后自动恢复</div>
|
||||
<div className="env-qr-actions">
|
||||
<Button size="mini" fill="outline" onClick={cancel}>取消</Button>
|
||||
<Button size="mini" color="primary" fill="outline" loading={!waiting && !failed} onClick={() => start()}>刷新二维码</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
+43
-5
@@ -67,8 +67,10 @@ input, textarea, select { font: inherit; }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Me/Article) */
|
||||
.page-with-navbar { display: flex; flex-direction: column; min-height: 100%; }
|
||||
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Macro/Article) */
|
||||
/* height:100% 让页面占满 app-main 高度, 内部 tab-content 独立滚动,
|
||||
头部 NavBar 与分类 tab 固定在顶部不上滚 */
|
||||
.page-with-navbar { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||
.page-with-navbar .adm-error-block { flex: 1; }
|
||||
|
||||
/* 底部 antd-mobile TabBar 定位 */
|
||||
@@ -92,7 +94,7 @@ input, textarea, select { font: inherit; }
|
||||
.swipe-tabs-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.tab-content {
|
||||
@@ -209,7 +211,7 @@ input, textarea, select { font: inherit; }
|
||||
.me-logout { color: var(--color-danger); }
|
||||
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
|
||||
/* 首页模块: 报警 + 新闻 */
|
||||
.home-content { padding: 0 0 16px; }
|
||||
.home-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.home-content .adm-tabs-content { --content-padding: 0; }
|
||||
.home-content .tab-label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.home-section { padding: 0 12px 16px; }
|
||||
@@ -238,7 +240,7 @@ input, textarea, select { font: inherit; }
|
||||
.news-content { font-size: 15px; line-height: 1.75; color: #333; white-space: pre-wrap; word-break: break-word; }
|
||||
.news-content-empty { margin-top: 12px; font-size: 12px; color: var(--color-muted); }
|
||||
/* ================== 监控页 ================== */
|
||||
.monitor-content { padding: 0 0 16px; }
|
||||
.monitor-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.monitor-content .adm-tabs-content { --content-padding: 0; }
|
||||
.monitor-tip { font-size: 11px; color: var(--color-muted); padding: 8px 14px 2px; }
|
||||
.monitor-loading { padding: 40px 0; text-align: center; color: var(--color-muted); font-size: 13px; }
|
||||
@@ -269,6 +271,42 @@ input, textarea, select { font: inherit; }
|
||||
.chart-title { font-size: 13px; font-weight: 600; color: var(--color-text); padding: 0 4px 6px; }
|
||||
.scroll-sentinel { text-align: center; padding: 14px 0 4px; color: var(--color-muted); font-size: 12px; }
|
||||
|
||||
/* ===== 环境监控 (米家温湿度) ===== */
|
||||
.env-view { padding-bottom: 16px; }
|
||||
.env-auth-banner {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
||||
margin: 10px 12px 0; padding: 10px 14px; border-radius: 8px;
|
||||
background: #fff7e6; color: #ad6800; font-size: 13px;
|
||||
}
|
||||
.env-auth-btn { color: var(--color-primary); font-weight: 600; cursor: pointer; flex-shrink: 0; }
|
||||
.env-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.env-homes { padding: 4px 12px 0; }
|
||||
.env-homes .adm-collapse-panel-header { font-size: 14px; font-weight: 600; }
|
||||
.env-home-title { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.env-home-count { font-size: 11px; font-weight: 400; color: var(--color-muted); }
|
||||
.env-devices { display: flex; flex-direction: column; gap: 10px; padding-top: 2px; }
|
||||
.env-device-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.env-device-card .adm-card-body { padding: 12px; }
|
||||
.env-device-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
|
||||
.env-device-title { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.env-device-name { font-weight: 600; font-size: 15px; color: var(--color-text); }
|
||||
.env-device-batt { font-size: 11px; color: var(--color-muted); border: 1px solid var(--color-border); padding: 1px 6px; border-radius: 10px; }
|
||||
.env-device-edit { font-size: 11px; color: var(--color-primary); cursor: pointer; }
|
||||
.env-missing-badge { flex-shrink: 0; font-size: 11px; color: #fff; background: #c0392b; border-radius: 10px; padding: 2px 8px; }
|
||||
.env-device-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0 10px; font-size: 13px; color: var(--color-text); }
|
||||
.env-device-meta em { font-style: normal; color: var(--color-muted); font-size: 11px; }
|
||||
.env-muted { color: var(--color-muted); font-size: 12px; }
|
||||
.env-range { margin: 0 0 6px; }
|
||||
.env-chart-loading, .env-chart-empty { padding: 30px 0; text-align: center; color: var(--color-muted); font-size: 12px; }
|
||||
.env-chart-err { color: var(--color-danger); font-size: 12px; padding: 10px; }
|
||||
.env-qr { text-align: center; }
|
||||
.env-qr-title { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
|
||||
.env-qr-img { width: 200px; height: 200px; border-radius: 8px; border: 1px solid var(--color-border); }
|
||||
.env-qr-wait { height: 200px; line-height: 200px; color: var(--color-muted); font-size: 13px; }
|
||||
.env-qr-err { color: var(--color-danger); font-size: 12px; margin-top: 8px; }
|
||||
.env-qr-tip { font-size: 12px; color: var(--color-muted); margin: 10px 0 14px; }
|
||||
.env-qr-actions { display: flex; justify-content: center; gap: 12px; }
|
||||
|
||||
|
||||
/* ================== OTA 升级区 (MePage) ================== */
|
||||
.me-ota-actions {
|
||||
|
||||
Reference in New Issue
Block a user