Files
claudetools/.claude/skills/yealink-ymcs/scripts/ymcs.py
Mike Swanson 850e685d8d feat: yealink-ymcs skill — YMCS v2 device-management API, pairs with packetdial
New skill to manage ACG's Yealink phone fleets via Yealink Management Cloud Service v2
(us-api.ymcs.yealink.com). RTFM'd the API (token auth via POST /v2/token Basic+bearer, NOT the
legacy RPS HMAC; legacy-TLS renegotiation required) + endpoint map from the dszp/n8n-nodes-
yealinkymcs community node. Live-verified: token auth, sites (one ACG AccessKey sees ALL client
sites — VWP/GuruHQ/Ace Pick Up Parks as children of the ACG parent), devices, accounts,
rps-servers (RPS = "WL - ACG" ftp://p.packetdials.net). Gated writes (--confirm): add-devices-by-mac,
add-sipaccount (push a NetSapiens SIP cred onto a phone = the PBX glue), reboot, reset, rps add/del;
+ raw passthrough (auto-recovers the MSYS /v2 path-mangling). Creds vaulted at
services/yealink-ymcs.sops.yaml. Pairs with packetdial onboard-domain for new-client phone
provisioning; VWP is the live pilot. Honest [V]/[P] verification status in SKILL.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:59:52 -07:00

112 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""ymcs.py — CLI for the Yealink Management Cloud Service (YMCS) v2 API.
Read-only by default; every write (add/delete/reboot/reset/RPS) is gated behind
--confirm. Pairs with the `packetdial` skill for phone provisioning.
ymcs.py sites|devices|accounts|sipaccounts|device-groups|device-configs
firmwares|official-firmwares|models|alarms|oplogs|rps-servers|rps-devices
ymcs.py devices --site <siteId> # filter a list by site
ymcs.py add-devices-by-mac --body '{...}' --confirm
ymcs.py reboot --body '{"ids":[...]}' --confirm
ymcs.py rps-add --body '{...}' --confirm
ymcs.py raw POST /v2/dm/listDevices --body '{"skip":0,"limit":50,"autoCount":true}'
"""
import sys, os, json, argparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ymcs_client import YmcsClient, YmcsError
def _emit(obj):
print(json.dumps(obj, indent=2, default=str))
def _parse_body(s):
if not s:
return {}
try:
return json.loads(s)
except json.JSONDecodeError as e:
print(f"[ERROR] --body is not valid JSON: {e}", file=sys.stderr); sys.exit(2)
def _require_confirm(args, action, detail=""):
if not getattr(args, "confirm", False):
print(f"[DRY RUN] Would {action}" + (f": {detail}" if detail else ""))
print("Refusing to perform a write without --confirm. Re-run with --confirm.")
sys.exit(0)
def main(argv=None):
p = argparse.ArgumentParser(prog="ymcs.py", description="Yealink YMCS v2 API client")
sub = p.add_subparsers(dest="cmd", required=True)
# reads
for name in ["sites", "accounts", "device-groups", "device-configs",
"firmwares", "official-firmwares", "models", "alarms", "oplogs",
"rps-servers", "rps-devices"]:
sub.add_parser(name)
d = sub.add_parser("devices"); d.add_argument("--site")
# writes (gated)
for name in ["add-devices-by-mac", "add-devices", "del-devices", "reboot", "reset",
"rps-add", "rps-del", "add-sipaccount"]:
w = sub.add_parser(name); w.add_argument("--body", required=True); w.add_argument("--confirm", action="store_true")
r = sub.add_parser("raw")
r.add_argument("method", choices=["GET", "POST", "PUT", "PATCH", "DELETE"])
r.add_argument("path"); r.add_argument("--body"); r.add_argument("--confirm", action="store_true")
args = p.parse_args(argv)
try:
c = YmcsClient()
if args.cmd == "sites": _emit(c.list_sites())
elif args.cmd == "devices": _emit(c.list_devices(**({"siteId": args.site} if args.site else {})))
elif args.cmd == "accounts": _emit(c.list_accounts())
elif args.cmd == "device-groups": _emit(c.list_device_groups())
elif args.cmd == "device-configs": _emit(c.list_device_configs())
elif args.cmd == "firmwares": _emit(c.list_firmwares())
elif args.cmd == "official-firmwares": _emit(c.list_official_firmwares())
elif args.cmd == "models": _emit(c.models())
elif args.cmd == "alarms": _emit(c.list_alarms())
elif args.cmd == "oplogs": _emit(c.list_oplogs())
elif args.cmd == "rps-servers": _emit(c.rps_list_servers())
elif args.cmd == "rps-devices": _emit(c.rps_list_devices())
elif args.cmd == "add-devices-by-mac":
b = _parse_body(args.body); _require_confirm(args, "ADD devices by MAC", json.dumps(b)[:160]); _emit(c.add_devices_by_mac(b))
elif args.cmd == "add-devices":
b = _parse_body(args.body); _require_confirm(args, "ADD devices", json.dumps(b)[:160]); _emit(c.add_devices(b))
elif args.cmd == "del-devices":
b = _parse_body(args.body); _require_confirm(args, "DELETE devices", json.dumps(b)[:160]); _emit(c.del_devices(b))
elif args.cmd == "reboot":
b = _parse_body(args.body); _require_confirm(args, "REBOOT devices", json.dumps(b)[:160]); _emit(c.device_reboot(b))
elif args.cmd == "reset":
b = _parse_body(args.body); _require_confirm(args, "FACTORY-RESET devices", json.dumps(b)[:160]); _emit(c.device_reset(b))
elif args.cmd == "rps-add":
b = _parse_body(args.body); _require_confirm(args, "RPS add devices", json.dumps(b)[:160]); _emit(c.rps_add_devices(b))
elif args.cmd == "rps-del":
b = _parse_body(args.body); _require_confirm(args, "RPS delete devices", json.dumps(b)[:160]); _emit(c.rps_delete_devices(b))
elif args.cmd == "add-sipaccount":
b = _parse_body(args.body); _require_confirm(args, "ADD SIP account to device", json.dumps(b)[:160]); _emit(c.add_sip_account(b))
elif args.cmd == "raw":
b = _parse_body(args.body) if args.body else None
path = args.path
# Recover from Git-Bash MSYS path-conversion (a leading /v2/... arg gets
# rewritten to C:/Program Files/Git/v2/...). Cut back to the API path.
if "/v2/" in path:
path = path[path.index("/v2/"):]
if args.method != "GET":
_require_confirm(args, f"{args.method} {path}", json.dumps(b or {})[:160])
_emit(c.request(args.method, path, body=b))
else:
p.error(f"unknown command {args.cmd}")
except YmcsError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()