หน้าแรก / สถาปัตยกรรม
สถาปัตยกรรม
English: architecture of Deye Solar Monitor — a real-time Deye inverter dashboard on Cloudflare Workers + D1: Worker routes, cron polling of the Deye Cloud Open API, data model, forecast and battery models. Website
PWA ดูข้อมูลระบบโซลาร์เซลล์แบบเรียลไทม์ ภาษาไทย สำหรับผู้สูงอายุ รันบน Cloudflare Workers ฟรี 100% ($0 free plan)
1. Stack
| Layer | Tech |
|---|---|
| Build | Vite 6 + @cloudflare/vite-plugin (dev + build + deploy รวบเดียว) |
| UI | React 19 + TypeScript |
| 3D | three.js — บ้าน 3D บนหน้าหลัก (src/three/, lazy chunk แยก) |
| Styling | Tailwind CSS v4 (@tailwindcss/vite, theme เป็น @theme ใน src/index.css) + design tokens รวมที่ src/lib/ui.ts |
| Weather icons | Meteocons (@meteocons/svg, MIT) — animated SVG จริงตามหลัก meteorology, bundle ลง assets แล้ว precache (ใช้ offline ได้) |
| Offline / PWA | vite-plugin-pwa (Workbox generateSW; globPatterns รวม svg/webp/woff จึง precache ครบ ใช้ offline ได้จริง) + InstallPrompt (แถบ "เพิ่มลงหน้าจอโฮม" ปรับตาม device: Android/Chrome one-tap, iOS/iPad/macOS Safari, Firefox) |
| API / Worker | Hono 4 บน Cloudflare Workers |
| Database | Cloudflare D1 (SQLite) — เก็บ history + cache + token |
| Cron | Cloudflare Cron Triggers (poll ทุก 5 นาที) |
| Static hosting | Workers Static Assets (binding ASSETS, SPA fallback, run_worker_first สำหรับ /api/*) |
| Tests / CI | Vitest (src/lib/__tests__) · GitHub Actions ci.yml = typecheck + test + build |
ทุกอย่างอยู่ใน Worker เดียว: เสิร์ฟ SPA + /api/* + cron + D1
2. Data flow
┌─────────────────────── Browser (PWA) ───────────────────────┐
│ React SPA ──fetch /api/*──► (cookie: PIN HMAC) │
└───────────────────────────────┬─────────────────────────────┘
│
┌──────────────▼──────────────┐
│ Cloudflare Worker (Hono) │
│ assets + /api/* + scheduled │
└───┬───────────┬───────────┬──┘
│ │ │
┌──────────────▼──┐ ┌────▼─────┐ ┌─▼───────────────┐
│ Deye Open API │ │ D1 │ │ TMD NWP (อุตุฯ) │
│ developer.deye │ │ history │ │ + Open-Meteo │
│ cloud.com/v1.0 │ │ cache │ │ (fallback) │
└─────────────────┘ └──────────┘ └──────────────────┘
ถ้า Deye ตอบหน้า 403 ของ CloudFront (บล็อกเครือข่าย Workers) และตั้ง DEYE_RELAY_URL ไว้ ระบบจะส่งคำขอเดิมซ้ำผ่าน relay (scripts/deye-relay.mjs บนเครื่องอื่น) แล้วใช้ relay ต่อ 15 นาทีก่อนลองตรงอีก (ดู §3)
3. แหล่งข้อมูล (data sources)
Deye — primary = Open API (https://eu1-developer.deyecloud.com/v1.0)
- Token:
POST /account/token?appId=ด้วยappSecret + email + sha256(password) + companyIdได้ token อายุ ~60 วัน, renew อัตโนมัติ (cache ใน D1meta, ขอใหม่เมื่อเหลือ <1 วัน) - Realtime:
POST /station/latestได้ค่ากำลังไฟ (generationPower, consumptionPower, wirePower, batteryPower, batterySOC) แล้วนำค่าตามเวลาจริงของอินเวอร์เตอร์ตัวแรกมาทับจากPOST /device/latest(TotalSolarPower, TotalConsumptionPower, TotalGridPower, BatteryPower, SOC) ให้ตรงกับแอป Deye - พลังงานรายวัน:
POST /station/historygranularity=2 (day) ได้ generationValue, consumptionValue, purchaseValue, gridValue, chargeValue, dischargeValue - กราฟ:
POST /station/historygranularity=1 (frame, 5 นาที 288 จุด) - Device detail:
POST /station/device(รายการเครื่อง) +POST /device/latest(measure point ทั้งหมด: PV string, เฟส AC, BMS) ·/device/measurePointsใช้เฉพาะ/api/_dev - Alarm:
POST /station/alertList(ดู §7b)
Latest = latest + day-history ประกอบกัน เพราะ Open API แยกค่า realtime กับพลังงานรายวันคนละ endpoint
selfSufficiencyคำนวณเอง =(1 − purchaseValue / consumptionValue) × 100(พึ่งพาตัวเอง = ไฟที่ใช้โดยไม่ซื้อ) Open API เท่านั้น — ไม่มี fallback ภายในอื่น (เอา session-token internal API ออกแล้ว) ค่าค้าง (frozen): เมื่อตัวส่งข้อมูลหลุดแล้ว Deye ส่งค่าเดิมซ้ำ หากobservedAt(เวลาของแหล่งที่เก่าสุด) เก่ากว่าSTALE_AFTER_S= cron ไม่บันทึก sample/daily, จดfrozen_sinceไว้ (src/lib/freeze.ts) Relay (DEYE_RELAY_URL, ไม่บังคับ): ตั้งแต่ 15 ต.ค. 2026 CloudFront ของ Deye ตอบ 403 HTML กับเครือข่าย Workers ·deyeFetchเรียกตรงก่อน ถ้าเจอ 403 ที่ไม่ใช่ JSON จึงส่งซ้ำผ่าน relay พร้อม headerx-relay-key = sha256("deye-relay:" + DEYE_APP_SECRET)· relay (scripts/deye-relay.mjs, Node, พอร์ต 47812) ส่งต่อเฉพาะ/account/token/station/*/device/*ไม่ log body · ความล้มเหลวของ relay เองไม่นับเป็น login fail
Weather — primary = TMD NWP (กรมอุตุฯ) / fallback = Open-Meteo
- พิกัด ดึงจาก station เอง (locationLat/Lng) — ไม่ hardcode
- แปลงค่าสภาพอากาศ cond 1–12 เป็นไอคอน Meteocons (clear-day/clear-night/partly/overcast/rain/storm ฯลฯ แยกกลางวัน-กลางคืน) + คำไทย + คำนวณ "พลังงานแสง" จาก swdown (
src/lib/wxicon.tsx) - ดัชนี UV ดึงจาก Open-Meteo
uv_index(TMD ไม่มี field นี้) ผ่านfetchUV()— แสดงค่า + ระดับ/สีตามมาตรฐาน WHO
4. Dynamic — ไม่ hardcode อะไรเลย
- Station:
listStations()ค้นจาก API แล้วให้getStationMeta()เลือกตัวแรก + cache ใน D1. ไม่มี stationId คาไว้ในโค้ด (ปักเจาะจงได้ด้วย envDEYE_STATION_ID) - พิกัดอากาศ / ชื่อ / กำลังติดตั้ง: มาจาก station ที่ค้นเจอ (override ได้ด้วย
WEATHER_LAT/LON/PLACE) - ค่าบัญชี / ติดต่อ / endpoint เป็น env ล้วน:
DEYE_APP_ID/DEYE_EMAILตั้งต่อ Worker (ไม่ commit),DEYE_COMPANY_ID/CONTACT_EMAIL/DEYE_BASE_URL/TMD_BASE/DEYE_RELAY_URLปรับได้ - ค่าไฟ / ค่าขายไฟ / ทุนติดตั้ง / CO₂: ผู้ใช้ตั้งในแอป (แท็บ "ตลอด" ส่วน
SettingsCard) ระบบส่งไปที่POST /api/settingsเก็บใน D1meta.settingsทุกเครื่องเห็นค่าเดียวกัน (cache ใน localStorage ด้วย) · ไม่ตั้ง = ค่าเริ่มต้นในsrc/lib/config.ts(4.4 ฿/หน่วย · ขายไฟ 0 · CO₂ 0.5 กก./หน่วย) - หลายสถานี (multi-station) — ทำแล้ว:
/api/stationsคืน array;/api/latest|device|historyรับ?station=param (cache แยก key ต่อสถานี). UI มี ตัวสลับสถานี (StationSwitcher— bottom sheet) ที่ด้านบนของจอ แสดงเฉพาะเมื่อมีมากกว่า 1 สถานี, จำค่าที่เลือก. สถานีเดียว = ไม่ส่ง param พฤติกรรมเหมือนเดิมทุกอย่าง (อากาศยังอิงพิกัดสถานีหลัก เพราะ Open API/station/listไม่ส่งพิกัดรายสถานี)
5. API routes (Hono — src/worker/index.ts)
| Route | ใช้ |
|---|---|
POST /api/login |
ตรวจเช็ก PIN แล้วตั้ง cookie HMAC (อายุ 1 ปี) · กรอกผิดถี่เกินไปตอบ 429 + Retry-After |
GET /api/session |
ตรวจเช็กการยืนยันตัวตน (auth) |
GET /api/station / GET /api/stations |
station ที่เลือก / ทั้งหมด |
GET /api/latest |
realtime + พลังงานวันนี้ + สถานะเตือน (cache D1 6 นาที, cron อุ่นให้ทุกรอบ) · 503 {offline:true} ถ้าเชื่อม Deye ไม่ได้ · รับ ?station= |
GET /api/device |
ค่าเครื่อง (เฟส/PV/BMS) + availability + การแจ้งเตือนจากอินเวอร์เตอร์ (cache D1 6 นาที, cron อุ่นให้) · รับ ?station= |
GET /api/weather |
TMD/Open-Meteo + UV + ดวงอาทิตย์ (cache 30 นาทีเมื่อได้ TMD, 5 นาทีเมื่อใช้ Open-Meteo) |
GET /api/history?range=day|month|year&date= |
กราฟย้อนหลัง (สถานีหลัก: D1 ก่อน, ช่วงปัจจุบันของเดือน/ปีเติมจาก Deye เบื้องหลัง · ถ้าไม่มีใน D1 หรือเป็นสถานีอื่นจะดึงจาก Deye) · รับ ?station= |
GET /api/totals |
ยอดสะสมตลอด + รายปี, genTotal, peak (95th pct 60 วัน), kWp ที่สอบเทียบ, ค่าเมฆที่เรียนรู้, PSH รายเดือน (cache 30 นาที) |
GET /api/forecast/accuracy?days= |
คาดพรุ่งนี้ (จาก forecast_log) vs daily.gen ของวันที่จบแล้ว — คะแนน 100−WAPE (Σ |
GET /api/battery/health?days= |
ความจุใช้ได้จริงจากช่วงคายประจุ (samples), SOH = ÷ (Ah × V ที่วัดกลาง SOC), รอบ, DoD · cache 6 ชม. |
GET /api/device/history?sn=&days=&keys= |
แนวโน้มค่าเครื่องจาก device_samples (bucket ≤240 จุด/คีย์, memo 10 นาที) |
GET /api/export?range=day|month|year|all&date= |
CSV จาก D1 ของสถานีหลัก (BOM, attachment) — สำหรับสคริปต์ · ปุ่มในแอปสร้าง CSV ฝั่ง client จากข้อมูลที่แสดงอยู่ (ทุกสถานี) |
GET /api/settings / POST /api/settings |
ค่าไฟ, ค่าขายไฟ, ทุนติดตั้ง, ค่า CO₂ (whitelist rate/sellRate/systemCost/co2Factor, ตัวเลข ≥0, null = กลับค่าเริ่มต้น) |
GET /api/_health |
public (ก่อน auth gate) — ok:false + 503 เมื่อทั้ง sample ล่าสุดและ poll ที่สำเร็จล่าสุด (last_poll_ok) เก่ากว่า STALE_AFTER_S (2 รอบ cron + 120 วิ = 12 นาที) · inverterFrozenSince · lastPollError · deyeLogin (fails/hold) · จำนวนแถว samples/daily/device_samples |
POST /api/_backfill?from=&to= |
operator — เติมข้อมูลจาก Deye history (json_each 1 statement/วัน, cap วัน/ครั้งคำนวณจากงบ D1+fetch, หยุดเมื่อ auth พัง, nextFrom/retryFrom) |
POST /api/_purge?from=&to=[&confirm=1] |
operator — ลบ samples/device_samples ช่วงเวลา (unix วินาที, ≤31 วัน) ที่บันทึกตอนตัวส่งข้อมูลหลุด · ไม่มี confirm = dry run · ไม่แตะ daily |
POST /api/_purge_day?day=[&confirm=1] |
operator — ลบแถว daily ของวันที่ผ่านมาแล้ว เฉพาะเมื่อ Deye ตอบสำเร็จว่าไม่มีข้อมูลวันนั้น (ทุก item ต้องเป็นวันที่จริง) · ถ้า Deye ยังมียอดตอบ 409 · ล้าง cache ที่เกี่ยว |
POST /api/_logout_all |
operator — เพิ่ม meta.auth_epoch ทำให้ทุก session (รวมของตัวเอง) ต้องใส่ PIN ใหม่ · isolate อื่นรู้ภายใน ~1 นาที |
POST /api/_alert_test |
operator — ส่งข้อความทดสอบทุกช่องทาง (ok เฉพาะเมื่อได้ 2xx) |
middleware ทำงานตามลำดับ: ensureSchema, auth gate (/api/* ยกเว้น login/session/_health), แล้ว operator gate (/api/_* ต้องมี APP_PIN ตั้งไว้ + login — ไม่ตั้ง PIN = ดูได้แต่เขียน/ดู raw ไม่ได้) · debug: /api/_poll (poll ทันที) /api/_debug (latest ดิบ) /api/_hist (history ดิบวันนี้) /api/_dev (อินเวอร์เตอร์ + measurePoints) · onError ส่งข้อความเต็มเฉพาะ operator · route อื่นนอก /api/* = SPA จาก assets พร้อม X-Robots-Tag: noindex, nofollow · ถ้าได้ 401 ระหว่างใช้งาน แอปจะกลับไปหน้าใส่ PIN
ความทนทาน (resilience):
- token: หาจาก memory ก่อน ไม่มีจึงดู D1 แล้วจึง login · login เป็น single-flight และมี พักแบบทวีคูณตั้งแต่ 60 วินาทีถึง 6 ชั่วโมง หลังล้มเหลว (6 ชม.ทันทีถ้า Deye บอกเหลือ ≤2 attempt) · error ทั่วไป ≥400 จะ re-login เฉพาะเมื่อ token อายุ ≥10 นาที · token ใหม่ที่ถูกปฏิเสธซ้ำนับเป็น fail · ล้าง fail เมื่อ API call สำเร็จจริง
- memo 30 วิ สำหรับ
/device/latest/station/device/station/list/station/alertList— cron 1 รอบเรียก inverter ครั้งเดียว /station/latestส่ง power ที่ top-level (ต้องอ่านให้ถูก ไม่เช่นนั้นจะขึ้น offline ผิด) · ถ้าเชื่อมต่อไม่ได้ frontend แสดงแถบ "เชื่อมต่อระบบไม่ได้" + คงข้อมูลล่าสุด + ลองใหม่ทุก 20s- cron ไม่บันทึกค่า 0 ตอน Deye ล่ม · ถ้า day-totals ล้มเหลว จะเก็บ sample แต่ไม่ทับ
daily(Latest.totalsOk) · scheduled catch เก็บlast_poll_error· self-heal: กลับมาแล้วถ้าพบช่วงข้อมูลขาดเกิน STALE จะเรียกbackfillRangeเติมวันล่าสุด (cap คำนวณจากงบของ tick) - PIN: จองสิทธิ์ตรวจแบบ CAS ก่อนเทียบ (
meta.pin_guard = fails|nextAllowed) ให้ลอง 3 ครั้งแรกโดยไม่หน่วง แล้วเริ่มหน่วง 5 วิ ×2 ≤15 นาที; คำขอที่ถูกปฏิเสธไม่แตะ state · cookie ดู §7
6. D1 schema (auto-migrate แบบ versioned — schema_v ใน meta, รันเองตอน request แรก ไม่ต้องสั่ง migration)
meta(k,v)— Deye token (deye_token+_exp,_at), login guard (deye_login_fails/_fail_at/_fail_msg), station cache (station_meta2,inv_sn_<station>), cache (latest_cache,device_cache,weather_cache3,totals_cache,hist_v3_*,batt_health_*),schema_v,last_prune,last_telemetry,last_poll_ok,last_poll_error,frozen_since,pin_guard,auth_epoch,alert_state,settings,batt_chan_seen_<sn>(ช่องแบตที่เคยมีกระแส)samples(ts, gen_power, use_power, grid_power, batt_power, soc, *_today, gen_total)— snapshot ทุก 5 นาที (SOC ไม่รู้ = NULL ไม่ใช่ 0) · prune >90 วัน (SAMPLES_RETENTION_DAYS, วันละครั้ง) — backfill/self-heal เขียนด้วย DO NOTHING (แถวจาก cron ชนะ) · backfill ย้อนได้ไม่เกินช่วงนี้device_samples(sn, ts, data)— measure point ทั้งหมดของทุก inverter ทุก ~15 นาที · prune >180 วัน (DEVICE_RETENTION_DAYS) — อ่านโดย/api/device/historyและ/api/battery/health(แรงดันกลาง SOC)daily(day, gen, use, buy, sell, charge, discharge, peak_power, peak_ts)— สรุปรายวัน เก็บถาวร · peak ขยับขึ้นเท่านั้นforecast_log(day, predicted, cond, psh, cap_kw, made_at)(schema v3) — "คาดพรุ่งนี้" ตัวสุดท้ายของแต่ละวัน เขียนทับทุก tick; ประเมินเทียบdaily.genเมื่อวันจบ ใช้วัดความแม่นยำและเรียนรู้ค่าเมฆ
โมเดลพยากรณ์ (forecastModel, memo 30 นาที): kWh ≈ kWp × PSH × sky[cond]
- kWp สอบเทียบ (
lib/calib.ts):daily.gen ÷ PSHของ 60 วันล่าสุด ใช้ median ของควอร์ไทล์บน หารด้วย 0.80 (CALIB_CLEAR_FACTOR= sky ของฟ้าแจ่มใส) · ต้องมี ≥7 วันที่ผลิตไฟ (CALIB_MIN_DAYS) ถ้าไม่ถึงจะเป็น 0 แล้วใช้กำลังติดตั้ง/peak แทน - ค่าเมฆ (
lib/skylearn.ts): จากforecast_log120 วันล่าสุด ratio = จริง ÷ (kWp × PSH) ต่อ cond แล้วหา median ถ่วงน้ำหนัก (น้ำหนักลดครึ่งทุกSKY_HALF_LIFE_DAYS= 14 วัน) ผสม prior ด้วยน้ำหนักSKY_PRIOR_WEIGHT= 1.5:(W·median + 1.5·prior) ÷ (W + 1.5)· ratio ตัดที่ 1.2 · ผลจำกัด 0.05–1 · cond ที่ไม่เคยเจอใช้ prior (DEFAULT_SKYในlib/forecast.ts) - ความแม่นยำ (
accuracySummary): คะแนน = 100 − WAPE, bias, MAE · ไม่นับวันที่ sample ช่วง 06–18 น. ไม่ถึง 115/144
7. Auth
กรอก PIN ถูกต้องจะได้ cookie deye_auth = iat.epoch.HMAC-SHA256(APP_PIN, "deye-monitor-v3:"+iat+"."+epoch) (HttpOnly, SameSite=Lax, Secure เฉพาะ https, อายุ 1 ปีนับจาก iat, เทียบ constant-time). epoch ต้องตรงกับ meta.auth_epoch (cache ต่อ isolate 60 วิ, อ่านไม่ได้เกินนั้น = ปฏิเสธ) · เรียก POST /api/_logout_all เพื่อเพิ่ม epoch ทุกเครื่องจะหลุดโดยไม่ต้องเปลี่ยน PIN · เปลี่ยน APP_PIN = หลุดทั้งหมดเช่นกัน · cookie รูปแบบเก่าใช้ไม่ได้. ล็อกอินมี brute-force guard (ดู §5).
ไม่มี PIN = หน้าแอปเปิด public แต่ route ผู้ดูแล /api/_* ปิด (ยกเว้น _health). secret ฝั่ง Deye อยู่ใน Worker เท่านั้น — ผู้ใช้ไม่ต้อง login บัญชี Deye
7b. สถานะ / แจ้งเตือน (แยก 3 เรื่อง ไม่ปนกัน)
- availability (
dev.availability) — เครื่องส่งข้อมูลอยู่ไหม:deviceState/connectStatus+ อายุcollectionTimeตัดสินเป็น online/offline/unknown (unknown = ไม่เดา) · ฝั่งแอปStaleBannerเป็นเจ้าของเรื่องนี้ - ALARM — Deye's own alarm log:
POST /station/alertList(unix วินาที, หน้าต่าง ≤180 วัน, paged) เก็บในdev.alarms{active,recent}·Latest.warningStatus="ALARM" - ATTENTION — heuristics ของเราจาก measure point (
src/lib/diagnostics.tsเฉพาะ tonewarn) เก็บในwarningReasons— ป้ายบอกชัดว่า "วิเคราะห์จากค่าที่วัดได้" ไม่ใช่การแจ้งเตือนจาก Deye - outbound alerts (
src/worker/alerts.ts) — ประเมินท้ายpollAndStoreจากข้อมูลที่มีอยู่แล้ว (ไม่เรียก Deye เพิ่ม) + จาก scheduled catch (poll_failed) · state 1 แถวในmeta.alert_state· commit สถานะ "ส่งแล้ว" เฉพาะเมื่อมีช่องทางตอบ 2xx ไม่งั้นลองใหม่รอบถัดไป · กติกาทุกข้อ "เงียบเมื่อไม่มีข้อมูล"offline/poll_failedติดกัน 3 รอบ ·no_production4 รอบ (PV < 2% ของขนาดระบบ ในช่วงแดดแรงตัดหัวท้าย 1 ชม. และอากาศ cond ≤2 เท่านั้น — เมฆหยุดนับ ไม่รีเซ็ต) ·grid_outแรงดันทุกเฟส < 50 V ขณะมีการใช้ไฟ ·soc_low(ต้องตั้งALERT_SOC_MIN) ·deye:<id>ต่อการแจ้งเตือนจากอินเวอร์เตอร์ที่ยังไม่หาย ·attention:*เฉพาะALERT_HEURISTICS=1- เตือนซ้ำทุก
ALERT_REPEAT_MIN(360) · หายแล้วส่ง "กลับมาปกติ" ·ALERT_MUTEปิดรายกติกา/กลุ่ม (deye:,attention) และลืม state โดยไม่ส่งข้อความ
8. ทำไม $0 (Cloudflare free plan)
| Resource | Free limit | ใช้จริง |
|---|---|---|
| Workers requests | 100,000/วัน | cron 288 + ผู้ใช้ไม่กี่คน ต่ำกว่า limit มาก |
| Workers Static Assets | ฟรี ไม่จำกัด | เสิร์ฟ SPA |
| D1 | 500 MB/ฐาน (5 GB รวม), 5M rows read + 100k write/วัน | samples ~288 + device_samples ~96 แถว/วัน/เครื่อง · prune 90/180 วัน |
| Cron Triggers | ฟรี | ทุก 5 นาที |
| TMD / Open-Meteo / Nominatim | ฟรี | cache 30 นาที (TMD limit 60 req/min) · ชื่อสถานที่ cache ถาวร |
จึงไม่มีทางเกิน free tier
9. โครงสร้างโปรเจกต์
wrangler.jsonc main=worker, assets, D1, cron, vars
vite.config.ts react + tailwind + cloudflare + pwa
scripts/setup.mjs one-command setup (D1 + secrets + deploy)
scripts/deye-relay.mjs relay สำรองตอน Deye บล็อก Workers (Node, รันบนเครื่องอื่น)
schema.sql schema แบบ manual สำหรับ `npm run db:init` (ไม่มี `forecast_log` — ปกติไม่ต้องใช้ Worker สร้างครบเอง)
design/ สคริปต์ถ่ายภาพหน้าจอ (mockup.mjs ฯลฯ) + ฉากบ้าน 3D ต้นแบบ
site/ เว็บไซต์โปรเจกต์ (GitHub Pages ผ่าน pages.yml)
src/
main.tsx App.tsx index.css
lib/ api.ts (ApiError + ?station=, ได้ 401 จะกลับหน้า PIN) format.ts weather.ts analysis.ts device.ts freeze.ts (ตรวจค่าค้าง)
diagnostics.ts (สุขภาพเครื่อง — config-or-silent) forecast.ts (PSH จาก sun) useCapacity.ts (kWp หรือ peakPower)
economics.ts (savings signed + savingsLabel, co2Of(settings)) settings.ts (rate/sellRate/systemCost/co2Factor)
calib.ts (kWp จากวันแดดดี) skylearn.ts (ค่าเมฆเรียนรู้ + accuracy) battery.ts (SOH/รอบ/DoD + จุดตัดที่เรียนรู้ + batteryCard/ETA) season.ts (ปี/ฤดู/คาดทั้งปี) peak.ts
config.ts (ค่าเริ่มต้น) ui.ts (glass/plate tokens) icons.tsx wxicon.tsx (Meteocons) haptics.ts scenarios.ts brand.ts usePoll.ts
__tests__/ (Vitest)
components/ Splash PinGate Header BottomNav StationSwitcher PullToRefresh DevPanel InstallPrompt
HomeView TodayView WeatherView HistoryView (compare + CSV) LifetimeView DeviceView (ประวัติการแจ้งเตือน + DeviceTrends)
HouseScene (บ้าน 3D) BatteryNow (แบตตอนนี้ + ใช้ได้ถึงกี่โมง) BatteryHealth WeatherHero
ForecastAccuracy (คะแนน + แท่งผลิตจริงพร้อมขีดที่คาด) SettingsCard YearInsights MetricSection Collapsible InfoTip
FlowDiagram ProductionRing PowerProfile SunPath SelfConsumption
AnalysisCard InsightList Tile AnimatedNumber Chart
three/ house.ts (โมเดลบ้านจาก primitive + ตำแหน่งอุปกรณ์ + เส้นทางสายไฟ) view.ts (renderer, OrbitControls, เส้นไฟวิ่ง, หยุดทำงานเมื่อไม่อยู่ในมุมมอง)
— โหลดแบบ lazy chunk แยก (~140 KB gz); ภาพนิ่ง public/hero/house-*.webp จาก design/house/scene.html แสดงระหว่างโหลด/ไม่มี WebGL
worker/
index.ts Hono app + /api/* + auth (PIN/epoch) + cron (poll, frozen check, self-heal, telemetry, cache warm, forecast log, alerts) + weather (+UV) + D1 schema/prune + backfill/purge + export
deye.ts Deye client (Open API only) + station discovery + token lifecycle/hold + memo + alertList + relay fallback (Env config)
alerts.ts กติกาแจ้งเตือน + ช่องทาง (webhook/telegram) + state
battChannels.ts จำช่องแบตเตอรี่ที่เคยมีกระแสไฟ (ใช้กับตรวจสุขภาพ + SOH)
sun.ts NOAA sunrise + Haurwitz clear-sky (การรับแดดคำนวณจริง)
10. Deploy ($0)
คำสั่งเดียว (CLI): ใส่ secret ใน .dev.vars แล้วรัน npx wrangler login ตามด้วย npm run setup (scripts/setup.mjs สร้าง D1 + เขียน id ลง wrangler.jsonc + secret bulk + build + deploy). อัปเดต: npm run deploy.
One-click: ปุ่ม Deploy to Cloudflare ใน README (deploy.workers.cloudflare.com/?url=<repo>) — CF อ่าน wrangler.jsonc provision D1 + ถาม secret จาก .dev.vars.example. ต้องเปิด repo เป็น public ก่อน (และลบ .dev.vars ออกจาก history).
table สร้างเองตอน request แรก. cron เริ่มอัตโนมัติ. เปิดบนมือถือแล้วเลือก Add to Home Screen จะได้แอป PWA ที่ใช้ offline ได้. รายละเอียดเต็มใน DEPLOY.md
11. UI / Design system (ออกแบบทิศทางเดียว — เน้นผู้สูงอายุ)
ธีม Solurna: Primary เหลือง #FFCC00 (ปุ่ม) + Secondary ม่วง #A20DDD (nav active) บนพื้น "sunrise wash" gradient. พื้นผิวเป็น iOS glass-lite: .glass-card/.glass-sm (กระจกฝ้า, recipe ใน index.css) + .metric-plate (พื้นทึบ รองกราฟ + ตัวเลขใหญ่ ให้คมชัดสำหรับผู้สูงอายุ). token รวมที่ src/lib/ui.ts (card/cardP/cardSm/plate/plateP/h2…) — แก้ที่เดียวเปลี่ยนทั้งแอป. เคารพ prefers-reduced-motion + fallback เป็นพื้นทึบเมื่อ blur ไม่ได้.
สีพลังงาน (เอกภาพทั้งแอป): โซลาร์ #f5a623 · บ้าน #0d4add · กริด #8b5cf6 · แบต #18a673. หน้าอากาศมี SunPath (golden hour) — ท้องฟ้าไล่สี + แสงเรืองดวงอาทิตย์ + ไฮไลต์ช่วงแดดดีสุด, และแถบ offline "เชื่อมต่อระบบไม่ได้" เมื่อดึงข้อมูลไม่ได้.
เน้นผู้สูงอายุ:
- nav 4 ปุ่ม (หน้าหลัก / วันนี้ / อากาศ / ย้อนหลัง) — หน้า "เครื่อง" (technical) ซ่อนหลังปุ่ม "รายละเอียด" บน Home (มีปุ่มย้อนกลับ)
- status banner ใหญ่มีคำ+ไอคอน บนสุดของ Home ดูได้ใน 1 วินาทีว่า "ปกติ" หรือ "มีปัญหา"
- ตัวเลขใหญ่ ตัวอักษร ≥14px คอนทราสต์สูง ภาษาไทยง่าย ปุ่มหลักแตะง่าย ไม่มี emoji
- การ์ดคำแนะนำ (
AnalysisCard/InsightList) วิเคราะห์จากเลขจริงตามสมการสมดุลพลังงาน (src/lib/analysis.ts) — ปรับตามชนิดระบบ on/hybrid/off-grid, ไม่ใช้ AI/สุ่ม
ภาพ/แอนิเมชัน: hero หน้าหลัก = บ้าน 3D (three.js) สร้างจาก primitive ในโค้ด ลากหมุน/ก้มเงยได้ ไม่หมุนเอง, เส้นไฟวิ่งตามสายจริงตามทิศพลังงาน, ป้ายค่าเกาะอุปกรณ์, แสงกลางวัน/ค่ำตามเวลาจริงของบ้าน; weather = หน้า immersive ม่วง night-sky + ดาว + Meteocons; energy FlowDiagram เส้นวิ่ง. ทุก animation เคารพ prefers-reduced-motion.
12. ข้อตกลงในโค้ด (conventions — อย่าเผลอแก้)
- คำว่า "ไฟย้อน" ตั้งใจใช้ (ไฟย้อนกลับเข้ากริด) — อย่าเปลี่ยนเป็น "ขายไฟ"
- คีย์ข้อมูลหน้า "เครื่อง" (Device) จาก Deye เป็น ภาษาอังกฤษ (
GridVoltageL1,LoadPhasePowerA…) แปลเป็นไทยที่src/lib/device.ts - หน้า "เครื่อง" เข้าผ่านปุ่ม "รายละเอียด" บน Home ไม่ใช่แท็บที่ 5
- การคำนวณทุกอย่างเป็น ค่าจริง จาก API / สูตรดาราศาสตร์ — ไม่ใส่ค่าสมมติ
- "ไม่ hardcode" ≠ "เดาจากข้อมูลที่กำลังตรวจ" — เกณฑ์ที่ได้จากค่าที่มันเองต้องตัดสิน (เช่น เดา nominal จากแรงดันที่วัด) จะซ่อนความผิดปกติแบบทั้งระบบ จึงให้ ตั้งค่า และ เงียบเมื่อไม่ตั้ง เสมอ (
GRID_NOMINAL_*) - ขีดจำกัดต่อ invocation (D1 query / subrequest) ต้อง คำนวณ ในโค้ด (
BACKFILL_MAX_DAYS,SELF_HEAL_MAX_DAYS) ไม่ใช่ตัวเลขลอย - ทุก batch คิดว่า "แต่ละ statement = 1 query" · ห้าม batch หลายร้อย statement — ใช้
json_each1 statement - ข้อมูลที่ Deye ให้ไม่ได้ = unknown ไม่ใช่ 0 (
Latest.totalsOk,availability.unknown,alarms undefined)