PWA ดูข้อมูลระบบโซลาร์เซลล์แบบเรียลไทม์ ภาษาไทย สำหรับผู้สูงอายุ รันบน Cloudflare Workers ฟรี 100% ($0 free plan)
| Layer | Tech |
|---|---|
| Build | Vite 6 + @cloudflare/vite-plugin (dev + build + deploy รวบเดียว) |
| UI | React 19 + TypeScript |
| 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) |
ทุกอย่างอยู่ใน Worker เดียว: เสิร์ฟ SPA + /api/* + cron + D1
┌─────────────────────── 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) │
└─────────────────┘ └──────────┘ └──────────────────┘
- Token:
POST /account/token?appId=ด้วยappSecret + email + sha256(password) + companyId→ token อายุ ~60 วัน, renew อัตโนมัติ (cache ใน D1meta, ขอใหม่เมื่อเหลือ <1 วัน) - Realtime:
POST /station/latest→ power (generationPower, consumptionPower, wirePower, batteryPower, batterySOC) - พลังงานรายวัน:
POST /station/historygranularity=2 (day) → generationValue, consumptionValue, purchaseValue, gridValue, chargeValue, dischargeValue - กราฟ:
POST /station/historygranularity=1 (frame, 5 นาที 288 จุด) - Device detail:
POST /device/latest+/device/measurePoints(PV strings, AC phase, BMS)
Latest = latest + day-history ประกอบกัน เพราะ Open API แยกค่า realtime กับพลังงานรายวันคนละ endpoint
selfSufficiencyคำนวณเอง =(1 − purchaseValue / consumptionValue) × 100(พึ่งพาตัวเอง = ไฟที่ใช้โดยไม่ซื้อ) Open API เท่านั้น — ไม่มี fallback ภายในอื่น (เอา session-token internal API ออกแล้ว)
- พิกัด ดึงจาก station เอง (locationLat/Lng) — ไม่ hardcode
- cond 1–12 → map เป็น Meteocons (clear-day/clear-night/partly/overcast/rain/storm ฯลฯ แยกกลางวัน-กลางคืน) + คำไทย + คำนวณ "พลังงานแสง" จาก swdown (
src/lib/wxicon.tsx) - ดัชนี UV ดึงจาก Open-Meteo
uv_index(TMD ไม่มี field นี้) ผ่านfetchUV()— แสดงค่า + ระดับ/สีตามมาตรฐาน WHO
- 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ปรับได้; ค่าไฟ/CO₂ ที่src/lib/config.ts - หลายสถานี (multi-station) — ทำแล้ว:
/api/stationsคืน array;/api/latest|device|historyรับ?station=param (cache แยก key ต่อสถานี). UI มี ตัวสลับสถานี (StationSwitcher— bottom sheet) บนหัวจอ โผล่เฉพาะตอนมี >1 สถานี, จำค่าที่เลือก. สถานีเดียว = ไม่ส่ง param → พฤติกรรมเดิมเป๊ะ (อากาศยังอิงพิกัดสถานีหลัก เพราะ Open API/station/listไม่ส่งพิกัดรายสถานี)
| Route | ใช้ |
|---|---|
POST /api/login |
ตรวจ PIN → ตั้ง cookie HMAC |
GET /api/session |
เช็ค auth |
GET /api/station / GET /api/stations |
station ที่เลือก / ทั้งหมด |
GET /api/latest |
realtime + พลังงานวันนี้ (cache 60s) · 503 {offline:true} ถ้าเชื่อม Deye ไม่ได้ · รับ ?station= |
GET /api/device |
ค่าเครื่อง (เฟส/PV/BMS) cache 60s · รับ ?station= |
GET /api/weather |
TMD/Open-Meteo + UV (cache 30 นาที) |
GET /api/history?range=day|month|year |
กราฟย้อนหลัง (D1 ก่อน → Deye) · รับ ?station= |
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) |
GET /api/_health |
public — ok:false + 503 เมื่อ sample ล่าสุดเก่ากว่า STALE_AFTER_S (2 รอบ cron + 120 วิ) · lastPollError · deyeLogin (fails/hold) |
POST /api/_backfill?from=&to= |
operator — เติมข้อมูลจาก Deye history (json_each 1 statement/วัน, cap วัน/ครั้งคำนวณจากงบ D1+fetch, หยุดเมื่อ auth พัง, nextFrom/retryFrom) |
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 /api/_debug /api/_hist /api/_dev · onError ส่งข้อความเต็มเฉพาะ operator
ความทนทาน (resilience):
- token: memory → D1 → login · login เป็น single-flight และมี hold ทวีคูณ 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: claim ที่นั่งแบบ CAS ก่อนเทียบ (
meta.pin_guard = fails|nextAllowed) → 3 ครั้งฟรี แล้วหน่วง 5 วิ ×2 ≤15 นาที; คำขอที่ถูกปฏิเสธไม่แตะ state · cookie =iat.HMAC(APP_PIN, scheme+iat)หมดอายุเอง เทียบ constant-time
6. D1 schema (auto-migrate แบบ versioned — schema_v ใน meta, รันเองตอน request แรก ไม่ต้องสั่ง migration)
meta(k,v)— token (+_exp,_at), login guard (deye_login_fails/_at/_msg), station cache, latest/device/weather cache,schema_v,last_prune,last_telemetry,last_poll_error,pin_guard,alert_state,settings, hist cachesamples(ts, gen_power, use_power, grid_power, batt_power, soc, *_today, gen_total)— snapshot ทุก 5 นาที · prune >90 วัน (SAMPLES_RETENTION_DAYS) — backfill/self-heal เขียนด้วย DO NOTHING (แถวจาก cron ชนะ)device_samples(sn, ts, data)— measure point ทั้งหมดของทุก inverter ทุก ~15 นาที · prune >180 วัน (DEVICE_RETENTION_DAYS) — อ่านโดย/api/device/historydaily(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เมื่อวันจบ → ความแม่นยำ + เรียนรู้ค่าเมฆ (lib/skylearn.ts: median ต่อ cond ถ่วงด้วย prior K=3)
PIN → cookie deye_auth = iat.HMAC-SHA256(APP_PIN, "deye-monitor-v2:"+iat) (HttpOnly, Secure เฉพาะ https, หมดอายุตาม iat, เทียบ constant-time). ล็อกอินมี brute-force guard (ดู §5).
ไม่มี PIN = หน้าแอปเปิด public แต่ route ผู้ดูแล /api/_* ปิด (ยกเว้น _health). secret ฝั่ง Deye อยู่ใน Worker เท่านั้น — ผู้ใช้ไม่ต้อง login บัญชี Deye
- 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— ป้ายบอกชัดว่า "วิเคราะห์จากค่าที่วัดได้" ไม่ใช่ alarm ของ Deye - outbound alerts (
src/worker/alerts.ts) — ประเมินท้ายpollAndStoreจากข้อมูลที่มีอยู่แล้ว (ไม่เรียก Deye เพิ่ม) + จาก scheduled catch (poll_failed) · state 1 แถวในmeta.alert_state· commit สถานะ "ส่งแล้ว" เฉพาะเมื่อมีช่องทางตอบ 2xx ไม่งั้นลองใหม่รอบถัดไป · กติกาทุกข้อ "เงียบเมื่อไม่มีข้อมูล"
| Resource | Free limit | ใช้จริง |
|---|---|---|
| Workers requests | 100,000/วัน | cron 288 + ผู้ใช้ไม่กี่คน → << limit |
| Workers Static Assets | ฟรี ไม่จำกัด | เสิร์ฟ SPA |
| D1 | 5GB, 5M reads/วัน | ~288 แถว/วัน |
| Cron Triggers | ฟรี | ทุก 5 นาที |
| TMD / Open-Meteo | ฟรี | cache 30 นาที (TMD limit 60 req/min) |
→ ไม่มีทางเกิน free tier
wrangler.jsonc main=worker, assets, D1, cron, vars
vite.config.ts react + tailwind + cloudflare + pwa
scripts/setup.mjs one-command setup (D1 + secrets + deploy)
src/
main.tsx App.tsx index.css
lib/ api.ts (ApiError + ?station=) format.ts weather.ts analysis.ts device.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
components/ Splash PinGate Header BottomNav StationSwitcher PullToRefresh DevPanel InstallPrompt
HomeView TodayView WeatherView HistoryView (compare + CSV) LifetimeView DeviceView (alarm log + DeviceTrends)
HouseScene (บ้าน 3D) BatteryNow (แบตตอนนี้ + ใช้ได้ถึงกี่โมง) WeatherHero
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/* + cron (poll, self-heal, telemetry, cache warm, alerts) + weather (+UV) + D1 schema/prune + backfill + export
deye.ts Deye client (Open API only) + station discovery + token lifecycle/hold + memo + alertList (Env config)
alerts.ts กติกาแจ้งเตือน + ช่องทาง (webhook/telegram) + state
sun.ts NOAA sunrise + Haurwitz clear-sky (การรับแดดคำนวณจริง)
คำสั่งเดียว (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
ธีม 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.
- คำว่า "ไฟย้อน" ตั้งใจใช้ (ไฟย้อนกลับเข้ากริด) — อย่าเปลี่ยนเป็น "ขายไฟ"
- คีย์ข้อมูลหน้า "เครื่อง" (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)