Building a Secure, Zero-Cost Yealink Phone Dashboard on Azure
Author
Amine Semouma
Date Published

We manage a fleet of Yealink Teams Phones spread across multiple sites. Every time someone asked "is that phone online?" or "what firmware are we running?", it meant logging into the YMCS portal, clicking around, and manually piecing together a picture.
So I built a proper dashboard. One URL, always up to date, zero manual effort. Here's how it works and everything I ran into along the way.
The Stack
Everything runs on Azure, mostly on the free tier:

The architecture is deliberately simple. A Python timer function hits the YMCS API every five minutes, writes a JSON snapshot to Blob Storage, and an HTTP function serves that JSON to the dashboard. The dashboard is a single HTML file that polls the function every five minutes and renders the data.
No databases. No servers to manage. No moving parts.
The Function App
The core is a single function_app.py file with three triggers registered on one FunctionApp instance.
Authentication
YMCS uses Basic Auth for token exchange, then Bearer tokens for API calls. Each request gets a fresh timestamp and nonce:
1def get_basic_auth_header(client_id, client_secret):2 credentials = f"{client_id}:{client_secret}"3 encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')4 return f"Basic {encoded}"56def get_auth_headers(access_token):7 timestamp, nonce = str(int(time.time() * 1000)), uuid.uuid4().hex8 return {9 'Authorization': f'Bearer {access_token}',10 'Content-Type': 'application/json',11 'timestamp': timestamp,12 'nonce': nonce13 }
The Timer Trigger
Runs every five minutes on startup, writes the full phone inventory to blob storage:
1@app.timer_trigger(schedule="0 */5 * * * *", arg_name="timer",2 run_on_startup=True)3def ymcs_refresh(timer: func.TimerRequest) -> None:4 client_id = os.environ.get('YMCS_CLIENT_ID', '')5 client_secret = os.environ.get('YMCS_CLIENT_SECRET', '')6 region = os.environ.get('YMCS_REGION', 'eu')78 data, error = discover_phones(client_id, client_secret, region)9 if error:10 logging.error(f"Discovery failed: {error}")11 return1213 upload_data(data)14
The HTTP Trigger
Serves the latest snapshot to the dashboard. Anonymous auth so the browser can call it directly:
1@app.route(route="phones", auth_level=func.AuthLevel.ANONYMOUS,2 methods=["GET"])3def get_phones(req: func.HttpRequest) -> func.HttpResponse:4 data = download_data()5 if not data:6 return func.HttpResponse(7 json.dumps({'error': 'No data available yet.'}),8 status_code=404,9 mimetype='application/json',10 headers={'Access-Control-Allow-Origin': '*'}11 )12 return func.HttpResponse(13 json.dumps(data),14 mimetype='application/json',15 headers={16 'Access-Control-Allow-Origin': '*',17 'Cache-Control': 'no-cache'18 }19 )20
The Dashboard
The entire UI is a single index.html - no build step, no framework, no dependencies beyond two Google Fonts. It uses CSS custom properties for theming and vanilla JS for data fetching and rendering.
On load it hits /.auth/me to check who's logged in, then calls the Function API and renders everything client-side. The dashboard auto-refreshes every five minutes to stay in sync with the timer function, with a live countdown in the header.
The phone table is sortable by any column, searchable across all fields, and filterable by online/offline status. Four summary cards give you the headline numbers — total phones, online count, offline count, and a health score percentage.
Deployment in One Command
The whole thing deploys with a single script. Fill in .env, run ./deploy.sh, done:
1YMCS_CLIENT_ID="your-client-id"2YMCS_CLIENT_SECRET="your-client-secret"3YMCS_REGION="eu"4AZURE_RESOURCE_GROUP="rg-ymcs-dashboard"5AZURE_LOCATION="uksouth"6AZURE_STORAGE_ACCOUNT="stymcsphones"7AZURE_FUNCTION_APP="func-ymcs-phones"
The script creates the resource group, storage account, and function app, pushes the Python code, enables static website hosting, and uploads the HTML — all idempotent, so re-running it is safe.
The Bugs (The Interesting Bit)
macOS bash 3.2 can't source a process substitution
The deploy script originally loaded .env using source with a process substitution pipe. This works fine on Linux but silently fails on macOS, which ships with bash 3.2 due to GPL licensing. Process substitution with source doesn't export variables in that version. Every credential showed up as empty.
The fix is trivially simple — bash handles # comments in sourced files natively, so you can source the file directly without piping through grep.
Azure blocks public blob access by default
The function creates its storage container on first run with public_access='blob'. Azure storage accounts created after 2023 have public blob access disabled at the account level. The ResourceExistsError this threw wasn't caught at the right level, causing every function invocation to return HTTP 500 with an empty body — making it look like a deployment or cold-start issue rather than a one-line bug.
Application Insights surfaced it immediately once queried. The fix was dropping the public_access argument entirely since the container doesn't need to be public.
Azure Static Web Apps Free tier strips identity claims
For access control I wanted to check the user's Entra ID tenant ID from /.auth/me. On the Free tier, the claims array is always empty — it's a Standard tier feature at $9/month. The tid claim I was looking for was never there.
The fix is to check userDetails (the email address) instead, which is available on all tiers. Not as cryptographically solid as a tenant ID check, but for an internal org dashboard it's perfectly adequate:
1const email = (user.userDetails || '').toLowerCase();2if (!email.endsWith('@' + ALLOWED_DOMAIN)) {3 // block access4}
What's Deployed
The live dashboard is running at the Azure Static Web App endpoint, monitoring 102 phones across multiple sites with 97 online at time of writing — 95.1% availability. It auto-refreshes every five minutes and is locked to @semouma.com accounts via Entra ID combined with an email domain check.
The full source is on GitHub: aminkuu/ymcs-phones.
What I'd add
Upgrade to SWA Standard tier for proper auth. The Free tier auth limitations — no claims, no custom app registration — are real. At £9/month it's cheap enough to justify for anything beyond a personal project.
Add alerting. The timer function already has all the data. It wouldn't take much to post to Teams or send an email when the offline count crosses a threshold.
Paginate the phone table. With 102 phones it's fine. At 500+ it'll get sluggish.
The full deployment guide, all scripts, and source code are at github.com/aminkuu/ymcs-phones.