Files
synology/scripts/synology_api_login.py
Timmy 321456d4e6 Initial commit: Synology DSM API skill
- Add synology_api.py: DSM API client library
- Add synology_api_login.py: CLI tool for login and system info
- Add SKILL.md: Skill definition with usage examples
- Add CLAUDE.md: Developer documentation
- Add references/: Example configs and usage guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:55:43 +08:00

432 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Synology DSM API 自動登入 CLI使用 synology_api.py
預設:
DS_URL=https://syno.lotimmy.com/
DS_USER, DS_PASSWORD 從環境變數或 .env 讀取
用法:
python3 scripts/synology_api_login.py
python3 scripts/synology_api_login.py --system-info
python3 scripts/synology_api_login.py --system-info --summary
python3 scripts/synology_api_login.py --system-info --json-compact
輸出:
- tmp/synology_api_login_result.json (回應摘要)
- tmp/synology_session.json (sid 與 cookie)
- tmp/synology_system_info.json (System Information--system-info 時)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Callable
HOSTS_FILE = Path(__file__).resolve().parents[1] / "references" / "hosts.json"
from synology_api import DSMApiClient, load_dotenv
load_dotenv()
DEFAULT_URL = os.getenv("DS_URL", "https://syno.lotimmy.com/")
DEFAULT_USER = os.getenv("DS_USER", "")
DEFAULT_PASSWORD = os.getenv("DS_PASSWORD", "")
OUT_RESULT = os.getenv("DS_LOGIN_RESULT", "tmp/synology_api_login_result.json")
OUT_SESSION = os.getenv("DS_SESSION_FILE", "tmp/synology_session.json")
OUT_SYSINFO = os.getenv("DS_SYSINFO_FILE", "tmp/synology_system_info.json")
def load_hosts() -> dict[str, Any]:
if not HOSTS_FILE.exists():
return {}
try:
return json.loads(HOSTS_FILE.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return {}
def resolve_profile(profile_name: str) -> tuple[str, str, str]:
hosts = load_hosts()
profile = hosts.get(profile_name)
if not isinstance(profile, dict):
raise SystemExit(f"[x] 找不到 profile{profile_name}(檔案:{HOSTS_FILE}")
url = str(profile.get("url", "")).strip()
user = str(profile.get("user", "")).strip()
password = str(profile.get("password", "")).strip()
password_env = str(profile.get("password_env", "")).strip()
if not password and password_env:
password = os.getenv(password_env, "").strip()
if not url or not user or not password:
raise SystemExit(f"[x] profile {profile_name} 缺少 url/user/password或 password_env 未設定)")
return url, user, password
def _find_key(obj: Any, keys: tuple[str, ...]) -> Any:
if isinstance(obj, dict):
for k, v in obj.items():
lk = k.lower()
if any(x in lk for x in keys):
return v
for v in obj.values():
hit = _find_key(v, keys)
if hit is not None:
return hit
elif isinstance(obj, list):
for item in obj:
hit = _find_key(item, keys)
if hit is not None:
return hit
return None
def _to_int(value: Any) -> int | None:
try:
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return int(value)
v = str(value).strip()
if not v:
return None
return int(float(v))
except (TypeError, ValueError):
return None
def _to_gib(value: int | None) -> float | None:
if value is None:
return None
return round(value / (1024 ** 3), 2)
def _build_compact_summary(bundle: dict[str, Any]) -> dict[str, Any]:
sysinfo = bundle.get("system_info", {})
util = bundle.get("utilization", {})
vols = bundle.get("volumes", {})
hms_disk = bundle.get("storage_hms_disk", {})
storage_cgi = bundle.get("storage_cgi_storage", {})
network_ethernet = bundle.get("network_ethernet", {})
sdata = sysinfo.get("response", {}).get("data", {})
udata = util.get("response", {}).get("data", {})
vdata = vols.get("response", {}).get("data", {})
hdata = hms_disk.get("response", {}).get("data", {})
scdata = storage_cgi.get("response", {}).get("data", {})
ndata = network_ethernet.get("response", {}).get("data", [])
if not ndata and isinstance(network_ethernet.get("data"), list):
ndata = network_ethernet.get("data", [])
cpu_obj = udata.get("cpu", {}) if isinstance(udata, dict) else {}
cpu_usage_pct = None
if isinstance(cpu_obj, dict):
cpu_usage_pct = cpu_obj.get("user_load", 0) + cpu_obj.get("system_load", 0) + cpu_obj.get("other_load", 0)
mem_obj = udata.get("memory", {}) if isinstance(udata, dict) else {}
mem_usage_pct = mem_obj.get("real_usage") if isinstance(mem_obj, dict) else None
volume_obj = vdata.get("volumes", vdata.get("volume", vdata))
if not volume_obj:
volume_obj = scdata.get("volumes", []) if isinstance(scdata, dict) else []
if not volume_obj:
volume_obj = udata.get("space", {}).get("volume", []) if isinstance(udata, dict) else []
volumes: list[str] = []
volume_usage: dict[str, Any] = {}
disk_size_usage: dict[str, Any] = {}
if isinstance(volume_obj, list) and volume_obj:
for v in volume_obj:
if not isinstance(v, dict):
continue
name = (
v.get("display_name")
or v.get("name")
or v.get("volume_path")
or v.get("path")
or (f"volume{v.get('num_id')}" if v.get('num_id') is not None else None)
or v.get("device")
or v.get("id")
)
if not name:
continue
volumes.append(name)
usage = v.get("utilization")
if usage is not None:
volume_usage[name] = {
"utilization_pct": usage,
"read_byte": v.get("read_byte"),
"write_byte": v.get("write_byte"),
}
size_obj = v.get("size") if isinstance(v.get("size"), dict) else {}
total_bytes = (
v.get("total")
or v.get("total_size")
or v.get("size_total")
or v.get("capacity")
or size_obj.get("total")
)
used_bytes = (
v.get("used")
or v.get("used_size")
or v.get("size_used")
or v.get("used_space")
or size_obj.get("used")
)
free_bytes = (
v.get("free")
or v.get("free_size")
or v.get("size_free")
or v.get("available")
or size_obj.get("free")
)
total_i = _to_int(total_bytes)
used_i = _to_int(used_bytes)
free_i = _to_int(free_bytes)
if free_i is None and total_i is not None and used_i is not None:
free_i = max(total_i - used_i, 0)
if total_i is not None or used_i is not None or free_i is not None:
disk_size_usage[name] = {
"total_bytes": total_i,
"used_bytes": used_i,
"free_bytes": free_i,
"total_gib": _to_gib(total_i),
"used_gib": _to_gib(used_i),
"free_gib": _to_gib(free_i),
}
elif isinstance(volume_obj, dict) and volume_obj:
volumes = list(volume_obj.keys())
disk_health = hdata.get("disk", hdata.get("disks", hdata))
if not disk_health:
disk_health = {}
for d in scdata.get("disks", []) if isinstance(scdata, dict) else []:
if not isinstance(d, dict):
continue
name = d.get("name") or d.get("device") or d.get("id")
if not name:
continue
vendor = d.get("vendor")
if isinstance(vendor, str):
vendor = vendor.strip()
disk_health[name] = {
"status": d.get("status"),
"smart_status": d.get("smart_status"),
"temp_c": d.get("temp"),
"model": d.get("model"),
"vendor": vendor,
"disk_type": d.get("diskType"),
}
if isinstance(disk_health, dict):
for _, info in disk_health.items():
if isinstance(info, dict) and isinstance(info.get("vendor"), str):
info["vendor"] = info["vendor"].strip()
primary_ip = None
if isinstance(ndata, list):
for nic in ndata:
if not isinstance(nic, dict):
continue
if nic.get("ifname") == "eth0" and nic.get("ip"):
primary_ip = nic.get("ip")
break
if primary_ip is None:
for nic in ndata:
if isinstance(nic, dict) and nic.get("ip") and nic.get("ip") != "":
primary_ip = nic.get("ip")
break
serial = sdata.get("serial")
result = {
"model": sdata.get("model"),
"serial": serial,
"ip": primary_ip,
"dsm_version": sdata.get("firmware_ver"),
"cpu_usage_pct": cpu_usage_pct,
"cpu_clock_mhz": sdata.get("cpu_clock_speed"),
"memory_total_mb": sdata.get("ram_size"),
"memory_usage_pct": mem_usage_pct,
"temperature_c": sdata.get("sys_temp", _find_key(sdata, ("temperature", "temp"))),
"volumes": volumes,
"disk_size_usage": disk_size_usage,
"disk_health": disk_health,
"time": sdata.get("time"),
"uptime": sdata.get("up_time"),
}
return result
def _safe_call(fn: Callable[[], dict[str, Any]], name: str) -> dict[str, Any]:
try:
return fn()
except Exception as exc: # noqa: BLE001
return {
"success": False,
"error": f"{name} failed: {exc}",
"response": {"data": {}},
}
def _load_cached_sid(session_file: str, expected_base: str) -> str:
p = Path(session_file)
if not p.exists():
return ""
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return ""
base = str(data.get("base", "")).strip().rstrip("/")
sid = str(data.get("sid", "")).strip()
if not sid:
return ""
if base and base != expected_base.rstrip("/"):
return ""
return sid
def _is_sid_valid(client: DSMApiClient) -> bool:
probe = _safe_call(client.get_system_info, "get_system_info")
return bool(probe.get("success") is True)
def _print_summary(bundle: dict[str, Any]) -> None:
c = _build_compact_summary(bundle)
vol_summary = ", ".join(c.get("volumes", [])) if c.get("volumes") else "N/A"
print("[SUMMARY] System Information")
print(f"- CPU 使用率: {str(c.get('cpu_usage_pct')) + '%' if c.get('cpu_usage_pct') is not None else 'N/A'}")
print(f"- CPU 頻率: {str(c.get('cpu_clock_mhz')) + ' MHz' if c.get('cpu_clock_mhz') is not None else 'N/A'}")
print(f"- 記憶體(總量): {str(c.get('memory_total_mb')) + ' MB' if c.get('memory_total_mb') is not None else 'N/A'}")
print(f"- 記憶體(使用): {str(c.get('memory_usage_pct')) + '%' if c.get('memory_usage_pct') is not None else 'N/A'}")
print(f"- 溫度: {str(c.get('temperature_c')) + '°C' if c.get('temperature_c') is not None else 'N/A'}")
print(f"- Volume: {vol_summary}")
size_usage = c.get("disk_size_usage", {})
if isinstance(size_usage, dict) and size_usage:
print("- 容量:")
for vname, vinfo in size_usage.items():
if not isinstance(vinfo, dict):
continue
t = vinfo.get("total_gib")
u = vinfo.get("used_gib")
f = vinfo.get("free_gib")
print(f" - {vname}: 總 {t} GiB / 已用 {u} GiB / 可用 {f} GiB")
def main() -> int:
parser = argparse.ArgumentParser(description="DSM API login (no Playwright)")
parser.add_argument("--profile", help="主機設定名稱(讀 references/hosts.json")
parser.add_argument("--url", default=DEFAULT_URL, help="DSM URL")
parser.add_argument("--user", default=DEFAULT_USER, help="帳號(預設 DS_USER")
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="密碼(預設 DS_PASSWORD")
parser.add_argument("--system-info", action="store_true", help="登入後抓取 System Information")
parser.add_argument("--summary", action="store_true", help="搭配 --system-info 顯示人類可讀摘要")
parser.add_argument("--json-compact", action="store_true", help="搭配 --system-info 輸出精簡 JSON")
args = parser.parse_args()
if args.profile:
args.url, args.user, args.password = resolve_profile(args.profile)
client = DSMApiClient(base_url=args.url)
Path("tmp").mkdir(parents=True, exist_ok=True)
used_cached_sid = False
cached_sid = _load_cached_sid(OUT_SESSION, client.base_url)
if cached_sid:
client.sid = cached_sid
if _is_sid_valid(client):
used_cached_sid = True
result_obj: dict[str, Any]
if used_cached_sid:
result_obj = {
"success": True,
"meta": {
"base": client.base_url,
"source": "cached_sid",
"session_file": OUT_SESSION,
},
"data": {"sid": client.sid},
}
print("[✓] DSM API Session 可用(沿用既有 SID")
print(f"[i] SID: {client.sid[:8]}... (來源 {OUT_SESSION})")
else:
if not args.user or not args.password:
print("[x] 缺少帳密:請設定 DS_USER / DS_PASSWORD 或傳 --user/--password", file=sys.stderr)
return 2
result = client.login(user=args.user, password=args.password)
result_obj = {
"success": result.success,
"meta": result.meta,
"data": result.data,
}
if not result.success:
Path(OUT_RESULT).write_text(json.dumps(result_obj, ensure_ascii=False, indent=2), encoding="utf-8")
print("[x] DSM API 登入失敗", file=sys.stderr)
print(f"[i] 詳細結果:{OUT_RESULT}", file=sys.stderr)
return 1
session_obj = client.export_session(result)
Path(OUT_SESSION).write_text(json.dumps(session_obj, ensure_ascii=False, indent=2), encoding="utf-8")
sid = session_obj.get("sid", "")
print("[✓] DSM API 登入成功")
print(f"[i] SID: {sid[:8]}... (已寫入 {OUT_SESSION})")
Path(OUT_RESULT).write_text(json.dumps(result_obj, ensure_ascii=False, indent=2), encoding="utf-8")
if args.system_info:
sysinfo = _safe_call(client.get_system_info, "get_system_info")
util = _safe_call(client.get_utilization, "get_utilization")
vols = _safe_call(client.get_volumes, "get_volumes")
hms_disk = _safe_call(client.get_storage_hms_disk, "get_storage_hms_disk")
storage_cgi = _safe_call(client.get_storage_cgi_storage, "get_storage_cgi_storage")
network_ethernet = _safe_call(
lambda: client.api_get(
"/webapi/entry.cgi",
{"api": "SYNO.Core.Network.Ethernet", "version": "1", "method": "list"},
),
"get_network_ethernet",
)
bundle = {
"system_info": sysinfo,
"utilization": util,
"volumes": vols,
"storage_hms_disk": hms_disk,
"storage_cgi_storage": storage_cgi,
"network_ethernet": network_ethernet,
}
Path(OUT_SYSINFO).write_text(json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8")
if sysinfo.get("success"):
print(f"[i] System Information 已寫入:{OUT_SYSINFO}")
if args.summary:
_print_summary(bundle)
if args.json_compact:
print(json.dumps(_build_compact_summary(bundle), ensure_ascii=False))
else:
print(f"[!] System Information 取得失敗,已寫入:{OUT_SYSINFO}")
print(f"[i] 詳細結果:{OUT_RESULT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())