From 1789dedf9724dd8da6ed31045d12db1afcfbec92 Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 5 Nov 2024 08:24:59 -0600 Subject: [PATCH] Add battery IORegistry and sysmontap cpuusage (#475) * Add battery ioregistry stats * Add sysmontap cpu usage stats * Add doc * fix: safe type assertions * fix: add system monitor wrapper * fix: simplify to just channel * Dispatch to dedicated Dispatcher in Connection * fix: close channel * fix: deliver CPU sample continuosly * Specify sampling rate --------- Authored-by: fish-sauce Co-authored-by: dmissmann <37073203+dmissmann@users.noreply.github.com> --- ios/diagnostics/diagnostics.go | 21 +++ ios/diagnostics/ioregistry.go | 43 +++--- ios/diagnostics/request.go | 18 ++- ios/dtx_codec/connection.go | 23 +++ ios/instruments/helper.go | 12 ++ ios/instruments/instruments_deviceinfo.go | 18 +++ ios/instruments/instruments_sysmontap.go | 177 ++++++++++++++++++++++ main.go | 69 ++++++++- 8 files changed, 356 insertions(+), 25 deletions(-) create mode 100644 ios/instruments/instruments_sysmontap.go diff --git a/ios/diagnostics/diagnostics.go b/ios/diagnostics/diagnostics.go index 52d7452a..32f536c3 100644 --- a/ios/diagnostics/diagnostics.go +++ b/ios/diagnostics/diagnostics.go @@ -33,6 +33,27 @@ func Reboot(device ios.DeviceEntry) error { return service.Close() } +// Battery extracts the battery ioregistry stats like Temperature, Voltage, CurrentCapacity +func (diagnosticsConn *Connection) Battery() (IORegistry, error) { + req := newIORegistryRequest() + req.addClass("IOPMPowerSource") + + reader := diagnosticsConn.deviceConn.Reader() + encoded, err := req.encoded() + if err != nil { + return IORegistry{}, err + } + err = diagnosticsConn.deviceConn.Send(encoded) + if err != nil { + return IORegistry{}, err + } + response, err := diagnosticsConn.plistCodec.Decode(reader) + if err != nil { + return IORegistry{}, err + } + return diagnosticsfromBytes(response).Diagnostics.IORegistry, nil +} + func (diagnosticsConn *Connection) Reboot() error { req := rebootRequest{Request: "Restart", WaitForDisconnect: true, DisplayFail: true, DisplayPass: true} reader := diagnosticsConn.deviceConn.Reader() diff --git a/ios/diagnostics/ioregistry.go b/ios/diagnostics/ioregistry.go index 551ce2ac..c05d2a12 100644 --- a/ios/diagnostics/ioregistry.go +++ b/ios/diagnostics/ioregistry.go @@ -2,27 +2,32 @@ package diagnostics import ios "github.com/danielpaulus/go-ios/ios" -func ioregentryRequest(key string) []byte { - requestMap := map[string]interface{}{ - "Request": "IORegistry", - "EntryName": key, - } - bt, err := ios.PlistCodec{}.Encode(requestMap) - if err != nil { - panic("query request encoding should never fail") - } - return bt +type ioregistryRequest struct { + reqMap map[string]string } -func (diagnosticsConn *Connection) IORegEntryQuery(key string) (interface{}, error) { - err := diagnosticsConn.deviceConn.Send(ioregentryRequest(key)) - if err != nil { - return "", err - } - respBytes, err := diagnosticsConn.plistCodec.Decode(diagnosticsConn.deviceConn.Reader()) +func newIORegistryRequest() *ioregistryRequest { + return &ioregistryRequest{map[string]string{ + "Request": "IORegistry", + }} +} + +func (req *ioregistryRequest) addPlane(plane string) { + req.reqMap["CurrentPlane"] = plane +} + +func (req *ioregistryRequest) addName(name string) { + req.reqMap["EntryName"] = name +} + +func (req *ioregistryRequest) addClass(class string) { + req.reqMap["EntryClass"] = class +} + +func (req *ioregistryRequest) encoded() ([]byte, error) { + bt, err := ios.PlistCodec{}.Encode(req.reqMap) if err != nil { - return "", err + return nil, err } - plist, err := ios.ParsePlist(respBytes) - return plist, err + return bt, nil } diff --git a/ios/diagnostics/request.go b/ios/diagnostics/request.go index 7fe07538..708607ef 100644 --- a/ios/diagnostics/request.go +++ b/ios/diagnostics/request.go @@ -30,10 +30,20 @@ type allDiagnosticsResponse struct { } type Diagnostics struct { - GasGauge GasGauge - HDMI HDMI - NAND NAND - WiFi WiFi + GasGauge GasGauge + HDMI HDMI + NAND NAND + WiFi WiFi + IORegistry IORegistry +} + +// IORegistry relates to the battery stats +type IORegistry struct { + InstantAmperage int + Temperature int + Voltage int + IsCharging bool + CurrentCapacity int } type WiFi struct { diff --git a/ios/dtx_codec/connection.go b/ios/dtx_codec/connection.go index ebec76da..e883f755 100644 --- a/ios/dtx_codec/connection.go +++ b/ios/dtx_codec/connection.go @@ -30,6 +30,14 @@ type Connection struct { mutex sync.Mutex requestChannelMessages chan Message + // MessageDispatcher use this prop to catch messages from GlobalDispatcher + // and handle it accordingly in a custom dispatcher of the dedicated service + // + // Set this prop when creating a connection instance + // + // Refer to end-to-end example of `instruments/instruments_sysmontap.go` + MessageDispatcher Dispatcher + closed chan struct{} err error closeOnce sync.Once @@ -87,6 +95,18 @@ func NewGlobalDispatcher(requestChannelMessages chan Message, dtxConnection *Con return dispatcher } +// Dispatch to a MessageDispatcher of the Connection if set +func (dtxConn *Connection) Dispatch(msg Message) { + msgDispatcher := dtxConn.MessageDispatcher + if msgDispatcher != nil { + log.Debugf("msg dispatcher found: %T", msgDispatcher) + msgDispatcher.Dispatch(msg) + return + } + + log.Errorf("no connection dispatcher registered for global channel, msg: %v", msg) +} + // Dispatch prints log messages and errors when they are received and also creates local Channels when requested by the device. func (g GlobalDispatcher) Dispatch(msg Message) { SendAckIfNeeded(g.dtxConnection, msg) @@ -111,6 +131,9 @@ func (g GlobalDispatcher) Dispatch(msg Message) { if msg.HasError() { log.Error(msg.Payload[0]) } + if msg.PayloadHeader.MessageType == UnknownTypeOne { + g.dtxConnection.Dispatch(msg) + } } func notifyOfPublishedCapabilities(msg Message) { diff --git a/ios/instruments/helper.go b/ios/instruments/helper.go index 42d19d1b..fcece086 100644 --- a/ios/instruments/helper.go +++ b/ios/instruments/helper.go @@ -2,6 +2,7 @@ package instruments import ( "fmt" + "reflect" "github.com/danielpaulus/go-ios/ios" dtx "github.com/danielpaulus/go-ios/ios/dtx_codec" @@ -24,6 +25,17 @@ func (p loggingDispatcher) Dispatch(m dtx.Message) { log.Debug(m) } +func connectInstrumentsWithMsgDispatcher(device ios.DeviceEntry, dispatcher dtx.Dispatcher) (*dtx.Connection, error) { + dtxConn, err := connectInstruments(device) + if err != nil { + return nil, err + } + dtxConn.MessageDispatcher = dispatcher + log.Debugf("msg dispatcher: %v attached to instruments connection", reflect.TypeOf(dispatcher)) + + return dtxConn, nil +} + func connectInstruments(device ios.DeviceEntry) (*dtx.Connection, error) { if device.SupportsRsd() { log.Debugf("Connecting to %s", serviceNameRsd) diff --git a/ios/instruments/instruments_deviceinfo.go b/ios/instruments/instruments_deviceinfo.go index 5f9e1760..8f91aa78 100644 --- a/ios/instruments/instruments_deviceinfo.go +++ b/ios/instruments/instruments_deviceinfo.go @@ -20,6 +20,24 @@ type ProcessInfo struct { StartDate time.Time } +// processAttributes returns the attributes list which can be used for monitoring +func (d DeviceInfoService) processAttributes() ([]interface{}, error) { + resp, err := d.channel.MethodCall("sysmonProcessAttributes") + if err != nil { + return nil, err + } + return resp.Payload[0].([]interface{}), nil +} + +// systemAttributes returns the attributes list which can be used for monitoring +func (d DeviceInfoService) systemAttributes() ([]interface{}, error) { + resp, err := d.channel.MethodCall("sysmonSystemAttributes") + if err != nil { + return nil, err + } + return resp.Payload[0].([]interface{}), nil +} + // ProcessList returns a []ProcessInfo, one for each process running on the iOS device func (d DeviceInfoService) ProcessList() ([]ProcessInfo, error) { resp, err := d.channel.MethodCall("runningProcesses") diff --git a/ios/instruments/instruments_sysmontap.go b/ios/instruments/instruments_sysmontap.go new file mode 100644 index 00000000..37c883da --- /dev/null +++ b/ios/instruments/instruments_sysmontap.go @@ -0,0 +1,177 @@ +package instruments + +import ( + "fmt" + + "github.com/danielpaulus/go-ios/ios" + dtx "github.com/danielpaulus/go-ios/ios/dtx_codec" + log "github.com/sirupsen/logrus" +) + +type sysmontapMsgDispatcher struct { + messages chan dtx.Message +} + +func newSysmontapMsgDispatcher() *sysmontapMsgDispatcher { + return &sysmontapMsgDispatcher{make(chan dtx.Message)} +} + +func (p *sysmontapMsgDispatcher) Dispatch(m dtx.Message) { + p.messages <- m +} + +const sysmontapName = "com.apple.instruments.server.services.sysmontap" + +type sysmontapService struct { + channel *dtx.Channel + conn *dtx.Connection + + deviceInfoService *DeviceInfoService + msgDispatcher *sysmontapMsgDispatcher +} + +// NewSysmontapService creates a new sysmontapService +// - samplingInterval is the rate how often to get samples, i.e Xcode's default is 10, which results in sampling output +// each 1 second, with 500 the samples are retrieved every 15 seconds. It doesn't make any correlation between +// the expected rate and the actual rate of samples delivery. We can only conclude, that the lower the rate in digits, +// the faster the samples are delivered +func NewSysmontapService(device ios.DeviceEntry, samplingInterval int) (*sysmontapService, error) { + deviceInfoService, err := NewDeviceInfoService(device) + if err != nil { + return nil, err + } + + msgDispatcher := newSysmontapMsgDispatcher() + dtxConn, err := connectInstrumentsWithMsgDispatcher(device, msgDispatcher) + if err != nil { + return nil, err + } + + processControlChannel := dtxConn.RequestChannelIdentifier(sysmontapName, loggingDispatcher{dtxConn}) + + sysAttrs, err := deviceInfoService.systemAttributes() + if err != nil { + return nil, err + } + + procAttrs, err := deviceInfoService.processAttributes() + if err != nil { + return nil, err + } + + config := map[string]interface{}{ + "ur": samplingInterval, + "bm": 0, + "procAttrs": procAttrs, + "sysAttrs": sysAttrs, + "cpuUsage": true, + "physFootprint": true, + "sampleInterval": 500000000, + } + _, err = processControlChannel.MethodCall("setConfig:", config) + if err != nil { + return nil, err + } + + err = processControlChannel.MethodCallAsync("start") + if err != nil { + return nil, err + } + + return &sysmontapService{processControlChannel, dtxConn, deviceInfoService, msgDispatcher}, nil +} + +// Close closes up the DTX connection, message dispatcher and dtx.Message channel +func (s *sysmontapService) Close() error { + close(s.msgDispatcher.messages) + + s.deviceInfoService.Close() + return s.conn.Close() +} + +// ReceiveCPUUsage returns a chan of SysmontapMessage with CPU Usage info +// The method will close the result channel automatically as soon as sysmontapMsgDispatcher's +// dtx.Message channel is closed. +func (s *sysmontapService) ReceiveCPUUsage() chan SysmontapMessage { + messages := make(chan SysmontapMessage) + go func() { + defer close(messages) + + for msg := range s.msgDispatcher.messages { + sysmontapMessage, err := mapToCPUUsage(msg) + if err != nil { + log.Debugf("expected `sysmontapMessage` from global channel, but received %v", msg) + continue + } + + messages <- sysmontapMessage + } + + log.Infof("sysmontap message dispatcher channel closed") + }() + + return messages +} + +// SysmontapMessage is a wrapper struct for incoming CPU samples +type SysmontapMessage struct { + CPUCount uint64 + EnabledCPUs uint64 + EndMachAbsTime uint64 + Type uint64 + SystemCPUUsage CPUUsage +} + +type CPUUsage struct { + CPU_TotalLoad float64 +} + +func mapToCPUUsage(msg dtx.Message) (SysmontapMessage, error) { + payload := msg.Payload + if len(payload) != 1 { + return SysmontapMessage{}, fmt.Errorf("payload of message should have only one element: %+v", msg) + } + + resultArray, ok := payload[0].([]interface{}) + if !ok { + return SysmontapMessage{}, fmt.Errorf("expected resultArray of type []interface{}: %+v", payload[0]) + } + resultMap, ok := resultArray[0].(map[string]interface{}) + if !ok { + return SysmontapMessage{}, fmt.Errorf("expected resultMap of type map[string]interface{} as a single element of resultArray: %+v", resultArray[0]) + } + cpuCount, ok := resultMap["CPUCount"].(uint64) + if !ok { + return SysmontapMessage{}, fmt.Errorf("expected CPUCount of type uint64 of resultMap: %+v", resultMap) + } + enabledCPUs, ok := resultMap["EnabledCPUs"].(uint64) + if !ok { + return SysmontapMessage{}, fmt.Errorf("expected EnabledCPUs of type uint64 of resultMap: %+v", resultMap) + } + endMachAbsTime, ok := resultMap["EndMachAbsTime"].(uint64) + if !ok { + return SysmontapMessage{}, fmt.Errorf("expected EndMachAbsTime of type uint64 of resultMap: %+v", resultMap) + } + typ, ok := resultMap["Type"].(uint64) + if !ok { + return SysmontapMessage{}, fmt.Errorf("expected Type of type uint64 of resultMap: %+v", resultMap) + } + sysmontapMessageMap, ok := resultMap["SystemCPUUsage"].(map[string]interface{}) + if !ok { + return SysmontapMessage{}, fmt.Errorf("expected SystemCPUUsage of type map[string]interface{} of resultMap: %+v", resultMap) + } + cpuTotalLoad, ok := sysmontapMessageMap["CPU_TotalLoad"].(float64) + if !ok { + return SysmontapMessage{}, fmt.Errorf("expected CPU_TotalLoad of type uint64 of sysmontapMessageMap: %+v", sysmontapMessageMap) + } + cpuUsage := CPUUsage{CPU_TotalLoad: cpuTotalLoad} + + sysmontapMessage := SysmontapMessage{ + cpuCount, + enabledCPUs, + endMachAbsTime, + typ, + cpuUsage, + } + return sysmontapMessage, nil +} diff --git a/main.go b/main.go index da7ca928..6b3355d4 100644 --- a/main.go +++ b/main.go @@ -105,6 +105,7 @@ Usage: ios forward [options] ios dproxy [--binary] [--mode=] [--iface=] [options] ios readpair [options] + ios sysmontap [options] ios pcap [options] [--pid=] [--process=] ios install --path= [options] ios uninstall [options] @@ -128,6 +129,7 @@ Usage: ios zoomtouch (enable | disable | toggle | get) [--force] [options] ios diskspace [options] ios batterycheck [options] + ios batteryregistry [options] ios tunnel start [options] [--pair-record-path=] [--userspace] ios tunnel ls [options] ios tunnel stopagent @@ -218,6 +220,7 @@ The commands work as following: > to stop usbmuxd and load to start it again should the proxy mess up things. > The --binary flag will dump everything in raw binary without any decoding. ios readpair Dump detailed information about the pairrecord for a device. + ios sysmontap Get system stats like MEM, CPU ios install --path= [options] Specify a .app folder or an installable ipa file that will be installed. ios pcap [options] [--pid=] [--process=] Starts a pcap dump of network traffic, use --pid or --process to filter specific processes. ios apps [--system] [--all] [--list] [--filesharing] Retrieves a list of installed applications. --system prints out preinstalled system apps. --all prints all apps, including system, user, and hidden apps. --list only prints bundle ID, bundle name and version number. --filesharing only prints apps which enable documents sharing. @@ -245,6 +248,7 @@ The commands work as following: ios timeformat (24h | 12h | toggle | get) [--force] [options] Sets, or returns the state of the "time format". iOS 11+ only (Use --force to try on older versions). ios diskspace [options] Prints disk space info. ios batterycheck [options] Prints battery info. + ios batteryregistry [options] Prints battery registry stats like Temperature, Voltage. ios tunnel start [options] [--pair-record-path=] [--enabletun] Creates a tunnel connection to the device. If the device was not paired with the host yet, device pairing will also be executed. > On systems with System Integrity Protection enabled the argument '--pair-record-path=default' can be used to point to /var/db/lockdown/RemotePairing/user_501. > If nothing is specified, the current dir is used for the pair record. @@ -832,6 +836,11 @@ The commands work as following: } } + b, _ = arguments.Bool("sysmontap") + if b { + printSysmontapStats(device) + } + b, _ = arguments.Bool("kill") if b { var response []installationproxy.AppInfo @@ -965,6 +974,11 @@ The commands work as following: } } + b, _ = arguments.Bool("batteryregistry") + if b { + printBatteryRegistry(device) + } + b, _ = arguments.Bool("reboot") if b { err := diagnostics.Reboot(device) @@ -1114,6 +1128,42 @@ The commands work as following: } } +func printSysmontapStats(device ios.DeviceEntry) { + const xcodeDefaultSamplingRate = 10 + sysmon, err := instruments.NewSysmontapService(device, xcodeDefaultSamplingRate) + if err != nil { + exitIfError("systemMonitor creation error", err) + } + defer sysmon.Close() + + cpuUsageChannel := sysmon.ReceiveCPUUsage() + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + + log.Info("starting to monitor CPU usage... Press CTRL+C to stop.") + + for { + select { + case cpuUsageMsg, ok := <-cpuUsageChannel: + if !ok { + log.Info("CPU usage channel closed.") + return + } + log.WithFields(log.Fields{ + "cpu_count": cpuUsageMsg.CPUCount, + "enabled_cpus": cpuUsageMsg.EnabledCPUs, + "end_time": cpuUsageMsg.EndMachAbsTime, + "cpu_total_load": cpuUsageMsg.SystemCPUUsage.CPU_TotalLoad, + }).Info("received CPU usage data") + + case <-c: + log.Info("shutting down sysmontap") + return + } + } +} + func mobileGestaltCommand(device ios.DeviceEntry, arguments docopt.Opts) bool { b, _ := arguments.Bool("mobilegestalt") if b { @@ -1646,8 +1696,8 @@ func startAx(device ios.DeviceEntry, arguments docopt.Opts) { /* conn.GetElement() time.Sleep(time.Second) conn.TurnOff()*/ - //conn.GetElement() - //conn.GetElement() + // conn.GetElement() + // conn.GetElement() exitIfError("ax failed", err) }() @@ -1763,6 +1813,21 @@ func printBatteryDiagnostics(device ios.DeviceEntry) { fmt.Println(convertToJSONString(battery)) } +func printBatteryRegistry(device ios.DeviceEntry) { + conn, err := diagnostics.New(device) + if err != nil { + exitIfError("failed diagnostics service", err) + } + defer conn.Close() + + stats, err := conn.Battery() + if err != nil { + exitIfError("failed to get battery stats", err) + } + + fmt.Println(convertToJSONString(stats)) +} + func printDeviceDate(device ios.DeviceEntry) { allValues, err := ios.GetValues(device) exitIfError("failed getting values", err)