用 Python + MySQL + Web 打造我的私有 Apple 设备监控面板

一个完整的全栈项目实战:从 iCloud 获取设备信息,存储在 MySQL 数据库,通过 RESTful API 提供数据接口,并打造美观的 Web 监控大屏。

作为 Apple 生态的重度用户,我拥有 iPhone、iPad、MacBook 等多台设备。日常使用中,我希望能在一个统一的界面上查看所有设备的电量、在线状态等信息。虽然 Apple 提供了"查找"应用,但我想要:

  1. 私有化部署:数据存储在自己控制的服务器上
  2. 历史记录:可以查看设备状态的历史变化
  3. 自定义展示:根据需求定制化的监控界面
  4. API 接口:方便与其他系统集成

于是,我决定自己动手搭建一个完整的设备监控系统。

仓库地址:
仓库地址
效果预览

项目功能

  • ✅ 自动从 iCloud 获取设备信息(电量、状态、位置等)
  • ✅ 数据持久化存储到 MySQL 数据库
  • ✅ RESTful API 提供灵活的数据查询接口
  • ✅ 美观的 Web 监控大屏,支持实时刷新
  • ✅ 按设备类型分类展示(手机、平板、电脑)
  • ✅ 设备统计信息(总数、在线数、平均电量等)
  • ✅ 定时任务自动更新设备状态

技术栈

  • Python 3 + pyicloud - 数据采集
  • Node.js + Koa2 - API 服务
  • MySQL - 数据存储
  • HTML5 + Tailwind CSS + Vanilla JavaScript - 前端展示

架构设计

复制代码
iCloud API → Python脚本 → MySQL数据库 → Koa2 API → Web前端

完整代码实现

1. Python 数据采集脚本

安装依赖
bash 复制代码
pip3 install pyicloud mysql-connector-python
完整代码(update_devices.py)
python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
iCloud 设备信息同步脚本
用途:定时拉取设备状态(电量等)存入 MySQL
"""

import os
import sys
import json
import logging
from datetime import datetime

# === 配置区(请按你的情况修改)===
ICLOUD_EMAIL = "your_apple_id@example.com"  # ← 改成你的 Apple ID
ICLOUD_PASSWORD = "your_app_specific_password"  # ← 强烈建议用「专用密码」!
CHINA_MAINLAND = True  # 如果是中国大陆账户,设为 True;否则设为 False

MYSQL_CONFIG = {
    "host": "localhost",  # 数据库主机
    "port": 3306,
    "user": "your_username",
    "password": "your_password",
    "database": "your_database",
    "charset": "utf8mb4"
}

LOG_FILE = ""  # 留空则只输出到 stdout

# === 初始化日志 ===
if LOG_FILE:
    log_dir = os.path.dirname(LOG_FILE)
    if log_dir:
        try:
            os.makedirs(log_dir, exist_ok=True)
        except (OSError, PermissionError) as e:
            print(f"警告: 无法创建日志目录 {log_dir}: {e}")
            LOG_FILE = ""
    
    if LOG_FILE:
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s | %(levelname)s | %(message)s',
            handlers=[
                logging.FileHandler(LOG_FILE, encoding='utf-8'),
                logging.StreamHandler(sys.stdout)
            ]
        )
    else:
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s | %(levelname)s | %(message)s',
            handlers=[logging.StreamHandler(sys.stdout)]
        )
else:
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s | %(levelname)s | %(message)s',
        handlers=[logging.StreamHandler(sys.stdout)]
    )

logger = logging.getLogger(__name__)

# === 主逻辑 ===
def main():
    try:
        logger.info("▶ 开始同步 iCloud 设备信息...")

        # 1. 登录 iCloud
        from pyicloud import PyiCloudService
        from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudServiceUnavailable
        
        try:
            api = PyiCloudService(ICLOUD_EMAIL, ICLOUD_PASSWORD, china_mainland=CHINA_MAINLAND)
        except PyiCloudFailedLoginException as e:
            logger.error(f"❌ 登录失败: {e}")
            sys.exit(1)

        # 检查是否需要二次验证
        IS_INTERACTIVE = os.isatty(sys.stdin.fileno()) if hasattr(sys.stdin, 'fileno') else False
        
        if api.requires_2fa:
            if not IS_INTERACTIVE:
                logger.error("❌ 需要双重认证(2FA),但当前运行在非交互模式下")
                logger.error("请先手动运行一次脚本来完成认证")
                sys.exit(2)
            
            logger.info("需要进行两步验证(2FA)")
            
            security_key_names = api.security_key_names
            if security_key_names:
                logger.info(f"需要安全密钥确认。请插入以下密钥之一: {', '.join(security_key_names)}")
                devices = api.fido2_devices
                logger.info("可用的 FIDO2 设备:")
                for idx, dev in enumerate(devices, start=1):
                    logger.info(f"  {idx}: {dev}")
                
                choice = input("请选择 FIDO2 设备编号(直接回车使用第一个): ")
                if not choice:
                    choice = 1
                else:
                    choice = int(choice)
                
                selected_device = devices[choice - 1]
                logger.info("请使用安全密钥确认操作")
                api.confirm_security_key(selected_device)
            else:
                logger.info("验证码已发送到你已批准的设备上。")
                code = input("请输入收到的验证码: ")
                result = api.validate_2fa_code(code)
                logger.info(f"验证码验证结果: {result}")

                if not result:
                    logger.error("验证码验证失败")
                    sys.exit(1)

            # 验证成功后,检查会话是否被信任
            if not api.is_trusted_session:
                logger.info("会话未被信任。正在请求信任...")
                result = api.trust_session()
                logger.info(f"会话信任结果: {result}")

        elif api.requires_2sa:
            if not IS_INTERACTIVE:
                logger.error("❌ 需要两步认证(2SA),但当前运行在非交互模式下")
                sys.exit(2)
            
            logger.info("需要进行两步认证(2SA)")
            logger.info("你的可信任设备:")
            
            devices = api.trusted_devices
            for i, device in enumerate(devices):
                device_name = device.get('deviceName', f"SMS to {device.get('phoneNumber', '未知')}")
                logger.info(f"  {i}: {device_name}")

            device_choice = input('请选择要使用的设备编号(直接回车使用第一个): ')
            if not device_choice:
                device_choice = 0
            else:
                device_choice = int(device_choice)
            
            device = devices[device_choice]
            if not api.send_verification_code(device):
                logger.error("发送验证码失败")
                sys.exit(1)

            code = input('请输入验证码: ')
            if not api.validate_verification_code(device, code):
                logger.error("验证码验证失败")
                sys.exit(1)
        
        logger.info("✓ 认证完成")

        # 2. 连接 MySQL
        import mysql.connector
        db = mysql.connector.connect(**MYSQL_CONFIG)
        cursor = db.cursor()

        # 3. 确保表存在
        cursor.execute("""
        CREATE TABLE IF NOT EXISTS `devices` (
          `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
          `device_id` VARCHAR(100) NOT NULL UNIQUE,
          `name` VARCHAR(100),
          `model` VARCHAR(60),
          `device_class` VARCHAR(20),
          `battery_level` DECIMAL(3,2),
          `battery_status` VARCHAR(20),
          `os_version` VARCHAR(30),
          `last_location` TEXT,
          `last_seen` DATETIME,
          `is_online` BOOLEAN,
          `raw_data` JSON,
          `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
        """)
        
        # 4. 获取设备列表
        try:
            devices = api.devices
        except PyiCloudServiceUnavailable as e:
            logger.error(f"❌ Find My iPhone 服务不可用: {e}")
            sys.exit(1)
        
        if not devices:
            logger.warning("⚠ 未找到任何设备")
            return
        
        # 5. 遍历设备并保存
        count = 0
        for dev in devices:
            try:
                # 请求完整的设备属性列表
                requested_properties = [
                    'deviceDisplayName', 'name', 'deviceStatus', 'batteryLevel', 'batteryStatus',
                    'deviceModel', 'model', 'modelDisplayName',
                    'deviceClass', 'deviceClassDisplay', 'deviceType',
                    'osVersion', 'deviceOSType', 'osVersionDisplay', 'systemVersion',
                    'serialNumber', 'id', 'deviceId',
                    'location', 'deviceStatusTime', 'timestamp', 'locationTimeStamp', 'timeStamp'
                ]
                
                try:
                    status = dev.status(requested_properties)
                except TypeError:
                    status = dev.status()
                
                # 提取设备信息
                device_id = (status.get('serialNumber') or 
                           status.get('id') or 
                           status.get('deviceId') or
                           status.get('deviceDisplayName') or 
                           str(dev.id) if hasattr(dev, 'id') else str(dev))
                
                name = status.get('deviceDisplayName') or status.get('name', 'Unknown')
                model = status.get('deviceModel') or status.get('model', 'Unknown')
                device_class = status.get('deviceClass') or status.get('deviceClassDisplay', 'Unknown')
                battery_level = status.get('batteryLevel')  # 0-1 之间的小数
                battery_status = status.get('batteryStatus', 'Unknown')
                os_version = (status.get('osVersion') or 
                            status.get('deviceOSType') or 
                            status.get('osVersionDisplay') or 'Unknown')
                
                location = status.get('location')
                if not location and hasattr(dev, 'location'):
                    try:
                        location_data = dev.location()
                        if location_data:
                            location = location_data
                    except Exception:
                        pass
                
                last_seen = (status.get('deviceStatusTime') or 
                           status.get('timestamp') or 
                           status.get('locationTimeStamp'))
                
                is_online = status.get('deviceStatus') == '200'

                # 时间转换
                if isinstance(last_seen, (int, float)):
                    last_seen = datetime.fromtimestamp(last_seen / 1000.0)
                else:
                    last_seen = None

                loc_str = json.dumps(location, ensure_ascii=False) if location else None

                # 插入/更新数据库
                sql = """
                INSERT INTO `devices` (
                    device_id, name, model, device_class,
                    battery_level, battery_status, os_version,
                    last_location, last_seen, is_online, raw_data
                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                ON DUPLICATE KEY UPDATE
                    name = VALUES(name),
                    model = VALUES(model),
                    battery_level = VALUES(battery_level),
                    battery_status = VALUES(battery_status),
                    os_version = VALUES(os_version),
                    last_location = VALUES(last_location),
                    last_seen = VALUES(last_seen),
                    is_online = VALUES(is_online),
                    raw_data = VALUES(raw_data),
                    updated_at = NOW()
                """
                cursor.execute(sql, (
                    device_id, name, model, device_class,
                    battery_level, battery_status, os_version,
                    loc_str, last_seen, is_online, json.dumps(status, ensure_ascii=False)
                ))
                count += 1
                logger.info(f"✓ 已同步: {name} | 电量: {battery_level or 'N/A'}")

            except Exception as e:
                logger.error(f"⚠ 设备同步失败 ({dev}): {e}", exc_info=True)

        db.commit()
        logger.info(f"✅ 同步完成!共 {count} 台设备")

    except Exception as e:
        logger.error(f"❌ 全局错误: {e}", exc_info=True)
        sys.exit(1)
    finally:
        try:
            cursor.close()
            db.close()
        except:
            pass

if __name__ == "__main__":
    main()

2. Node.js API 服务

安装依赖
bash 复制代码
npm install koa koa-router koa-bodyparser mysql2 dotenv
完整代码(app.js)
javascript 复制代码
const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');
require('dotenv').config();

const app = new Koa();
const router = new Router();

// 数据库配置
const dbConfig = {
  host: process.env.DB_HOST || 'localhost',
  port: process.env.DB_PORT || 3306,
  user: process.env.DB_USER || 'root',
  password: process.env.DB_PASSWORD || '',
  database: process.env.DB_NAME || 'devices',
  charset: 'utf8mb4'
};

const mysql = require('mysql2/promise');

// 跨域中间件
app.use(async (ctx, next) => {
  ctx.set('Access-Control-Allow-Origin', '*');
  ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
  ctx.set('Access-Control-Allow-Credentials', 'true');
  
  if (ctx.method === 'OPTIONS') {
    ctx.status = 204;
    return;
  }
  
  await next();
});

app.use(bodyParser());

// 数据库连接池
let pool = null;

async function getPool() {
  if (!pool) {
    pool = mysql.createPool({
      ...dbConfig,
      waitForConnections: true,
      connectionLimit: 10,
      queueLimit: 0
    });
  }
  return pool;
}

// 查询所有设备(完整信息)
router.get('/api/devices/all', async (ctx) => {
  try {
    const pool = await getPool();
    const [rows] = await pool.execute(`
      SELECT 
        id, device_id, name, model, device_class,
        battery_level, battery_status, os_version,
        last_location, last_seen, is_online, raw_data, updated_at
      FROM devices
      ORDER BY updated_at DESC
    `);
    
    // 解析 JSON 字段
    const devices = rows.map(device => {
      let parsedData = {};
      try {
        if (device.raw_data) {
          parsedData = typeof device.raw_data === 'string' 
            ? JSON.parse(device.raw_data) 
            : device.raw_data;
        }
      } catch (e) {
        console.error('解析 raw_data 失败:', e);
      }
      
      let parsedLocation = null;
      try {
        if (device.last_location) {
          parsedLocation = typeof device.last_location === 'string'
            ? JSON.parse(device.last_location)
            : device.last_location;
        }
      } catch (e) {
        console.error('解析 last_location 失败:', e);
      }
      
      return {
        ...device,
        raw_data: parsedData,
        last_location: parsedLocation
      };
    });
    
    ctx.body = {
      code: 200,
      message: 'success',
      data: devices,
      total: devices.length,
      timestamp: new Date().toISOString()
    };
  } catch (error) {
    ctx.status = 500;
    ctx.body = {
      code: 500,
      message: '查询失败',
      error: error.message
    };
  }
});

// 查询所有设备(简化信息)
router.get('/api/devices', async (ctx) => {
  try {
    const pool = await getPool();
    const [rows] = await pool.execute(`
      SELECT 
        id, device_id, name, model, device_class,
        battery_level, battery_status, os_version,
        last_location, last_seen, is_online, updated_at
      FROM devices
      ORDER BY updated_at DESC
    `);
    
    ctx.body = {
      code: 200,
      message: 'success',
      data: rows,
      total: rows.length
    };
  } catch (error) {
    ctx.status = 500;
    ctx.body = {
      code: 500,
      message: '查询失败',
      error: error.message
    };
  }
});

// 查询在线设备
router.get('/api/devices/online', async (ctx) => {
  try {
    const pool = await getPool();
    const [rows] = await pool.execute(`
      SELECT * FROM devices WHERE is_online = 1 ORDER BY updated_at DESC
    `);
    
    ctx.body = {
      code: 200,
      message: 'success',
      data: rows,
      total: rows.length
    };
  } catch (error) {
    ctx.status = 500;
    ctx.body = {
      code: 500,
      message: '查询失败',
      error: error.message
    };
  }
});

// 查询低电量设备(< 30%)
router.get('/api/devices/low-battery', async (ctx) => {
  try {
    const pool = await getPool();
    const [rows] = await pool.execute(`
      SELECT * FROM devices
      WHERE battery_level < 0.3 AND battery_level IS NOT NULL
      ORDER BY battery_level ASC
    `);
    
    ctx.body = {
      code: 200,
      message: 'success',
      data: rows,
      total: rows.length
    };
  } catch (error) {
    ctx.status = 500;
    ctx.body = {
      code: 500,
      message: '查询失败',
      error: error.message
    };
  }
});

// 设备统计信息
router.get('/api/devices/stats', async (ctx) => {
  try {
    const pool = await getPool();
    
    const [totalRows] = await pool.execute('SELECT COUNT(*) as total FROM devices');
    const [onlineRows] = await pool.execute('SELECT COUNT(*) as count FROM devices WHERE is_online = 1');
    const [lowBatteryRows] = await pool.execute('SELECT COUNT(*) as count FROM devices WHERE battery_level < 0.3 AND battery_level IS NOT NULL');
    const [avgBatteryRows] = await pool.execute('SELECT AVG(battery_level) as avg FROM devices WHERE battery_level IS NOT NULL');
    
    const [typeRows] = await pool.execute(`
      SELECT device_class, COUNT(*) as count 
      FROM devices 
      WHERE device_class IS NOT NULL 
      GROUP BY device_class
    `);

    ctx.body = {
      code: 200,
      message: 'success',
      data: {
        total: totalRows[0].total,
        online: onlineRows[0].count,
        offline: totalRows[0].total - onlineRows[0].count,
        lowBattery: lowBatteryRows[0].count,
        avgBattery: avgBatteryRows[0].avg ? parseFloat(avgBatteryRows[0].avg).toFixed(2) : null,
        byType: typeRows
      }
    };
  } catch (error) {
    ctx.status = 500;
    ctx.body = {
      code: 500,
      message: '查询失败',
      error: error.message
    };
  }
});

// 健康检查
router.get('/api/health', async (ctx) => {
  try {
    const pool = await getPool();
    await pool.execute('SELECT 1');
    ctx.body = {
      code: 200,
      message: '服务正常',
      timestamp: new Date().toISOString()
    };
  } catch (error) {
    ctx.status = 500;
    ctx.body = {
      code: 500,
      message: '数据库连接失败',
      error: error.message
    };
  }
});

app.use(router.routes()).use(router.allowedMethods());

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`🚀 服务器运行在 http://localhost:${PORT}`);
});

3. Web 前端页面

完整代码(index.html)

由于 HTML 代码较长,这里提供关键部分。完整代码请查看项目仓库或根据以下结构自行实现:

核心功能:

  1. 设备卡片渲染:根据设备类型(手机/平板/电脑)渲染不同的卡片样式
  2. 电量状态判断:自动识别电量格式(小数/百分比),正确显示状态
  3. 自动刷新:每30秒自动更新设备数据
  4. 响应式布局:使用 Tailwind CSS 实现美观的界面

关键 JavaScript 函数:

javascript 复制代码
const API_BASE_URL = 'http://your-api-server:3000';

// 标准化电池电量值(统一转换为 0-1 之间的小数)
function normalizeBatteryLevel(batteryLevel) {
  if (batteryLevel === null || batteryLevel === undefined) return null;
  if (batteryLevel > 1) {
    return batteryLevel / 100; // 百分比格式转小数
  }
  return batteryLevel; // 已经是小数格式
}

// 获取电池颜色
function getBatteryColor(batteryLevel) {
  const normalized = normalizeBatteryLevel(batteryLevel);
  if (normalized === null) return 'bg-gray-400';
  if (normalized >= 0.8) return 'bg-green-500';  // ≥80%
  if (normalized >= 0.3) return 'bg-orange-500'; // 30%-79%
  return 'bg-red-500';  // <30%
}

// 获取状态标签
function getStatusBadge(device) {
  const batteryLevel = device.battery_level;
  const isOnline = device.is_online;
  
  if (!isOnline) {
    return '<span class="status-badge bg-gray-100 text-gray-800">离线</span>';
  }
  
  if (batteryLevel === null || batteryLevel === undefined) {
    return '<span class="status-badge bg-blue-100 text-blue-800">在线</span>';
  }
  
  const normalized = normalizeBatteryLevel(batteryLevel);
  
  if (normalized >= 0.8) {
    return '<span class="status-badge bg-green-100 text-green-800">电量充足</span>';
  } else if (normalized >= 0.3) {
    return '<span class="status-badge bg-orange-100 text-orange-800">电量中等</span>';
  } else {
    return '<span class="status-badge bg-red-100 text-red-800">电量不足</span>';
  }
}

// 加载设备数据
async function loadDevices() {
  try {
    const response = await fetch(`${API_BASE_URL}/api/devices/all`);
    const result = await response.json();
    
    if (result.code === 200 && result.data) {
      const devices = result.data;
      
      // 按设备类型分类
      const phoneDevices = devices.filter(d => 
        d.device_class?.toLowerCase().includes('iphone') || 
        d.name?.toLowerCase().includes('iphone')
      );
      const tabletDevices = devices.filter(d => 
        d.device_class?.toLowerCase().includes('ipad') || 
        d.name?.toLowerCase().includes('ipad')
      );
      const computerDevices = devices.filter(d => 
        d.device_class?.toLowerCase().includes('mac') || 
        d.name?.toLowerCase().includes('mac')
      );
      
      // 渲染到对应区域
      document.getElementById('phoneDevices').innerHTML = 
        phoneDevices.map(device => renderDeviceCard(device)).join('');
      // ... 其他设备类型
      
      // 更新统计信息
      updateStats(devices);
    }
  } catch (error) {
    console.error('加载设备失败:', error);
  }
}

// 页面加载时自动加载,每30秒自动刷新
document.addEventListener('DOMContentLoaded', () => {
  loadDevices();
  setInterval(loadDevices, 30000);
});

完整 HTML 代码请参考项目中的 web/index.html 文件。

配置说明

环境变量配置

创建 .env 文件:

env 复制代码
DB_HOST=localhost
DB_PORT=3306
DB_USER=your_username
DB_PASSWORD=your_password
DB_NAME=your_database
PORT=3000

Python 脚本配置

修改 update_devices.py 中的配置:

python 复制代码
ICLOUD_EMAIL = "your_apple_id@example.com"
ICLOUD_PASSWORD = "your_app_specific_password"  # 建议使用专用密码
CHINA_MAINLAND = True  # 中国大陆账户设为 True

MYSQL_CONFIG = {
    "host": "localhost",
    "port": 3306,
    "user": "your_username",
    "password": "your_password",
    "database": "your_database",
    "charset": "utf8mb4"
}

关键要点

1. batteryLevel 格式

根据 pyicloud 文档batteryLevel 是 0-1 之间的小数(如 0.85 表示 85%)。前端代码已兼容两种格式(小数和百分比),会自动识别并转换。

2. 设备状态码

  • deviceStatus = "200" - 设备在线
  • deviceStatus = "201" - 设备离线

3. 离线设备限制

离线时 iCloud API 只返回基本信息(名称、电量),无法获取型号、系统版本等详细信息。这是 iCloud API 的限制,不是代码问题。

4. 会话管理

认证后的会话保存在 .pyicloud/ 目录,有效期约 2 个月。过期后需要重新手动运行脚本完成认证。

使用方法

1. 首次运行

bash 复制代码
# 运行 Python 脚本(会提示输入验证码)
python3 update_devices.py

# 启动 API 服务
cd api
npm install
npm start

# 在浏览器打开前端页面
open index.html

2. 设置定时任务

macOS - launchd:

bash 复制代码
# 创建 plist 文件(每5分钟执行一次)
launchctl load ~/Library/LaunchAgents/com.icloud.devices.update.plist

Linux - Crontab:

bash 复制代码
# 编辑 crontab
crontab -e

# 添加任务(每5分钟执行一次)
*/5 * * * * cd /path/to/project && /usr/bin/python3 update_devices.py >> logs/cron.log 2>&1

参考资源

相关推荐
Hi_kenyon18 小时前
快速入门VUE与JS(二)--getter函数(取值器)与setter(存值器)
前端·javascript·vue.js
kaico201818 小时前
MYSQL的日志文件
数据库·mysql
海云前端118 小时前
前端面试加分技巧:文本省略 + Tooltip 优雅实现,附可直接复用代码(求职党必看)
前端
在西安放羊的牛油果18 小时前
浅谈 storeToRefs
前端·typescript·vuex
triumph_passion18 小时前
Zustand 从入门到精通:我的工程实践笔记
前端·性能优化
pusheng202518 小时前
双气联防技术在下一代储能系统安全预警中的应用
前端·安全
C_心欲无痕18 小时前
ts - 交叉类型
前端·git·typescript
彭涛36118 小时前
Blog-SSR 系统操作手册(v1.0.0)
前端
全栈前端老曹18 小时前
【前端路由】React Router 权限路由控制 - 登录验证、私有路由封装、高阶组件实现路由守卫
前端·javascript·react.js·前端框架·react-router·前端路由·权限路由