dsh-mobilecode 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/lib/client.js +1054 -999
- package/lib/device-build.js +169 -0
- package/lib/index.js +459 -0
- package/package.json +2 -2
package/lib/device-build.js
CHANGED
|
@@ -763,6 +763,63 @@ async function adbConnect(serial) {
|
|
|
763
763
|
return /connected|already connected/i.test(`${result.out}\n${result.err}`)
|
|
764
764
|
}
|
|
765
765
|
|
|
766
|
+
/** Attach a Wi-Fi device: `adb connect <ip>:<port>`; true on success. Never throws (returns false with no side effect). */
|
|
767
|
+
export async function adbConnectSerial(serial) {
|
|
768
|
+
return adbConnect(serial)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Pair a Wi-Fi device (Android 11+ requires pairing before the first connect):
|
|
773
|
+
* `adb pair <ip>:<pair-port> <code>`. Resolves when pairing succeeded.
|
|
774
|
+
*/
|
|
775
|
+
export async function adbPair(serial, code) {
|
|
776
|
+
const result = await captureFull(adb(), ["pair", serial, String(code)], { timeoutMs: 20_000 })
|
|
777
|
+
if (/successfully paired/i.test(`${result.out}\n${result.err}`)) return true
|
|
778
|
+
throw new Error(`adb pair ${serial} failed: ${adbFailTail(result) || "no output"}`)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Parse `adb mdns services` output for the pairing service the device advertises
|
|
783
|
+
* while its "pair using QR code" dialog is open. Returns the `ip:port` the
|
|
784
|
+
* pairing broadcast listens on, or undefined.
|
|
785
|
+
*/
|
|
786
|
+
export function parseMdnsPairing(output) {
|
|
787
|
+
const line = String(output).split(/\r?\n/).find((row) => row.includes("_adb-tls-pairing._tcp"))
|
|
788
|
+
if (line === undefined) return undefined
|
|
789
|
+
const address = /(\d{1,3}(?:\.\d{1,3}){3}:\d+)/.exec(line)?.[1] ?? /(\[[0-9a-f:]+\]:\d+)/.exec(line)?.[1]
|
|
790
|
+
return address ?? undefined
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** A Wi-Fi QR code the device scanner accepts: `WIFI:T:ADB;S:<name>;P:<password>;;`. */
|
|
794
|
+
export function generateQrAdbWifi(name, password) {
|
|
795
|
+
return `WIFI:T:ADB;S:${name};P:${password};;`
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Poll `adb mdns services` until the phone's pairing dialog advertises
|
|
800
|
+
* `_adb-tls-pairing._tcp` (resolves to its `ip:port`), or undefined on timeout.
|
|
801
|
+
* Requires adb with mdns support (>= 31). Non-serial, read-only.
|
|
802
|
+
*/
|
|
803
|
+
export async function waitForMdnsPairing(timeoutMs = 60_000, pollMs = 1_500) {
|
|
804
|
+
const deadline = Date.now() + timeoutMs
|
|
805
|
+
for (;;) {
|
|
806
|
+
const result = await captureFull(adb(), ["mdns", "services"], { timeoutMs: 5_000 }).catch(() => null)
|
|
807
|
+
if (result && result.code === 0) {
|
|
808
|
+
const address = parseMdnsPairing(result.out)
|
|
809
|
+
if (address !== undefined) return address
|
|
810
|
+
}
|
|
811
|
+
if (Date.now() >= deadline) return undefined
|
|
812
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs))
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const QR_NAME_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
817
|
+
/** Random ADB QR name (ADB_WIFI_xxxxxxxxxxxxxx-yyyyyy) + 21-char password, matching Android's own structure. */
|
|
818
|
+
export function randomQrCredentials(prefix = "ADB_WIFI") {
|
|
819
|
+
const pick = (n) => Array.from({ length: n }, () => QR_NAME_CHARS[Math.floor(Math.random() * QR_NAME_CHARS.length)]).join("")
|
|
820
|
+
return { name: `${prefix}_${pick(14)}-${pick(6)}`, password: pick(21) }
|
|
821
|
+
}
|
|
822
|
+
|
|
766
823
|
/**
|
|
767
824
|
* The single classified boundary for every serial-targeted adb command.
|
|
768
825
|
* On a transient transport failure with a Wi-Fi serial (ip:port) it attempts
|
|
@@ -808,6 +865,118 @@ export function adbIntentArgs({ action, uri, component } = {}) {
|
|
|
808
865
|
return parts
|
|
809
866
|
}
|
|
810
867
|
|
|
868
|
+
// ── perf / app-info parsers (pattern credited to newborne/dsh-adb-ultimate) ──
|
|
869
|
+
|
|
870
|
+
/** Parse `cat /proc/meminfo` into {totalKb, availableKb, usedKb, usedPercent}. */
|
|
871
|
+
export function parseMeminfo(output) {
|
|
872
|
+
const grab = (name) => {
|
|
873
|
+
const match = new RegExp(`^${name}:\\s*(\\d+)`, "m").exec(String(output))
|
|
874
|
+
return match ? Number(match[1]) : undefined
|
|
875
|
+
}
|
|
876
|
+
const totalKb = grab("MemTotal")
|
|
877
|
+
const availableKb = grab("MemAvailable")
|
|
878
|
+
if (totalKb === undefined) return undefined
|
|
879
|
+
const usedKb = availableKb === undefined ? undefined : totalKb - availableKb
|
|
880
|
+
return {
|
|
881
|
+
totalKb,
|
|
882
|
+
availableKb,
|
|
883
|
+
usedKb,
|
|
884
|
+
usedPercent: usedKb !== undefined && totalKb > 0 ? Math.round((usedKb / totalKb) * 1000) / 10 : undefined,
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Parse `dumpsys battery` into {level, temperatureC, status, health, powered}. */
|
|
889
|
+
export function parseBattery(output) {
|
|
890
|
+
const grab = (name) => {
|
|
891
|
+
const match = new RegExp(`^\\s*${name}:\\s*(.+)`, "m").exec(String(output))
|
|
892
|
+
return match ? match[1].trim() : undefined
|
|
893
|
+
}
|
|
894
|
+
const levelRaw = grab("level")
|
|
895
|
+
const tempRaw = grab("temperature")
|
|
896
|
+
const level = levelRaw ? Number(levelRaw) : undefined
|
|
897
|
+
const tempRawNum = tempRaw ? Number(tempRaw) : undefined
|
|
898
|
+
return {
|
|
899
|
+
level,
|
|
900
|
+
temperatureC: tempRawNum !== undefined && Number.isFinite(tempRawNum) ? Math.round(tempRawNum) / 10 : undefined,
|
|
901
|
+
status: grab("status"),
|
|
902
|
+
health: grab("health"),
|
|
903
|
+
powered: grab("AC powered") === "true" || grab("USB powered") === "true" || grab("Wireless powered") === "true",
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Parse `cat /proc/cpuinfo` into {cores, hardware}. */
|
|
908
|
+
export function parseCpuinfo(output) {
|
|
909
|
+
const text = String(output)
|
|
910
|
+
const cores = (text.match(/^processor\s*:/gm) ?? []).length
|
|
911
|
+
const hardware = /^Hardware\s*:\s*(.+)$/m.exec(text)?.[1]?.trim()
|
|
912
|
+
return { cores: cores > 0 ? cores : undefined, hardware }
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Deep-clean a parsed value for a DSH tool output: drop `undefined` keys (and
|
|
917
|
+
* undefined array holes) and turn non-finite numbers into `null`, so the value
|
|
918
|
+
* round-trips JSON losslessly (DSH rejects outputs that lose keys/values on
|
|
919
|
+
* serialize). Leaves everything else untouched; primitives pass through.
|
|
920
|
+
*/
|
|
921
|
+
export function jsonSafe(value) {
|
|
922
|
+
if (Array.isArray(value)) {
|
|
923
|
+
const out = []
|
|
924
|
+
for (const item of value) {
|
|
925
|
+
if (item !== undefined) out.push(jsonSafe(item))
|
|
926
|
+
}
|
|
927
|
+
return out
|
|
928
|
+
}
|
|
929
|
+
if (value !== null && typeof value === "object") {
|
|
930
|
+
const out = {}
|
|
931
|
+
for (const [key, item] of Object.entries(value)) {
|
|
932
|
+
if (item !== undefined) out[key] = jsonSafe(item)
|
|
933
|
+
}
|
|
934
|
+
return out
|
|
935
|
+
}
|
|
936
|
+
if (typeof value === "number" && !Number.isFinite(value)) return null
|
|
937
|
+
return value
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Parse `dumpsys package <pkg>` into {versionName, versionCode, minSdk, targetSdk, permissions, activities}. */
|
|
941
|
+
export function parseAppInfo(output, packageName) {
|
|
942
|
+
const text = String(output)
|
|
943
|
+
const grab = (name) => {
|
|
944
|
+
const match = new RegExp(`${name}=(\\S+)`, "m").exec(text)
|
|
945
|
+
return match ? match[1] : undefined
|
|
946
|
+
}
|
|
947
|
+
const permissions = []
|
|
948
|
+
const textLines = text.split(/\r?\n/)
|
|
949
|
+
const headerIndex = textLines.findIndex((line) => line.trim() === "requested permissions:")
|
|
950
|
+
if (headerIndex >= 0) {
|
|
951
|
+
for (let i = headerIndex + 1; i < textLines.length; i += 1) {
|
|
952
|
+
const trimmed = textLines[i].trim()
|
|
953
|
+
if (trimmed === "" || !/^(android\.permission\.|com\.[^\s]+\.permission\.)[A-Z0-9_.]+$/.test(trimmed)) break
|
|
954
|
+
permissions.push(trimmed)
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const activities = []
|
|
958
|
+
const resolverIndex = textLines.findIndex((line) => line.trim() === "Activity Resolver Table:")
|
|
959
|
+
if (resolverIndex >= 0) {
|
|
960
|
+
// Real resolver-table rows are `<hex> pkg/.Activity filter <hex>` (or `pkg/.Activity (hex)`):
|
|
961
|
+
// 5f60459 com.android.settings/.Settings$ApnEditorActivity filter 4e9281e
|
|
962
|
+
// MIME-type headers (vnd.android.cursor.item/telephony-carrier:) contain a `-` so the
|
|
963
|
+
// component class [\w.$]+ cannot span it, keeping them out of the match.
|
|
964
|
+
for (let i = resolverIndex + 1; i < textLines.length && activities.length < 20; i += 1) {
|
|
965
|
+
const match = /([A-Za-z_][\w.$]*\/[\w.$]+)\s+(?:filter\s+[0-9a-f]+|\([0-9a-f]+\))/i.exec(textLines[i].trim())
|
|
966
|
+
if (match && !activities.includes(match[1])) activities.push(match[1])
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
package: packageName,
|
|
971
|
+
versionName: grab("versionName"),
|
|
972
|
+
versionCode: grab("versionCode"),
|
|
973
|
+
minSdk: grab("minSdk"),
|
|
974
|
+
targetSdk: grab("targetSdk"),
|
|
975
|
+
permissions: [...new Set(permissions)].slice(0, 60),
|
|
976
|
+
activities: activities.slice(0, 20),
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
811
980
|
/** SDK emulator binary (emulator.exe on Windows), or undefined. */
|
|
812
981
|
export function emulatorBinary() {
|
|
813
982
|
const sdk = androidSdk()
|
package/lib/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import * as Vision from './vision.js'
|
|
|
25
25
|
import { DevicePreviewEngine } from './device-preview.js'
|
|
26
26
|
import * as Setup from './setup.js'
|
|
27
27
|
import { registerMobileSkill } from './skill.js'
|
|
28
|
+
import { existsSync } from 'node:fs'
|
|
28
29
|
|
|
29
30
|
export const name = 'mobilecode'
|
|
30
31
|
export const inject = ['webServer', 'tools', 'systemPrompt']
|
|
@@ -234,6 +235,60 @@ function makeRoutes(engine, config, stream) {
|
|
|
234
235
|
}
|
|
235
236
|
},
|
|
236
237
|
},
|
|
238
|
+
// POST /api/dsh-mobilecode/connect {host, port?, pairing_code?, pairing_port?} — attach a Wi-Fi device (with optional pairing).
|
|
239
|
+
{
|
|
240
|
+
kind: 'exact',
|
|
241
|
+
path: API_BASE + '/connect',
|
|
242
|
+
handler: async (req, res) => {
|
|
243
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
244
|
+
const body = await readBody(req, res)
|
|
245
|
+
if (body === undefined) return
|
|
246
|
+
try {
|
|
247
|
+
const host = String(body.host ?? '').trim().replace(/^adb:\/\//, '')
|
|
248
|
+
if (host === '') { writeJson(res, 400, { error: 'host is required' }); return }
|
|
249
|
+
const port = body.port ?? 5555
|
|
250
|
+
const target = `${host}:${port}`
|
|
251
|
+
let paired
|
|
252
|
+
const code = String(body.pairing_code ?? '').trim()
|
|
253
|
+
if (code !== '') {
|
|
254
|
+
const pairPort = body.pairing_port ?? 37000
|
|
255
|
+
await DeviceBuild.adbPair(`${host}:${pairPort}`, code)
|
|
256
|
+
paired = true
|
|
257
|
+
}
|
|
258
|
+
const connected = await DeviceBuild.adbConnectSerial(target)
|
|
259
|
+
if (!connected) { writeJson(res, 409, { error: `adb connect ${target} failed — check the Wi-Fi IP/port and Wireless debugging; pass pairing_code + pairing_port if it needs pairing.` }); return }
|
|
260
|
+
writeJson(res, 200, { ok: true, serial: target, paired: paired === true })
|
|
261
|
+
} catch (error) {
|
|
262
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) })
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
// POST /api/dsh-mobilecode/pair-qr {timeout_ms?} — WIFI:T:ADB QR + mDNS auto-pair + connect (one blocking call).
|
|
267
|
+
{
|
|
268
|
+
kind: 'exact',
|
|
269
|
+
path: API_BASE + '/pair-qr',
|
|
270
|
+
handler: async (req, res) => {
|
|
271
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
272
|
+
const body = await readBody(req, res)
|
|
273
|
+
if (body === undefined) return
|
|
274
|
+
try {
|
|
275
|
+
const timeoutMs = Math.min(Math.max(Number(body.timeout_ms) || 60_000, 10_000), 180_000)
|
|
276
|
+
const { name, password } = DeviceBuild.randomQrCredentials()
|
|
277
|
+
const qrText = DeviceBuild.generateQrAdbWifi(name, password)
|
|
278
|
+
const how = 'Render the returned qr_text as a QR code and scan it with the phone (Settings → Connected devices → Pair by QR). Pairing + connect happen automatically once scanned.'
|
|
279
|
+
const pairingSerial = await DeviceBuild.waitForMdnsPairing(timeoutMs)
|
|
280
|
+
if (pairingSerial === undefined) { writeJson(res, 200, { qr_text: qrText, name, password, status: 'timeout', how }); return }
|
|
281
|
+
await DeviceBuild.adbPair(pairingSerial, password)
|
|
282
|
+
const connectHost = pairingSerial.split(':')[0]
|
|
283
|
+
const connected = await DeviceBuild.adbConnectSerial(`${connectHost}:5555`)
|
|
284
|
+
writeJson(res, 200, connected
|
|
285
|
+
? { qr_text: qrText, name, password, status: 'connected', serial: `${connectHost}:5555`, how }
|
|
286
|
+
: { qr_text: qrText, name, password, status: 'paired-not-connected', serial: pairingSerial, how })
|
|
287
|
+
} catch (error) {
|
|
288
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) })
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
},
|
|
237
292
|
// GET /api/dsh-mobilecode/doctor → [{name, ok, detail, fix?}] — plugin health check.
|
|
238
293
|
{
|
|
239
294
|
kind: 'exact',
|
|
@@ -927,6 +982,392 @@ function deviceWaitForTool() {
|
|
|
927
982
|
})
|
|
928
983
|
}
|
|
929
984
|
|
|
985
|
+
/** First node matching the tap-element selector (exact, then contains), or undefined. Used by device_scroll_to. */
|
|
986
|
+
function findMatchingNode(roots, selector) {
|
|
987
|
+
const identifier = selector.identifier !== undefined && selector.identifier.trim() !== '' ? selector.identifier.trim() : undefined
|
|
988
|
+
const label = selector.label !== undefined && selector.label.trim() !== '' ? selector.label.trim() : undefined
|
|
989
|
+
if (identifier === undefined && label === undefined) return undefined
|
|
990
|
+
const matchesValue = (actual, wanted, mode) => actual !== undefined && (mode === 'exact' ? actual === wanted : actual.toLowerCase().includes(wanted.toLowerCase()))
|
|
991
|
+
const matchesNode = (node, mode) => {
|
|
992
|
+
if (identifier !== undefined && !matchesValue(node.resourceId, identifier, mode)) return false
|
|
993
|
+
if (label !== undefined && !matchesValue(node.text, label, mode) && !matchesValue(node.contentDesc, label, mode)) return false
|
|
994
|
+
return true
|
|
995
|
+
}
|
|
996
|
+
const flat = UiTree.flattenNodes(roots)
|
|
997
|
+
return flat.find((node) => matchesNode(node, 'exact')) ?? flat.find((node) => matchesNode(node, 'contains'))
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function deviceScrollToTool() {
|
|
1001
|
+
return defineTool({
|
|
1002
|
+
name: 'device_scroll_to',
|
|
1003
|
+
description: 'Scroll until an element (by resource_id or text/content-desc, exact then substring) comes into view, then ' +
|
|
1004
|
+
'tap nothing — just report it. Repeats: read UI tree → check the selector → swipe up (or left) until found or max_swipes ' +
|
|
1005
|
+
'exhausted. Completes the control loop for long lists: run device_wait_for won\'t scroll, this brings the element to the ' +
|
|
1006
|
+
'screen so a follow-up device_tap_element can hit it. Returns the found node and how many swipes it took.',
|
|
1007
|
+
parameters: {
|
|
1008
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1009
|
+
resource_id: { type: 'string', description: 'The resource-id to find, e.g. com.android.settings:id/some_item.' },
|
|
1010
|
+
text: { type: 'string', description: 'Text or content-desc to find (exact wins; falls back to substring).' },
|
|
1011
|
+
max_swipes: { type: 'integer', description: 'Swipe budget before giving up (default 8).' },
|
|
1012
|
+
direction: { type: 'string', enum: ['up', 'left'], description: 'Which way to swipe (default up).' },
|
|
1013
|
+
},
|
|
1014
|
+
output: {
|
|
1015
|
+
schema: {
|
|
1016
|
+
type: 'object',
|
|
1017
|
+
additionalProperties: false,
|
|
1018
|
+
properties: {
|
|
1019
|
+
serial: { type: 'string', required: true },
|
|
1020
|
+
found: { type: 'boolean', required: true },
|
|
1021
|
+
swipes: { type: 'integer', required: true },
|
|
1022
|
+
node: { type: 'string' },
|
|
1023
|
+
bounds: { type: 'array', items: { type: 'integer' } },
|
|
1024
|
+
x: { type: 'integer' },
|
|
1025
|
+
y: { type: 'integer' },
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
render: (_args, value) => {
|
|
1029
|
+
const v = value ?? { serial: '', found: false, swipes: 0 }
|
|
1030
|
+
return [{ type: 'text', text: v.found
|
|
1031
|
+
? `Found ${v.node} at ${v.bounds?.join(',') ?? ''} (center ${v.x},${v.y}) after ${v.swipes} swipe(s) on ${v.serial} — device_tap_element it (text/resource_id) or tap ${v.x},${v.y} via device_input`
|
|
1032
|
+
: `Not found after ${v.swipes} swipes on ${v.serial} — the element may live behind a deeper navigation, or the selector is wrong (run device_ui_tree / device_screen to check).` }]
|
|
1033
|
+
},
|
|
1034
|
+
},
|
|
1035
|
+
async execute(args) {
|
|
1036
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1037
|
+
const selector = { identifier: args.resource_id, label: args.text }
|
|
1038
|
+
const maxSwipes = Math.min(Math.max(args.max_swipes ?? 8, 1), 20)
|
|
1039
|
+
const direction = args.direction === 'left' ? 'left' : 'up'
|
|
1040
|
+
// Screen size drives the swipe path so it works on any panel.
|
|
1041
|
+
let swipes = 0
|
|
1042
|
+
for (;;) {
|
|
1043
|
+
const parsed = await UiTree.readUiTree(serial)
|
|
1044
|
+
const found = findMatchingNode(parsed.roots, selector)
|
|
1045
|
+
if (found) {
|
|
1046
|
+
const center = UiTree.boundsCenter(found.bounds)
|
|
1047
|
+
return { serial, found: true, swipes, node: found.text || found.contentDesc || found.resourceId || found.type, bounds: [found.bounds.x, found.bounds.y, found.bounds.w, found.bounds.h], x: center.x, y: center.y }
|
|
1048
|
+
}
|
|
1049
|
+
if (swipes >= maxSwipes) return { serial, found: false, swipes }
|
|
1050
|
+
const sizeStr = await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size']).catch(() => '')
|
|
1051
|
+
const override = /Override size:\s*(\d+)x(\d+)/.exec(sizeStr)
|
|
1052
|
+
const physical = /Physical size:\s*(\d+)x(\d+)/.exec(sizeStr)
|
|
1053
|
+
const match = override ?? physical
|
|
1054
|
+
if (!match) throw new Error(`cannot read the screen size of ${serial} (wm size returned nothing) — cannot swipe`)
|
|
1055
|
+
const w = Number(match[1]); const h = Number(match[2])
|
|
1056
|
+
const fromX = Math.round(w / 2); const fromY = Math.round(h * 0.7); const toY = Math.round(h * 0.3)
|
|
1057
|
+
if (direction === 'left') {
|
|
1058
|
+
await DeviceBuild.adbRun(serial, ['shell', 'input', 'swipe', String(Math.round(w * 0.8)), String(Math.round(h / 2)), String(Math.round(w * 0.2)), String(Math.round(h / 2)), '400'])
|
|
1059
|
+
} else {
|
|
1060
|
+
await DeviceBuild.adbRun(serial, ['shell', 'input', 'swipe', String(fromX), String(fromY), String(fromX), String(toY), '400'])
|
|
1061
|
+
}
|
|
1062
|
+
swipes += 1
|
|
1063
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
})
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function deviceConnectTool() {
|
|
1070
|
+
return defineTool({
|
|
1071
|
+
name: 'device_connect',
|
|
1072
|
+
description: 'Attach an Android device over Wi-Fi: `adb connect <host>:<port>` (requires the phone\'s Wireless debugging' +
|
|
1073
|
+
' "Pair device" flow the first time — pass pairing_code to pair first). Reattaching a known wireless device needs no' +
|
|
1074
|
+
' code. Returns the connected serial.',
|
|
1075
|
+
parameters: {
|
|
1076
|
+
host: { type: 'string', description: 'IP address (or mDNS name) of the device.' },
|
|
1077
|
+
port: { type: 'integer', description: 'Connect port (default 5555).' },
|
|
1078
|
+
pairing_code: { type: 'string', description: 'Six-digit pairing code shown by "Wireless debugging → Pair device".' },
|
|
1079
|
+
pairing_port: { type: 'integer', description: 'Pairing port shown by the pairing dialog (default 37000).' },
|
|
1080
|
+
},
|
|
1081
|
+
output: {
|
|
1082
|
+
schema: {
|
|
1083
|
+
type: 'object',
|
|
1084
|
+
additionalProperties: false,
|
|
1085
|
+
properties: {
|
|
1086
|
+
serial: { type: 'string', required: true },
|
|
1087
|
+
paired: { type: 'boolean' },
|
|
1088
|
+
connected: { type: 'boolean', required: true },
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1091
|
+
render: (_args, value) => {
|
|
1092
|
+
const v = value ?? { serial: '', connected: false }
|
|
1093
|
+
return [{ type: 'text', text: `${v.connected ? 'Connected' : 'Failed to connect'} to ${v.serial}${v.paired ? ' (paired)' : ''}` }]
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
async execute(args) {
|
|
1097
|
+
const host = String(args.host ?? '').trim().replace(/^adb:\/\//, '')
|
|
1098
|
+
if (host === '') throw new Error('device_connect requires a host (IP address).')
|
|
1099
|
+
const port = args.port ?? 5555
|
|
1100
|
+
const target = `${host}:${port}`
|
|
1101
|
+
let paired
|
|
1102
|
+
const code = String(args.pairing_code ?? '').trim()
|
|
1103
|
+
if (code !== '') {
|
|
1104
|
+
const pairPort = args.pairing_port ?? 37000
|
|
1105
|
+
await DeviceBuild.adbPair(`${host}:${pairPort}`, code)
|
|
1106
|
+
paired = true
|
|
1107
|
+
}
|
|
1108
|
+
const connected = await DeviceBuild.adbConnectSerial(target)
|
|
1109
|
+
if (!connected) throw new Error(`adb connect ${target} failed — check the Wi-Fi IP/port and that Wireless debugging is on; if it needs pairing, pass pairing_code + pairing_port.`)
|
|
1110
|
+
return { serial: target, paired, connected: true }
|
|
1111
|
+
},
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function devicePairQrTool() {
|
|
1116
|
+
return defineTool({
|
|
1117
|
+
name: 'device_pair_qr',
|
|
1118
|
+
description: 'The full Wi-Fi pairing flow: generates a WIFI:T:ADB QR string (render it as a QR code — any QR generator ' +
|
|
1119
|
+
'works — and scan it with the phone: Settings → Connected devices → Pair by QR), waits for the phone to advertise its ' +
|
|
1120
|
+
'pairing service over mDNS (`adb mdns services`, needs adb >= 31), then auto-pairs and connects. One call covers ' +
|
|
1121
|
+
'generate → wait → pair → connect.',
|
|
1122
|
+
parameters: {
|
|
1123
|
+
timeout_ms: { type: 'integer', description: 'How long to wait for the QR scan (default 60000).' },
|
|
1124
|
+
serial: { type: 'string', description: 'Only used to pre-check adb is present; pairing targets whatever the QR scan names.' },
|
|
1125
|
+
},
|
|
1126
|
+
output: {
|
|
1127
|
+
schema: {
|
|
1128
|
+
type: 'object',
|
|
1129
|
+
additionalProperties: false,
|
|
1130
|
+
properties: {
|
|
1131
|
+
qr_text: { type: 'string', required: true },
|
|
1132
|
+
name: { type: 'string', required: true },
|
|
1133
|
+
password: { type: 'string', required: true },
|
|
1134
|
+
status: { type: 'string', required: true },
|
|
1135
|
+
serial: { type: 'string' },
|
|
1136
|
+
how: { type: 'string', required: true },
|
|
1137
|
+
},
|
|
1138
|
+
},
|
|
1139
|
+
render: (_args, value) => {
|
|
1140
|
+
const v = value ?? { qr_text: '', name: '', password: '', status: '' }
|
|
1141
|
+
const lines = [`WIFI:T:ADB QR (status ${v.status})`, ` ${v.qr_text}`, '']
|
|
1142
|
+
if (v.serial) lines.push(`Paired & connected: ${v.serial}`)
|
|
1143
|
+
lines.push('Scan the QR text above with the phone: Settings → Connected devices → Pair by QR. Any QR generator renders it.')
|
|
1144
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
async execute(args) {
|
|
1148
|
+
const timeoutMs = Math.min(Math.max(args.timeout_ms ?? 60_000, 10_000), 180_000)
|
|
1149
|
+
const { name, password } = DeviceBuild.randomQrCredentials()
|
|
1150
|
+
const qrText = DeviceBuild.generateQrAdbWifi(name, password)
|
|
1151
|
+
const how = 'Render the returned qr_text as a QR code and scan it with the phone (Settings → Connected devices → Pair by QR). Pairing + connect happen automatically once scanned.'
|
|
1152
|
+
const pairingSerial = await DeviceBuild.waitForMdnsPairing(timeoutMs)
|
|
1153
|
+
if (pairingSerial === undefined) {
|
|
1154
|
+
return { qr_text: qrText, name, password, status: 'timeout', how }
|
|
1155
|
+
}
|
|
1156
|
+
await DeviceBuild.adbPair(pairingSerial, password)
|
|
1157
|
+
// The pairing dialog usually pairs the tls-port; the connect port is 5555 or mdns-advertised — try 5555.
|
|
1158
|
+
const connectHost = pairingSerial.split(':')[0]
|
|
1159
|
+
const connected = await DeviceBuild.adbConnectSerial(`${connectHost}:5555`)
|
|
1160
|
+
if (!connected) {
|
|
1161
|
+
return { qr_text: qrText, name, password, status: 'paired-not-connected', serial: pairingSerial, how }
|
|
1162
|
+
}
|
|
1163
|
+
return { qr_text: qrText, name, password, status: 'connected', serial: `${connectHost}:5555`, how }
|
|
1164
|
+
},
|
|
1165
|
+
})
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function devicePerfTool() {
|
|
1169
|
+
return defineTool({
|
|
1170
|
+
name: 'device_perf',
|
|
1171
|
+
description: 'One-shot memory / battery / CPU snapshot of an attached Android device (reads /proc/meminfo, dumpsys battery, ' +
|
|
1172
|
+
'/proc/cpuinfo). Use to check the device\'s resource state before or after a run — cheap and deterministic.',
|
|
1173
|
+
parameters: {
|
|
1174
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1175
|
+
},
|
|
1176
|
+
output: {
|
|
1177
|
+
schema: {
|
|
1178
|
+
type: 'object',
|
|
1179
|
+
additionalProperties: false,
|
|
1180
|
+
properties: {
|
|
1181
|
+
serial: { type: 'string', required: true },
|
|
1182
|
+
memory: { type: 'object', additionalProperties: true },
|
|
1183
|
+
battery: { type: 'object', additionalProperties: true },
|
|
1184
|
+
cpu: { type: 'object', additionalProperties: true },
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
render: (_args, value) => {
|
|
1188
|
+
const v = value ?? { serial: '' }
|
|
1189
|
+
const mem = v.memory ? `RAM ${v.memory.usedPercent}% used (${Math.round(v.memory.usedKb / 1024)}/${Math.round(v.memory.totalKb / 1024)} MB)` : 'RAM n/a'
|
|
1190
|
+
const bat = v.battery && v.battery.level !== undefined ? `Battery ${v.battery.level}% ${v.battery.temperatureC !== undefined ? `(${v.battery.temperatureC}°C) ` : ''}${v.battery.status}` : 'Battery n/a'
|
|
1191
|
+
const cpu = v.cpu && v.cpu.cores ? `CPU ${v.cpu.cores} cores${v.cpu.hardware ? ` (${v.cpu.hardware})` : ''}` : 'CPU n/a'
|
|
1192
|
+
return [{ type: 'text', text: `${v.serial}: ${mem} · ${bat} · ${cpu}` }]
|
|
1193
|
+
},
|
|
1194
|
+
},
|
|
1195
|
+
async execute(args) {
|
|
1196
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1197
|
+
const [meminfo, battery, cpuinfo] = await Promise.all([
|
|
1198
|
+
DeviceBuild.adbRun(serial, ['shell', 'cat', '/proc/meminfo']).catch(() => ''),
|
|
1199
|
+
DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'battery']).catch(() => ''),
|
|
1200
|
+
DeviceBuild.adbRun(serial, ['shell', 'cat', '/proc/cpuinfo']).catch(() => ''),
|
|
1201
|
+
])
|
|
1202
|
+
return DeviceBuild.jsonSafe({
|
|
1203
|
+
serial,
|
|
1204
|
+
memory: DeviceBuild.parseMeminfo(meminfo),
|
|
1205
|
+
battery: parseBatteryShaped(battery),
|
|
1206
|
+
cpu: DeviceBuild.parseCpuinfo(cpuinfo),
|
|
1207
|
+
})
|
|
1208
|
+
},
|
|
1209
|
+
})
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** battery status words: dumpsys battery status 2=charging, 5=full etc. */
|
|
1213
|
+
function parseBatteryShaped(output) {
|
|
1214
|
+
const raw = DeviceBuild.parseBattery(output)
|
|
1215
|
+
if (raw === undefined) return undefined
|
|
1216
|
+
const status = { 1: 'unknown', 2: 'charging', 3: 'discharging', 4: 'not charging', 5: 'full' }[raw.status] ?? raw.status
|
|
1217
|
+
const health = { 1: 'unknown', 2: 'good', 3: 'overheat', 4: 'dead', 5: 'over-voltage', 6: 'unspecified', 7: 'cold' }[raw.health] ?? raw.health
|
|
1218
|
+
return { ...raw, status, health }
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function deviceAppInfoTool() {
|
|
1222
|
+
return defineTool({
|
|
1223
|
+
name: 'device_app_info',
|
|
1224
|
+
description: 'Detail for one installed app: versionName/versionCode, minSdk/targetSdk, requested permissions, and exported ' +
|
|
1225
|
+
'activities — straight from `dumpsys package <pkg>`. Use to answer "what does this app do / what permissions / which ' +
|
|
1226
|
+
'version" without guessing.',
|
|
1227
|
+
parameters: {
|
|
1228
|
+
package: { type: 'string', description: 'Package name, e.g. com.android.chrome.' },
|
|
1229
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1230
|
+
},
|
|
1231
|
+
output: {
|
|
1232
|
+
schema: {
|
|
1233
|
+
type: 'object',
|
|
1234
|
+
additionalProperties: false,
|
|
1235
|
+
properties: {
|
|
1236
|
+
package: { type: 'string', required: true },
|
|
1237
|
+
versionName: { type: 'string' },
|
|
1238
|
+
versionCode: { type: 'string' },
|
|
1239
|
+
minSdk: { type: 'string' },
|
|
1240
|
+
targetSdk: { type: 'string' },
|
|
1241
|
+
permissions: { type: 'array', items: { type: 'string' } },
|
|
1242
|
+
activities: { type: 'array', items: { type: 'string' } },
|
|
1243
|
+
},
|
|
1244
|
+
},
|
|
1245
|
+
render: (_args, value) => {
|
|
1246
|
+
const v = value ?? { package: '' }
|
|
1247
|
+
const lines = [`${v.package}`, ` version ${v.versionName ?? '?'} (${v.versionCode ?? '?'}) · minSdk ${v.minSdk ?? '?'} · targetSdk ${v.targetSdk ?? '?'}`]
|
|
1248
|
+
if (v.permissions?.length) lines.push(` permissions (${v.permissions.length}):`, v.permissions.slice(0, 15).map((p) => ` ${p}`).join('\n'))
|
|
1249
|
+
if (v.activities?.length) lines.push(` activities (${v.activities.length}):`, v.activities.slice(0, 8).map((a) => ` ${a}`).join('\n'))
|
|
1250
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1251
|
+
},
|
|
1252
|
+
},
|
|
1253
|
+
async execute(args) {
|
|
1254
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1255
|
+
const pkg = String(args.package ?? '').trim()
|
|
1256
|
+
if (pkg === '') throw new Error('device_app_info requires a package name.')
|
|
1257
|
+
const output = await DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'package', pkg])
|
|
1258
|
+
const info = DeviceBuild.parseAppInfo(output, pkg)
|
|
1259
|
+
if (info.versionName === undefined) throw new Error(`package ${pkg} is not installed (dumpsys package returned nothing for it) — run device_apps to list what is.`)
|
|
1260
|
+
return DeviceBuild.jsonSafe(info)
|
|
1261
|
+
},
|
|
1262
|
+
})
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function deviceInstallTool() {
|
|
1266
|
+
return defineTool({
|
|
1267
|
+
name: 'device_install',
|
|
1268
|
+
description: 'Install a local APK onto an attached Android device (`adb install -r -g`: replace + grant runtime permissions). ' +
|
|
1269
|
+
'The apk_path is a LOCAL path on this host (e.g. a build output). Independent of device_run — use it to sideload a debug ' +
|
|
1270
|
+
'build onto a device whose app is already set up.',
|
|
1271
|
+
parameters: {
|
|
1272
|
+
apk_path: { type: 'string', description: 'Absolute local path to the APK, e.g. F:/app/build/outputs/apk/debug/app-debug.apk.' },
|
|
1273
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1274
|
+
},
|
|
1275
|
+
output: {
|
|
1276
|
+
schema: {
|
|
1277
|
+
type: 'object',
|
|
1278
|
+
additionalProperties: false,
|
|
1279
|
+
properties: {
|
|
1280
|
+
serial: { type: 'string', required: true },
|
|
1281
|
+
apk: { type: 'string', required: true },
|
|
1282
|
+
installed: { type: 'boolean', required: true },
|
|
1283
|
+
},
|
|
1284
|
+
},
|
|
1285
|
+
render: (_args, value) => {
|
|
1286
|
+
const v = value ?? { serial: '', apk: '', installed: false }
|
|
1287
|
+
return [{ type: 'text', text: `Installed ${v.apk} on ${v.serial} (${v.installed ? 'ok' : 'failed'})` }]
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
async execute(args) {
|
|
1291
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1292
|
+
const apk = String(args.apk_path ?? '').trim()
|
|
1293
|
+
if (apk === '') throw new Error('device_install requires apk_path.')
|
|
1294
|
+
if (!existsSync(apk)) throw new Error(`APK not found on this host: ${apk}`)
|
|
1295
|
+
const output = await DeviceBuild.adbRun(serial, ['install', '-r', '-g', apk], { timeoutMs: 180_000 })
|
|
1296
|
+
if (!/success/i.test(output)) throw new Error(`adb install failed on ${serial}: ${output.slice(-300)}`)
|
|
1297
|
+
return { serial, apk, installed: true }
|
|
1298
|
+
},
|
|
1299
|
+
})
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function deviceUninstallTool() {
|
|
1303
|
+
return defineTool({
|
|
1304
|
+
name: 'device_uninstall',
|
|
1305
|
+
description: 'Uninstall an app from an attached Android device (`adb uninstall <pkg>`, keeps data unless -k is wanted). ' +
|
|
1306
|
+
'Irreversible — the app\'s data is removed. Confirm the intent before calling.',
|
|
1307
|
+
parameters: {
|
|
1308
|
+
package: { type: 'string', description: 'Package name to remove, e.g. com.example.app.' },
|
|
1309
|
+
keep_data: { type: 'boolean', description: 'Pass -k to keep the app data (default false).' },
|
|
1310
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1311
|
+
},
|
|
1312
|
+
output: {
|
|
1313
|
+
schema: {
|
|
1314
|
+
type: 'object',
|
|
1315
|
+
additionalProperties: false,
|
|
1316
|
+
properties: {
|
|
1317
|
+
serial: { type: 'string', required: true },
|
|
1318
|
+
package: { type: 'string', required: true },
|
|
1319
|
+
uninstalled: { type: 'boolean', required: true },
|
|
1320
|
+
},
|
|
1321
|
+
},
|
|
1322
|
+
render: (_args, value) => {
|
|
1323
|
+
const v = value ?? { serial: '', package: '', uninstalled: false }
|
|
1324
|
+
return [{ type: 'text', text: `Uninstalled ${v.package} on ${v.serial} (${v.uninstalled ? 'ok' : 'failed'})` }]
|
|
1325
|
+
},
|
|
1326
|
+
},
|
|
1327
|
+
async execute(args) {
|
|
1328
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1329
|
+
const pkg = String(args.package ?? '').trim()
|
|
1330
|
+
if (pkg === '') throw new Error('device_uninstall requires a package name.')
|
|
1331
|
+
const argv = args.keep_data === true ? ['uninstall', '-k', pkg] : ['uninstall', pkg]
|
|
1332
|
+
const output = await DeviceBuild.adbRun(serial, argv)
|
|
1333
|
+
if (!/success/i.test(output)) throw new Error(`adb uninstall failed on ${serial}: ${output.slice(-300)}`)
|
|
1334
|
+
return { serial, package: pkg, uninstalled: true }
|
|
1335
|
+
},
|
|
1336
|
+
})
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function deviceRebootTool() {
|
|
1340
|
+
return defineTool({
|
|
1341
|
+
name: 'device_reboot',
|
|
1342
|
+
description: 'Reboot an attached Android device: `adb reboot` (normal), recovery, or bootloader. The device goes offline and ' +
|
|
1343
|
+
'comes back in a minute or two — a subsequent call to a device_* tool will auto-wait or fail with a reconnect hint. ' +
|
|
1344
|
+
'Disruptive: rebooting interrupts anything running on the device.',
|
|
1345
|
+
parameters: {
|
|
1346
|
+
mode: { type: 'string', enum: ['normal', 'recovery', 'bootloader'], description: 'Where to reboot into (default normal).' },
|
|
1347
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1348
|
+
},
|
|
1349
|
+
output: {
|
|
1350
|
+
schema: {
|
|
1351
|
+
type: 'object',
|
|
1352
|
+
additionalProperties: false,
|
|
1353
|
+
properties: {
|
|
1354
|
+
serial: { type: 'string', required: true },
|
|
1355
|
+
mode: { type: 'string', required: true },
|
|
1356
|
+
rebooting: { type: 'boolean', required: true },
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
render: (_args, value) => [{ type: 'text', text: `Rebooting ${value?.serial} into ${value?.mode}` }],
|
|
1360
|
+
},
|
|
1361
|
+
async execute(args) {
|
|
1362
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1363
|
+
const mode = args.mode === 'recovery' || args.mode === 'bootloader' ? args.mode : 'normal'
|
|
1364
|
+
const argv = mode === 'normal' ? ['reboot'] : ['reboot', mode]
|
|
1365
|
+
await DeviceBuild.adbRun(serial, argv)
|
|
1366
|
+
return { serial, mode, rebooting: true }
|
|
1367
|
+
},
|
|
1368
|
+
})
|
|
1369
|
+
}
|
|
1370
|
+
|
|
930
1371
|
function deviceBootTool() {
|
|
931
1372
|
return defineTool({
|
|
932
1373
|
name: 'device_boot',
|
|
@@ -1649,6 +2090,16 @@ function guidance() {
|
|
|
1649
2090
|
' expect_text / expect_gone (no separate screenshot needed to know the tap landed).',
|
|
1650
2091
|
'- device_wait_for: wait for text to appear/disappear (polls the UI tree, falls back to OCR on textless surfaces);',
|
|
1651
2092
|
' a timeout is a normal matched:false result, never an error. One call replaces an agent-side poll loop.',
|
|
2093
|
+
'- device_scroll_to: scroll until an element (resource_id or text) comes into view, then report it — use before tapping',
|
|
2094
|
+
' elements that are off-screen in long lists (Settings pages, app lists). Returns the found node + its center tap point.',
|
|
2095
|
+
'- device_connect / device_pair_qr: attach a Wi-Fi device. device_connect does `adb connect host:port` (pass pairing_code +',
|
|
2096
|
+
' pairing_port for the first-time "Pair device" flow). device_pair_qr does the full journey: generates a WIFI:T:ADB QR',
|
|
2097
|
+
' string (render it as a QR code and scan it with the phone: Settings → Connected devices → Pair by QR), waits for the',
|
|
2098
|
+
' pairing service over mDNS, then auto-pairs and connects. Needs adb >= 31 for the QR flow.',
|
|
2099
|
+
'- device_perf: one-shot RAM / battery / CPU snapshot of an attached device (meminfo + dumpsys battery + cpuinfo).',
|
|
2100
|
+
'- device_app_info / device_install / device_uninstall: per-app detail (version, SDK, permissions, activities via dumpsys',
|
|
2101
|
+
' package) / sideload a local APK onto a running device (`install -r -g`) / remove an app. Uninstall is destructive.',
|
|
2102
|
+
'- device_reboot: reboot the device (normal / recovery / bootloader). The device drops off then comes back; disruptive.',
|
|
1652
2103
|
'- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
|
|
1653
2104
|
' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_ui_tree → device_tap_element, falling back to',
|
|
1654
2105
|
' device_screen → device_input when a surface exposes no accessibility tree. Typing is ASCII over plain adb; non-ASCII',
|
|
@@ -1743,6 +2194,14 @@ export function apply(ctx, config) {
|
|
|
1743
2194
|
deviceAppsTool(),
|
|
1744
2195
|
deviceLaunchAppTool(),
|
|
1745
2196
|
deviceIntentTool(),
|
|
2197
|
+
deviceScrollToTool(),
|
|
2198
|
+
deviceConnectTool(),
|
|
2199
|
+
devicePairQrTool(),
|
|
2200
|
+
devicePerfTool(),
|
|
2201
|
+
deviceAppInfoTool(),
|
|
2202
|
+
deviceInstallTool(),
|
|
2203
|
+
deviceUninstallTool(),
|
|
2204
|
+
deviceRebootTool(),
|
|
1746
2205
|
deviceStreamTool(streamHost, streamAccess),
|
|
1747
2206
|
deviceLogTool(engine),
|
|
1748
2207
|
deviceStatusTool(engine),
|