get_meter_balance
Get meter balance
Get the current prepaid credit balance of a meter, in SGD.
systemrequiredmeter_idrequiredio.github.nicholasohjjPublished by GitHub account nicholasohjjv2.0.1Updated Nov 5, 2026
Check NUS and SUTD hostel EVS electricity meters and get top-up links. Unofficial.
Read-only MCP server for NUS and SUTD hostel EVS prepaid electricity meters: check balance, usage, top-ups, and get a card top-up link.
Web executable via Streamable HTTP. Remote servers run from the web runtime once configured in a workspace.
Paste this prompt into Claude Code, Codex, Cursor or another agent — or into SourceWeft chat. The agent reads this server's install guide, shows you its publisher, endpoint and sign-in, and adds it to your client once you agree. Keys never go into a command.
Read https://sourceweft.com/mcp/io.github.nicholasohjj%2Fnusaircon/install.md and connect the MCP server it describes to my client. Before changing any config, show me its publisher, endpoint and sign-in method and wait for my OK. Never put an API key in a command or in a file I would commit; ask me where it should come from.Add this to your client's mcpServers config.
{
"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.
Source: README.md at commit 0cbcdb9
Get meter balance
Get the current prepaid credit balance of a meter, in SGD.
systemrequiredmeter_idrequiredGet 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_idrequiredGet 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.
systemrequiredmeter_idrequiredGet 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.
systemrequiredmeter_idrequiredamount_sgdrequired100% of the applicable checks met
How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.
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