get_meter_balance
Get meter balance
Get the current prepaid credit balance of a meter, in SGD.
system必填meter_id必填io.github.nicholasohjj由 GitHub 账号 nicholasohjj 发布v2.0.1更新于 Nov 5, 2026
Check NUS and SUTD hostel EVS electricity meters and get top-up links. Unofficial.
面向 NUS 与 SUTD 宿舍 EVS 预付费电表的只读 MCP 服务器,可查余额、用量、充值记录并生成刷卡充值链接。
Web executable,通过 Streamable HTTP。 远程服务在工作区中配置后即可从网页运行时运行。
把这段提示词粘贴到 Claude Code、Codex、Cursor 等智能体,或 SourceWeft 对话中。智能体会读取该服务器的安装指南,先向你展示发布者、端点和登录方式,经你同意后再把它添加到你的客户端。密钥不会写进任何命令。
阅读 https://sourceweft.com/mcp/io.github.nicholasohjj%2Fnusaircon/install.md,并按其中说明把这个 MCP 服务器接入我的客户端。修改任何配置之前,先告诉我它的发布者、端点和登录方式,等我确认。不要把 API 密钥写进命令或我会提交的文件;问我密钥应从哪里获取。把它添加到你客户端的 mcpServers 配置中。
{
"mcpServers": {
"evs-meter-tools": {
"type": "http",
"url": "https://nusaircon-production.up.railway.app/mcp"
}
}
}A Telegram bot and web app for supported EVS electricity meters. Supported NUS and SUTD meters can be topped up by credit card, and supported lookup flows show balance, usage, and top-up history where available.
The legacy NUS CP2 fallback (cp2.evs.com.sg) is hidden from the web app and bot. Its backend routes and services are kept but are not linked from any UI; see CP2 — legacy NUS fallback (hidden).
Hidden. This flow is no longer offered in the web app or bot. The
/app/cp2,/app/loading,/app/pay, and/app/resultReact routes are commented out infrontend/src/App.jsx,/app/cp2is excluded from the sitemap and robots.txt, and the bot no longer shows a hostel picker. The backend routes and services below are kept for reference and possible reinstatement.
Scrapes the legacy EVS WebPOS portal to create a transaction, then proxies through eNETS. It was used only for NUS meters that had not migrated to CP2NUS.
/webapp — fetches meter address and balance from ORE, redirects to React loading page with address/balance in query params/webapp/bootstrap) — runs runPurchaseFlow and getMeterSummary in parallel:
GET /EVSWebPOS/ → login → POST /loginServlet (meter validation)POST /selectOfferServlet (amount selection)GET /paymentServlet → extract merchant_txn_refPOST creditpayment.jsp → extract eNETS messagePOST /enets2/PaymentListener.do → extract RSA public key (n, e), netsMid, netsTxnRef/app/pay) — React component; fetches session via /webapp/session; encrypts cardNo + cvv with eNETS RSA scripts client-side/webapp/enets_pay):
POST https://www.enets.sg/GW2/uCredit/payPOST /EVSWebPOS/transSumServlet → parseEvsTransactionSummaryparseEnetsResult scrapes the eNETS receipt HTML directlystatus, merchantTxnRef, reason) back to the server-side session/app/result) — React component; reads outcome from server session via /webapp/sessionUses the EVS JSON API and the eNETS Payment Page (enetspp) host directly. This is the primary NUS hostel flow for migrated meters.
/webapp — same as cp2; fetches meter info, redirects to /app/cp2nus/loading/cp2nus/webapp/bootstrap) — runBootstrap runs sequentially:
isCp2Meter() guard: rejects cp2 meters with a WRONG_SYSTEM error; on network failure the check is skipped and flow proceedsinit_pay — POST /enets/init_pay → { txn_identifier, req, sign }meter_info — getMeterSummary → buildPayDisplayAddressenetspp_pay — buildEnetsPayUrl → GET enetspp/pay?p=… → extract txnReq, keyId, hmacTxnReqListener — POST /GW2/TxnReqListener → RSA key, netsTxnRef, netsMid, paymtNetsMid, txnRand, keyId, hmac/app/cp2nus/pay) — same RSA encryption; paymtNetsMid (acquiring MID) used in panSubmitForm, not top-level netsMid/cp2nus/webapp/enets_pay):
GET /GW2/pluginpages/env.jsp → seed JSESSIONIDPOST /GW2/credit/init;jsessionid=…POST /GW2/credit/panSubmitFormnetsTxnStatus in response → preParsed result (no b2s call)POST /enets/b2s → 303 redirect → parsePayResult base64-decodes params/app/cp2nus/result) — reads outcome from server sessionUses the SUTD EVS WebPOS pages and the SUTD credit payment bridge.
/sutd/webapp — fetches SUTD meter credit, redirects to /app/sutd/loading/sutd/webapp/bootstrap) — runs the SUTD WebPOS flow:
POST /EVSSUTDWebPOS/loginServlet with the meter IDPOST /EVSSUTDWebPOS/selectOfferServlet with the selected amountGET /EVSSUTDWebPOS/paymentServlet → extract merchant_txn_refPOST http://120.50.44.233/payment_sutd_credit/creditpayment.jspPOST /enets2/PaymentListener.do → extract RSA public key, netsMid, netsTxnRef/app/sutd/pay) — same RSA-encrypted card entry as NUS flows/sutd/webapp/enets_pay) — posts encrypted card data to eNETS, then posts the callback to /EVSSUTDWebPOS/transSumServlet/app/sutd/result) — reads outcome from server sessionCreate a .env file:
SERVER_URL must be HTTPS for the Telegram WebApp payment button to work. If it is HTTP, the bot falls back to a plain browser link instead. On Railway, set it to https://${{RAILWAY_PUBLIC_DOMAIN}} or leave it unset and the app will derive it from RAILWAY_PUBLIC_DOMAIN.
PAYMENT_SESSION_SECRET should be a stable random value, for example from openssl rand -hex 32. If it is omitted, the app falls back to the configured bot token, which still survives Railway sleeps as long as the bot token does not change.
For Google Search Console URL-prefix verification, use the HTML file method and set GOOGLE_SITE_VERIFICATION_FILE to the downloaded file name. The default response body is google-site-verification: <file name>. If Google's downloaded file contains different text, set GOOGLE_SITE_VERIFICATION_CONTENT to the exact file body.
In production, or when RAILWAY_PUBLIC_DOMAIN is present, each configured bot uses a Telegram webhook by default so Railway Serverless can sleep and wake from inbound Telegram requests. Set TELEGRAM_BOT_MODE=polling only for an always-on deployment. Optional webhook variables are TELEGRAM_WEBHOOK_PATH, TELEGRAM_WEBHOOK_SECRET, and TELEGRAM_DROP_PENDING_UPDATES for single-bot mode. For concurrent bots, use NUS_TELEGRAM_WEBHOOK_PATH, NUS_TELEGRAM_WEBHOOK_SECRET, SUTD_TELEGRAM_WEBHOOK_PATH, and SUTD_TELEGRAM_WEBHOOK_SECRET only if you need custom paths; otherwise unique paths are generated automatically.
For concurrent Telegram bots in one deployment, set both NUS_TELEGRAM_BOT_TOKEN and SUTD_TELEGRAM_BOT_TOKEN. The same service starts both nusairconbot and sutdairconbot, with separate command menus, webhook paths, in-memory sessions, and payment notifications. Single-bot deployments can still use TELEGRAM_BOT_TOKEN plus TELEGRAM_BOT_AUDIENCE.
The frontend is served at /app/ by Express in production. In development, Vite proxies /webapp, /cp2nus, and /sutd to the backend.
For local development, keep DB_DIR=. or omit it. On Railway, set DB_DIR=/data only after attaching a volume mounted at /data.
The repo includes railway.json with the production build, start command, and /health check. Railway Serverless itself is enabled in the dashboard:
TELEGRAM_BOT_TOKEN for one bot, or NUS_TELEGRAM_BOT_TOKEN and SUTD_TELEGRAM_BOT_TOKEN for concurrent bots. Also set PAYMENT_SESSION_SECRET, SERVER_URL=https://${{RAILWAY_PUBLIC_DOMAIN}}, and any optional variables such as OWNER_CHAT_ID./data and set DB_DIR=/data so saved users survive restarts.Cold starts can make the first Telegram or web request slower, and Railway may return a first-request 502 while waking the service. Bot sessions and owner reply threads are in-memory and reset by restarts or sleeps; payment/result tokens are sealed with PAYMENT_SESSION_SECRET and can still be read after a restart until their TTL expires. Saved meter IDs remain in SQLite.
The service exposes a public, read-only MCP server at POST /mcp, so people can connect their own AI assistant and decide how it behaves. No account or API key is needed.
The tools cannot make payments and never receive card details. Responses leave out the room address, because they are passed on to the user's AI provider.
To connect, add https://<your-domain>/mcp as a custom connector in Claude's settings. In Claude Code, run:
Each person writes their own instructions in their assistant, for example in a Claude Project:
The server is stateless, so it works with Railway Serverless. Tool calls are limited to 120 per 5 minutes per IP address, and can't be sent as a batch. Hosted clients share IP addresses, so that limit is higher than the website's lookup limit.
Tool errors are JSON, so an agent can decide what to do next:
get_topup_link checks the meter first. If EVS can't be reached, it still returns a link with meterVerified: false.
Every tool declares an output schema and returns structuredContent, with the same JSON as text for clients that don't read it.
To have an agent check your balance daily and send you a payment link when it runs low, see scheduled balance check. To run your own copy, or build your own automation on top of it, see the self-hosting guide.
People opt in with /alerts on, which covers the saved meters on that bot. Meters are saved when someone starts a top-up in the bot. Once a day, each opted-in meter is checked and its chat gets a message if the balance is below SGD 5. The message has a button that starts the bot's normal top-up flow, so the person still chooses the amount and pays by card.
Each meter gets one alert per low-balance episode, and can alert again once a check sees the balance back at SGD 5 or more. A failed lookup is skipped and never counts as a zero balance. Alerts stop if someone blocks the bot, and /forget removes them along with the meter.
To turn on the daily check:
openssl rand -hex 32.LOW_BALANCE_CRON_SECRET on Railway..github/workflows/low-balance-check.yml then calls POST /internal/low-balance-check at 09:00 Singapore time, which also wakes the service if Railway Serverless has put it to sleep. To test it, run the workflow manually from the GitHub Actions tab. GitHub can delay scheduled runs, and it disables them after 60 days without repository activity.
Sessions are stored in-memory with a 15-minute TTL. All messages for a given chat are serialised through a per-chat lock to prevent race conditions. On serverless deployments, sessions reset when Railway stops and later restarts the service. The top-up flow stages are:
/balance, /usage, and /topups use single-step stages that return to idle after one response.
Payment sessions (created by /webapp/bootstrap) are sealed encrypted tokens with a 10-minute TTL, separate from bot sessions. The token holds the meter ID, amount, address, balance, and eNETS gateway fields needed for payment. This avoids losing an active payment session when Railway Free wakes the service on a new process.
Once /webapp/enets_pay has a final outcome, the server sends the Telegram payment notification immediately and returns a sealed result token with a 24-hour TTL. Payment and result tokens carry an internal tokenKind; payment routes reject result tokens, and result routes reject payment tokens. The React result page reads outcome data from GET /webapp/session?token=; query params are never trusted for payment results. CP2NUS receipt PDFs are still held in a volatile in-memory cache, so the result page only shows the receipt link when that cache entry is available.
Saved meter IDs and hostels are persisted in a SQLite database (evs_users.db) using better-sqlite3. The database is written to DB_DIR (Railway Volume at /data if present, otherwise the project root). Unlike bot sessions and payment sessions, the user store survives restarts.
When a user submits feedback, the bot forwards a notification to OWNER_CHAT_ID. The owner can reply directly to that Telegram notification and the bot forwards the reply back to the user. Reply threads are tracked via an in-memory pendingReplies map with a 7-day TTL.
Note: threading only follows the original notification message. If the owner replies to their own reply, that message is not automatically routed back to the user — only replies to the original forwarded notification are intercepted.
PAYMENT_SESSION_SECRET is stable; pending payment tokens expire after 10 minutes, completed result tokens after 24 hours. The user store (saved meter IDs) is SQLite-backed and persists across restarts./webapp/enets_pay uses a process-local submit lock keyed by merchant transaction reference to reject duplicate in-flight payment submits with HTTP 409.netsMid (UMID_xxx) and paymtNetsMid (acquiring MID from paymtSvcInfoList[0]). Using the wrong MID will cause the payment to fail silently.ore.evs.com.sg) and the eNETS init endpoint (p-1.evs.com.sg) are currently called with an empty Authorization: Bearer header and succeed — no token is required. The EVS2 portal also runs a user-auth service (evs2u.evs.com.sg/login) that issues short-lived (~1h) JWTs, so EVS could start enforcing a real token on these endpoints in future. If lookups or top-ups begin returning 401/403, this is the likely cause and the flow would need to obtain and attach a JWT.$10 or S$ 10.00; malformed mixed strings are rejected. NUS minimum top-up is $6.00 SGD, SUTD minimum top-up is $10.00 SGD, and all systems currently cap top-ups at $50.00 SGD./app/) and the Telegram Mini App use the same Express routes and React pages — no separate codepaths./maintenanceon (owner command) or at startup with MAINTENANCE_MODE=true. Users see the global maintenance message across the app and bot; /health and Telegram webhooks remain available so the service stays reachable and the owner can run /maintenanceoff. Set MAINTENANCE_MESSAGE to customize the copy. GLOBAL_MAINTENANCE=true is accepted as a legacy alias./topupoff (owner command) or at startup with TOPUP_DISABLED=true. Bot users in an active top-up session when the flag is set will have their session reset and see the maintenance message. Website payment start, loading, card, bootstrap, and card-submit routes also return the maintenance response while top-ups are disabled; lookup, terms, result, and feedback routes remain available.MIT. This is an unofficial tool, not affiliated with NUS, SUTD, EVS or eNETS.
来源:README.md,提交 0cbcdb9
Get meter balance
Get the current prepaid credit balance of a meter, in SGD.
system必填meter_id必填Get meter usage
Get up to 7 days of usage for an NUS meter, with spending analysis, month-to-date spending, and how long the balance may last. Amounts are in SGD. Dates are as EVS reports them, with no time zone, and recent days may be missing. Not available for SUTD meters.
meter_id必填Get recent top-ups
Get up to 10 recent top-ups for a meter. To confirm a payment, look for a top-up with the same amount dated after the createdAt of its link, and with no status showing it failed. Earlier top-ups can have the same amount, so always check the date. Dates have no time zone and appear to be Singapore time (UTC+8), while createdAt is ISO 8601. If nothing matches, the payment may not be recorded yet or may have failed: check again later, and have the user check their card statement before paying again.
system必填meter_id必填Get top-up link
Create a link to the secure top-up page, for the user to open and pay by card themselves. Creating the link charges nothing. Refuses a meter EVS doesn't recognise. If EVS can't be reached, meterVerified is false, so ask the user to check the meter on the page. NUS allows $6-$50 and SUTD allows $10-$50.
system必填meter_id必填amount_sgd必填已满足适用检查项的 100%
该评分衡量此收录条目的完整程度,依据注册中心条目、端点与公开来源计算得出。它不是对服务器本身的评价,也不表示服务器安全或当前可用:可连通性是带日期的检查结果,评分也不包含安装量或用户评价。
Telegram Bot (telegraf) Website (React, /app/) │ │ ├── /topup └── HomePage ├── /balance └── system selection ├── /usage + meter ID + amount └── /topups │ webhook in production/Railway, │ │ polling in development │ ▼ ▼ Express (server.js) ←──────────────────────────── │ ├── POST /telegram/webhook/* — Telegram webhook receivers ├── GET /webapp — legacy CP2 (hidden from UI): fetches meter summary, redirects to React ├── GET /webapp/bootstrap — runs full payment init, returns token ├── GET /webapp/session — returns session data as JSON for React ├── GET /webapp/pay — redirects to React card entry page ├── POST /webapp/enets_pay — proxies encrypted card data to eNETS ├── POST /webapp/notify — fallback Telegram payment notification └── GET /webapp/result — redirects to React result page
React Frontend (/app/) ├── / — HomePage (system + meter ID + amount) ├── /balance — HomePage opened in balance-check mode ├── /cp2nus — HomePage preselected for primary NUS CP2NUS flow ├── /cp2nus/loading — cp2nus variant ├── /cp2nus/pay — cp2nus variant ├── /cp2nus/result — cp2nus variant ├── /sutd/loading — SUTD variant ├── /sutd/pay — SUTD variant └── /sutd/result — SUTD variant# Install backend dependenciesnpm install
# Install and build the frontendnpm run build:frontendTELEGRAM_BOT_TOKEN=your_bot_token_here # legacy single-bot tokenNUS_TELEGRAM_BOT_TOKEN=your_nus_bot_token # optional, for concurrent nusairconbotSUTD_TELEGRAM_BOT_TOKEN=your_sutd_bot_token # optional, for concurrent sutdairconbotPAYMENT_SESSION_SECRET=replace_with_openssl_rand_hex_32 # stable secret for signed payment/result tokensSERVER_URL=https://your-public-server.example.comOWNER_CHAT_ID=your_telegram_chat_id # receives feedback notificationsMAINTENANCE_MODE=false # set to "true" to put the app and bot in global maintenance modeMAINTENANCE_MESSAGE= # optional custom global maintenance messageOWNER_STATUS_TIMEOUT_MS=4000 # optional timeout for owner /status EVS/eNETS checksTOPUP_DISABLED=false # set to "true" to show maintenance messageDB_DIR=. # local SQLite dir; use /data on Railway with a mounted volumeTELEGRAM_BOT_MODE= # production/Railway defaults to webhook; dev defaults to pollingTELEGRAM_BOT_AUDIENCE=nus # legacy single-bot mode: "nus" or "sutd"LOW_BALANCE_CRON_SECRET= # optional, enables the low-balance check endpointMCP_ALLOWED_ORIGINS= # optional, comma-separated extra browser origins allowed to call /mcpGOOGLE_SITE_VERIFICATION_FILE= # optional Search Console HTML file name, e.g. googleabc123.htmlGOOGLE_SITE_VERIFICATION_CONTENT= # optional exact file body if Google provides non-default content# Development (two terminals)npm run dev # Express backend on :3000npm run dev:frontend # Vite dev server on :5173
# Productionnpm run build:frontendnpm start# Backend testsnpm test
# Frontend testscd frontend && npm test
# Lint backend and frontendnpm run lintclaude mcp add --transport http evs-meter https://<your-domain>/mcpMy NUS meter ID is 12345678. When I ask about aircon credit, check the balanceand work out how many days it will last from my recent usage. If it will lastunder 3 days, suggest a top-up amount and give me the top-up link.{ "code": "METER_NOT_FOUND", "message": "EVS has no NUS meter with this ID. ...", "retryable": false }idle → awaiting_meter_id (8-digit ID; prefetches balance + 7-day usage; NUS always uses cp2nus, no hostel picker) → awaiting_amount ($6–$50 SGD) → awaiting_payment (WebApp Pay button; re-prompts on text) → idle (reset after WebApp closes)
→ awaiting_meter_id_balance (/balance with no saved meter) → awaiting_meter_id_usage (/usage with no saved meter) → awaiting_meter_id_topups (/topups with no saved meter)
→ awaiting_feedback_rating (/feedback — star rating keyboard) → awaiting_feedback_text (free-text or ⏭ Skip) → idle├── server.js # Express entry point; serves React at /app/├── routes/│ ├── cp2.js # WebApp + API routes for legacy cp2 (hidden from UI)│ ├── cp2nus.js # WebApp + API routes for cp2nus│ ├── sutd.js # WebApp + API routes for SUTD│ ├── mcp.js # Read-only MCP server for AI assistants│ ├── alerts.js # Secret-protected trigger for the low-balance check│ └── website.js # Standalone website lookup + feedback API├── services/│ ├── cp2Service.js # Legacy cp2 purchase flow: EVS WebPOS scraping + eNETS proxy│ ├── cp2nusService.js # Purchase flow: EVS JSON API + eNETS PP + NETS API│ ├── sutdService.js # SUTD balance, history, and WebPOS purchase flow│ ├── ore.js # ORE API: meter summary, usage history, top-up history│ ├── meterLookup.js # Balance, usage, and top-up lookups shared by the website and MCP│ ├── paymentSession.js # Sealed payment/result tokens and receipt cache│ ├── paymentNotification.js # Telegram payment result notification helper│ ├── paymentSubmitLock.js # Short-lived duplicate payment submit guard│ ├── utils.js # HTML parsing, result normalisation, XSS escaping│ ├── validators.js # Meter ID validation and strict amount parsing│ ├── config.js # Base URLs and shared HTTP headers│ └── analytics.js # Event tracking and exception capture├── bot/│ ├── index.js # Telegraf handlers + webhook/polling runtime│ ├── handlers/ # Command and text message handlers│ ├── services/ # Bot session, user store, lookup helpers│ └── constants.js # Stage names, keyboards, shared messages├── views/│ └── errorPage.js # Shared HTML error page for Express error responses└── frontend/ # React + Vite frontend ├── src/ │ ├── App.jsx # React Router routes │ ├── pages/ │ │ ├── HomePage.jsx # System selection + meter ID + amount entry │ │ ├── LoadingPage.jsx # Spinner; calls /webapp/bootstrap │ │ ├── CardPaymentPage.jsx # RSA card form; calls /webapp/enets_pay │ │ ├── ResultPage.jsx # Payment outcome │ │ └── TermsPage.jsx # Terms of Use │ ├── components/ # Card, DetailRow, Logo, ErrorCard │ └── lib/ # rsa.js, cardBrand.js, validation.js └── __tests__/ # Vitest + Testing Library tests