YYPOST群发软件 发表于 2026-9-2 06:58:11

AIWROK手机执行器MQTT浏览器控端






AIWROK手机执行器MQTT浏览器控端






/**
* AIWROK 手机执行器(MQTT 群控端)
* ------------------------------------------------------------------
* 配套:指挥台.html(电脑浏览器打开)
* 作用:手机连上 MQTT 后待命,电脑发指令 → 手机执行 → 结果回传
*
* 主题约定(ROOM 换成你自己的房间号,避免和别人串台)
*   收:aiwrok/<ROOM>/cmd/<本机机号>   只发给某一台
*   收:aiwrok/<ROOM>/cmd/all            广播,所有机器都执行
*   发:aiwrok/<ROOM>/ret/<本机机号>   心跳 + 执行结果 + 截图
*
* 支持的指令(JSON)
*   {"cmd":"PING"}
*   {"cmd":"STATUS"}
*   {"cmd":"SHOT"}                                  截图回传
*   {"cmd":"CLICK","x":540,"y":960}               点坐标
*   {"cmd":"SWIPE","x1":540,"y1":1400,"x2":540,"y2":500,"ms":300}   滑动
*   {"cmd":"TEXT","text":"你好"}                     输入中文
*   {"cmd":"APP","pkg":"com.tencent.mm"}            打开应用
*   {"cmd":"HOME"} / {"cmd":"BACK"}
*   {"cmd":"LOOP","act":"SWIPE","count":30,"min":1500,"max":4000}循环养机
*   {"cmd":"STOP"}                                  停止循环
*   带 "id":"xxx" 的回执会原样带回,方便电脑配对
* ------------------------------------------------------------------
*/

// ==================== 配置:只改这三行 ====================
var ROOM      = "room001";               // 房间号,多台电脑/多套设备用它隔离
var DEV_ID    = "";                        // 机号,留空=自动用 IMEI 后 6 位
var BROKER    = "tcp://broker.emqx.io:1883";
var SHOT_MAXW = 540;      // 回传截图最大宽度,超出等比缩小
var SHOT_QUAL = 60;       // JPEG 质量 1-100,调小更省流量更不易掉线
// ========================================================

var T = {
    cmdMine : "", cmdAll : "", ret : ""
};

// ---------- 日志 ----------
// 重要:printl 只能在脚本主线程调用。Paho 的回调(收消息/掉线)跑在别的
// Java 线程上,在那里直接 printl 会抛异常并打死消息派发线程,表现为
// "只收到第一条指令,之后全部丢失,最后 32000 超时掉线"。
// 所以回调里只入队,由主线程定时器统一刷出。
var IN_CALLBACK = false;
var LOGQ = [];

function log(s) {
    if (IN_CALLBACK) {
      if (LOGQ.length < 500) LOGQ.push(s);   // 回调线程:只入队,绝不 printl
    } else {
      try { printl("[执行器] " + s); } catch (e) {}
    }
}
function logk(s) { log(s); }

// 主线程刷日志队列
function flushLog() {
    var n = 0;
    while (LOGQ.length > 0 && n < 30) {
      try { printl("[执行器] " + LOGQ.shift()); } catch (e) { LOGQ.length = 0; break; }
      n++;
    }
}

// ==================== 1. 加载 Paho ====================
var dexName = "paho-mqtt.dex";
try {
    rhino.loadDex(dexName);
    log("dex 已加载");
} catch (e) {
    log("loadDex 跳过(可能系统已自动加载插件):" + e.message);
}

try {
    importClass(Packages.org.eclipse.paho.client.mqttv3.MqttClient);
    importClass(Packages.org.eclipse.paho.client.mqttv3.MqttConnectOptions);
    importClass(Packages.org.eclipse.paho.client.mqttv3.MqttCallback);
    importClass(Packages.org.eclipse.paho.client.mqttv3.MqttMessage);
    importClass(Packages.org.eclipse.paho.client.mqttv3.persist.MemoryPersistence);
    log("Paho 类导入成功");
} catch (e) {
    log("Paho 类导入失败:" + e.message);
    log("请把 paho-mqtt.dex 放到本工程的 插件\\ 目录后重跑");
    exit();
}

// ==================== 2. 设备信息 ====================
function deviceInfo() {
    var o = { brand: "?", model: "?", imei: "", w: 0, h: 0, android: "?" };
    try { o.brand = "" + device.getBrand(); } catch (e) {}
    try { o.model = "" + device.getModel(); }catch (e) {}
    try { o.imei= "" + device.getIMEI(); }   catch (e) {}
    try { o.w = screen.getScreenWidth(); o.h = screen.getScreenHeight(); } catch (e) {}
    try { o.android = "" + java.lang.System.getProperty("os.version"); } catch (e) {}
    return o;
}
var INFO = deviceInfo();
if (!DEV_ID) {
    DEV_ID = INFO.imei ? ("" + INFO.imei).slice(-6) : ("dev" + Math.floor(Math.random() * 10000));
}
DEV_ID = "" + DEV_ID;
T.cmdMine = "aiwrok/" + ROOM + "/cmd/" + DEV_ID;
T.cmdAll= "aiwrok/" + ROOM + "/cmd/all";
T.ret   = "aiwrok/" + ROOM + "/ret/" + DEV_ID;

// ==================== 3. 状态 ====================
var client = null, connected = false;
var needReconnect = false;   // 掉线标记,主线程看门狗处理
var startAt = (new Date()).getTime();
var nRecv = 0, nSent = 0, nRun = 0;
var loopOn = false;          // 循环任务开关
var CMDQ = [];               // 指令队列:回调线程入队,主线程执行

// ==================== 4. 回传 ====================
function report(obj, retain) {
    if (!connected) { log("未连接,回传丢弃:" + obj.type); return; }
    try {
      // 每条都带设备信息,指挥台晚打开也能立刻显示品牌型号屏幕
      obj.dev = DEV_ID; obj.room = ROOM;
      obj.brand = INFO.brand; obj.model = INFO.model;
      obj.screen = INFO.w + "x" + INFO.h;
      obj.ts = (new Date()).getTime();
      var m = new MqttMessage();
      m.setPayload(new java.lang.String(JSON.stringify(obj)).getBytes("UTF-8"));
      m.setQos(retain ? 1 : 0);
      if (retain) m.setRetained(true);   // 上线消息保留,新连接的网页立刻能看到
      client.publish(T.ret, m);
      nSent++;
    } catch (e) { log("回传失败:" + e.message); }
}

// ==================== 5. 指令实现 ====================
function doClick(x, y) {
    auto.clickPoint(x, y);
    return { x: x, y: y };
}
function doSwipe(o) {
    var x1 = o.x1 || INFO.w / 2, y1 = o.y1 || INFO.h * 0.75;
    var x2 = o.x2 || INFO.w / 2, y2 = o.y2 || INFO.h * 0.25;
    var ms = o.ms || 300;
    auto.swip(x1, y1, x2, y2, ms, 0);
    return { x1: x1, y1: y1, x2: x2, y2: y2, ms: ms };
}
function doText(t) {
    input.sendString(t);
    return { len: ("" + t).length };
}
function doShot() {
    var bmp = screen.screenShotFull();
    if (!bmp) throw new Error("截图返回空");
    var b64 = null;
    try {
      // 先等比缩放到 shotMaxW 宽,再压成 JPEG:
      // 原始 PNG base64 动辄 200KB,大包容易让连接超时掉线
      var w = bmp.getWidth(), h = bmp.getHeight();
      var target = bmp;
      var from = null;
      try {
            if (w > SHOT_MAXW) {
                var sc = SHOT_MAXW / w;
                var m = new Packages.android.graphics.Matrix();
                m.postScale(sc, sc);
                target = Packages.android.graphics.Bitmap.createBitmap(bmp, 0, 0, w, h, m, true);
                from = target;
            }
      } catch (eScale) { target = bmp; }

      var bos = new java.io.ByteArrayOutputStream();
      target.compress(Packages.android.graphics.Bitmap.CompressFormat.JPEG, SHOT_QUAL, bos);
      var bytes = bos.toByteArray();
      b64 = "" + Packages.android.util.Base64.encodeToString(bytes, Packages.android.util.Base64.NO_WRAP);
      if (from) { try { from.recycle(); } catch (e2) {} }
    } catch (e) {
      // 压缩链路失败:退回原图 base64,至少截图功能还能用
      try { b64 = "" + bmp.toBase64(); } catch (e5) { b64 = null; }
    }
    if (!b64 || b64.length < 32) {
      try { bmp.recycle(); } catch (e3) {}
      throw new Error("截图压缩失败");
    }
    try { bmp.recycle(); } catch (e4) {}
    var kb = Math.round(b64.length / 1024);
    if (kb > 400) throw new Error("截图仍过大 " + kb + "KB,请调小 shotMaxW 或 shotQuality");
    return { img: b64, kb: kb };
}
function doApp(pkg) {
    app.openApp(pkg);
    return { pkg: pkg };
}
function doHome() { auto.home(); return {}; }
function doBack() {
    // 不同版本方法名可能不同,逐个试,失败也打日志
    if (typeof auto.back === "function") { auto.back(); return {}; }
    throw new Error("此版本 auto 无 back(),可用 CLICK 点返回键坐标");
}

// 循环养机:反复做同一个动作 + 随机等待,模拟真人
function runLoop(o) {
    loopOn = true;
    var act = o.act || "SWIPE";
    var count = o.count || 0;          // 0 = 无限,直到 STOP
    var minMs = o.min || 2000, maxMs = o.max || 6000;
    var i = 0;
    logk("循环开始 act=" + act + " count=" + count);
    report({ type: "LOOP", state: "start", act: act, count: count });

    (function step() {
      if (!loopOn) { logk("循环已停止,共 " + i + " 次"); report({ type: "LOOP", state: "stop", times: i }); return; }
      try {
            if (act === "SWIPE") doSwipe(o);
            else if (act === "SHOT") { var r = doShot(); report({ type: "SHOT", ok: true, kb: r.kb, img: r.img }); }
            else if (act === "CLICK") doClick(o.x || INFO.w / 2, o.y || INFO.h / 2);
            nRun++;
            i++;
            if (i % 10 === 0) { log("循环进度 " + i + " 次"); report({ type: "LOOP", state: "run", times: i }); }
      } catch (e) {
            log("循环单次失败:" + e.message);
            report({ type: "LOOP", state: "err", err: e.message, times: i });
      }
      if (count > 0 && i >= count) {
            loopOn = false;
            logk("循环完成 " + i + " 次");
            report({ type: "LOOP", state: "done", times: i });
            return;
      }
      var wait = minMs + Math.floor(Math.random() * Math.max(1, maxMs - minMs));
      setTimeout(step, wait);
    })();
}

// ==================== 6. 指令分发 ====================
function handle(raw, from) {
    nRecv++;
    var o;
    try { o = JSON.parse(raw); }
    catch (e) { log("非 JSON 指令,忽略:" + raw); return; }

    var cmd = (o.cmd || "").toUpperCase();
    log("收到 " + cmd + " 来自 " + from + " id=" + (o.id || "-"));

    var res = { type: "ACK", cmd: cmd, id: o.id || "" };
    try {
      if (cmd === "PING")       { res.ok = true; res.msg = "pong"; }
      else if (cmd === "STATUS"){
            res.ok = true;
            res.info = { brand: INFO.brand, model: INFO.model, screen: INFO.w + "x" + INFO.h,
                         android: INFO.android, up: Math.round(((new Date()).getTime() - startAt) / 1000),
                         recv: nRecv, sent: nSent, run: nRun, loop: loopOn, connected: connected };
      }
      else if (cmd === "CLICK") { res.ok = true; res.data = doClick(parseInt(o.x, 10), parseInt(o.y, 10)); }
      else if (cmd === "SWIPE") { res.ok = true; res.data = doSwipe(o); }
      else if (cmd === "TEXT"){ res.ok = true; res.data = doText(o.text || ""); }
      else if (cmd === "SHOT"){
            log("截图中…");
            var r = doShot();
            res.ok = true; res.kb = r.kb; res.img = r.img;   // 截图直接带图回传
            log("截图完成 " + r.kb + "KB");
      }
      else if (cmd === "APP")   { res.ok = true; res.data = doApp(o.pkg); }
      else if (cmd === "HOME"){ res.ok = true; doHome(); }
      else if (cmd === "BACK"){ res.ok = true; doBack(); }
      else if (cmd === "LOOP"){ runLoop(o); res.ok = true; res.msg = "循环已启动"; }
      else if (cmd === "STOP"){ loopOn = false; res.ok = true; res.msg = "已发送停止"; }
      else { res.ok = false; res.err = "未知指令 " + cmd; }
    } catch (e) {
      res.ok = false; res.err = "" + e.message;
      log("执行失败:" + e.message);
    }
    report(res);
}

// ==================== 7. 连接 ====================
function connect() {
    var cid = "aiwrok_" + ROOM + "_" + DEV_ID + "_" + Math.floor(Math.random() * 1000);
    logk("连接 " + BROKER + "clientId=" + cid);
    client = new MqttClient(BROKER, cid, new MemoryPersistence());

    var opt = new MqttConnectOptions();
    opt.setCleanSession(true);
    opt.setKeepAliveInterval(45);
    opt.setConnectionTimeout(15);
    opt.setAutomaticReconnect(true);

    client.setCallback(new MqttCallback({
      connectionLost: function (c) {
            IN_CALLBACK = true;
            try {
                connected = false;
                needReconnect = true;
                log("掉线:" + c + "(等主线程看门狗重连)");
            } catch (e) {} finally { IN_CALLBACK = false; }
      },
      messageArrived: function (topic, msg) {
            IN_CALLBACK = true;               // 本线程禁止 printl / 禁止做重活
            try {
                var text = "" + new java.lang.String(msg.getPayload(), "UTF-8");
                var from = ("" + topic === T.cmdAll) ? "广播" : "定向";
                // 只入队,真正的执行交给主线程(截图/点击在回调线程里会卡住 MQTT 读循环)
                if (CMDQ.length < 200) CMDQ.push({ raw: text, from: from });
            } catch (e) {
                // 绝不把异常抛回 Paho,否则派发线程会被打死
                if (LOGQ.length < 500) LOGQ.push("收包异常:" + e.message);
            } finally { IN_CALLBACK = false; }
      },
      deliveryComplete: function (t) { }
    }));

    client.connect(opt);
    connected = true;
    needReconnect = false;
    client.subscribe(T.cmdMine, 1);
    client.subscribe(T.cmdAll, 1);
    logk("已订阅 " + T.cmdMine + " 和 " + T.cmdAll);
    report({ type: "ONLINE" }, true);      // 保留消息:网页后开也能看到
    flushLog();
}

// ==================== 8. 启动 ====================
logk("启动 机号=" + DEV_ID + " 房间=" + ROOM + " 屏幕=" + INFO.w + "x" + INFO.h + " " + INFO.brand + " " + INFO.model);
try { connect(); }
catch (e) { connected = false; needReconnect = true; log("首次连接失败:" + e.message + ",看门狗会持续重试"); }

// ---------- 主线程看门狗:刷日志 + 重连 + 重订阅 ----------
// 回调线程不能 printl,日志靠这里刷出来;重连后必须重新订阅,
// 因为 cleanSession=true 时 Paho 不会替你恢复订阅。
var resubTried = 0;
setInterval(function () {
    // 1) 主线程执行排队的指令
    var k = 0;
    while (CMDQ.length > 0 && k < 5) {
      var job = CMDQ.shift();
      try { handle(job.raw, job.from); }
      catch (e) { log("指令执行异常:" + e.message); }
      k++;
    }
    flushLog();                                 // 2) 刷出回调攒下的日志
    try {
      var real = client ? client.isConnected() : false;

      if (real && !connected) {                     // 自动重连成功了
            connected = true; needReconnect = false;
            try {
                client.subscribe(T.cmdMine, 1);
                client.subscribe(T.cmdAll, 1);
                log("已恢复连接并重新订阅");
            } catch (e) { log("重订阅失败:" + e.message); }
            report({ type: "ONLINE" }, true);
      }

      if (!real && client) {                        // 掉线:主动重连
            connected = false;
            resubTried++;
            if (resubTried % 8 === 0) {               // 约每 8 秒试一次
                log("看门狗触发重连…");
                try { client.reconnect(); }
                catch (e) {
                  log("reconnect 失败:" + e.message + ",重建连接");
                  try { connect(); } catch (e2) { log("重建失败:" + e2.message); }
                }
            }
      }
    } catch (e) { log("看门狗异常:" + e.message); }
}, 1000);

// ---------- 心跳:电脑端据此判断在线 ----------
var needShot = false;   // 改成 true 则每次心跳附带一张截图
var beat = 0;
setInterval(function () {
    beat += 1;
    if (connected) {
      var o = { type: "HEART", up: beat * 20, recv: nRecv, sent: nSent, run: nRun, loop: loopOn };
      if (needShot) { try { var r = doShot(); o.img = r.img; o.kb = r.kb; } catch (e) {} }
      report(o);
    } else {
      log("当前离线,心跳跳过(看门狗重连中)");
    }
}, 20000);
log("已进入待命:每 1 秒刷日志/查连接,每 20 秒上报心跳。停止请点 IDE 停止按钮");


页: [1]
查看完整版本: AIWROK手机执行器MQTT浏览器控端