Add metadata am: 5222d76c31 am: 4ea07eb5ff
am: 885de8d53c

Change-Id: I84b273543440eba6a741807254edf0eb3a39a5d3
diff --git a/devlib/__init__.py b/devlib/__init__.py
index 911c50d..2b3f3b6 100644
--- a/devlib/__init__.py
+++ b/devlib/__init__.py
@@ -13,10 +13,15 @@
 from devlib.instrument import MEASUREMENT_TYPES, INSTANTANEOUS, CONTINUOUS
 from devlib.instrument.daq import DaqInstrument
 from devlib.instrument.energy_probe import EnergyProbeInstrument
-from devlib.instrument.frames import GfxInfoFramesInstrument
+from devlib.instrument.frames import GfxInfoFramesInstrument, SurfaceFlingerFramesInstrument
 from devlib.instrument.hwmon import HwmonInstrument
 from devlib.instrument.monsoon import MonsoonInstrument
 from devlib.instrument.netstats import NetstatsInstrument
+from devlib.instrument.gem5power import Gem5PowerInstrument
+
+from devlib.derived import DerivedMeasurements, DerivedMetric
+from devlib.derived.energy import DerivedEnergyMeasurements
+from devlib.derived.fps import DerivedGfxInfoStats, DerivedSurfaceFlingerStats
 
 from devlib.trace.ftrace import FtraceCollector
 
diff --git a/devlib/bin/scripts/shutils.in b/devlib/bin/scripts/shutils.in
index 04712aa..c6ef572 100755
--- a/devlib/bin/scripts/shutils.in
+++ b/devlib/bin/scripts/shutils.in
@@ -187,6 +187,62 @@
         done
 }
 
+cgroups_freezer_set_state() {
+    STATE=${1}
+    SYSFS_ENTRY=${2}/freezer.state
+
+    # Set the state of the freezer
+    echo $STATE > $SYSFS_ENTRY
+    
+    # And check it applied cleanly
+    for i in `seq 1 10`; do
+        [ $($CAT $SYSFS_ENTRY) = $STATE ] && exit 0
+        sleep 1
+    done
+
+    # We have an issue
+    echo "ERROR: Freezer stalled while changing state to \"$STATE\"." >&2
+    exit 1
+}
+
+################################################################################
+# Hotplug
+################################################################################
+
+hotplug_online_all() {
+    for path in /sys/devices/system/cpu/cpu[0-9]*; do
+        if [ $(cat $path/online) -eq 0 ]; then
+            echo 1 > $path/online
+        fi
+    done
+}
+
+################################################################################
+# Misc
+################################################################################
+
+read_tree_values() {
+    BASEPATH=$1
+    MAXDEPTH=$2
+
+    if [ ! -e $BASEPATH ]; then
+        echo "ERROR: $BASEPATH does not exist"
+        exit 1
+    fi
+
+    PATHS=$($BUSYBOX find $BASEPATH -follow -maxdepth $MAXDEPTH)
+    i=0
+    for path in $PATHS; do
+        i=$(expr $i + 1)
+        if [ $i -gt 1 ]; then
+            break;
+        fi
+    done
+    if [ $i -gt 1 ]; then
+        $BUSYBOX grep -s '' $PATHS
+    fi
+}
+
 ################################################################################
 # Main Function Dispatcher
 ################################################################################
@@ -225,9 +281,18 @@
 cgroups_tasks_in)
 	cgroups_tasks_in $*
 	;;
+cgroups_freezer_set_state)
+	cgroups_freezer_set_state $*
+	;;
 ftrace_get_function_stats)
     ftrace_get_function_stats
     ;;
+hotplug_online_all)
+	hotplug_online_all
+    ;;
+read_tree_values)
+	read_tree_values $*
+    ;;
 *)
     echo "Command [$CMD] not supported"
     exit -1
diff --git a/devlib/derived/__init__.py b/devlib/derived/__init__.py
new file mode 100644
index 0000000..24ac060
--- /dev/null
+++ b/devlib/derived/__init__.py
@@ -0,0 +1,60 @@
+#    Copyright 2015 ARM Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from devlib.instrument import MeasurementType, MEASUREMENT_TYPES
+
+
+class DerivedMetric(object):
+
+    __slots__ = ['name', 'value', 'measurement_type']
+
+    @property
+    def units(self):
+        return self.measurement_type.units
+
+    def __init__(self, name, value, measurement_type):
+        self.name = name
+        self.value = value
+        if isinstance(measurement_type, MeasurementType):
+            self.measurement_type = measurement_type
+        else:
+            try:
+                self.measurement_type = MEASUREMENT_TYPES[measurement_type]
+            except KeyError:
+                msg = 'Unknown measurement type:  {}'
+                raise ValueError(msg.format(measurement_type))
+
+    def __cmp__(self, other):
+        if hasattr(other, 'value'):
+            return cmp(self.value, other.value)
+        else:
+            return cmp(self.value, other)
+
+    def __str__(self):
+        if self.units:
+            return '{}: {} {}'.format(self.name, self.value, self.units)
+        else:
+            return '{}: {}'.format(self.name, self.value)
+
+    __repr__ = __str__
+
+
+class DerivedMeasurements(object):
+
+    def process(self, measurements_csv):
+        return []
+
+    def process_raw(self, *args):
+        return []
diff --git a/devlib/derived/energy.py b/devlib/derived/energy.py
new file mode 100644
index 0000000..84d3d7c
--- /dev/null
+++ b/devlib/derived/energy.py
@@ -0,0 +1,97 @@
+#    Copyright 2013-2015 ARM Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+from __future__ import division
+from collections import defaultdict
+
+from devlib import DerivedMeasurements, DerivedMetric
+from devlib.instrument import  MEASUREMENT_TYPES, InstrumentChannel
+
+
+class DerivedEnergyMeasurements(DerivedMeasurements):
+
+    @staticmethod
+    def process(measurements_csv):
+
+        should_calculate_energy = []
+        use_timestamp = False
+
+        # Determine sites to calculate energy for
+        channel_map = defaultdict(list)
+        for channel in measurements_csv.channels:
+            channel_map[channel].append(channel.kind)
+        for channel, kinds in channel_map.iteritems():
+            if 'power' in kinds and not 'energy' in kinds:
+                should_calculate_energy.append(channel.site)
+            if channel.site == 'timestamp':
+                use_timestamp = True
+                time_measurment = channel.measurement_type
+
+        if measurements_csv.sample_rate_hz is None and not use_timestamp:
+            msg = 'Timestamp data is unavailable, please provide a sample rate'
+            raise ValueError(msg)
+
+        if use_timestamp:
+            # Find index of timestamp column
+            ts_index = [i for i, chan in enumerate(measurements_csv.channels)
+                        if chan.site == 'timestamp']
+            if len(ts_index) > 1:
+                raise ValueError('Multiple timestamps detected')
+            ts_index = ts_index[0]
+
+        row_ts = 0
+        last_ts = 0
+        energy_results = defaultdict(dict)
+        power_results = defaultdict(float)
+
+        # Process data
+        for count, row in enumerate(measurements_csv.iter_measurements()):
+            if use_timestamp:
+                last_ts = row_ts
+                row_ts = time_measurment.convert(float(row[ts_index].value), 'time')
+            for entry in row:
+                channel = entry.channel
+                site = channel.site
+                if channel.kind == 'energy':
+                    if count == 0:
+                        energy_results[site]['start'] = entry.value
+                    else:
+                        energy_results[site]['end'] = entry.value
+
+                if channel.kind == 'power':
+                    power_results[site] += entry.value
+
+                    if site in should_calculate_energy:
+                        if count == 0:
+                            energy_results[site]['start'] = 0
+                            energy_results[site]['end'] = 0
+                        elif use_timestamp:
+                            energy_results[site]['end'] += entry.value * (row_ts - last_ts)
+                        else:
+                            energy_results[site]['end'] += entry.value * (1 /
+                                                           measurements_csv.sample_rate_hz)
+
+        # Calculate final measurements
+        derived_measurements = []
+        for site in energy_results:
+            total_energy = energy_results[site]['end'] - energy_results[site]['start']
+            name = '{}_total_energy'.format(site)
+            derived_measurements.append(DerivedMetric(name, total_energy, MEASUREMENT_TYPES['energy']))
+
+        for site in power_results:
+            power = power_results[site] / (count + 1)  #pylint: disable=undefined-loop-variable
+            name = '{}_average_power'.format(site)
+            derived_measurements.append(DerivedMetric(name, power, MEASUREMENT_TYPES['power']))
+
+        return derived_measurements
diff --git a/devlib/derived/fps.py b/devlib/derived/fps.py
new file mode 100644
index 0000000..2695c4c
--- /dev/null
+++ b/devlib/derived/fps.py
@@ -0,0 +1,214 @@
+from __future__ import division
+import csv
+import os
+import re
+
+try:
+    import pandas as pd
+except ImportError:
+    pd = None
+
+from devlib import DerivedMeasurements, DerivedMetric, MeasurementsCsv, InstrumentChannel
+from devlib.exception import HostError
+from devlib.utils.rendering import gfxinfo_get_last_dump, VSYNC_INTERVAL
+from devlib.utils.types import numeric
+
+
+class DerivedFpsStats(DerivedMeasurements):
+
+    def __init__(self, drop_threshold=5, suffix=None, filename=None, outdir=None):
+        self.drop_threshold = drop_threshold
+        self.suffix = suffix
+        self.filename = filename
+        self.outdir = outdir
+        if (filename is None) and (suffix is None):
+            self.suffix = '-fps'
+        elif (filename is not None) and (suffix is not None):
+            raise ValueError('suffix and filename cannot be specified at the same time.')
+        if filename is not None and os.sep in filename:
+            raise ValueError('filename cannot be a path (cannot countain "{}"'.format(os.sep))
+
+    def process(self, measurements_csv):
+        if isinstance(measurements_csv, basestring):
+            measurements_csv = MeasurementsCsv(measurements_csv)
+        if pd is not None:
+            return self._process_with_pandas(measurements_csv)
+        return self._process_without_pandas(measurements_csv)
+
+    def _get_csv_file_name(self, frames_file):
+        outdir = self.outdir or os.path.dirname(frames_file)
+        if self.filename:
+            return os.path.join(outdir, self.filename)
+
+        frames_basename = os.path.basename(frames_file)
+        rest, ext = os.path.splitext(frames_basename)
+        csv_basename = rest + self.suffix + ext
+        return os.path.join(outdir, csv_basename)
+
+
+class DerivedGfxInfoStats(DerivedFpsStats):
+
+    @staticmethod
+    def process_raw(filepath, *args):
+        metrics = []
+        dump = gfxinfo_get_last_dump(filepath)
+        seen_stats = False
+        for line in dump.split('\n'):
+            if seen_stats and not line.strip():
+                break
+            elif line.startswith('Janky frames:'):
+                text = line.split(': ')[-1]
+                val_text, pc_text = text.split('(')
+                metrics.append(DerivedMetric('janks', numeric(val_text.strip()), 'count'))
+                metrics.append(DerivedMetric('janks_pc', numeric(pc_text[:-3]), 'percent'))
+            elif ' percentile: ' in line:
+                ptile, val_text = line.split(' percentile: ')
+                name = 'render_time_{}_ptile'.format(ptile)
+                value = numeric(val_text.strip()[:-2])
+                metrics.append(DerivedMetric(name, value, 'time_ms'))
+            elif line.startswith('Number '):
+                name_text, val_text = line.strip().split(': ')
+                name = name_text[7:].lower().replace(' ', '_')
+                value = numeric(val_text)
+                metrics.append(DerivedMetric(name, value, 'count'))
+            else:
+                continue
+            seen_stats = True
+        return metrics
+
+    def _process_without_pandas(self, measurements_csv):
+        per_frame_fps = []
+        start_vsync, end_vsync = None, None
+        frame_count = 0
+
+        for frame_data in measurements_csv.iter_values():
+            if frame_data.Flags_flags != 0:
+                continue
+            frame_count += 1
+
+            if start_vsync is None:
+                start_vsync = frame_data.Vsync_time_us
+            end_vsync = frame_data.Vsync_time_us
+
+            frame_time = frame_data.FrameCompleted_time_us - frame_data.IntendedVsync_time_us
+            pff = 1e9 / frame_time
+            if pff > self.drop_threshold:
+                per_frame_fps.append([pff])
+
+        if frame_count:
+            duration = end_vsync - start_vsync
+            fps = (1e6 * frame_count) / float(duration)
+        else:
+            duration = 0
+            fps = 0
+
+        csv_file = self._get_csv_file_name(measurements_csv.path)
+        with open(csv_file, 'wb') as wfh:
+            writer = csv.writer(wfh)
+            writer.writerow(['fps'])
+            writer.writerows(per_frame_fps)
+
+        return [DerivedMetric('fps', fps, 'fps'),
+                DerivedMetric('total_frames', frame_count, 'frames'),
+                MeasurementsCsv(csv_file)]
+
+    def _process_with_pandas(self, measurements_csv):
+        data = pd.read_csv(measurements_csv.path)
+        data = data[data.Flags_flags == 0]
+        frame_time = data.FrameCompleted_time_us - data.IntendedVsync_time_us
+        per_frame_fps = (1e6 / frame_time)
+        keep_filter = per_frame_fps > self.drop_threshold
+        per_frame_fps = per_frame_fps[keep_filter]
+        per_frame_fps.name = 'fps'
+
+        frame_count = data.index.size
+        if frame_count > 1:
+            duration = data.Vsync_time_us.iloc[-1] - data.Vsync_time_us.iloc[0]
+            fps = (1e9 * frame_count) / float(duration)
+        else:
+            duration = 0
+            fps = 0
+
+        csv_file = self._get_csv_file_name(measurements_csv.path)
+        per_frame_fps.to_csv(csv_file, index=False, header=True)
+
+        return [DerivedMetric('fps', fps, 'fps'),
+                DerivedMetric('total_frames', frame_count, 'frames'),
+                MeasurementsCsv(csv_file)]
+
+
+class DerivedSurfaceFlingerStats(DerivedFpsStats):
+
+    def _process_with_pandas(self, measurements_csv):
+        data = pd.read_csv(measurements_csv.path)
+
+        # fiter out bogus frames.
+        bogus_frames_filter = data.actual_present_time_us != 0x7fffffffffffffff
+        actual_present_times = data.actual_present_time_us[bogus_frames_filter]
+        actual_present_time_deltas = actual_present_times.diff().dropna()
+
+        vsyncs_to_compose = actual_present_time_deltas.div(VSYNC_INTERVAL)
+        vsyncs_to_compose.apply(lambda x: int(round(x, 0)))
+
+        # drop values lower than drop_threshold FPS as real in-game frame
+        # rate is unlikely to drop below that (except on loading screens
+        # etc, which should not be factored in frame rate calculation).
+        per_frame_fps = (1.0 / (vsyncs_to_compose.multiply(VSYNC_INTERVAL / 1e9)))
+        keep_filter = per_frame_fps > self.drop_threshold
+        filtered_vsyncs_to_compose = vsyncs_to_compose[keep_filter]
+        per_frame_fps.name = 'fps'
+
+        csv_file = self._get_csv_file_name(measurements_csv.path)
+        per_frame_fps.to_csv(csv_file, index=False, header=True)
+
+        if not filtered_vsyncs_to_compose.empty:
+            fps = 0
+            total_vsyncs = filtered_vsyncs_to_compose.sum()
+            frame_count = filtered_vsyncs_to_compose.size
+
+            if total_vsyncs:
+                fps = 1e9 * frame_count / (VSYNC_INTERVAL * total_vsyncs)
+
+            janks = self._calc_janks(filtered_vsyncs_to_compose)
+            not_at_vsync = self._calc_not_at_vsync(vsyncs_to_compose)
+        else:
+            fps = 0
+            frame_count = 0
+            janks = 0
+            not_at_vsync = 0
+
+        return [DerivedMetric('fps', fps, 'fps'),
+                DerivedMetric('total_frames', frame_count, 'frames'),
+                MeasurementsCsv(csv_file),
+                DerivedMetric('janks', janks, 'count'),
+                DerivedMetric('janks_pc', janks * 100 / frame_count, 'percent'),
+                DerivedMetric('missed_vsync', not_at_vsync, 'count')]
+
+    def _process_without_pandas(self, measurements_csv):
+        # Given that SurfaceFlinger has been deprecated in favor of GfxInfo,
+        # it does not seem worth it implementing this.
+        raise HostError('Please install "pandas" Python package to process SurfaceFlinger frames')
+
+    @staticmethod
+    def _calc_janks(filtered_vsyncs_to_compose):
+        """
+        Internal method for calculating jank frames.
+        """
+        pause_latency = 20
+        vtc_deltas = filtered_vsyncs_to_compose.diff().dropna()
+        vtc_deltas = vtc_deltas.abs()
+        janks = vtc_deltas.apply(lambda x: (pause_latency > x > 1.5) and 1 or 0).sum()
+
+        return janks
+
+    @staticmethod
+    def _calc_not_at_vsync(vsyncs_to_compose):
+        """
+        Internal method for calculating the number of frames that did not
+        render in a single vsync cycle.
+        """
+        epsilon = 0.0001
+        func = lambda x: (abs(x - 1.0) > epsilon) and 1 or 0
+        not_at_vsync = vsyncs_to_compose.apply(func).sum()
+
+        return not_at_vsync
diff --git a/devlib/host.py b/devlib/host.py
index 8c8a069..8062d24 100644
--- a/devlib/host.py
+++ b/devlib/host.py
@@ -14,6 +14,7 @@
 #
 from glob import iglob
 import os
+import signal
 import shutil
 import subprocess
 import logging
@@ -24,6 +25,11 @@
 
 PACKAGE_BIN_DIRECTORY = os.path.join(os.path.dirname(__file__), 'bin')
 
+def kill_children(pid, signal=signal.SIGKILL):
+    with open('/proc/{0}/task/{0}/children'.format(pid), 'r') as fd:
+        for cpid in map(int, fd.read().strip().split()):
+            kill_children(cpid, signal)
+            os.kill(cpid, signal)
 
 class LocalConnection(object):
 
diff --git a/devlib/instrument/__init__.py b/devlib/instrument/__init__.py
index 044c7d4..0d2c1ed 100644
--- a/devlib/instrument/__init__.py
+++ b/devlib/instrument/__init__.py
@@ -48,6 +48,8 @@
         if not isinstance(to, MeasurementType):
             msg = 'Unexpected conversion target: "{}"'
             raise ValueError(msg.format(to))
+        if to.name == self.name:
+            return value
         if not to.name in self.conversions:
             msg = 'No conversion from {} to {} available'
             raise ValueError(msg.format(self.name, to.name))
@@ -70,30 +72,60 @@
             return text.format(self.name, self.units)
 
 
-# Standard measures
+# Standard measures. In order to make sure that downstream data processing is not tied
+# to particular insturments (e.g. a particular method of mearuing power), instruments
+# must, where possible, resport their measurments formatted as on of the standard types
+# defined here.
 _measurement_types = [
+    # For whatever reason, the type of measurement could not be established.
     MeasurementType('unknown', None),
-    MeasurementType('time', 'seconds',
+
+    # Generic measurements
+    MeasurementType('count', 'count'),
+    MeasurementType('percent', 'percent'),
+
+    # Time measurement. While there is typically a single "canonical" unit
+    # used for each type of measurmenent, time may be measured to a wide variety
+    # of events occuring at a wide range of scales. Forcing everying into a
+    # single scale will lead to inefficient and awkward to work with result tables.
+    # Coversion functions between the formats are specified, so that downstream
+    # processors that expect all times time be at a particular scale can automatically
+    # covert without being familar with individual instruments.
+    MeasurementType('time', 'seconds', 'time',
         conversions={
+            'time_us': lambda x: x * 1000000,
+            'time_ms': lambda x: x * 1000,
+        }
+    ),
+    MeasurementType('time_us', 'microseconds', 'time',
+        conversions={
+            'time': lambda x: x / 1000000,
+            'time_ms': lambda x: x / 1000,
+        }
+    ),
+    MeasurementType('time_ms', 'milliseconds', 'time',
+        conversions={
+            'time': lambda x: x / 1000,
             'time_us': lambda x: x * 1000,
         }
     ),
-    MeasurementType('time_us', 'microseconds',
-        conversions={
-            'time': lambda x: x / 1000,
-        }
-    ),
-    MeasurementType('temperature', 'degrees'),
 
+    # Measurements related to thermals.
+    MeasurementType('temperature', 'degrees', 'thermal'),
+
+    # Measurements related to power end energy consumption.
     MeasurementType('power', 'watts', 'power/energy'),
     MeasurementType('voltage', 'volts', 'power/energy'),
     MeasurementType('current', 'amps', 'power/energy'),
     MeasurementType('energy', 'joules', 'power/energy'),
 
+    # Measurments realted to data transfer, e.g. neworking,
+    # memory, or backing storage.
     MeasurementType('tx', 'bytes', 'data transfer'),
     MeasurementType('rx', 'bytes', 'data transfer'),
     MeasurementType('tx/rx', 'bytes', 'data transfer'),
 
+    MeasurementType('fps', 'fps', 'ui render'),
     MeasurementType('frames', 'frames', 'ui render'),
 ]
 for m in _measurement_types:
@@ -117,7 +149,7 @@
         self.channel = channel
 
     def __cmp__(self, other):
-        if isinstance(other, Measurement):
+        if hasattr(other, 'value'):
             return cmp(self.value, other.value)
         else:
             return cmp(self.value, other)
@@ -133,29 +165,36 @@
 
 class MeasurementsCsv(object):
 
-    def __init__(self, path, channels=None):
+    def __init__(self, path, channels=None, sample_rate_hz=None):
         self.path = path
         self.channels = channels
-        self._fh = open(path, 'rb')
+        self.sample_rate_hz = sample_rate_hz
         if self.channels is None:
             self._load_channels()
+        headings = [chan.label for chan in self.channels]
+        self.data_tuple = collections.namedtuple('csv_entry', headings)
 
     def measurements(self):
-        return list(self.itermeasurements())
+        return list(self.iter_measurements())
 
-    def itermeasurements(self):
-        self._fh.seek(0)
-        reader = csv.reader(self._fh)
-        reader.next()  # headings
-        for row in reader:
+    def iter_measurements(self):
+        for row in self._iter_rows():
             values = map(numeric, row)
             yield [Measurement(v, c) for (v, c) in zip(values, self.channels)]
 
+    def values(self):
+        return list(self.iter_values())
+
+    def iter_values(self):
+        for row in self._iter_rows():
+            values = map(numeric, row)
+            yield self.data_tuple(*values)
+
     def _load_channels(self):
-        self._fh.seek(0)
-        reader = csv.reader(self._fh)
-        header = reader.next()
-        self._fh.seek(0)
+        header = []
+        with open(self.path, 'rb') as fh:
+            reader = csv.reader(fh)
+            header = reader.next()
 
         self.channels = []
         for entry in header:
@@ -164,22 +203,35 @@
                 if entry.endswith(suffix):
                     site =  entry[:-len(suffix)]
                     measure = mt
-                    name = '{}_{}'.format(site, measure)
                     break
             else:
-                site = entry
-                measure = 'unknown'
-                name = entry
+                if entry in MEASUREMENT_TYPES:
+                    site = None
+                    measure = entry
+                else:
+                    site = entry
+                    measure = 'unknown'
 
-            chan = InstrumentChannel(name, site, measure)
+            chan = InstrumentChannel(site, measure)
             self.channels.append(chan)
 
+    def _iter_rows(self):
+        with open(self.path, 'rb') as fh:
+            reader = csv.reader(fh)
+            reader.next()  # headings
+            for row in reader:
+                yield row
+
 
 class InstrumentChannel(object):
 
     @property
     def label(self):
-        return '{}_{}'.format(self.site, self.kind)
+        if self.site is not None:
+            return '{}_{}'.format(self.site, self.kind)
+        return self.kind
+
+    name = label
 
     @property
     def kind(self):
@@ -189,8 +241,7 @@
     def units(self):
         return self.measurement_type.units
 
-    def __init__(self, name, site, measurement_type, **attrs):
-        self.name = name
+    def __init__(self, site, measurement_type, **attrs):
         self.site = site
         if isinstance(measurement_type, MeasurementType):
             self.measurement_type = measurement_type
@@ -232,10 +283,8 @@
             measure = measure.name
         return [c for c in self.list_channels() if c.kind == measure]
 
-    def add_channel(self, site, measure, name=None, **attrs):
-        if name is None:
-            name = '{}_{}'.format(site, measure)
-        chan = InstrumentChannel(name, site, measure, **attrs)
+    def add_channel(self, site, measure, **attrs):
+        chan = InstrumentChannel(site, measure, **attrs)
         self.channels[chan.label] = chan
 
     # initialization and teardown
@@ -247,24 +296,29 @@
         pass
 
     def reset(self, sites=None, kinds=None, channels=None):
+        self.active_channels = []
         if kinds is None and sites is None and channels is None:
             self.active_channels = sorted(self.channels.values(), key=lambda x: x.label)
-        else:
-            if isinstance(sites, basestring):
-                sites = [sites]
-            if isinstance(kinds, basestring):
-                kinds = [kinds]
-            self.active_channels = []
-            for chan_name in (channels or []):
+        elif channels is not None:
+            if sites is not None or kinds is not None:
+                raise ValueError(
+                    'sites and kinds should not be set if channels is set')
+            for chan_name in channels:
                 try:
                     self.active_channels.append(self.channels[chan_name])
                 except KeyError:
                     msg = 'Unexpected channel "{}"; must be in {}'
                     raise ValueError(msg.format(chan_name, self.channels.keys()))
-            for chan in self.channels.values():
-                if (kinds is None or chan.kind in kinds) and \
-                   (sites is None or chan.site in sites):
-                    self.active_channels.append(chan)
+        else:
+            if isinstance(sites, basestring):
+                sites = [sites]
+            if isinstance(kinds, basestring):
+                kinds = [kinds]
+            else:
+                for chan in self.channels.values():
+                    if (kinds is None or chan.kind in kinds) and \
+                       (sites is None or chan.site in sites):
+                        self.active_channels.append(chan)
 
     # instantaneous
 
@@ -281,3 +335,6 @@
 
     def get_data(self, outfile):
         pass
+
+    def get_raw(self):
+        return []
diff --git a/devlib/instrument/acmecape.py b/devlib/instrument/acmecape.py
new file mode 100644
index 0000000..5c10598
--- /dev/null
+++ b/devlib/instrument/acmecape.py
@@ -0,0 +1,141 @@
+#pylint: disable=attribute-defined-outside-init
+from __future__ import division
+import csv
+import os
+import time
+import tempfile
+from fcntl import fcntl, F_GETFL, F_SETFL
+from string import Template
+from subprocess import Popen, PIPE, STDOUT
+
+from devlib import Instrument, CONTINUOUS, MeasurementsCsv
+from devlib.exception import HostError
+from devlib.utils.misc import which
+
+OUTPUT_CAPTURE_FILE = 'acme-cape.csv'
+IIOCAP_CMD_TEMPLATE = Template("""
+${iio_capture} -n ${host} -b ${buffer_size} -c -f ${outfile} ${iio_device}
+""")
+
+def _read_nonblock(pipe, size=1024):
+    fd = pipe.fileno()
+    flags = fcntl(fd, F_GETFL)
+    flags |= os.O_NONBLOCK
+    fcntl(fd, F_SETFL, flags)
+
+    output = ''
+    try:
+        while True:
+            output += pipe.read(size)
+    except IOError:
+        pass
+    return output
+
+
+class AcmeCapeInstrument(Instrument):
+
+    mode = CONTINUOUS
+
+    def __init__(self, target,
+                 iio_capture=which('iio-capture'),
+                 host='baylibre-acme.local',
+                 iio_device='iio:device0',
+                 buffer_size=256):
+        super(AcmeCapeInstrument, self).__init__(target)
+        self.iio_capture = iio_capture
+        self.host = host
+        self.iio_device = iio_device
+        self.buffer_size = buffer_size
+        self.sample_rate_hz = 100
+        if self.iio_capture is None:
+            raise HostError('Missing iio-capture binary')
+        self.command = None
+        self.process = None
+
+        self.add_channel('shunt', 'voltage')
+        self.add_channel('bus', 'voltage')
+        self.add_channel('device', 'power')
+        self.add_channel('device', 'current')
+        self.add_channel('timestamp', 'time_ms')
+
+    def __del__(self):
+        if self.process and self.process.pid:
+            self.logger.warning('killing iio-capture process [%d]...',
+                                self.process.pid)
+            self.process.kill()
+
+    def reset(self, sites=None, kinds=None, channels=None):
+        super(AcmeCapeInstrument, self).reset(sites, kinds, channels)
+        self.raw_data_file = tempfile.mkstemp('.csv')[1]
+        params = dict(
+            iio_capture=self.iio_capture,
+            host=self.host,
+            buffer_size=self.buffer_size,
+            iio_device=self.iio_device,
+            outfile=self.raw_data_file
+        )
+        self.command = IIOCAP_CMD_TEMPLATE.substitute(**params)
+        self.logger.debug('ACME cape command: {}'.format(self.command))
+
+    def start(self):
+        self.process = Popen(self.command.split(), stdout=PIPE, stderr=STDOUT)
+
+    def stop(self):
+        self.process.terminate()
+        timeout_secs = 10
+        output = ''
+        for _ in xrange(timeout_secs):
+            if self.process.poll() is not None:
+                break
+            time.sleep(1)
+        else:
+            output += _read_nonblock(self.process.stdout)
+            self.process.kill()
+            self.logger.error('iio-capture did not terminate gracefully')
+            if self.process.poll() is None:
+                msg = 'Could not terminate iio-capture:\n{}'
+                raise HostError(msg.format(output))
+        if self.process.returncode != 15: # iio-capture exits with 15 when killed
+            output += self.process.stdout.read()
+            self.logger.info('ACME instrument encountered an error, '
+                             'you may want to try rebooting the ACME device:\n'
+                             '  ssh root@{} reboot'.format(self.host))
+            raise HostError('iio-capture exited with an error ({}), output:\n{}'
+                            .format(self.process.returncode, output))
+        if not os.path.isfile(self.raw_data_file):
+            raise HostError('Output CSV not generated.')
+        self.process = None
+
+    def get_data(self, outfile):
+        if os.stat(self.raw_data_file).st_size == 0:
+            self.logger.warning('"{}" appears to be empty'.format(self.raw_data_file))
+            return
+
+        all_channels = [c.label for c in self.list_channels()]
+        active_channels = [c.label for c in self.active_channels]
+        active_indexes = [all_channels.index(ac) for ac in active_channels]
+
+        with open(self.raw_data_file, 'rb') as fh:
+            with open(outfile, 'wb') as wfh:
+                writer = csv.writer(wfh)
+                writer.writerow(active_channels)
+
+                reader = csv.reader(fh, skipinitialspace=True)
+                header = reader.next()
+                ts_index = header.index('timestamp ms')
+
+
+                for row in reader:
+                    output_row = []
+                    for i in active_indexes:
+                        if i == ts_index:
+                            # Leave time in ms
+                            output_row.append(float(row[i]))
+                        else:
+                            # Convert rest into standard units.
+                            output_row.append(float(row[i])/1000)
+                    writer.writerow(output_row)
+        return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
+
+    def get_raw(self):
+        return [self.raw_data_file]
diff --git a/devlib/instrument/daq.py b/devlib/instrument/daq.py
index 58d2f3e..d497151 100644
--- a/devlib/instrument/daq.py
+++ b/devlib/instrument/daq.py
@@ -33,6 +33,7 @@
         # pylint: disable=no-member
         super(DaqInstrument, self).__init__(target)
         self._need_reset = True
+        self._raw_files = []
         if execute_command is None:
             raise HostError('Could not import "daqpower": {}'.format(import_error_mesg))
         if labels is None:
@@ -68,6 +69,7 @@
         if not result.status == Status.OK:  # pylint: disable=no-member
             raise HostError(result.message)
         self._need_reset = False
+        self._raw_files = []
 
     def start(self):
         if self._need_reset:
@@ -86,6 +88,7 @@
             site = os.path.splitext(entry)[0]
             path = os.path.join(tempdir, entry)
             raw_file_map[site] = path
+            self._raw_files.append(path)
 
         active_sites = unique([c.site for c in self.active_channels])
         file_handles = []
@@ -126,11 +129,14 @@
                     writer.writerow(row)
                     raw_row = _read_next_rows()
 
-            return MeasurementsCsv(outfile, self.active_channels)
+            return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
         finally:
             for fh in file_handles:
                 fh.close()
 
+    def get_raw(self):
+        return self._raw_files
+
     def teardown(self):
         self.execute('close')
 
diff --git a/devlib/instrument/energy_probe.py b/devlib/instrument/energy_probe.py
index ed502f5..c8f179e 100644
--- a/devlib/instrument/energy_probe.py
+++ b/devlib/instrument/energy_probe.py
@@ -52,6 +52,7 @@
         self.raw_output_directory = None
         self.process = None
         self.sample_rate_hz = 10000 # Determined empirically
+        self.raw_data_file = None
 
         for label in self.labels:
             for kind in self.attributes:
@@ -64,6 +65,7 @@
                  for i, rval in enumerate(self.resistor_values)]
         rstring = ''.join(parts)
         self.command = '{} -d {} -l {} {}'.format(self.caiman, self.device_entry, rstring, self.raw_output_directory)
+        self.raw_data_file = None
 
     def start(self):
         self.logger.debug(self.command)
@@ -92,10 +94,10 @@
         num_of_ports = len(self.resistor_values)
         struct_format = '{}I'.format(num_of_ports * self.attributes_per_sample)
         not_a_full_row_seen = False
-        raw_data_file = os.path.join(self.raw_output_directory, '0000000000')
+        self.raw_data_file = os.path.join(self.raw_output_directory, '0000000000')
 
-        self.logger.debug('Parsing raw data file: {}'.format(raw_data_file))
-        with open(raw_data_file, 'rb') as bfile:
+        self.logger.debug('Parsing raw data file: {}'.format(self.raw_data_file))
+        with open(self.raw_data_file, 'rb') as bfile:
             with open(outfile, 'wb') as wfh:
                 writer = csv.writer(wfh)
                 writer.writerow(active_channels)
@@ -113,4 +115,7 @@
                             continue
                         else:
                             not_a_full_row_seen = True
-        return MeasurementsCsv(outfile, self.active_channels)
+        return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
+
+    def get_raw(self):
+        return [self.raw_data_file]
diff --git a/devlib/instrument/frames.py b/devlib/instrument/frames.py
index d5a2147..a2e06b3 100644
--- a/devlib/instrument/frames.py
+++ b/devlib/instrument/frames.py
@@ -1,3 +1,4 @@
+from __future__ import division
 from devlib.instrument import (Instrument, CONTINUOUS,
                                MeasurementsCsv, MeasurementType)
 from devlib.utils.rendering import (GfxinfoFrameCollector,
@@ -16,9 +17,11 @@
         self.collector_target = collector_target
         self.period = period
         self.keep_raw = keep_raw
+        self.sample_rate_hz = 1 / self.period
         self.collector = None
         self.header = None
         self._need_reset = True
+        self._raw_file = None
         self._init_channels()
 
     def reset(self, sites=None, kinds=None, channels=None):
@@ -26,6 +29,7 @@
         self.collector = self.collector_cls(self.target, self.period,
                                             self.collector_target, self.header)
         self._need_reset = False
+        self._raw_file = None
 
     def start(self):
         if self._need_reset:
@@ -37,13 +41,15 @@
         self._need_reset = True
 
     def get_data(self, outfile):
-        raw_outfile = None
         if self.keep_raw:
-            raw_outfile = outfile + '.raw'
-        self.collector.process_frames(raw_outfile)
+            self._raw_file = outfile + '.raw'
+        self.collector.process_frames(self._raw_file)
         active_sites = [chan.label for chan in self.active_channels]
         self.collector.write_frames(outfile, columns=active_sites)
-        return MeasurementsCsv(outfile, self.active_channels)
+        return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
+
+    def get_raw(self):
+        return [self._raw_file] if self._raw_file else []
 
     def _init_channels(self):
         raise NotImplementedError()
diff --git a/devlib/instrument/gem5power.py b/devlib/instrument/gem5power.py
new file mode 100644
index 0000000..4b145d9
--- /dev/null
+++ b/devlib/instrument/gem5power.py
@@ -0,0 +1,81 @@
+#    Copyright 2017 ARM Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import division
+import csv
+import re
+
+from devlib.platform.gem5 import Gem5SimulationPlatform
+from devlib.instrument import Instrument, CONTINUOUS, MeasurementsCsv
+from devlib.exception import TargetError, HostError
+
+
+class Gem5PowerInstrument(Instrument):
+    '''
+    Instrument enabling power monitoring in gem5
+    '''
+
+    mode = CONTINUOUS
+    roi_label = 'power_instrument'
+    site_mapping = {'timestamp': 'sim_seconds'}
+
+    def __init__(self, target, power_sites):
+        '''
+        Parameter power_sites is a list of gem5 identifiers for power values.
+        One example of such a field:
+            system.cluster0.cores0.power_model.static_power
+        '''
+        if not isinstance(target.platform, Gem5SimulationPlatform):
+            raise TargetError('Gem5PowerInstrument requires a gem5 platform')
+        if not target.has('gem5stats'):
+            raise TargetError('Gem5StatsModule is not loaded')
+        super(Gem5PowerInstrument, self).__init__(target)
+
+        # power_sites is assumed to be a list later
+        if isinstance(power_sites, list):
+            self.power_sites = power_sites
+        else:
+            self.power_sites = [power_sites]
+        self.add_channel('timestamp', 'time')
+        for field in self.power_sites:
+            self.add_channel(field, 'power')
+        self.target.gem5stats.book_roi(self.roi_label)
+        self.sample_period_ns = 10000000
+        # Sample rate must remain unset as gem5 does not provide samples
+        # at regular intervals therefore the reported timestamp should be used.
+        self.sample_rate_hz = None
+        self.target.gem5stats.start_periodic_dump(0, self.sample_period_ns)
+        self._base_stats_dump = 0
+
+    def start(self):
+        self.target.gem5stats.roi_start(self.roi_label)
+
+    def stop(self):
+        self.target.gem5stats.roi_end(self.roi_label)
+
+    def get_data(self, outfile):
+        active_sites = [c.site for c in self.active_channels]
+        with open(outfile, 'wb') as wfh:
+            writer = csv.writer(wfh)
+            writer.writerow([c.label for c in self.active_channels]) # headers
+            sites_to_match = [self.site_mapping.get(s, s) for s in active_sites]
+            for rec, rois in self.target.gem5stats.match_iter(sites_to_match,
+                    [self.roi_label], self._base_stats_dump):
+                writer.writerow([rec[s] for s in active_sites])
+        return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
+
+    def reset(self, sites=None, kinds=None, channels=None):
+        super(Gem5PowerInstrument, self).reset(sites, kinds, channels)
+        self._base_stats_dump = self.target.gem5stats.next_dump_no()
+
diff --git a/devlib/instrument/hwmon.py b/devlib/instrument/hwmon.py
index ae49f40..5a9d8af 100644
--- a/devlib/instrument/hwmon.py
+++ b/devlib/instrument/hwmon.py
@@ -45,7 +45,7 @@
                 measure = self.measure_map.get(ts.kind)[0]
                 if measure:
                     self.logger.debug('\tAdding sensor {}'.format(ts.name))
-                    self.add_channel(_guess_site(ts), measure, name=ts.name, sensor=ts)
+                    self.add_channel(_guess_site(ts), measure, sensor=ts)
                 else:
                     self.logger.debug('\tSkipping sensor {} (unknown kind "{}")'.format(ts.name, ts.kind))
             except ValueError:
diff --git a/devlib/instrument/monsoon.py b/devlib/instrument/monsoon.py
index dd99df0..ceaa627 100644
--- a/devlib/instrument/monsoon.py
+++ b/devlib/instrument/monsoon.py
@@ -136,4 +136,4 @@
                     row.append(usb)
                 writer.writerow(row)
 
-        return MeasurementsCsv(outfile, self.active_channels)
+        return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
diff --git a/devlib/module/__init__.py b/devlib/module/__init__.py
index 5cf0a43..38a2315 100644
--- a/devlib/module/__init__.py
+++ b/devlib/module/__init__.py
@@ -56,7 +56,7 @@
 
     def __init__(self, target):
         self.target = target
-        self.logger = logging.getLogger(self.__class__.__name__)
+        self.logger = logging.getLogger(self.name)
 
 
 class HardRestModule(Module):  # pylint: disable=R0921
diff --git a/devlib/module/biglittle.py b/devlib/module/biglittle.py
index eafcb0a..e353229 100644
--- a/devlib/module/biglittle.py
+++ b/devlib/module/biglittle.py
@@ -44,79 +44,151 @@
     # cpufreq
 
     def list_bigs_frequencies(self):
-        return self.target.cpufreq.list_frequencies(self.bigs_online[0])
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            return self.target.cpufreq.list_frequencies(bigs_online[0])
 
     def list_bigs_governors(self):
-        return self.target.cpufreq.list_governors(self.bigs_online[0])
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            return self.target.cpufreq.list_governors(bigs_online[0])
 
     def list_bigs_governor_tunables(self):
-        return self.target.cpufreq.list_governor_tunables(self.bigs_online[0])
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            return self.target.cpufreq.list_governor_tunables(bigs_online[0])
 
     def list_littles_frequencies(self):
-        return self.target.cpufreq.list_frequencies(self.littles_online[0])
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            return self.target.cpufreq.list_frequencies(littles_online[0])
 
     def list_littles_governors(self):
-        return self.target.cpufreq.list_governors(self.littles_online[0])
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            return self.target.cpufreq.list_governors(littles_online[0])
 
     def list_littles_governor_tunables(self):
-        return self.target.cpufreq.list_governor_tunables(self.littles_online[0])
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            return self.target.cpufreq.list_governor_tunables(littles_online[0])
 
     def get_bigs_governor(self):
-        return self.target.cpufreq.get_governor(self.bigs_online[0])
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            return self.target.cpufreq.get_governor(bigs_online[0])
 
     def get_bigs_governor_tunables(self):
-        return self.target.cpufreq.get_governor_tunables(self.bigs_online[0])
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            return self.target.cpufreq.get_governor_tunables(bigs_online[0])
 
     def get_bigs_frequency(self):
-        return self.target.cpufreq.get_frequency(self.bigs_online[0])
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            return self.target.cpufreq.get_frequency(bigs_online[0])
 
     def get_bigs_min_frequency(self):
-        return self.target.cpufreq.get_min_frequency(self.bigs_online[0])
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            return self.target.cpufreq.get_min_frequency(bigs_online[0])
 
     def get_bigs_max_frequency(self):
-        return self.target.cpufreq.get_max_frequency(self.bigs_online[0])
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            return self.target.cpufreq.get_max_frequency(bigs_online[0])
 
     def get_littles_governor(self):
-        return self.target.cpufreq.get_governor(self.littles_online[0])
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            return self.target.cpufreq.get_governor(littles_online[0])
 
     def get_littles_governor_tunables(self):
-        return self.target.cpufreq.get_governor_tunables(self.littles_online[0])
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            return self.target.cpufreq.get_governor_tunables(littles_online[0])
 
     def get_littles_frequency(self):
-        return self.target.cpufreq.get_frequency(self.littles_online[0])
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            return self.target.cpufreq.get_frequency(littles_online[0])
 
     def get_littles_min_frequency(self):
-        return self.target.cpufreq.get_min_frequency(self.littles_online[0])
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            return self.target.cpufreq.get_min_frequency(littles_online[0])
 
     def get_littles_max_frequency(self):
-        return self.target.cpufreq.get_max_frequency(self.littles_online[0])
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            return self.target.cpufreq.get_max_frequency(littles_online[0])
 
     def set_bigs_governor(self, governor, **kwargs):
-        self.target.cpufreq.set_governor(self.bigs_online[0], governor, **kwargs)
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            self.target.cpufreq.set_governor(bigs_online[0], governor, **kwargs)
+        else:
+            raise ValueError("All bigs appear to be offline")
 
     def set_bigs_governor_tunables(self, governor, **kwargs):
-        self.target.cpufreq.set_governor_tunables(self.bigs_online[0], governor, **kwargs)
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            self.target.cpufreq.set_governor_tunables(bigs_online[0], governor, **kwargs)
+        else:
+            raise ValueError("All bigs appear to be offline")
 
     def set_bigs_frequency(self, frequency, exact=True):
-        self.target.cpufreq.set_frequency(self.bigs_online[0], frequency, exact)
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            self.target.cpufreq.set_frequency(bigs_online[0], frequency, exact)
+        else:
+            raise ValueError("All bigs appear to be offline")
 
     def set_bigs_min_frequency(self, frequency, exact=True):
-        self.target.cpufreq.set_min_frequency(self.bigs_online[0], frequency, exact)
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            self.target.cpufreq.set_min_frequency(bigs_online[0], frequency, exact)
+        else:
+            raise ValueError("All bigs appear to be offline")
 
     def set_bigs_max_frequency(self, frequency, exact=True):
-        self.target.cpufreq.set_max_frequency(self.bigs_online[0], frequency, exact)
+        bigs_online = self.bigs_online
+        if len(bigs_online) > 0:
+            self.target.cpufreq.set_max_frequency(bigs_online[0], frequency, exact)
+        else:
+            raise ValueError("All bigs appear to be offline")
 
     def set_littles_governor(self, governor, **kwargs):
-        self.target.cpufreq.set_governor(self.littles_online[0], governor, **kwargs)
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            self.target.cpufreq.set_governor(littles_online[0], governor, **kwargs)
+        else:
+            raise ValueError("All littles appear to be offline")
 
     def set_littles_governor_tunables(self, governor, **kwargs):
-        self.target.cpufreq.set_governor_tunables(self.littles_online[0], governor, **kwargs)
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            self.target.cpufreq.set_governor_tunables(littles_online[0], governor, **kwargs)
+        else:
+            raise ValueError("All littles appear to be offline")
 
     def set_littles_frequency(self, frequency, exact=True):
-        self.target.cpufreq.set_frequency(self.littles_online[0], frequency, exact)
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            self.target.cpufreq.set_frequency(littles_online[0], frequency, exact)
+        else:
+            raise ValueError("All littles appear to be offline")
 
     def set_littles_min_frequency(self, frequency, exact=True):
-        self.target.cpufreq.set_min_frequency(self.littles_online[0], frequency, exact)
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            self.target.cpufreq.set_min_frequency(littles_online[0], frequency, exact)
+        else:
+            raise ValueError("All littles appear to be offline")
 
     def set_littles_max_frequency(self, frequency, exact=True):
-        self.target.cpufreq.set_max_frequency(self.littles_online[0], frequency, exact)
+        littles_online = self.littles_online
+        if len(littles_online) > 0:
+            self.target.cpufreq.set_max_frequency(littles_online[0], frequency, exact)
+        else:
+            raise ValueError("All littles appear to be offline")
diff --git a/devlib/module/cgroups.py b/devlib/module/cgroups.py
index 472304e..ff930fa 100644
--- a/devlib/module/cgroups.py
+++ b/devlib/module/cgroups.py
@@ -471,11 +471,11 @@
         if freezer is None:
             raise RuntimeError('freezer cgroup controller not present')
         freezer_cg = freezer.cgroup('/DEVLIB_FREEZER')
-        thawed_cg = freezer.cgroup('/')
+        cmd = 'cgroups_freezer_set_state {{}} {}'.format(freezer_cg.directory)
 
         if thaw:
             # Restart froozen tasks
-            freezer_cg.set(state='THAWED')
+            freezer.target._execute_util(cmd.format('THAWED'), as_root=True)
             # Remove all tasks from freezer
             freezer.move_all_tasks_to('/')
             return
@@ -487,7 +487,7 @@
         tasks = freezer.tasks('/')
 
         # Freeze all tasks
-        freezer_cg.set(state='FROZEN')
+        freezer.target._execute_util(cmd.format('FROZEN'), as_root=True)
 
         return tasks
 
diff --git a/devlib/module/cpufreq.py b/devlib/module/cpufreq.py
index e18d95b..5ce3c83 100644
--- a/devlib/module/cpufreq.py
+++ b/devlib/module/cpufreq.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
+import json
 from devlib.module import Module
 from devlib.exception import TargetError
 from devlib.utils.misc import memoized
@@ -412,15 +413,26 @@
         """
         return self.target._execute_util('cpufreq_trace_all_frequencies', as_root=True)
 
+    def get_affected_cpus(self, cpu):
+        """
+        Get the online CPUs that share a frequency domain with the given CPU
+        """
+        if isinstance(cpu, int):
+            cpu = 'cpu{}'.format(cpu)
+
+        sysfile = '/sys/devices/system/cpu/{}/cpufreq/affected_cpus'.format(cpu)
+
+        return [int(c) for c in self.target.read_value(sysfile).split()]
+
     @memoized
-    def get_domain_cpus(self, cpu):
+    def get_related_cpus(self, cpu):
         """
         Get the CPUs that share a frequency domain with the given CPU
         """
         if isinstance(cpu, int):
             cpu = 'cpu{}'.format(cpu)
 
-        sysfile = '/sys/devices/system/cpu/{}/cpufreq/affected_cpus'.format(cpu)
+        sysfile = '/sys/devices/system/cpu/{}/cpufreq/related_cpus'.format(cpu)
 
         return [int(c) for c in self.target.read_value(sysfile).split()]
 
@@ -431,6 +443,58 @@
         cpus = set(range(self.target.number_of_cpus))
         while cpus:
             cpu = iter(cpus).next()
-            domain = self.target.cpufreq.get_domain_cpus(cpu)
+            domain = self.target.cpufreq.get_related_cpus(cpu)
             yield domain
             cpus = cpus.difference(domain)
+
+    def get_time_in_state(self, clusters):
+        """
+        Gets the time at each frequency on each cluster
+        :param clusters: A list of clusters on the device. Each cluster is a
+                    list of cpus on that cluster.
+        """
+        time_in_state_by_cluster = {}
+
+        for i, cluster in enumerate(clusters):
+            frequencies = self.list_frequencies(cluster[0])
+            time_in_state = dict((str(freq), 0) for freq in frequencies)
+
+            for cpu in cluster:
+                stats = self.target.execute('cat '\
+                        '/sys/devices/system/cpu/cpu{}/cpufreq/stats'
+                        '/time_in_state'.format(cpu))
+                for entry in stats.split('\n'):
+                    if len(entry) > 0:
+                        freq, time = entry.split(' ')
+                        time_in_state[freq] += int(time)
+
+            time_in_state_by_cluster[str(i)] = time_in_state
+
+        return time_in_state_by_cluster
+
+    def dump_time_in_state_delta(self, start, clusters, dump_file):
+        """
+        Dumps the time between the stop and start by cluster by frequency
+        :param start: The inital output from a call to cpufreq.get_time_in_state
+        :param clusters: A list of clusters on the device. Each cluster is a
+                    list of cpus on that cluster.
+        :param dump_file: A file to dump the delta time_in_state_delta to.
+        """
+        stop = self.get_time_in_state(clusters)
+
+        time_in_state_delta = {}
+
+        for cl in start:
+            time_in_state_delta[cl] = {}
+
+            for freq in start[cl].keys():
+                time_in_start = start[cl][freq]
+                time_in_stop = stop[cl][freq]
+                time_in_state_delta[cl][freq] = time_in_stop - time_in_start
+
+        output = {'time_delta' : time_in_state_delta,
+                'clusters' : {str(i) : [str(c) for c in cl]
+                        for i, cl in enumerate(clusters)}}
+
+        with open(dump_file, 'w') as dfile:
+            json.dump(output, dfile, indent=4, sort_keys=True)
diff --git a/devlib/module/cpuidle.py b/devlib/module/cpuidle.py
index fd986c0..b17f1f5 100644
--- a/devlib/module/cpuidle.py
+++ b/devlib/module/cpuidle.py
@@ -41,51 +41,17 @@
                 raise ValueError('invalid idle state name: "{}"'.format(self.id))
         return int(self.id[i:])
 
-    def __init__(self, target, index, path):
+    def __init__(self, target, index, path, name, desc, power, latency, residency):
         self.target = target
         self.index = index
         self.path = path
+        self.name = name
+        self.desc = desc
+        self.power = power
+        self.latency = latency
         self.id = self.target.path.basename(self.path)
         self.cpu = self.target.path.basename(self.target.path.dirname(path))
 
-    @property
-    @memoized
-    def desc(self):
-        return self.get('desc')
-
-    @property
-    @memoized
-    def name(self):
-        return self.get('name')
-
-    @property
-    @memoized
-    def latency(self):
-        """Exit latency in uS"""
-        return self.get('latency')
-
-    @property
-    @memoized
-    def power(self):
-        """Power usage in mW
-
-        ..note::
-
-            This value is not always populated by the kernel and may be garbage.
-        """
-        return self.get('power')
-
-    @property
-    @memoized
-    def target_residency(self):
-        """Target residency in uS
-
-        This is the amount of time in the state required to 'break even' on
-        power - the system should avoid entering the state for less time than
-        this.
-        """
-        return self.get('residency')
-
     def enable(self):
         self.set('disable', 0)
 
@@ -126,23 +92,47 @@
     def probe(target):
         return target.file_exists(Cpuidle.root_path)
 
-    def get_driver(self):
-        return self.target.read_value(self.target.path.join(self.root_path, 'current_driver'))
+    def __init__(self, target):
+        super(Cpuidle, self).__init__(target)
+        self._states = {}
 
-    def get_governor(self):
-        return self.target.read_value(self.target.path.join(self.root_path, 'current_governor_ro'))
+        basepath = '/sys/devices/system/cpu/'
+        values_tree = self.target.read_tree_values(basepath, depth=4, check_exit_code=False)
+        i = 0
+        cpu_id = 'cpu{}'.format(i)
+        while cpu_id in values_tree:
+            cpu_node = values_tree[cpu_id]
 
-    @memoized
+            if 'cpuidle' in cpu_node:
+                idle_node = cpu_node['cpuidle']
+                self._states[cpu_id] = []
+                j = 0
+                state_id = 'state{}'.format(j)
+                while state_id in idle_node:
+                    state_node = idle_node[state_id]
+                    state = CpuidleState(
+                        self.target,
+                        index=j,
+                        path=self.target.path.join(basepath, cpu_id, 'cpuidle', state_id),
+                        name=state_node['name'],
+                        desc=state_node['desc'],
+                        power=int(state_node['power']),
+                        latency=int(state_node['latency']),
+                        residency=int(state_node['residency']) if 'residency' in state_node else None,
+                    )
+                    msg = 'Adding {} state {}: {} {}'
+                    self.logger.debug(msg.format(cpu_id, j, state.name, state.desc))
+                    self._states[cpu_id].append(state)
+                    j += 1
+                    state_id = 'state{}'.format(j)
+
+            i += 1
+            cpu_id = 'cpu{}'.format(i)
+
     def get_states(self, cpu=0):
         if isinstance(cpu, int):
             cpu = 'cpu{}'.format(cpu)
-        states_dir = self.target.path.join(self.target.path.dirname(self.root_path), cpu, 'cpuidle')
-        idle_states = []
-        for state in self.target.list_directory(states_dir):
-            if state.startswith('state'):
-                index = int(state[5:])
-                idle_states.append(CpuidleState(self.target, index, self.target.path.join(states_dir, state)))
-        return idle_states
+        return self._states.get(cpu)
 
     def get_state(self, state, cpu=0):
         if isinstance(state, int):
@@ -176,3 +166,9 @@
         """
         output = self.target._execute_util('cpuidle_wake_all_cpus')
         print(output)
+
+    def get_driver(self):
+        return self.target.read_value(self.target.path.join(self.root_path, 'current_driver'))
+
+    def get_governor(self):
+        return self.target.read_value(self.target.path.join(self.root_path, 'current_governor_ro'))
diff --git a/devlib/module/gem5stats.py b/devlib/module/gem5stats.py
new file mode 100644
index 0000000..0f0fbd7
--- /dev/null
+++ b/devlib/module/gem5stats.py
@@ -0,0 +1,254 @@
+#    Copyright 2017 ARM Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import re
+import sys
+import logging
+import os.path
+from collections import defaultdict
+
+import devlib
+from devlib.exception import TargetError
+from devlib.module import Module
+from devlib.platform import Platform
+from devlib.platform.gem5 import Gem5SimulationPlatform
+from devlib.utils.gem5 import iter_statistics_dump, GEM5STATS_ROI_NUMBER, GEM5STATS_DUMP_TAIL
+
+
+class Gem5ROI:
+    def __init__(self, number, target):
+        self.target = target
+        self.number = number
+        self.running = False
+        self.field = 'ROI::{}'.format(number)
+
+    def start(self):
+        if self.running:
+            return False
+        self.target.execute('m5 roistart {}'.format(self.number))
+        self.running = True
+        return True
+    
+    def stop(self):
+        if not self.running:
+            return False
+        self.target.execute('m5 roiend {}'.format(self.number))
+        self.running = False
+        return True
+
+class Gem5StatsModule(Module):
+    '''
+    Module controlling Region of Interest (ROIs) markers, satistics dump 
+    frequency and parsing statistics log file when using gem5 platforms.
+
+    ROIs are identified by user-defined labels and need to be booked prior to
+    use. The translation of labels into gem5 ROI numbers will be performed
+    internally in order to avoid conflicts between multiple clients.
+    '''
+    name = 'gem5stats'
+
+    @staticmethod
+    def probe(target):
+        return isinstance(target.platform, Gem5SimulationPlatform)
+
+    def __init__(self, target):
+        super(Gem5StatsModule, self).__init__(target)
+        self._current_origin = 0
+        self._stats_file_path = os.path.join(target.platform.gem5_out_dir,
+                                            'stats.txt')
+        self.rois = {}
+        self._dump_pos_cache = {0: 0}
+
+    def book_roi(self, label):
+        if label in self.rois:
+            raise KeyError('ROI label {} already used'.format(label))
+        if len(self.rois) >= GEM5STATS_ROI_NUMBER:
+            raise RuntimeError('Too many ROIs reserved')
+        all_rois = set(xrange(GEM5STATS_ROI_NUMBER))
+        used_rois = set([roi.number for roi in self.rois.values()])
+        avail_rois = all_rois - used_rois
+        self.rois[label] = Gem5ROI(list(avail_rois)[0], self.target)
+
+    def free_roi(self, label):
+        if label not in self.rois:
+            raise KeyError('ROI label {} not reserved yet'.format(label))
+        self.rois[label].stop()
+        del self.rois[label]
+
+    def roi_start(self, label):
+        if label not in self.rois:
+            raise KeyError('Incorrect ROI label: {}'.format(label))
+        if not self.rois[label].start():
+            raise TargetError('ROI {} was already running'.format(label))
+    
+    def roi_end(self, label):
+        if label not in self.rois:
+            raise KeyError('Incorrect ROI label: {}'.format(label))
+        if not self.rois[label].stop():
+            raise TargetError('ROI {} was not running'.format(label))
+
+    def start_periodic_dump(self, delay_ns=0, period_ns=10000000):
+        # Default period is 10ms because it's roughly what's needed to have
+        # accurate power estimations
+        if delay_ns < 0 or period_ns < 0:
+            msg = 'Delay ({}) and period ({}) for periodic dumps must be positive'
+            raise ValueError(msg.format(delay_ns, period_ns))
+        self.target.execute('m5 dumpresetstats {} {}'.format(delay_ns, period_ns))
+    
+    def match(self, keys, rois_labels, base_dump=0):
+        '''
+        Extract specific values from the statistics log file of gem5
+
+        :param keys: a list of key name or regular expression patterns that
+            will be matched in the fields of the statistics file. ``match()``
+            returns only the values of fields matching at least one these
+            keys.
+        :type keys: list
+
+        :param rois_labels: list of ROIs labels. ``match()`` returns the 
+            values of the specified fields only during dumps spanned by at
+            least one of these ROIs.
+        :type rois_label: list
+
+        :param base_dump: dump number from which ``match()`` should operate. By 
+            specifying a non-zero dump number, one can virtually truncate 
+            the head of the stats file and ignore all dumps before a specific
+            instant. The value of ``base_dump`` will typically (but not 
+            necessarily) be the result of a previous call to ``next_dump_no``.
+            Default value is 0.
+        :type base_dump: int
+
+        :returns: a dict indexed by key parameters containing a dict indexed by
+        ROI labels containing an in-order list of records for the key under
+        consideration during the active intervals of the ROI. 
+        
+        Example of return value:
+         * Result of match(['sim_'],['roi_1']):
+            {
+                'sim_inst': 
+                {
+                    'roi_1': [265300176, 267975881]
+                }
+                'sim_ops': 
+                {
+                    'roi_1': [324395787, 327699419]
+                }
+                'sim_seconds': 
+                {
+                    'roi_1': [0.199960, 0.199897]
+                }
+                'sim_freq': 
+                {
+                    'roi_1': [1000000000000, 1000000000000]
+                }
+                'sim_ticks': 
+                {
+                    'roi_1': [199960234227, 199896897330]
+                }
+            }
+        '''
+        records = defaultdict(lambda : defaultdict(list))
+        for record, active_rois in self.match_iter(keys, rois_labels, base_dump):
+            for key in record:
+                for roi_label in active_rois:
+                    records[key][roi_label].append(record[key])
+        return records
+
+    def match_iter(self, keys, rois_labels, base_dump=0):
+        '''
+        Yield specific values dump-by-dump from the statistics log file of gem5
+
+        :param keys: same as ``match()``
+        :param rois_labels: same as ``match()``
+        :param base_dump: same as ``match()``
+        :returns: a pair containing:
+            1. a dict storing the values corresponding to each of the found keys
+            2. the list of currently active ROIs among those passed as parameters
+
+        Example of return value:
+         * Result of match_iter(['sim_'],['roi_1', 'roi_2']).next()
+            ( 
+                { 
+                    'sim_inst': 265300176,
+                    'sim_ops': 324395787,
+                    'sim_seconds': 0.199960, 
+                    'sim_freq': 1000000000000,
+                    'sim_ticks': 199960234227,
+                },
+                [ 'roi_1 ' ] 
+            )
+        '''
+        for label in rois_labels:
+            if label not in self.rois:
+                raise KeyError('Impossible to match ROI label {}'.format(label))
+            if self.rois[label].running:
+                self.logger.warning('Trying to match records in statistics file'
+                        ' while ROI {} is running'.format(label))
+        
+        # Construct one large regex that concatenates all keys because
+        # matching one large expression is more efficient than several smaller
+        all_keys_re = re.compile('|'.join(keys))
+        
+        def roi_active(roi_label, dump):
+            roi = self.rois[roi_label]
+            return (roi.field in dump) and (int(dump[roi.field]) == 1)
+
+        with open(self._stats_file_path, 'r') as stats_file:
+            self._goto_dump(stats_file, base_dump)
+            for dump in iter_statistics_dump(stats_file):
+                active_rois = [l for l in rois_labels if roi_active(l, dump)]
+                if active_rois:
+                    rec = {k: dump[k] for k in dump if all_keys_re.search(k)}
+                    yield (rec, active_rois)
+
+    def next_dump_no(self):
+        '''
+        Returns the number of the next dump to be written to the stats file.
+        
+        For example, if next_dump_no is called while there are 5 (0 to 4) full 
+        dumps in the stats file, it will return 5. This will be usefull to know
+        from which dump one should match() in the future to get only data from
+        now on.
+        '''
+        with open(self._stats_file_path, 'r') as stats_file:
+            # _goto_dump reach EOF and returns the total number of dumps + 1
+            return self._goto_dump(stats_file, sys.maxint)
+    
+    def _goto_dump(self, stats_file, target_dump):
+        if target_dump < 0:
+            raise HostError('Cannot go to dump {}'.format(target_dump))
+
+        # Go to required dump quickly if it was visited before
+        if target_dump in self._dump_pos_cache:
+            stats_file.seek(self._dump_pos_cache[target_dump])
+            return target_dump
+        # Or start from the closest dump already visited before the required one
+        prev_dumps = filter(lambda x: x < target_dump, self._dump_pos_cache.keys())
+        curr_dump = max(prev_dumps)
+        curr_pos = self._dump_pos_cache[curr_dump]
+        stats_file.seek(curr_pos)
+        
+        # And iterate until target_dump
+        dump_iterator = iter_statistics_dump(stats_file)
+        while curr_dump < target_dump:
+            try:
+                dump = dump_iterator.next()
+            except StopIteration:
+                break
+            # End of passed dump is beginning og next one
+            curr_pos = stats_file.tell()
+            curr_dump += 1
+        self._dump_pos_cache[curr_dump] = curr_pos
+        return curr_dump
+
diff --git a/devlib/module/hotplug.py b/devlib/module/hotplug.py
index 8ae238e..cfce2e5 100644
--- a/devlib/module/hotplug.py
+++ b/devlib/module/hotplug.py
@@ -21,7 +21,8 @@
         return target.path.join(cls.base_path, cpu, 'online')
 
     def online_all(self):
-        self.online(*range(self.target.number_of_cpus))
+        self.target._execute_util('hotplug_online_all',
+                                  as_root=self.target.is_rooted)
 
     def online(self, *args):
         for cpu in args:
diff --git a/devlib/module/hwmon.py b/devlib/module/hwmon.py
index dc00442..d04bce7 100644
--- a/devlib/module/hwmon.py
+++ b/devlib/module/hwmon.py
@@ -12,9 +12,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
+import os
 import re
 from collections import defaultdict
 
+from devlib import TargetError
 from devlib.module import Module
 from devlib.utils.types import integer
 
@@ -77,16 +79,15 @@
             all_sensors.extend(sensors_of_kind.values())
         return all_sensors
 
-    def __init__(self, target, path):
+    def __init__(self, target, path, name, fields):
         self.target = target
         self.path = path
-        self.name = self.target.read_value(self.target.path.join(self.path, 'name'))
+        self.name = name
         self._sensors = defaultdict(dict)
         path = self.path
         if not path.endswith(self.target.path.sep):
             path += self.target.path.sep
-        for entry in self.target.list_directory(path,
-                                                as_root=self.target.is_rooted):
+        for entry in fields:
             match = HWMON_FILE_REGEX.search(entry)
             if match:
                 kind = match.group('kind')
@@ -116,7 +117,12 @@
 
     @staticmethod
     def probe(target):
-        return target.file_exists(HWMON_ROOT)
+        try:
+            target.list_directory(HWMON_ROOT, as_root=target.is_rooted)
+        except TargetError:
+            # Doesn't exist or no permissions
+            return False
+        return True
 
     @property
     def sensors(self):
@@ -132,11 +138,13 @@
         self.scan()
 
     def scan(self):
-        for entry in self.target.list_directory(self.root,
-                                                as_root=self.target.is_rooted):
-            if entry.startswith('hwmon'):
-                entry_path = self.target.path.join(self.root, entry)
-                if self.target.file_exists(self.target.path.join(entry_path, 'name')):
-                    device = HwmonDevice(self.target, entry_path)
-                    self.devices.append(device)
+        values_tree = self.target.read_tree_values(self.root, depth=3)
+        for entry_id, fields in values_tree.iteritems():
+            path = self.target.path.join(self.root, entry_id)
+            name = fields.pop('name', None)
+            if name is None:
+                continue
+            self.logger.debug('Adding device {}'.format(name))
+            device = HwmonDevice(self.target, path, name, fields)
+            self.devices.append(device)
 
diff --git a/devlib/module/thermal.py b/devlib/module/thermal.py
index 4fa8e15..fa13fbb 100644
--- a/devlib/module/thermal.py
+++ b/devlib/module/thermal.py
@@ -61,8 +61,8 @@
         value = self.target.read_value(self.target.path.join(self.path, 'mode'))
         return value == 'enabled'
 
-    def set_mode(self, enable):
-        value = 'enabled' if enable else 'disabled'
+    def set_enabled(self, enabled=True):
+        value = 'enabled' if enabled else 'disabled'
         self.target.write_value(self.target.path.join(self.path, 'mode'), value)
 
     def get_temperature(self):
@@ -100,5 +100,5 @@
 
     def disable_all_zones(self):
         """Disables all the thermal zones in the target"""
-        for zone in self.zones:
-            zone.set_mode('disabled')
+        for zone in self.zones.itervalues():
+            zone.set_enabled(False)
diff --git a/devlib/platform/arm.py b/devlib/platform/arm.py
index e760eaf..7d3ced2 100644
--- a/devlib/platform/arm.py
+++ b/devlib/platform/arm.py
@@ -33,6 +33,7 @@
                  core_names=None,
                  core_clusters=None,
                  big_core=None,
+                 model=None,
                  modules=None,
 
                  # serial settings
@@ -61,6 +62,7 @@
                                                        core_names,
                                                        core_clusters,
                                                        big_core,
+                                                       model,
                                                        modules)
         self.serial_port = serial_port
         self.baudrate = baudrate
@@ -210,22 +212,22 @@
     mode = CONTINUOUS | INSTANTANEOUS
 
     _channels = [
-        InstrumentChannel('sys_curr', 'sys', 'current'),
-        InstrumentChannel('a57_curr', 'a57', 'current'),
-        InstrumentChannel('a53_curr', 'a53', 'current'),
-        InstrumentChannel('gpu_curr', 'gpu', 'current'),
-        InstrumentChannel('sys_volt', 'sys', 'voltage'),
-        InstrumentChannel('a57_volt', 'a57', 'voltage'),
-        InstrumentChannel('a53_volt', 'a53', 'voltage'),
-        InstrumentChannel('gpu_volt', 'gpu', 'voltage'),
-        InstrumentChannel('sys_pow', 'sys', 'power'),
-        InstrumentChannel('a57_pow', 'a57', 'power'),
-        InstrumentChannel('a53_pow', 'a53', 'power'),
-        InstrumentChannel('gpu_pow', 'gpu', 'power'),
-        InstrumentChannel('sys_cenr', 'sys', 'energy'),
-        InstrumentChannel('a57_cenr', 'a57', 'energy'),
-        InstrumentChannel('a53_cenr', 'a53', 'energy'),
-        InstrumentChannel('gpu_cenr', 'gpu', 'energy'),
+        InstrumentChannel('sys', 'current'),
+        InstrumentChannel('a57', 'current'),
+        InstrumentChannel('a53', 'current'),
+        InstrumentChannel('gpu', 'current'),
+        InstrumentChannel('sys', 'voltage'),
+        InstrumentChannel('a57', 'voltage'),
+        InstrumentChannel('a53', 'voltage'),
+        InstrumentChannel('gpu', 'voltage'),
+        InstrumentChannel('sys', 'power'),
+        InstrumentChannel('a57', 'power'),
+        InstrumentChannel('a53', 'power'),
+        InstrumentChannel('gpu', 'power'),
+        InstrumentChannel('sys', 'energy'),
+        InstrumentChannel('a57', 'energy'),
+        InstrumentChannel('a53', 'energy'),
+        InstrumentChannel('gpu', 'energy'),
     ]
 
     def __init__(self, target):
diff --git a/devlib/target.py b/devlib/target.py
index f3aaea0..350ce55 100644
--- a/devlib/target.py
+++ b/devlib/target.py
@@ -14,7 +14,7 @@
 from devlib.platform import Platform
 from devlib.exception import TargetError, TargetNotRespondingError, TimeoutError
 from devlib.utils.ssh import SshConnection
-from devlib.utils.android import AdbConnection, AndroidProperties, adb_command, adb_disconnect
+from devlib.utils.android import AdbConnection, AndroidProperties, LogcatMonitor, adb_command, adb_disconnect
 from devlib.utils.misc import memoized, isiterable, convert_new_lines, merge_lists
 from devlib.utils.misc import ABI_MAP, get_cpu_name, ranges_to_list, escape_double_quotes
 from devlib.utils.types import integer, boolean, bitmask, identifier, caseless_string
@@ -31,6 +31,8 @@
     r'(?P<version>\d+)(\.(?P<major>\d+)(\.(?P<minor>\d+)(-rc(?P<rc>\d+))?)?)?(.*-g(?P<sha1>[0-9a-fA-F]{7,}))?'
 )
 
+GOOGLE_DNS_SERVER_ADDRESS = '8.8.8.8'
+
 
 class Target(object):
 
@@ -102,6 +104,10 @@
         return None
 
     @property
+    def supported_abi(self):
+        return [self.abi]
+
+    @property
     @memoized
     def cpuinfo(self):
         return Cpuinfo(self.execute('cat /proc/cpuinfo'))
@@ -145,6 +151,12 @@
         else:
             return None
 
+    @property
+    def shutils(self):
+        if self._shutils is None:
+            self._setup_shutils()
+        return self._shutils
+
     def __init__(self,
                  connection_settings=None,
                  platform=None,
@@ -185,6 +197,7 @@
         self._installed_modules = {}
         self._cache = {}
         self._connections = {}
+        self._shutils = None
         self.busybox = None
 
         if load_default_modules:
@@ -204,7 +217,9 @@
         tid = id(threading.current_thread())
         self._connections[tid] = self.get_connection(timeout=timeout)
         self._resolve_paths()
-        self.busybox = self.get_installed('busybox')
+        self.execute('mkdir -p {}'.format(self.working_directory))
+        self.execute('mkdir -p {}'.format(self.executables_directory))
+        self.busybox = self.install(os.path.join(PACKAGE_BIN_DIRECTORY, self.abi, 'busybox'))
         self.platform.update_from_target(self)
         self._update_modules('connected')
         if self.platform.big_core and self.load_default_modules:
@@ -221,24 +236,7 @@
         return self.conn_cls(timeout=timeout, **self.connection_settings)  # pylint: disable=not-callable
 
     def setup(self, executables=None):
-        self.execute('mkdir -p {}'.format(self.working_directory))
-        self.execute('mkdir -p {}'.format(self.executables_directory))
-        self.busybox = self.install(os.path.join(PACKAGE_BIN_DIRECTORY, self.abi, 'busybox'))
-
-        # Setup shutils script for the target
-        shutils_ifile = os.path.join(PACKAGE_BIN_DIRECTORY, 'scripts', 'shutils.in')
-        shutils_ofile = os.path.join(PACKAGE_BIN_DIRECTORY, 'scripts', 'shutils')
-        shell_path = '/bin/sh'
-        if self.os == 'android':
-            shell_path = '/system/bin/sh'
-        with open(shutils_ifile) as fh:
-            lines = fh.readlines()
-        with open(shutils_ofile, 'w') as ofile:
-            for line in lines:
-                line = line.replace("__DEVLIB_SHELL__", shell_path)
-                line = line.replace("__DEVLIB_BUSYBOX__", self.busybox)
-                ofile.write(line)
-        self.shutils = self.install(os.path.join(PACKAGE_BIN_DIRECTORY, 'scripts', 'shutils'))
+        self._setup_shutils()
 
         for host_exe in (executables or []):  # pylint: disable=superfluous-parens
             self.install(host_exe)
@@ -351,6 +349,38 @@
             command = 'cd {} && {}'.format(in_directory, command)
         return self.execute(command, as_root=as_root, timeout=timeout)
 
+    def background_invoke(self, binary, args=None, in_directory=None,
+                          on_cpus=None, as_root=False):
+        """
+        Executes the specified binary as a background task under the
+        specified conditions.
+
+        :binary: binary to execute. Must be present and executable on the device.
+        :args: arguments to be passed to the binary. The can be either a list or
+               a string.
+        :in_directory:  execute the binary in the  specified directory. This must
+                        be an absolute path.
+        :on_cpus:  taskset the binary to these CPUs. This may be a single ``int`` (in which
+                   case, it will be interpreted as the mask), a list of ``ints``, in which
+                   case this will be interpreted as the list of cpus, or string, which
+                   will be interpreted as a comma-separated list of cpu ranges, e.g.
+                   ``"0,4-7"``.
+        :as_root: Specify whether the command should be run as root
+
+        :returns: the subprocess instance handling that command
+        """
+        command = binary
+        if args:
+            if isiterable(args):
+                args = ' '.join(args)
+            command = '{} {}'.format(command, args)
+        if on_cpus:
+            on_cpus = bitmask(on_cpus)
+            command = '{} taskset 0x{:x} {}'.format(self.busybox, on_cpus, command)
+        if in_directory:
+            command = 'cd {} && {}'.format(in_directory, command)
+        return self.background(command, as_root=as_root)
+
     def kick_off(self, command, as_root=False):
         raise NotImplementedError()
 
@@ -584,8 +614,39 @@
         timeout = duration + 10
         self.execute('sleep {}'.format(duration), timeout=timeout)
 
+    def read_tree_values_flat(self, path, depth=1, check_exit_code=True):
+        command = 'read_tree_values {} {}'.format(path, depth)
+        output = self._execute_util(command, as_root=self.is_rooted,
+                                    check_exit_code=check_exit_code)
+        result = {}
+        for entry in output.strip().split('\n'):
+            if ':' not in entry:
+                continue
+            path, value = entry.strip().split(':', 1)
+            result[path] = value
+        return result
+
+    def read_tree_values(self, path, depth=1, dictcls=dict, check_exit_code=True):
+	value_map = self.read_tree_values_flat(path, depth, check_exit_code)
+	return _build_path_tree(value_map, path, self.path.sep, dictcls)
+
     # internal methods
 
+    def _setup_shutils(self):
+        shutils_ifile = os.path.join(PACKAGE_BIN_DIRECTORY, 'scripts', 'shutils.in')
+        shutils_ofile = os.path.join(PACKAGE_BIN_DIRECTORY, 'scripts', 'shutils')
+        shell_path = '/bin/sh'
+        if self.os == 'android':
+            shell_path = '/system/bin/sh'
+        with open(shutils_ifile) as fh:
+            lines = fh.readlines()
+        with open(shutils_ofile, 'w') as ofile:
+            for line in lines:
+                line = line.replace("__DEVLIB_SHELL__", shell_path)
+                line = line.replace("__DEVLIB_BUSYBOX__", self.busybox)
+                ofile.write(line)
+        self._shutils = self.install(os.path.join(PACKAGE_BIN_DIRECTORY, 'scripts', 'shutils'))
+
     def _execute_util(self, command, timeout=None, check_exit_code=True, as_root=False):
         command = '{} {}'.format(self.shutils, command)
         return self.conn.execute(command, timeout, check_exit_code, as_root)
@@ -642,6 +703,44 @@
     def _resolve_paths(self):
         raise NotImplementedError()
 
+    def is_network_connected(self):
+        self.logger.debug('Checking for internet connectivity...')
+
+        timeout_s = 5
+        # It would be nice to use busybox for this, but that means we'd need
+        # root (ping is usually setuid so it can open raw sockets to send ICMP)
+        command = 'ping -q -c 1 -w {} {} 2>&1'.format(timeout_s,
+                                                      GOOGLE_DNS_SERVER_ADDRESS)
+
+        # We'll use our own retrying mechanism (rather than just using ping's -c
+        # to send multiple packets) so that we don't slow things down in the
+        # 'good' case where the first packet gets echoed really quickly.
+        attempts = 5
+        for _ in range(attempts):
+            try:
+                self.execute(command)
+                return True
+            except TargetError as e:
+                err = str(e).lower()
+                if '100% packet loss' in err:
+                    # We sent a packet but got no response.
+                    # Try again - we don't want this to fail just because of a
+                    # transient drop in connection quality.
+                    self.logger.debug('No ping response from {} after {}s'
+                                      .format(GOOGLE_DNS_SERVER_ADDRESS, timeout_s))
+                    continue
+                elif 'network is unreachable' in err:
+                    # No internet connection at all, we can fail straight away
+                    self.logger.debug('Network unreachable')
+                    return False
+                else:
+                    # Something else went wrong, we don't know what, raise an
+                    # error.
+                    raise
+
+        self.logger.debug('Failed to ping {} after {} attempts'.format(
+            GOOGLE_DNS_SERVER_ADDRESS, attempts))
+        return False
 
 class LinuxTarget(Target):
 
@@ -798,6 +897,30 @@
 
     @property
     @memoized
+    def supported_abi(self):
+        props = self.getprop()
+        result = [props['ro.product.cpu.abi']]
+        if 'ro.product.cpu.abi2' in props:
+            result.append(props['ro.product.cpu.abi2'])
+        if 'ro.product.cpu.abilist' in props:
+            for abi in props['ro.product.cpu.abilist'].split(','):
+                if abi not in result:
+                    result.append(abi)
+
+        mapped_result = []
+        for supported_abi in result:
+            for abi, architectures in ABI_MAP.iteritems():
+                found = False
+                if supported_abi in architectures and abi not in mapped_result:
+                    mapped_result.append(abi)
+                    found = True
+                    break
+            if not found and supported_abi not in mapped_result:
+                mapped_result.append(supported_abi)
+        return mapped_result
+
+    @property
+    @memoized
     def os_version(self):
         os_version = {}
         for k, v in self.getprop().iteritems():
@@ -867,6 +990,7 @@
                                             shell_prompt=shell_prompt,
                                             conn_cls=conn_cls)
         self.package_data_directory = package_data_directory
+        self.clear_logcat_lock = threading.Lock()
 
     def reset(self, fastboot=False):  # pylint: disable=arguments-differ
         try:
@@ -877,8 +1001,16 @@
             pass
         self._connected_as_root = None
 
-    def connect(self, timeout=10, check_boot_completed=True):  # pylint: disable=arguments-differ
+    def wait_boot_complete(self, timeout=10):
         start = time.time()
+        boot_completed = boolean(self.getprop('sys.boot_completed'))
+        while not boot_completed and timeout >= time.time() - start:
+            time.sleep(5)
+            boot_completed = boolean(self.getprop('sys.boot_completed'))
+        if not boot_completed:
+            raise TargetError('Connected but Android did not fully boot.')
+
+    def connect(self, timeout=10, check_boot_completed=True):  # pylint: disable=arguments-differ
         device = self.connection_settings.get('device')
         if device and ':' in device:
             # ADB does not automatically remove a network device from it's
@@ -890,12 +1022,7 @@
         super(AndroidTarget, self).connect(timeout=timeout)
 
         if check_boot_completed:
-            boot_completed = boolean(self.getprop('sys.boot_completed'))
-            while not boot_completed and timeout >= time.time() - start:
-                time.sleep(5)
-                boot_completed = boolean(self.getprop('sys.boot_completed'))
-            if not boot_completed:
-                raise TargetError('Connected but Android did not fully boot.')
+            self.wait_boot_complete(timeout)
 
     def setup(self, executables=None):
         super(AndroidTarget, self).setup(executables)
@@ -948,11 +1075,12 @@
             self.uninstall_executable(name)
 
     def get_pids_of(self, process_name):
-        result = self.execute('ps {}'.format(process_name[-15:]), check_exit_code=False).strip()
-        if result and 'not found' not in result:
-            return [int(x.split()[1]) for x in result.split('\n')[1:]]
-        else:
-            return []
+        result = []
+        search_term = process_name[-15:]
+        for entry in self.ps():
+            if search_term in entry.name:
+                result.append(entry.pid)
+        return result
 
     def ps(self, **kwargs):
         lines = iter(convert_new_lines(self.execute('ps')).split('\n'))
@@ -960,8 +1088,12 @@
         result = []
         for line in lines:
             parts = line.split(None, 8)
-            if parts:
-                result.append(PsEntry(*(parts[0:1] + map(int, parts[1:5]) + parts[5:])))
+            if not parts:
+                continue
+            if len(parts) == 8:
+                # wchan was blank; insert an empty field where it should be.
+                parts.insert(5, '')
+            result.append(PsEntry(*(parts[0:1] + map(int, parts[1:5]) + parts[5:])))
         if not kwargs:
             return result
         else:
@@ -998,20 +1130,25 @@
 
     # Android-specific
 
-    def swipe_to_unlock(self, direction="horizontal"):
+    def swipe_to_unlock(self, direction="diagonal"):
         width, height = self.screen_resolution
         command = 'input swipe {} {} {} {}'
-        if direction == "horizontal":
-            swipe_heigh = height * 2 // 3
+        if direction == "diagonal":
             start = 100
             stop = width - start
-            self.execute(command.format(start, swipe_heigh, stop, swipe_heigh))
-        if direction == "vertical":
-            swipe_middle = height / 2
-            swipe_heigh = height * 2 // 3
-            self.execute(command.format(swipe_middle, swipe_heigh, swipe_middle, 0))
+            swipe_height = height * 2 // 3
+            self.execute(command.format(start, swipe_height, stop, 0))
+        elif direction == "horizontal":
+            swipe_height = height * 2 // 3
+            start = 100
+            stop = width - start
+            self.execute(command.format(start, swipe_height, stop, swipe_height))
+        elif direction == "vertical":
+            swipe_middle = width / 2
+            swipe_height = height * 2 // 3
+            self.execute(command.format(swipe_middle, swipe_height, swipe_middle, 0))
         else:
-            raise DeviceError("Invalid swipe direction: {}".format(self.swipe_to_unlock))
+            raise TargetError("Invalid swipe direction: {}".format(direction))
 
     def getprop(self, prop=None):
         props = AndroidProperties(self.execute('getprop'))
@@ -1086,7 +1223,17 @@
         adb_command(self.adb_name, command, timeout=timeout)
 
     def clear_logcat(self):
-        adb_command(self.adb_name, 'logcat -c', timeout=30)
+        with self.clear_logcat_lock:
+            adb_command(self.adb_name, 'logcat -c', timeout=30)
+
+    def get_logcat_monitor(self, regexps=None):
+        return LogcatMonitor(self, regexps)
+
+    def adb_kill_server(self, timeout=30):
+        adb_command(self.adb_name, 'kill-server', timeout)
+
+    def adb_wait_for_device(self, timeout=30):
+        adb_command(self.adb_name, 'wait-for-device', timeout)
 
     def adb_reboot_bootloader(self, timeout=30):
         adb_command(self.adb_name, 'reboot-bootloader', timeout)
@@ -1117,6 +1264,71 @@
         if self.is_screen_on():
             self.execute('input keyevent 26')
 
+    def set_auto_brightness(self, auto_brightness):
+        cmd = 'settings put system screen_brightness_mode {}'
+        self.execute(cmd.format(int(boolean(auto_brightness))))
+
+    def get_auto_brightness(self):
+        cmd = 'settings get system screen_brightness_mode'
+        return boolean(self.execute(cmd).strip())
+
+    def set_brightness(self, value):
+        if not 0 <= value <= 255:
+            msg = 'Invalid brightness "{}"; Must be between 0 and 255'
+            raise ValueError(msg.format(value))
+        self.set_auto_brightness(False)
+        cmd = 'settings put system screen_brightness {}'
+        self.execute(cmd.format(int(value)))
+
+    def get_brightness(self):
+        cmd = 'settings get system screen_brightness'
+        return integer(self.execute(cmd).strip())
+
+    def get_airplane_mode(self):
+        cmd = 'settings get global airplane_mode_on'
+        return boolean(self.execute(cmd).strip())
+
+    def set_airplane_mode(self, mode):
+        root_required = self.get_sdk_version() > 23
+        if root_required and not self.is_rooted:
+            raise TargetError('Root is required to toggle airplane mode on Android 7+')
+        mode = int(boolean(mode))
+        cmd = 'settings put global airplane_mode_on {}'
+        self.execute(cmd.format(mode))
+        self.execute('am broadcast -a android.intent.action.AIRPLANE_MODE '
+                     '--ez state {}'.format(mode), as_root=root_required)
+
+    def get_auto_rotation(self):
+        cmd = 'settings get system accelerometer_rotation'
+        return boolean(self.execute(cmd).strip())
+
+    def set_auto_rotation(self, autorotate):
+        cmd = 'settings put system accelerometer_rotation {}'
+        self.execute(cmd.format(int(boolean(autorotate))))
+
+    def set_natural_rotation(self):
+        self.set_rotation(0)
+
+    def set_left_rotation(self):
+        self.set_rotation(1)
+
+    def set_inverted_rotation(self):
+        self.set_rotation(2)
+
+    def set_right_rotation(self):
+        self.set_rotation(3)
+
+    def get_rotation(self):
+        cmd = 'settings get system user_rotation'
+        return int(self.execute(cmd).strip())
+
+    def set_rotation(self, rotation):
+        if not 0 <= rotation <= 3:
+            raise ValueError('Rotation value must be between 0 and 3')
+        self.set_auto_rotation(False)
+        cmd = 'settings put system user_rotation {}'
+        self.execute(cmd.format(rotation))
+
     def homescreen(self):
         self.execute('am start -a android.intent.action.MAIN -c android.intent.category.HOME')
 
@@ -1404,3 +1616,32 @@
     if name is None:
         name = '{}/{}/{}'.format(implementer, part, variant)
     return name
+
+
+def _build_path_tree(path_map, basepath, sep=os.path.sep, dictcls=dict):
+    """
+    Convert a flat mapping of paths to values into a nested structure of
+    dict-line object (``dict``'s by default), mirroring the directory hierarchy
+    represented by the paths relative to ``basepath``.
+
+    """
+    def process_node(node, path, value):
+        parts = path.split(sep, 1)
+        if len(parts) == 1:   # leaf
+            node[parts[0]] = value
+        else:  # branch
+            if parts[0] not in node:
+                node[parts[0]] = dictcls()
+            process_node(node[parts[0]], parts[1], value)
+
+    relpath_map = {os.path.relpath(p, basepath): v
+                   for p, v in path_map.iteritems()}
+
+    if len(relpath_map) == 1 and relpath_map.keys()[0] == '.':
+        result = relpath_map.values()[0]
+    else:
+        result = dictcls()
+        for path, value in relpath_map.iteritems():
+            process_node(result, path, value)
+
+    return result
diff --git a/devlib/trace/logcat.py b/devlib/trace/logcat.py
new file mode 100644
index 0000000..1372d7a
--- /dev/null
+++ b/devlib/trace/logcat.py
@@ -0,0 +1,58 @@
+import os
+import re
+import shutil
+
+from devlib.trace import TraceCollector
+from devlib.utils.android import LogcatMonitor
+
+class LogcatCollector(TraceCollector):
+
+    def __init__(self, target, regexps=None):
+        super(LogcatCollector, self).__init__(target)
+        self.regexps = regexps
+        self._collecting = False
+        self._prev_log = None
+
+    def reset(self):
+        """
+        Clear Collector data but do not interrupt collection
+        """
+        if not self._monitor:
+            return
+
+        if self._collecting:
+            self._monitor.clear_log()
+        elif self._prev_log:
+            os.remove(self._prev_log)
+            self._prev_log = None
+
+    def start(self):
+        """
+        Start collecting logcat lines
+        """
+        self._monitor = LogcatMonitor(self.target, self.regexps)
+        if self._prev_log:
+            # Append new data collection to previous collection
+            self._monitor.start(self._prev_log)
+        else:
+            self._monitor.start()
+
+        self._collecting = True
+
+    def stop(self):
+        """
+        Stop collecting logcat lines
+        """
+        if not self._collecting:
+            raise RuntimeError('Logcat monitor not running, nothing to stop')
+
+        self._monitor.stop()
+        self._collecting = False
+        self._prev_log = self._monitor.logfile
+
+    def get_trace(self, outfile):
+        """
+        Output collected logcat lines to designated file
+        """
+        # copy self._monitor.logfile to outfile
+        shutil.copy(self._monitor.logfile, outfile)
diff --git a/devlib/utils/android.py b/devlib/utils/android.py
index d2a1629..bdf2854 100644
--- a/devlib/utils/android.py
+++ b/devlib/utils/android.py
@@ -20,15 +20,20 @@
 """
 # pylint: disable=E1103
 import os
+import pexpect
 import time
 import subprocess
 import logging
 import re
+import threading
+import tempfile
+import Queue
 from collections import defaultdict
 
 from devlib.exception import TargetError, HostError, DevlibError
-from devlib.utils.misc import check_output, which, memoized
+from devlib.utils.misc import check_output, which, memoized, ABI_MAP
 from devlib.utils.misc import escape_single_quotes, escape_double_quotes
+from devlib import host
 
 
 logger = logging.getLogger('android')
@@ -124,13 +129,18 @@
         self.label = None
         self.version_name = None
         self.version_code = None
+        self.native_code = None
         self.parse(path)
 
     def parse(self, apk_path):
         _check_env()
         command = [aapt, 'dump', 'badging', apk_path]
         logger.debug(' '.join(command))
-        output = subprocess.check_output(command)
+        try:
+            output = subprocess.check_output(command, stderr=subprocess.STDOUT)
+        except subprocess.CalledProcessError as e:
+            raise HostError('Error parsing APK file {}. `aapt` says:\n{}'
+                            .format(apk_path, e.output))
         for line in output.split('\n'):
             if line.startswith('application-label:'):
                 self.label = line.split(':')[1].strip().replace('\'', '')
@@ -143,6 +153,19 @@
             elif line.startswith('launchable-activity:'):
                 match = self.name_regex.search(line)
                 self.activity = match.group('name')
+            elif line.startswith('native-code'):
+                apk_abis = [entry.strip() for entry in line.split(':')[1].split("'") if entry.strip()]
+                mapped_abis = []
+                for apk_abi in apk_abis:
+                    found = False
+                    for abi, architectures in ABI_MAP.iteritems():
+                        if apk_abi in architectures:
+                            mapped_abis.append(abi)
+                            found = True
+                            break
+                    if not found:
+                        mapped_abis.append(apk_abi)
+                self.native_code = mapped_abis
             else:
                 pass  # not interested
 
@@ -163,7 +186,7 @@
     @memoized
     def newline_separator(self):
         output = adb_command(self.device,
-                             "shell '({}); echo \"\n$?\"'".format(self.ls_command))
+                             "shell '({}); echo \"\n$?\"'".format(self.ls_command), adb_server=self.adb_server)
         if output.endswith('\r\n'):
             return '\r\n'
         elif output.endswith('\n'):
@@ -178,7 +201,7 @@
     def _setup_ls(self):
         command = "shell '(ls -1); echo \"\n$?\"'"
         try:
-            output = adb_command(self.device, command, timeout=self.timeout)
+            output = adb_command(self.device, command, timeout=self.timeout, adb_server=self.adb_server)
         except subprocess.CalledProcessError as e:
             raise HostError(
                 'Failed to set up ls command on Android device. Output:\n'
@@ -191,11 +214,12 @@
             self.ls_command = 'ls'
         logger.debug("ls command is set to {}".format(self.ls_command))
 
-    def __init__(self, device=None, timeout=None, platform=None):
+    def __init__(self, device=None, timeout=None, platform=None, adb_server=None):
         self.timeout = timeout if timeout is not None else self.default_timeout
         if device is None:
-            device = adb_get_device(timeout=timeout)
+            device = adb_get_device(timeout=timeout, adb_server=adb_server)
         self.device = device
+        self.adb_server = adb_server
         adb_connect(self.device)
         AdbConnection.active_connections[self.device] += 1
         self._setup_ls()
@@ -206,7 +230,7 @@
         command = "push '{}' '{}'".format(source, dest)
         if not os.path.exists(source):
             raise HostError('No such file "{}"'.format(source))
-        return adb_command(self.device, command, timeout=timeout)
+        return adb_command(self.device, command, timeout=timeout, adb_server=self.adb_server)
 
     def pull(self, source, dest, timeout=None):
         if timeout is None:
@@ -215,18 +239,18 @@
         if os.path.isdir(dest) and \
            ('*' in source or '?' in source):
             command = 'shell {} {}'.format(self.ls_command, source)
-            output = adb_command(self.device, command, timeout=timeout)
+            output = adb_command(self.device, command, timeout=timeout, adb_server=self.adb_server)
             for line in output.splitlines():
                 command = "pull '{}' '{}'".format(line.strip(), dest)
-                adb_command(self.device, command, timeout=timeout)
+                adb_command(self.device, command, timeout=timeout, adb_server=self.adb_server)
             return
         command = "pull '{}' '{}'".format(source, dest)
-        return adb_command(self.device, command, timeout=timeout)
+        return adb_command(self.device, command, timeout=timeout, adb_server=self.adb_server)
 
     def execute(self, command, timeout=None, check_exit_code=False,
                 as_root=False, strip_colors=True):
         return adb_shell(self.device, command, timeout, check_exit_code,
-                         as_root, self.newline_separator)
+                         as_root, self.newline_separator,adb_server=self.adb_server)
 
     def background(self, command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, as_root=False):
         return adb_background_shell(self.device, command, stdout, stderr, as_root)
@@ -258,7 +282,7 @@
     fastboot_command(command)
 
 
-def adb_get_device(timeout=None):
+def adb_get_device(timeout=None, adb_server=None):
     """
     Returns the serial number of a connected android device.
 
@@ -267,13 +291,17 @@
     """
     # TODO this is a hacky way to issue a adb command to all listed devices
 
+    # Ensure server is started so the 'daemon started successfully' message
+    # doesn't confuse the parsing below
+    adb_command(None, 'start-server', adb_server=adb_server)
+
     # The output of calling adb devices consists of a heading line then
     # a list of the devices sperated by new line
     # The last line is a blank new line. in otherwords, if there is a device found
     # then the output length is 2 + (1 for each device)
     start = time.time()
     while True:
-        output = adb_command(None, "devices").splitlines()  # pylint: disable=E1103
+        output = adb_command(None, "devices", adb_server=adb_server).splitlines()  # pylint: disable=E1103
         output_length = len(output)
         if output_length == 3:
             # output[1] is the 2nd line in the output which has the device name
@@ -339,11 +367,14 @@
 
 
 def adb_shell(device, command, timeout=None, check_exit_code=False,
-              as_root=False, newline_separator='\r\n'):  # NOQA
+              as_root=False, newline_separator='\r\n', adb_server=None):  # NOQA
     _check_env()
     if as_root:
         command = 'echo \'{}\' | su'.format(escape_single_quotes(command))
-    device_part = ['-s', device] if device else []
+    device_part = []
+    if adb_server:
+        device_part = ['-H', adb_server]
+    device_part += ['-s', device] if device else []
 
     # On older combinations of ADB/Android versions, the adb host command always
     # exits with 0 if it was able to run the command on the target, even if the
@@ -381,8 +412,9 @@
                 raise TargetError(message.format(re_search[0]))
             else:
                 message = 'adb has returned early; did not get an exit code. '\
-                          'Was kill-server invoked?'
-                raise TargetError(message)
+                          'Was kill-server invoked?\nOUTPUT:\n-----\n{}\n'\
+                          '-----\nERROR:\n-----\n{}\n-----'
+                raise TargetError(message.format(raw_output, error))
 
     return output
 
@@ -401,8 +433,8 @@
     return subprocess.Popen(full_command, stdout=stdout, stderr=stderr, shell=True)
 
 
-def adb_list_devices():
-    output = adb_command(None, 'devices')
+def adb_list_devices(adb_server=None):
+    output = adb_command(None, 'devices',adb_server=adb_server)
     devices = []
     for line in output.splitlines():
         parts = [p.strip() for p in line.split()]
@@ -411,14 +443,39 @@
     return devices
 
 
-def adb_command(device, command, timeout=None):
+def get_adb_command(device, command, timeout=None,adb_server=None):
     _check_env()
-    device_string = ' -s {}'.format(device) if device else ''
-    full_command = "adb{} {}".format(device_string, command)
+    device_string = ""
+    if adb_server != None:
+        device_string = ' -H {}'.format(adb_server)
+    device_string += ' -s {}'.format(device) if device else ''
+    return "adb{} {}".format(device_string, command)
+
+def adb_command(device, command, timeout=None,adb_server=None):
+    full_command = get_adb_command(device, command, timeout, adb_server)
     logger.debug(full_command)
     output, _ = check_output(full_command, timeout, shell=True)
     return output
 
+def grant_app_permissions(target, package):
+    """
+    Grant an app all the permissions it may ask for
+    """
+    dumpsys = target.execute('dumpsys package {}'.format(package))
+
+    permissions = re.search(
+        'requested permissions:\s*(?P<permissions>(android.permission.+\s*)+)', dumpsys
+    )
+    if permissions is None:
+        return
+    permissions = permissions.group('permissions').replace(" ", "").splitlines()
+
+    for permission in permissions:
+        try:
+            target.execute('pm grant {} {}'.format(package, permission))
+        except TargetError:
+            logger.debug('Cannot grant {}'.format(permission))
+
 
 # Messy environment initialisation stuff...
 
@@ -494,3 +551,109 @@
         platform_tools = _env.platform_tools
         adb = _env.adb
         aapt = _env.aapt
+
+class LogcatMonitor(object):
+    """
+    Helper class for monitoring Anroid's logcat
+
+    :param target: Android target to monitor
+    :type target: :class:`AndroidTarget`
+
+    :param regexps: List of uncompiled regular expressions to filter on the
+                    device. Logcat entries that don't match any will not be
+                    seen. If omitted, all entries will be sent to host.
+    :type regexps: list(str)
+    """
+
+    @property
+    def logfile(self):
+        return self._logfile
+
+    def __init__(self, target, regexps=None):
+        super(LogcatMonitor, self).__init__()
+
+        self.target = target
+        self._regexps = regexps
+
+    def start(self, outfile=None):
+        """
+        Start logcat and begin monitoring
+
+        :param outfile: Optional path to file to store all logcat entries
+        :type outfile: str
+        """
+        if outfile:
+            self._logfile = open(outfile, 'w')
+        else:
+            self._logfile = tempfile.NamedTemporaryFile()
+
+        self.target.clear_logcat()
+
+        logcat_cmd = 'logcat'
+
+        # Join all requested regexps with an 'or'
+        if self._regexps:
+            regexp = '{}'.format('|'.join(self._regexps))
+            if len(self._regexps) > 1:
+                regexp = '({})'.format(regexp)
+            logcat_cmd = '{} -e "{}"'.format(logcat_cmd, regexp)
+
+        logcat_cmd = get_adb_command(self.target.conn.device, logcat_cmd)
+
+        logger.debug('logcat command ="{}"'.format(logcat_cmd))
+        self._logcat = pexpect.spawn(logcat_cmd, logfile=self._logfile)
+
+    def stop(self):
+        self._logcat.terminate()
+        self._logfile.close()
+
+    def get_log(self):
+        """
+        Return the list of lines found by the monitor
+        """
+        with open(self._logfile.name) as fh:
+            return [line for line in fh]
+
+    def clear_log(self):
+        with open(self._logfile.name, 'w') as fh:
+            pass
+
+    def search(self, regexp):
+        """
+        Search a line that matches a regexp in the logcat log
+        Return immediatly
+        """
+        return [line for line in self.get_log() if re.match(regexp, line)]
+
+    def wait_for(self, regexp, timeout=30):
+        """
+        Search a line that matches a regexp in the logcat log
+        Wait for it to appear if it's not found
+
+        :param regexp: regexp to search
+        :type regexp: str
+
+        :param timeout: Timeout in seconds, before rasing RuntimeError.
+                        ``None`` means wait indefinitely
+        :type timeout: number
+
+        :returns: List of matched strings
+        """
+        log = self.get_log()
+        res = [line for line in log if re.match(regexp, line)]
+
+        # Found some matches, return them
+        if len(res) > 0:
+            return res
+
+        # Store the number of lines we've searched already, so we don't have to
+        # re-grep them after 'expect' returns
+        next_line_num = len(log)
+
+        try:
+            self._logcat.expect(regexp, timeout=timeout)
+        except pexpect.TIMEOUT:
+            raise RuntimeError('Logcat monitor timeout ({}s)'.format(timeout))
+
+        return [line for line in self.get_log()[next_line_num:]
+                if re.match(regexp, line)]
diff --git a/devlib/utils/android_build.py b/devlib/utils/android_build.py
new file mode 100644
index 0000000..de638ba
--- /dev/null
+++ b/devlib/utils/android_build.py
@@ -0,0 +1,56 @@
+# SPDX-License-Identifier: Apache-2.0
+#
+# Copyright (C) 2015, ARM Limited and contributors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import logging
+import os
+import shutil
+import subprocess
+from collections import namedtuple
+
+class Build(object):
+    """
+    Collection of Android related build actions
+    """
+    def __init__(self, te):
+        if (te.ANDROID_BUILD_TOP and te.TARGET_PRODUCT and te.TARGET_BUILD_VARIANT):
+            self._te = te
+        else:
+            te._log.warning('Build initialization failed: invalid paramterers')
+            raise
+
+    def exec_cmd(self, cmd):
+        ret = subprocess.call(cmd, shell=True)
+        if ret != 0:
+            raise RuntimeError('Command \'{}\' returned error code {}'.format(cmd, ret))
+
+    def build_module(self, module_path):
+        """
+        Build a module and its dependencies.
+
+        :param module_path: module path
+        :type module_path: str
+
+        """
+        cur_dir = os.getcwd()
+        os.chdir(self._te.ANDROID_BUILD_TOP)
+        lunch_target = self._te.TARGET_PRODUCT + '-' + self._te.TARGET_BUILD_VARIANT
+        self.exec_cmd('source build/envsetup.sh && lunch ' +
+            lunch_target + ' && mmma -j16 ' + module_path)
+        os.chdir(cur_dir)
+
+
+# vim :set tabstop=4 shiftwidth=4 expandtab
diff --git a/devlib/utils/gem5.py b/devlib/utils/gem5.py
new file mode 100644
index 0000000..0ca42ec
--- /dev/null
+++ b/devlib/utils/gem5.py
@@ -0,0 +1,53 @@
+#    Copyright 2017 ARM Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import re
+import logging
+
+from devlib.utils.types import numeric
+
+
+GEM5STATS_FIELD_REGEX = re.compile("^(?P<key>[^- ]\S*) +(?P<value>[^#]+).+$")
+GEM5STATS_DUMP_HEAD = '---------- Begin Simulation Statistics ----------'
+GEM5STATS_DUMP_TAIL = '---------- End Simulation Statistics   ----------'
+GEM5STATS_ROI_NUMBER = 8
+
+logger = logging.getLogger('gem5')
+
+
+def iter_statistics_dump(stats_file):
+    '''
+    Yields statistics dumps as dicts. The parameter is assumed to be a stream 
+    reading from the statistics log file.
+    '''
+    cur_dump = {}
+    while True:
+        line = stats_file.readline()
+        if not line:
+            break
+        if GEM5STATS_DUMP_TAIL in line:
+            yield cur_dump
+            cur_dump = {}
+        else:
+            res = GEM5STATS_FIELD_REGEX.match(line) 
+            if res:
+                k = res.group("key")
+                vtext = res.group("value")
+                try:
+                    v = map(numeric, vtext.split())
+                    cur_dump[k] = v[0] if len(v)==1 else set(v)
+                except ValueError:
+                    msg = 'Found non-numeric entry in gem5 stats ({}: {})'
+                    logger.warning(msg.format(k, vtext))
+
diff --git a/devlib/utils/misc.py b/devlib/utils/misc.py
index b8626aa..9565f47 100644
--- a/devlib/utils/misc.py
+++ b/devlib/utils/misc.py
@@ -41,7 +41,7 @@
 
 # ABI --> architectures list
 ABI_MAP = {
-    'armeabi': ['armeabi', 'armv7', 'armv7l', 'armv7el', 'armv7lh'],
+    'armeabi': ['armeabi', 'armv7', 'armv7l', 'armv7el', 'armv7lh', 'armeabi-v7a'],
     'arm64': ['arm64', 'armv8', 'arm64-v8a', 'aarch64'],
 }
 
@@ -79,9 +79,19 @@
         0xd08: {None: 'A72'},
         0xd09: {None: 'A73'},
     },
+    0x42: {  # Broadcom
+        0x516: {None: 'Vulcan'},
+    },
+    0x43: {  # Cavium
+        0x0a1: {None: 'Thunderx'},
+        0x0a2: {None: 'Thunderx81xx'},
+    },
     0x4e: {  # Nvidia
         0x0: {None: 'Denver'},
     },
+    0x50: {  # AppliedMicro
+        0x0: {None: 'xgene'},
+    },
     0x51: {  # Qualcomm
         0x02d: {None: 'Scorpion'},
         0x04d: {None: 'MSM8960'},
@@ -91,6 +101,10 @@
         },
         0x205: {0x1: 'KryoSilver'},
         0x211: {0x1: 'KryoGold'},
+        0x800: {None: 'Falkor'},
+    },
+    0x53: {  # Samsung LSI
+        0x001: {0x1: 'MongooseM1'},
     },
     0x56: {  # Marvell
         0x131: {
@@ -460,8 +474,8 @@
             return None
 
 
-_bash_color_regex = re.compile('\x1b\\[[0-9;]+m')
-
+# This matches most ANSI escape sequences, not just colors
+_bash_color_regex = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')
 
 def strip_bash_colors(text):
     return _bash_color_regex.sub('', text)
diff --git a/devlib/utils/rendering.py b/devlib/utils/rendering.py
index 3b7b6c4..6c3909d 100644
--- a/devlib/utils/rendering.py
+++ b/devlib/utils/rendering.py
@@ -18,6 +18,8 @@
 SurfaceFlingerFrame = namedtuple('SurfaceFlingerFrame',
                                  'desired_present_time actual_present_time frame_ready_time')
 
+VSYNC_INTERVAL = 16666667
+
 
 class FrameCollector(threading.Thread):
 
@@ -83,9 +85,14 @@
             header = self.header
             frames = self.frames
         else:
-            header = [c for c in self.header if c in columns]
-            indexes = [self.header.index(c) for c in header]
+            indexes = []
+            for c in columns:
+                if c not in self.header:
+                    msg = 'Invalid column "{}"; must be in {}'
+                    raise ValueError(msg.format(c, self.header))
+                indexes.append(self.header.index(c))
             frames = [[f[i] for i in indexes] for f in self.frames]
+            header = columns
         with open(outfile, 'w') as wfh:
             writer = csv.writer(wfh)
             if header:
@@ -122,7 +129,8 @@
         return self.target.execute(cmd.format(activity))
 
     def list(self):
-        return self.target.execute('dumpsys SurfaceFlinger --list').split('\r\n')
+        text = self.target.execute('dumpsys SurfaceFlinger --list')
+        return text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
 
     def _process_raw_file(self, fh):
         text = fh.read().replace('\r\n', '\n').replace('\r', '\n')
@@ -187,6 +195,7 @@
     def _process_raw_file(self, fh):
         found = False
         try:
+            last_vsync = 0
             while True:
                 for line in fh:
                     if line.startswith('---PROFILEDATA---'):
@@ -197,9 +206,53 @@
                 for line in fh:
                     if line.startswith('---PROFILEDATA---'):
                         break
-                    self.frames.append(map(int, line.strip().split(',')[:-1]))  # has a trailing ','
+                    entries = map(int, line.strip().split(',')[:-1])  # has a trailing ','
+                    if entries[1] <= last_vsync:
+                        continue  # repeat frame
+                    last_vsync = entries[1]
+                    self.frames.append(entries)
         except StopIteration:
             pass
         if not found:
             logger.warning('Could not find frames data in gfxinfo output')
             return
+
+
+def _file_reverse_iter(fh, buf_size=1024):
+    fh.seek(0, os.SEEK_END)
+    offset = 0
+    file_size = remaining_size = fh.tell()
+    while remaining_size > 0:
+        offset = min(file_size, offset + buf_size)
+        fh.seek(file_size - offset)
+        buf = fh.read(min(remaining_size, buf_size))
+        remaining_size -= buf_size
+        yield buf
+
+
+def gfxinfo_get_last_dump(filepath):
+    """
+    Return the last gfxinfo dump from the frame collector's raw output.
+
+    """
+    record = ''
+    with open(filepath, 'r') as fh:
+        fh_iter = _file_reverse_iter(fh)
+        try:
+            while True:
+                buf = fh_iter.next()
+                ix = buf.find('** Graphics')
+                if ix >= 0:
+                    return buf[ix:] + record
+
+                ix = buf.find(' **\n')
+                if ix >= 0:
+                    buf =  fh_iter.next() + buf
+                    ix = buf.find('** Graphics')
+                    if ix < 0:
+                        msg = '"{}" appears to be corrupted'
+                        raise RuntimeError(msg.format(filepath))
+                    return buf[ix:] + record
+                record = buf + record
+        except StopIteration:
+            pass
diff --git a/devlib/utils/ssh.py b/devlib/utils/ssh.py
index 8704008..27c52d2 100644
--- a/devlib/utils/ssh.py
+++ b/devlib/utils/ssh.py
@@ -160,7 +160,8 @@
                  telnet=False,
                  password_prompt=None,
                  original_prompt=None,
-                 platform=None
+                 platform=None,
+                 sudo_cmd="sudo -- sh -c '{}'"
                  ):
         self.host = host
         self.username = username
@@ -169,6 +170,7 @@
         self.port = port
         self.lock = threading.Lock()
         self.password_prompt = password_prompt if password_prompt is not None else self.default_password_prompt
+        self.sudo_cmd = sudo_cmd
         logger.debug('Logging in {}@{}'.format(username, host))
         timeout = timeout if timeout is not None else self.default_timeout
         self.conn = ssh_get_shell(host, username, password, self.keyfile, port, timeout, False, None)
@@ -212,7 +214,7 @@
             port_string = '-p {}'.format(self.port) if self.port else ''
             keyfile_string = '-i {}'.format(self.keyfile) if self.keyfile else ''
             if as_root:
-                command = "sudo -- sh -c '{}'".format(command)
+                command = self.sudo_cmd.format(command)
             command = '{} {} {} {}@{} {}'.format(ssh, keyfile_string, port_string, self.username, self.host, command)
             logger.debug(command)
             if self.password:
@@ -240,7 +242,7 @@
             # As we're already root, there is no need to use sudo.
             as_root = False
         if as_root:
-            command = "sudo -- sh -c '{}'".format(escape_single_quotes(command))
+            command = self.sudo_cmd.format(escape_single_quotes(command))
             if log:
                 logger.debug(command)
             self.conn.sendline(command)
@@ -282,16 +284,18 @@
         port_string = '-P {}'.format(self.port) if (self.port and self.port != 22) else ''
         keyfile_string = '-i {}'.format(self.keyfile) if self.keyfile else ''
         command = '{} -r {} {} {} {}'.format(scp, keyfile_string, port_string, source, dest)
-        pass_string = ''
+        command_redacted = command
         logger.debug(command)
         if self.password:
             command = _give_password(self.password, command)
+            command_redacted = command.replace(self.password, '<redacted>')
         try:
             check_output(command, timeout=timeout, shell=True)
         except subprocess.CalledProcessError as e:
-            raise subprocess.CalledProcessError(e.returncode, e.cmd.replace(pass_string, ''), e.output)
+            raise HostError("Failed to copy file with '{}'. Output:\n{}".format(
+                command_redacted, e.output))
         except TimeoutError as e:
-            raise TimeoutError(e.command.replace(pass_string, ''), e.output)
+            raise TimeoutError(command_redacted, e.output)
 
 
 class TelnetConnection(SshConnection):
@@ -440,7 +444,7 @@
         self._check_ready()
 
         result = self._gem5_shell("ls {}".format(source))
-        files = result.split()
+        files = strip_bash_colors(result).split()
 
         for filename in files:
             dest_file = os.path.basename(filename)
diff --git a/devlib/utils/types.py b/devlib/utils/types.py
index be30bfc..645328d 100644
--- a/devlib/utils/types.py
+++ b/devlib/utils/types.py
@@ -68,6 +68,15 @@
     """
     if isinstance(value, int):
         return value
+
+    if isinstance(value, basestring):
+        value = value.strip()
+        if value.endswith('%'):
+            try:
+                return float(value.rstrip('%')) / 100
+            except ValueError:
+                raise ValueError('Not numeric: {}'.format(value))
+
     try:
         fvalue = float(value)
     except ValueError:
diff --git a/doc/connection.rst b/doc/connection.rst
index 1d8f098..70d9e25 100644
--- a/doc/connection.rst
+++ b/doc/connection.rst
@@ -99,7 +99,7 @@
     ``adb`` is part of the Android SDK (though stand-alone versions are also
     available).
 
-    :param device: The name of the adb divice. This is usually a unique hex
+    :param device: The name of the adb device. This is usually a unique hex
                    string for USB-connected devices, or an ip address/port
                    combination. To see connected devices, you can run ``adb
                    devices`` on the host.
@@ -126,21 +126,21 @@
                     .. note:: ``keyfile`` and ``password`` can't be specified
                               at the same time.
 
-    :param port: TCP port on which SSH server is litening on the remoted device.
+    :param port: TCP port on which SSH server is listening on the remote device.
                  Omit to use the default port.
     :param timeout: Timeout for the connection in seconds. If a connection
                     cannot be established within this time, an error will be
                     raised.
     :param password_prompt: A string with the password prompt used by
                             ``sshpass``. Set this if your version of ``sshpass``
-                            uses somethin other than ``"[sudo] password"``.
+                            uses something other than ``"[sudo] password"``.
 
 
 .. class:: TelnetConnection(host, username, password=None, port=None,\
                             timeout=None, password_prompt=None,\
                             original_prompt=None)
 
-    A connectioned to a device on the network over Telenet.
+    A connection to a device on the network over Telenet.
 
     .. note:: Since Telenet protocol is does not support file transfer, scp is
               used for that purpose.
@@ -153,7 +153,7 @@
                                ``sshpass`` utility must be installed on the
                                system.
 
-    :param port: TCP port on which SSH server is litening on the remoted device.
+    :param port: TCP port on which SSH server is listening on the remote device.
                  Omit to use the default port.
     :param timeout: Timeout for the connection in seconds. If a connection
                     cannot be established within this time, an error will be
diff --git a/doc/derived_measurements.rst b/doc/derived_measurements.rst
new file mode 100644
index 0000000..d56f94b
--- /dev/null
+++ b/doc/derived_measurements.rst
@@ -0,0 +1,221 @@
+Derived Measurements
+=====================
+
+
+The ``DerivedMeasurements`` API provides a consistent way of performing post
+processing on a provided :class:`MeasurementCsv` file.
+
+Example
+-------
+
+The following example shows how to use an implementation of a
+:class:`DerivedMeasurement` to obtain a list of calculated ``DerivedMetric``'s.
+
+.. code-block:: ipython
+
+    # Import the relevant derived measurement module
+    # in this example the derived energy module is used.
+    In [1]: from devlib import DerivedEnergyMeasurements
+
+    # Obtain a MeasurementCsv file from an instrument or create from
+    # existing .csv file. In this example an existing csv file is used which was
+    # created with a sampling rate of 100Hz
+    In [2]: from devlib import MeasurementsCsv
+    In [3]: measurement_csv = MeasurementsCsv('/example/measurements.csv', sample_rate_hz=100)
+
+    # Process the file and obtain a list of the derived measurements
+    In [4]: derived_measurements = DerivedEnergyMeasurements.process(measurement_csv)
+
+    In [5]: derived_measurements
+    Out[5]: [device_energy: 239.1854075 joules, device_power: 5.5494089227 watts]
+
+API
+---
+
+Derived Measurements
+~~~~~~~~~~~~~~~~~~~~
+
+.. class:: DerivedMeasurements
+
+   The ``DerivedMeasurements`` class provides an API for post-processing
+   instrument output offline (i.e. without a connection to the target device) to
+   generate additional metrics.
+
+.. method:: DerivedMeasurements.process(measurement_csv)
+
+   Process a :class:`MeasurementsCsv`, returning  a list of
+   :class:`DerivedMetric` and/or :class:`MeasurementsCsv` objects that have been
+   derived from the input. The exact nature and ordering of the list memebers
+   is specific to indivial 'class'`DerivedMeasurements` implementations.
+
+.. method:: DerivedMeasurements.process_raw(\*args)
+
+   Process raw output from an instrument, returnin a list :class:`DerivedMetric`
+   and/or :class:`MeasurementsCsv` objects that have been derived from the
+   input. The exact nature and ordering of the list memebers is specific to
+   indivial 'class'`DerivedMeasurements` implewmentations.
+
+   The arguents to this method should be paths to raw output files generated by
+   an instrument. The number and order of expected arguments is specific to
+   particular implmentations.
+
+
+Derived Metric
+~~~~~~~~~~~~~~
+
+.. class:: DerivedMetric
+
+  Represents a metric derived from previously collected ``Measurement``s.
+  Unlike, a ``Measurement``, this was not measured directly from the target.
+
+
+.. attribute:: DerivedMetric.name
+
+   The name of the derived metric. This uniquely defines a metric -- two
+   ``DerivedMetric`` objects with the same ``name`` represent to instances of
+   the same metric (e.g. computed from two different inputs).
+
+.. attribute:: DerivedMetric.value
+
+   The ``numeric`` value of the metric that has been computed for a particular
+   input.
+
+.. attribute:: DerivedMetric.measurement_type
+
+   The ``MeasurementType`` of the metric. This indicates which conceptual
+   category the metric falls into, its units, and conversions to other
+   measurement types.
+
+.. attribute:: DerivedMetric.units
+
+   The units in which the metric's value is expressed.
+
+
+Available Derived Measurements
+-------------------------------
+
+.. note:: If a method of the API is not documented for a particular
+          implementation, that means that it s not overriden by that
+          implementation. It is still safe to call it -- an empty list will be
+          returned.
+
+Energy
+~~~~~~
+
+.. class:: DerivedEnergyMeasurements
+
+   The ``DerivedEnergyMeasurements`` class is used to calculate average power and
+   cumulative energy for each site if the required data is present.
+
+   The calculation of cumulative energy can occur in 3 ways. If a
+   ``site`` contains ``energy`` results, the first and last measurements are extracted
+   and the delta calculated. If not, a ``timestamp`` channel will be used to calculate
+   the energy from the power channel, failing back to using the sample rate attribute
+   of the :class:`MeasurementCsv` file if timestamps are not available. If neither
+   timestamps or a sample rate are available then an error will be raised.
+
+
+.. method:: DerivedEnergyMeasurements.process(measurement_csv)
+
+   This will return total cumulative energy for each energy channel, and the
+   average power for each power channel in the input CSV. The output will contain
+   all energy metrics followed by power metrics. The ordering of both will match
+   the ordering of channels in the input. The metrics will by named based on the
+   sites of the coresponding channels according to the following patters:
+   ``"<site>_total_energy"`` and ``"<site>_average_power"``.
+
+
+FPS / Rendering
+~~~~~~~~~~~~~~~
+
+.. class:: DerivedGfxInfoStats(drop_threshold=5, suffix='-fps', filename=None, outdir=None)
+
+   Produces FPS (frames-per-second) and other dervied statistics from
+   :class:`GfxInfoFramesInstrument` output. This takes several optional
+   parameters in creation:
+
+   :param drop_threshold: FPS in an application, such as a game, which this
+                          processor is primarily targeted at, cannot reasonably
+                          drop to a very low value. This is specified to this
+                          threhold. If an FPS for a frame is computed to be
+                          lower than this treshold, it will be dropped on the
+                          assumption that frame rednering was suspended by the
+                          system (e.g. when idling), or there was some sort of
+                          error, and therefore this should be used in
+                          performance calculations. defaults to ``5``.
+   :param  suffix: The name of the gerated per-frame FPS csv file will be
+                   derived from the input frames csv file by appending this
+                   suffix. This cannot be specified at the same time as
+                   a ``filename``.
+   :param filename: As an alternative to the suffix, a complete file name for
+                    FPS csv can be specified. This cannot be used at the same
+                    time as the ``suffix``.
+   :param outdir: By default, the FPS csv file will be placed in the same
+                  directory as the input frames csv file. This can be changed
+                  by specifying an alternate directory here
+
+   .. warning:: Specifying both ``filename`` and ``oudir`` will mean that exactly
+                the same file will be used for FPS output on each invocation of
+                ``process()`` (even for different inputs) resulting in previous
+                results being overwritten.
+
+.. method:: DerivedGfxInfoStats.process(measurement_csv)
+
+   Process the fames csv generated by :class:`GfxInfoFramesInstrument` and
+   returns a list containing exactly three entries: :class:`DerivedMetric`\ s
+   ``fps`` and ``total_frames``, followed by a :class:`MeasurentCsv` containing
+   per-frame FPSs values.
+
+.. method:: DerivedGfxInfoStats.process_raw(gfxinfo_frame_raw_file)
+
+   As input, this takes a single argument, which should be the path to the raw
+   output file of  :class:`GfxInfoFramesInstrument`. The returns stats
+   accumulated by gfxinfo. At the time of wrinting, the stats (in order) are:
+   ``janks``, ``janks_pc`` (percentage of all frames),
+   ``render_time_50th_ptile`` (50th percentile, or median, for time to render a
+   frame), ``render_time_90th_ptile``, ``render_time_95th_ptile``,
+   ``render_time_99th_ptile``, ``missed_vsync``, ``hight_input_latency``,
+   ``slow_ui_thread``, ``slow_bitmap_uploads``, ``slow_issue_draw_commands``.
+   Please see the `gfxinfo documentation`_ for details.
+
+.. _gfxinfo documentation: https://developer.android.com/training/testing/performance.html
+
+
+.. class:: DerivedSurfaceFlingerStats(drop_threshold=5, suffix='-fps', filename=None, outdir=None)
+
+   Produces FPS (frames-per-second) and other dervied statistics from
+   :class:`SurfaceFlingerFramesInstrument` output. This takes several optional
+   parameters in creation:
+
+   :param drop_threshold: FPS in an application, such as a game, which this
+                          processor is primarily targeted at, cannot reasonably
+                          drop to a very low value. This is specified to this
+                          threhold. If an FPS for a frame is computed to be
+                          lower than this treshold, it will be dropped on the
+                          assumption that frame rednering was suspended by the
+                          system (e.g. when idling), or there was some sort of
+                          error, and therefore this should be used in
+                          performance calculations. defaults to ``5``.
+   :param  suffix: The name of the gerated per-frame FPS csv file will be
+                   derived from the input frames csv file by appending this
+                   suffix. This cannot be specified at the same time as
+                   a ``filename``.
+   :param filename: As an alternative to the suffix, a complete file name for
+                    FPS csv can be specified. This cannot be used at the same
+                    time as the ``suffix``.
+   :param outdir: By default, the FPS csv file will be placed in the same
+                  directory as the input frames csv file. This can be changed
+                  by specifying an alternate directory here
+
+   .. warning:: Specifying both ``filename`` and ``oudir`` will mean that exactly
+                the same file will be used for FPS output on each invocation of
+                ``process()`` (even for different inputs) resulting in previous
+                results being overwritten.
+
+.. method:: DerivedSurfaceFlingerStats.process(measurement_csv)
+
+   Process the fames csv generated by :class:`SurfaceFlingerFramesInstrument` and
+   returns a list containing exactly three entries: :class:`DerivedMetric`\ s
+   ``fps`` and ``total_frames``, followed by a :class:`MeasurentCsv` containing
+   per-frame FPSs values, followed by ``janks`` ``janks_pc``, and
+   ``missed_vsync`` metrics.
diff --git a/doc/index.rst b/doc/index.rst
index 2c6d72f..5f4dda5 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -19,6 +19,7 @@
    target
    modules
    instrumentation
+   derived_measurements
    platform
    connection
 
diff --git a/doc/instrumentation.rst b/doc/instrumentation.rst
index 7beec79..0d4a6ce 100644
--- a/doc/instrumentation.rst
+++ b/doc/instrumentation.rst
@@ -65,8 +65,8 @@
    :INSTANTANEOUS: The instrument supports taking a single sample via
                    ``take_measurement()``.
    :CONTINUOUS: The instrument supports collecting measurements over a
-                period of time via ``start()``, ``stop()``, and
-                ``get_data()`` methods.
+                period of time via ``start()``, ``stop()``, ``get_data()``,
+		and (optionally) ``get_raw`` methods.
 
    .. note:: It's possible for one instrument to support more than a single
              mode.
@@ -99,14 +99,21 @@
    ``teardown()`` has been called), but see documentation for the instrument
    you're interested in.
 
-.. method:: Instrument.reset([sites, [kinds]])
+.. method:: Instrument.reset(sites=None, kinds=None, channels=None)
 
    This is used to configure an instrument for collection. This must be invoked
-   before ``start()`` is called to begin collection. ``sites`` and ``kinds``
-   parameters may be used to specify which channels measurements should be
-   collected from (if omitted, then measurements will be collected for all
-   available sites/kinds). This methods sets the ``active_channels`` attribute
-   of the ``Instrument``.
+   before ``start()`` is called to begin collection. This methods sets the
+   ``active_channels`` attribute of the ``Instrument``.
+
+   If ``channels`` is provided, it is a list of names of channels to enable and
+   ``sites`` and ``kinds`` must both be ``None``.
+
+   Otherwise, if one of ``sites`` or ``kinds`` is provided, all channels
+   matching the given sites or kinds are enabled. If both are provided then all
+   channels of the given kinds at the given sites are enabled.
+
+   If none of ``sites``, ``kinds`` or ``channels`` are provided then all
+   available channels are enabled.
 
 .. method:: Instrument.take_measurment()
 
@@ -114,14 +121,14 @@
    :class:`Measurement` objects (one for each active channel).
 
    .. note:: This method is only implemented by :class:`Instrument`\ s that
-             support ``INSTANTANEOUS`` measurment.
+             support ``INSTANTANEOUS`` measurement.
 
 .. method:: Instrument.start()
 
    Starts collecting measurements from ``active_channels``.
 
    .. note:: This method is only implemented by :class:`Instrument`\ s that
-             support ``CONTINUOUS`` measurment.
+             support ``CONTINUOUS`` measurement.
 
 .. method:: Instrument.stop()
 
@@ -129,29 +136,44 @@
    :func:`start()`.
 
    .. note:: This method is only implemented by :class:`Instrument`\ s that
-             support ``CONTINUOUS`` measurment.
+             support ``CONTINUOUS`` measurement.
 
 .. method:: Instrument.get_data(outfile)
 
    Write collected data into ``outfile``. Must be called after :func:`stop()`.
    Data will be written in CSV format with a column for each channel and a row
    for each sample. Column heading will be channel, labels in the form
-   ``<site>_<kind>`` (see :class:`InstrumentChannel`). The order of the coluns
+   ``<site>_<kind>`` (see :class:`InstrumentChannel`). The order of the columns
    will be the same as the order of channels in ``Instrument.active_channels``.
 
+   If reporting timestamps, one channel must have a ``site`` named ``"timestamp"``
+   and a ``kind`` of a :class:`MeasurmentType` of an appropriate time unit which will
+   be used, if appropriate, during any post processing.
+
+   .. note:: Currently supported time units are seconds, milliseconds and
+             microseconds, other units can also be used if an appropriate
+             conversion is provided.
+
    This returns a :class:`MeasurementCsv` instance associated with the outfile
    that can be used to stream :class:`Measurement`\ s lists (similar to what is
    returned by ``take_measurement()``.
 
    .. note:: This method is only implemented by :class:`Instrument`\ s that
-             support ``CONTINUOUS`` measurment.
+             support ``CONTINUOUS`` measurement.
+
+.. method:: Instrument.get_raw()
+
+   Returns a list of paths to files containing raw output from the underlying
+   source(s) that is used to produce the data CSV. If now raw output is
+   generated or saved, an empty list will be returned. The format of the
+   contents of the raw files is entirely source-dependent.
 
 .. attribute:: Instrument.sample_rate_hz
 
    Sample rate of the instrument in Hz. Assumed to be the same for all channels.
 
    .. note:: This attribute is only provided by :class:`Instrument`\ s that
-             support ``CONTINUOUS`` measurment.
+             support ``CONTINUOUS`` measurement.
 
 Instrument Channel
 ~~~~~~~~~~~~~~~~~~
@@ -163,16 +185,16 @@
    ``site`` and a ``measurement_type``.
 
    A ``site`` indicates where  on the target a measurement is collected from
-   (e.g. a volage rail or location of a sensor).
+   (e.g. a voltage rail or location of a sensor).
 
    A ``measurement_type`` is an instance of :class:`MeasurmentType` that
-   describes what sort of measurment this is (power, temperature, etc). Each
-   mesurement type has a standard unit it is reported in, regardless of an
+   describes what sort of measurement this is (power, temperature, etc). Each
+   measurement type has a standard unit it is reported in, regardless of an
    instrument used to collect it.
 
    A channel (i.e. site/measurement_type combination) is unique per instrument,
    however there may be more than one channel associated with one site (e.g. for
-   both volatage and power).
+   both voltage and power).
 
    It should not be assumed that any site/measurement_type combination is valid.
    The list of available channels can queried with
@@ -180,22 +202,22 @@
 
 .. attribute:: InstrumentChannel.site
 
-   The name of the "site" from which the measurments are collected (e.g. voltage
+   The name of the "site" from which the measurements are collected (e.g. voltage
    rail, sensor, etc).
 
 .. attribute:: InstrumentChannel.kind
 
-   A string indingcating the type of measrument that will be collted. This is
+   A string indicating the type of measurement that will be collected. This is
    the ``name`` of the :class:`MeasurmentType` associated with this channel.
 
 .. attribute:: InstrumentChannel.units
 
-   Units in which measurment will be reported. this is determined by the
+   Units in which measurement will be reported. this is determined by the
    underlying :class:`MeasurmentType`.
 
 .. attribute:: InstrumentChannel.label
 
-   A label that can be attached to measurments associated with with channel.
+   A label that can be attached to measurements associated with with channel.
    This is constructed with ::
 
        '{}_{}'.format(self.site, self.kind)
@@ -211,27 +233,33 @@
 defined measurement types are
 
 
-+-------------+---------+---------------+
-| name        | units   | category      |
-+=============+=========+===============+
-| time        | seconds |               |
-+-------------+---------+---------------+
-| temperature | degrees |               |
-+-------------+---------+---------------+
-| power       | watts   | power/energy  |
-+-------------+---------+---------------+
-| voltage     | volts   | power/energy  |
-+-------------+---------+---------------+
-| current     | amps    | power/energy  |
-+-------------+---------+---------------+
-| energy      | joules  | power/energy  |
-+-------------+---------+---------------+
-| tx          | bytes   | data transfer |
-+-------------+---------+---------------+
-| rx          | bytes   | data transfer |
-+-------------+---------+---------------+
-| tx/rx       | bytes   | data transfer |
-+-------------+---------+---------------+
++-------------+-------------+---------------+
+| name        | units       | category      |
++=============+=============+===============+
+| count       | count       |               |
++-------------+-------------+---------------+
+| percent     | percent     |               |
++-------------+-------------+---------------+
+| time_us     | microseconds|  time         |
++-------------+-------------+---------------+
+| time_ms     | milliseconds|  time         |
++-------------+-------------+---------------+
+| temperature | degrees     |  thermal      |
++-------------+-------------+---------------+
+| power       | watts       | power/energy  |
++-------------+-------------+---------------+
+| voltage     | volts       | power/energy  |
++-------------+-------------+---------------+
+| current     | amps        | power/energy  |
++-------------+-------------+---------------+
+| energy      | joules      | power/energy  |
++-------------+-------------+---------------+
+| tx          | bytes       | data transfer |
++-------------+-------------+---------------+
+| rx          | bytes       | data transfer |
++-------------+-------------+---------------+
+| tx/rx       | bytes       | data transfer |
++-------------+-------------+---------------+
 
 
 .. instruments:
diff --git a/doc/modules.rst b/doc/modules.rst
index ac75f99..b89b488 100644
--- a/doc/modules.rst
+++ b/doc/modules.rst
@@ -72,7 +72,7 @@
 
    :param cpu: The cpu; could be a numeric or the corresponding string (e.g.
         ``1`` or ``"cpu1"``).
-   :param governor: The name of the governor. This must be one of the governors 
+   :param governor: The name of the governor. This must be one of the governors
                 supported by the CPU (as returned by ``list_governors()``.
 
    Keyword arguments may be used to specify governor tunable values.
@@ -126,7 +126,7 @@
 cpuidle
 -------
 
-``cpufreq`` is the kernel subsystem for managing CPU low power (idle) states.
+``cpuidle`` is the kernel subsystem for managing CPU low power (idle) states.
 
 .. method:: target.cpuidle.get_driver()
 
@@ -155,7 +155,7 @@
     Enable or disable the specified or all states (optionally on the specified
     CPU.
 
-You can also call ``enable()`` or ``disable()`` on :class:`CpuidleState` objects 
+You can also call ``enable()`` or ``disable()`` on :class:`CpuidleState` objects
 returned by get_state(s).
 
 cgroups
@@ -182,7 +182,7 @@
 define the following class attributes:
 
 :name: A unique name for the module. This cannot clash with any of the existing
-       names and must be a valid Python identifier, but is otherwise free-from.
+       names and must be a valid Python identifier, but is otherwise free-form.
 :kind: This identifies the type of functionality a module implements, which in
        turn determines the interface implemented by the module (all modules of
        the same kind must expose a consistent interface). This must be a valid
@@ -271,7 +271,7 @@
 .. method:: HardResetModule.__call__()
 
     Must be implemented by derived classes.
-    
+
     Implements hard reset for a target devices. The equivalent of physically
     power cycling the device.  This may be used by client code in situations
     where the target becomes unresponsive and/or a regular reboot is not
@@ -355,7 +355,7 @@
         name = 'acme_hard_reset'
 
         def __call__(self):
-            # Assuming Acme board comes with a "reset-acme-board" utility 
+            # Assuming Acme board comes with a "reset-acme-board" utility
             os.system('reset-acme-board {}'.format(self.target.name))
 
     register_module(AcmeHardReset)
diff --git a/doc/overview.rst b/doc/overview.rst
index 421f053..7a60fb8 100644
--- a/doc/overview.rst
+++ b/doc/overview.rst
@@ -74,13 +74,13 @@
 working directories, deploying busybox, etc. It's usually enough to do this once
 for a new device, as the changes this makes will persist across reboots.
 However, there is no issue with calling this multiple times, so, to be on the
-safe site, it's a good idea to call this once at the beginning of your scripts.
+safe side, it's a good idea to call this once at the beginning of your scripts.
 
 Command Execution
 ~~~~~~~~~~~~~~~~~
 
 There are several ways to execute a command on the target. In each case, a
-:class:`TargetError` will be raised if something goes wrong. In very case, it is
+:class:`TargetError` will be raised if something goes wrong. In each case, it is
 also possible to specify ``as_root=True`` if the specified command should be
 executed as root.
 
@@ -154,7 +154,7 @@
    # kill all running instances of a process.
    t.killall('badexe', signal=signal.SIGKILL)
 
-   # List processes running on the target. This retruns a list of parsed
+   # List processes running on the target. This returns a list of parsed
    # PsEntry records.
    entries = t.ps()
    # e.g.  print virtual memory sizes of all running sshd processes:
diff --git a/doc/platform.rst b/doc/platform.rst
index 5270c09..3449db3 100644
--- a/doc/platform.rst
+++ b/doc/platform.rst
@@ -18,7 +18,7 @@
     :param core_names: A list of CPU core names in the order they appear
                        registered with the OS. If they are not specified,
                        they will be queried at run time.
-    :param core_clusters: Alist with cluster ids of each core (starting with
+    :param core_clusters: A list with cluster ids of each core (starting with
                           0). If this is not specified, clusters will be
                           inferred from core names (cores with the same name are
                           assumed to be in a cluster).
@@ -38,13 +38,13 @@
 The generic platform may be extended to support hardware- or
 infrastructure-specific functionality. Platforms exist for ARM
 VersatileExpress-based :class:`Juno` and :class:`TC2` development boards. In
-addition to the standard :class:`Platform` parameters above, these platfroms
+addition to the standard :class:`Platform` parameters above, these platforms
 support additional configuration:
 
 
 .. class:: VersatileExpressPlatform
 
-    Normally, this would be instatiated via one of its derived classes
+    Normally, this would be instantiated via one of its derived classes
     (:class:`Juno` or :class:`TC2`) that set appropriate defaults for some of
     the parameters.
 
@@ -63,7 +63,7 @@
                         mounted on the host system.
     :param hard_reset_method: Specifies the method for hard-resetting the devices
                             (e.g. if it becomes unresponsive and normal reboot
-                            method doesn not work). Currently supported methods
+                            method doesn't not work). Currently supported methods
                             are:
 
                             :dtr: reboot by toggling DTR line on the serial
@@ -80,7 +80,7 @@
                       The following values are currently supported:
 
                        :uefi: Boot via UEFI menu, by selecting the entry
-                              specified by ``uefi_entry`` paramter. If this
+                              specified by ``uefi_entry`` parameter. If this
                               entry does not exist, it will be automatically
                               created based on values provided for ``image``,
                               ``initrd``, ``fdt``, and ``bootargs`` parameters.
diff --git a/doc/target.rst b/doc/target.rst
index d14942e..5a6583e 100644
--- a/doc/target.rst
+++ b/doc/target.rst
@@ -2,18 +2,18 @@
 ======
 
 
-.. class:: Target(connection_settings=None, platform=None, working_directory=None, executables_directory=None, connect=True, modules=None, load_default_modules=True, shell_prompt=DEFAULT_SHELL_PROMPT)
-   
+.. class:: Target(connection_settings=None, platform=None, working_directory=None, executables_directory=None, connect=True, modules=None, load_default_modules=True, shell_prompt=DEFAULT_SHELL_PROMPT, conn_cls=None)
+
     :class:`Target` is the primary interface to the remote device. All interactions
     with the device are performed via a :class:`Target` instance, either
     directly, or via its modules or a wrapper interface (such as an
     :class:`Instrument`).
 
-    :param connection_settings: A ``dict`` that specifies how to connect to the remote 
+    :param connection_settings: A ``dict`` that specifies how to connect to the remote
        device. Its contents depend on the specific :class:`Target` type (used see
        :ref:`connection-types`\ ).
 
-    :param platform: A :class:`Target` defines interactions at Operating System level. A 
+    :param platform: A :class:`Target` defines interactions at Operating System level. A
         :class:`Platform` describes the underlying hardware (such as CPUs
         available). If a :class:`Platform` instance is not specified on
         :class:`Target` creation, one will be created automatically and it will
@@ -22,8 +22,8 @@
 
     :param working_directory: This is primary location for on-target file system
         interactions performed by ``devlib``. This location *must* be readable and
-        writable directly (i.e. without sudo) by the connection's user account. 
-        It may or may not allow execution. This location will be created, 
+        writable directly (i.e. without sudo) by the connection's user account.
+        It may or may not allow execution. This location will be created,
         if necessary, during ``setup()``.
 
         If not explicitly specified, this will be set to a default value
@@ -35,10 +35,10 @@
         (obviously). It should also be possible to write to this location,
         possibly with elevated privileges (i.e. on a rooted Linux target, it
         should be possible to write here with sudo, but not necessarily directly
-        by the connection's account). This location will be created, 
+        by the connection's account). This location will be created,
         if necessary, during ``setup()``.
 
-        This location does *not* to be same as the system's executables
+        This location does *not* need to be same as the system's executables
         location. In fact, to prevent devlib from overwriting system's defaults,
         it better if this is a separate location, if possible.
 
@@ -52,7 +52,7 @@
 
     :param modules: a list of additional modules to be installed. Some modules will
         try to install by default (if supported by the underlying target).
-        Current default modules are ``hotplug``, ``cpufreq``, ``cpuidle``, 
+        Current default modules are ``hotplug``, ``cpufreq``, ``cpuidle``,
         ``cgroups``, and ``hwmon`` (See :ref:`modules`\ ).
 
         See modules documentation for more detail.
@@ -68,6 +68,9 @@
          prompted on the target. This may be used by some modules that establish
          auxiliary connections to a target over UART.
 
+    :param conn_cls: This is the type of connection that will be used to communicate
+        with the device.
+
 .. attribute:: Target.core_names
 
    This is a list containing names of CPU cores on the target, in the order in
@@ -83,18 +86,18 @@
 
 .. attribute:: Target.big_core
 
-   This is the name of the cores that the "big"s in an ARM big.LITTLE
+   This is the name of the cores that are the "big"s in an ARM big.LITTLE
    configuration. This is obtained via the underlying :class:`Platform`.
 
 .. attribute:: Target.little_core
 
-   This is the name of the cores that the "little"s in an ARM big.LITTLE
+   This is the name of the cores that are the "little"s in an ARM big.LITTLE
    configuration. This is obtained via the underlying :class:`Platform`.
 
 .. attribute:: Target.is_connected
 
    A boolean value that indicates whether an active connection exists to the
-   target device. 
+   target device.
 
 .. attribute:: Target.connected_as_root
 
@@ -146,7 +149,7 @@
              thread.
 
 .. method:: Target.connect([timeout])
-   
+
    Establish a connection to the target. It is usually not necessary to call
    this explicitly, as a connection gets automatically established on
    instantiation.
@@ -225,7 +228,7 @@
    :param timeout: Timeout (in seconds) for the execution of the command. If
        specified, an exception will be raised if execution does not complete
        with the specified period.
-   :param check_exit_code: If ``True`` (the default) the exit code (on target) 
+   :param check_exit_code: If ``True`` (the default) the exit code (on target)
        from execution of the command will be checked, and an exception will be
        raised if it is not ``0``.
    :param as_root: The command will be executed as root. This will fail on
@@ -262,9 +265,27 @@
           will be interpreted as a comma-separated list of cpu ranges, e.g.
           ``"0,4-7"``.
    :param as_root: Specify whether the command should be run as root
-   :param timeout: If this is specified and invocation does not terminate within this number 
+   :param timeout: If this is specified and invocation does not terminate within this number
            of seconds, an exception will be raised.
 
+.. method:: Target.background_invoke(binary [, args [, in_directory [, on_cpus [, as_root ]]]])
+
+   Execute the specified binary on target (must already be installed) as a background
+   task, under the specified conditions and return the :class:`subprocess.Popen`
+   instance for the command.
+
+   :param binary: binary to execute. Must be present and executable on the device.
+   :param args: arguments to be passed to the binary. The can be either a list or
+          a string.
+   :param in_directory:  execute the binary in the  specified directory. This must
+                   be an absolute path.
+   :param on_cpus:  taskset the binary to these CPUs. This may be a single ``int`` (in which
+          case, it will be interpreted as the mask), a list of ``ints``, in which
+          case this will be interpreted as the list of cpus, or string, which
+          will be interpreted as a comma-separated list of cpu ranges, e.g.
+          ``"0,4-7"``.
+   :param as_root: Specify whether the command should be run as root
+
 .. method:: Target.kick_off(command [, as_root])
 
    Kick off the specified command on the target and return immediately. Unlike
@@ -296,16 +317,42 @@
 
 .. method:: Target.write_value(path, value [, verify])
 
-   Write the value to the specified path on the target. This is primarily 
+   Write the value to the specified path on the target. This is primarily
    intended for sysfs/procfs/debugfs etc.
 
    :param path: file to write into
    :param value: value to be written
    :param verify: If ``True`` (the default) the value will be read back after
-       it is written to make sure it has been written successfully. This due to 
+       it is written to make sure it has been written successfully. This due to
        some sysfs entries silently failing to set the written value without
        returning an error code.
 
+.. method:: Target.read_tree_values(path, depth=1, dictcls=dict):
+
+   Read values of all sysfs (or similar) file nodes under ``path``, traversing
+   up to the maximum depth ``depth``.
+
+   Returns a nested structure of dict-like objects (``dict``\ s by default) that
+   follows the structure of the scanned sub-directory tree. The top-level entry
+   has a single item who's key is ``path``. If ``path`` points to a single file,
+   the value of the entry is the value ready from that file node. Otherwise, the
+   value is a dict-line object  with a key for every entry under ``path``
+   mapping onto its value or further dict-like objects as appropriate.
+
+   :param path: sysfs path to scan
+   :param depth: maximum depth to descend
+   :param dictcls: a dict-like type to be used for each level of the hierarchy.
+
+.. method:: Target.read_tree_values_flat(path, depth=1):
+
+   Read values of all sysfs (or similar) file nodes under ``path``, traversing
+   up to the maximum depth ``depth``.
+
+   Returns a dict mapping paths of file nodes to corresponding values.
+
+   :param path: sysfs path to scan
+   :param depth: maximum depth to descend
+
 .. method:: Target.reset()
 
    Soft reset the target. Typically, this means executing ``reboot`` on the
@@ -422,13 +469,127 @@
 
 .. method:: Target.extract(path, dest=None)
 
-   Extracts the specified archive/file and returns the path to the extrated
+   Extracts the specified archive/file and returns the path to the extracted
    contents. The extraction method is determined based on the file extension.
    ``zip``, ``tar``, ``gzip``, and ``bzip2`` are supported.
 
    :param dest: Specified an on-target destination directory (which must exist)
-                for the extrated contents.
+                for the extracted contents.
 
     Returns the path to the extracted contents. In case of files (gzip and
     bzip2), the path to the decompressed file is returned; for archives, the
     path to the directory with the archive's contents is returned.
+
+.. method:: Target.is_network_connected()
+   Checks for internet connectivity on the device. This doesn't actually
+   guarantee that the internet connection is "working" (which is rather
+   nebulous), it's intended just for failing early when definitively _not_
+   connected to the internet.
+
+   :returns: ``True`` if internet seems available, ``False`` otherwise.
+
+Android Target
+---------------
+
+.. class:: AndroidTarget(connection_settings=None, platform=None, working_directory=None, executables_directory=None, connect=True, modules=None, load_default_modules=True, shell_prompt=DEFAULT_SHELL_PROMPT, conn_cls=AdbConnection, package_data_directory="/data/data")
+
+    :class:`AndroidTarget` is a subclass of :class:`Target` with additional features specific to a device running Android.
+
+    :param package_data_directory: This is the location of the data stored
+        for installed Android packages on the device.
+
+.. method:: AndroidTarget.set_rotation(rotation)
+
+   Specify an integer representing the desired screen rotation with the
+   following mappings: Natural: ``0``, Rotated Left: ``1``, Inverted : ``2``
+   and Rotated Right : ``3``.
+
+.. method:: AndroidTarget.get_rotation(rotation)
+
+   Returns an integer value representing the orientation of the devices
+   screen. ``0`` : Natural, ``1`` : Rotated Left, ``2`` : Inverted
+   and ``3`` : Rotated Right.
+
+.. method:: AndroidTarget.set_natural_rotation()
+
+   Sets the screen orientation of the device to its natural (0 degrees)
+   orientation.
+
+.. method:: AndroidTarget.set_left_rotation()
+
+   Sets the screen orientation of the device to 90 degrees.
+
+.. method:: AndroidTarget.set_inverted_rotation()
+
+   Sets the screen orientation of the device to its inverted (180 degrees)
+   orientation.
+
+.. method:: AndroidTarget.set_right_rotation()
+
+   Sets the screen orientation of the device to 270 degrees.
+
+.. method:: AndroidTarget.set_auto_rotation(autorotate)
+
+   Specify a boolean value for whether the devices auto-rotation should
+   be enabled.
+
+.. method:: AndroidTarget.get_auto_rotation()
+
+   Returns ``True`` if the targets auto rotation is currently enabled and
+   ``False`` otherwise.
+
+.. method:: AndroidTarget.set_airplane_mode(mode)
+
+   Specify a boolean value for whether the device should be in airplane mode.
+
+   .. note:: Requires the device to be rooted if the device is running Android 7+.
+
+.. method:: AndroidTarget.get_airplane_mode()
+
+   Returns ``True`` if the target is currently in airplane mode and
+   ``False`` otherwise.
+
+.. method:: AndroidTarget.set_brightness(value)
+
+   Sets the devices screen brightness to a specified integer between ``0`` and
+   ``255``.
+
+.. method:: AndroidTarget.get_brightness()
+
+   Returns an integer between ``0`` and ``255`` representing the devices
+   current screen brightness.
+
+.. method:: AndroidTarget.set_auto_brightness(auto_brightness)
+
+   Specify a boolean value for whether the devices auto brightness
+   should be enabled.
+
+.. method:: AndroidTarget.get_auto_brightness()
+
+   Returns ``True`` if the targets auto brightness is currently
+   enabled and ``False`` otherwise.
+
+.. method:: AndroidTarget.ensure_screen_is_off()
+
+   Checks if the devices screen is on and if so turns it off.
+
+.. method:: AndroidTarget.ensure_screen_is_on()
+
+   Checks if the devices screen is off and if so turns it on.
+
+.. method:: AndroidTarget.is_screen_on()
+
+   Returns ``True`` if the targets screen is currently on and ``False``
+   otherwise.
+
+.. method:: AndroidTarget.homescreen()
+
+   Returns the device to its home screen.
+
+.. method:: AndroidTarget.swipe_to_unlock(direction="diagonal")
+
+   Performs a swipe input on the device to try and unlock the device.
+   A direction of ``"horizontal"``, ``"vertical"`` or ``"diagonal"``
+   can be supplied to specify in which direction the swipe should be
+   performed. By default ``"diagonal"`` will be used to try and
+   support the majority of newer devices.
diff --git a/src/readenergy/readenergy.c b/src/readenergy/readenergy.c
index 6e4f35f..890a454 100644
--- a/src/readenergy/readenergy.c
+++ b/src/readenergy/readenergy.c
@@ -114,7 +114,7 @@
 	double sys_enm_ch0_gpu;
 };
 
-inline uint64_t join_64bit_register(uint32_t *buffer, int index)
+static inline uint64_t join_64bit_register(uint32_t *buffer, int index)
 {
 	uint64_t result = 0;
 	result |= buffer[index];