Initial code submit for Open HST. am: 25850f04a1 am: d73fe15626 am: 2d60bbc8e8

Original change: https://googleplex-android-review.googlesource.com/c/platform/tools/test/openhst/+/11890425

Change-Id: I06b9f6fa806027494e116b402d985a19708cce57
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   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.
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4016bab
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,87 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+.PHONY: help start
+
+SHELL := /bin/bash
+SRC_DIR ?= .
+DST_DIR ?= .
+PROTOC_DIR ?= .
+PROTO_SRC_FILE ?= stress_test.proto
+
+ifeq ($(OS),Windows_NT)
+	detected_OS := Windows
+	USER_NAME ?=
+else
+    detected_OS := $(shell uname)
+	USER_NAME ?= $(shell whoami)
+	ENV_PATH ?= $(shell pwd)
+endif
+
+.DEFAULT: help
+help:
+	@echo "make start"
+	@echo "       prepare development environment, use only once"
+	@echo "make proto-compile"
+	@echo "       compile protubuf"
+	@echo "make clean"
+	@echo "       delete test result and cache directories"
+
+start:
+ifeq ($(detected_OS),Windows)
+	@echo "please install python3 and pytyon3-pip manually"
+	py -m pip install --upgrade pip
+	py -m pip install --user virtualenv
+	python -m venv .\env
+endif
+ifeq ($(detected_OS),Darwin)
+	/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
+	sudo chown -R $(USER_NAME) /usr/local/bin /usr/local/etc /usr/local/sbin /usr/local/share
+	chmod u+w /usr/local/bin /usr/local/etc /usr/local/sbin /usr/local/share
+	brew install python3 protobuf sox
+	python3 -m pip install --user virtualenv
+	python3 -m venv env
+endif
+ifeq ($(detected_OS),Linux)
+	sudo apt-get install python3 sox
+	sudo apt install python3-pip
+	python3 -m pip install --user --upgrade pip
+	python3 -m pip install --user virtualenv
+	sudo apt-get install python3-venv
+	python3 -m venv env
+	sudo apt install protobuf-compiler
+endif
+
+proto-compile: ${PROTO_SRC_FILE}
+ifeq ($(detected_OS),Windows)
+ifndef PROTOC_DIR
+	@echo "Error! Please download protoc.exe from https://github.com/google/protobuf/releases/ and set PROTOC_DIR accordingly"
+else
+	$(PROTOC_DIR)/protoc.exe -I=${SRC_DIR} --python_out=${DST_DIR} ${SRC_DIR}/${PROTO_SRC_FILE}
+endif
+else
+	protoc -I=${SRC_DIR} --python_out=${DST_DIR} ${SRC_DIR}/${PROTO_SRC_FILE}
+endif
+
+clean:
+ifeq ($(detected_OS),Windows)
+	@for /d %%x in (dsp_*) do rd /s /q "%%x"
+	@for /d %%x in (enroll_*) do rd /s /q "%%x"
+	@for /d %%x in (__pycache*) do rd /s /q "%%x"
+else
+	@rm -rf __pycache__
+	@rm -rf dsp_*
+	@rm -rf enroll*
+endif
+	@echo "cleanning completed"
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..7259ddc
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,5 @@
+# Default maintainers and code reviewers: 
+jschung@google.com
+wonil@google.com
+kwangun@google.com
+mokani@google.com
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c4031cd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+# OpenHST : Open sourced Hotword Stress Test tool
+
+Hotword stress test tool is a python script to measure the Google Hotword
+performance on Android devices with simple test set up. The goal of OpenHST is
+making Google Hotword Stress Test tool as an open source project so that Android
+Partners can easily adapt on their development.
+
+## Overview
+
+![Hotsord Stress Test concept diagram](docs/image/HST_concept_diagram.png)
+
+## Prerequsite
+
+Before setting up your HST test environment, please make sure to meet following
+preconditions.
+
+*   DSP hotword capability on test device (android handset)
+*   ADB connection between the test server and android handset
+*   Internet connection on android handset
+*   No screen lock setting on android handset
+*   Python 3.x installed test machine (Linux, Mac, Windows)
+*   Install GNU make(3.81 above) on test machine (Linux, Mac, Windows)
+
+## Test environment setup
+
+Please follow below steps to set up HST on your test machine and make sure that
+no error popped up during the installation.
+
+__Linux & Mac__
+
+1.  make start
+1.  make proto-compile
+1.  source env/bin/activate
+1.  ./start_venv.sh
+
+__Windows__
+
+1.  make start
+1.  make proto-compile
+1.  .\env\Scripts\activate
+1.  .\start_venv.bat
+1.  pip3 install -r requirements.txt
+
+## Documentation
+
+Read our [detailed documentation](docs/OpenHST.pdf) to learn how to run the
+Hotword Stress Test.
+
+## Contacts
+
+Join our [mailing list](https://groups.google.com/g/openhst) for discussions and
+announcements.
diff --git a/docs/OpenHST.pdf b/docs/OpenHST.pdf
new file mode 100644
index 0000000..2b1cb8f
--- /dev/null
+++ b/docs/OpenHST.pdf
Binary files differ
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..22b241c
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,29 @@
+# How to Contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Contributor License Agreement
+
+Contributions to this project must be accompanied by a Contributor License
+Agreement (CLA). You (or your employer) retain the copyright to your
+contribution; this simply gives us permission to use and redistribute your
+contributions as part of the project. Head over to
+<https://cla.developers.google.com/> to see your current agreements on file or
+to sign a new one.
+
+You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
+## Code reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
+information on using pull requests.
+
+## Community Guidelines
+
+This project follows
+[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
diff --git a/docs/image/HST_concept_diagram.png b/docs/image/HST_concept_diagram.png
new file mode 100644
index 0000000..5b62a38
--- /dev/null
+++ b/docs/image/HST_concept_diagram.png
Binary files differ
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..a963e06
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+google==2.0.3
+protobuf==3.11.3
+protobuf-compiler==1.0.20
+absl-py==0.9.0
+pexpect==4.8.0
diff --git a/resources/device_config.common.ascii_proto b/resources/device_config.common.ascii_proto
new file mode 100644
index 0000000..0d4ae8a
--- /dev/null
+++ b/resources/device_config.common.ascii_proto
@@ -0,0 +1,102 @@
+file_to_watch {
+  source: "LOGCAT"
+  destination: "%(device)s_logcat.txt"
+  repeats_output_on_open: true
+  time_stamp {
+    type: DATE
+    regex: "^(\\d+-\\d+ \\d+:\\d+:\\d+.\\d+)"
+    date_format: "%m-%d %H:%M:%S.%f"
+  }
+}
+
+file_to_watch {
+  source: "/dev/kmsg"
+  destination: "%(device)s_kmsg.txt"
+  repeats_output_on_open: true
+  time_stamp {
+    type: MONOTONIC
+    regex: "^\\d+,\\d+,(\\d+)"
+    monotonic_to_seconds_multiplier: 1e-6
+  }
+}
+
+file_to_move {
+  source: "/sdcard/captured_dsp_audio.wav"
+  destination: "%(device)s/%(iteration)06d_captured_dsp_audio.wav"
+}
+
+event {
+  source: "LOGCAT"
+  name: "software_hotword"
+  regex: "MicroDetectionWorker: #onHotwordDetected"
+}
+
+event {
+  source: "LOGCAT"
+  name: "vis_software_hotword"
+  regex: "GsaVoiceInteractionSrv: onHotwordDetected"
+}
+
+event {
+  source: "LOGCAT"
+  name: "dsp_false_accept"
+  regex: "Software didn't trigger but DSP did"
+}
+
+event {
+  source: "LOGCAT"
+  name: "speaker_id_rejected"
+  regex: ": Speaker Verification failed"
+  regex: "Software based speaker id triggered: false"
+}
+
+event {
+  source: "LOGCAT"
+  name: "logcat_iteration"
+  regex: "STRESS_TEST: Iteration \d+ complete"
+}
+
+event {
+  source: "LOGCAT"
+  name: "assistant_started"
+  regex: "START.*com\.google\.android\.googlequicksearchbox.*opa\.OpaActivity.*"
+}
+
+event {
+  source: "LOGCAT"
+  name: "aohd_hotword_detected"
+  regex: "AlwaysOnHotwordDetector: onDetected"
+}
+
+setup_command: "shell setprop log.tag.AlwaysOnHotwordDetector DEBUG"
+setup_command: "shell setprop log.tag.ClockworkHomeGoogle DEBUG"
+setup_command: "shell setprop log.tag.DSPMicrophoneIS DEBUG"
+setup_command: "shell setprop log.tag.DSPMicrophoneInputStrea DEBUG"
+setup_command: "shell setprop log.tag.GsaVoiceInteractionSrv DEBUG"
+setup_command: "shell setprop log.tag.HotwordAudioProvider DEBUG"
+setup_command: "shell setprop log.tag.HotwordConfig DEBUG"
+setup_command: "shell setprop log.tag.HotwordConfigController DEBUG"
+setup_command: "shell setprop log.tag.HotwordDetector DEBUG"
+setup_command: "shell setprop log.tag.HotwordHelper DEBUG"
+setup_command: "shell setprop log.tag.HotwordRecognitionEngn DEBUG"
+setup_command: "shell setprop log.tag.HotwordRecognitionRnr DEBUG"
+setup_command: "shell setprop log.tag.HotwordSettingsCntrlr DEBUG"
+setup_command: "shell setprop log.tag.HotwordState DEBUG"
+setup_command: "shell setprop log.tag.HotwordWorker DEBUG"
+setup_command: "shell setprop log.tag.HotwordWorkerImpl DEBUG"
+setup_command: "shell setprop log.tag.MicroDetectionState DEBUG"
+setup_command: "shell setprop log.tag.MicroRecognitionEngine DEBUG"
+setup_command: "shell setprop log.tag.SWHotwordRecognizer DEBUG"
+setup_command: "shell setprop log.tag.SoundTrigger DEBUG"
+setup_command: "shell setprop log.tag.SoundTriggerHelper DEBUG"
+setup_command: "shell setprop log.tag.SoundTriggerTest DEBUG"
+setup_command: "shell setprop log.tag.VISHotwordAdapter DEBUG"
+setup_command: "shell setprop log.tag.WearVIS DEBUG"
+setup_command: "shell setprop log.tag.MicroDetectionWorker DEBUG"
+setup_command: "shell setprop log.tag.MicroDetectionWrkImpl DEBUG"
+setup_command: "shell setprop log.tag.MicroDetector DEBUG"
+setup_command: "shell setprop log.tag.MicroRecognitionEngine DEBUG"
+setup_command: "shell setprop log.tag.MicroRecognitionRnrImpl DEBUG"
+setup_command: "logcat -P ''"
+
+tag_to_suppress: "GoogleApiClientConnected"
diff --git a/resources/heyg-us-female.wav b/resources/heyg-us-female.wav
new file mode 100644
index 0000000..e431ed7
--- /dev/null
+++ b/resources/heyg-us-female.wav
Binary files differ
diff --git a/resources/heyg-us-male.wav b/resources/heyg-us-male.wav
new file mode 100644
index 0000000..c190b61
--- /dev/null
+++ b/resources/heyg-us-male.wav
Binary files differ
diff --git a/resources/okg-us-female.wav b/resources/okg-us-female.wav
new file mode 100644
index 0000000..5b3ba0f
--- /dev/null
+++ b/resources/okg-us-female.wav
Binary files differ
diff --git a/resources/okg-us-male.wav b/resources/okg-us-male.wav
new file mode 100644
index 0000000..74c9c1b
--- /dev/null
+++ b/resources/okg-us-male.wav
Binary files differ
diff --git a/resources/stress_test.dsp_monitoring_on_idle.ascii_proto b/resources/stress_test.dsp_monitoring_on_idle.ascii_proto
new file mode 100644
index 0000000..a1d490f
--- /dev/null
+++ b/resources/stress_test.dsp_monitoring_on_idle.ascii_proto
@@ -0,0 +1,29 @@
+description: "Let device in idle and check the DSP wakeup counts"
+             "Stop Testing if DSP detects hotword over 30 times"
+
+step {
+  command: "shell dumpsys battery unplug"
+  delay_after: 1
+}
+step {
+  command: "shell dumpsys deviceidle force-idle"
+  delay_after: 59
+}
+
+expected_result {
+    aohd_hotword_detected : 0
+    assistant_started  : 0
+    dsp_false_accept  : 0
+    speaker_id_rejected : 0
+    logcat_iteration  : 1
+    software_hotword  : 0
+    vis_software_hotword  : 0
+}
+
+event {
+  name: "device_crashed"
+  condition: "aohd_hotword_detected >= 30"
+  action: "BUGREPORT"
+  action: "NOTIFY"
+  action: "REMOVE_DEVICE"
+}
diff --git a/resources/stress_test.dsp_trigger_and_screen_off.ascii_proto b/resources/stress_test.dsp_trigger_and_screen_off.ascii_proto
new file mode 100644
index 0000000..47241fb
--- /dev/null
+++ b/resources/stress_test.dsp_trigger_and_screen_off.ascii_proto
@@ -0,0 +1,43 @@
+description: "Switches between the homescreen and screen off, launching a "
+             "voice search when the DSP should be enabled. The search is "
+             "also just of OK Google."
+
+step {
+  audio_file : "okg-us-female.wav"
+  audio_file_sample_rate : 24000
+  delay_after : 3
+}
+step {
+  command: "shell input keyevent 3"
+  delay_after: 1
+}
+step {
+  command: "shell input keyevent 26"
+  delay_after: 1
+}
+step {
+  command: "shell dumpsys battery unplug"
+  delay_after: 1
+}
+step {
+  command: "shell dumpsys deviceidle force-idle"
+  delay_after: 10
+}
+
+expected_result {
+    aohd_hotword_detected : 1
+    assistant_started  : 1
+    dsp_false_accept  : 0
+    logcat_iteration  : 1
+    software_hotword  : 1
+    speaker_id_rejected  : 0
+    vis_software_hotword  : 1
+}
+
+event {
+  name: "device_crashed"
+  condition: "iterations_since_aohd_hotword_detected >= 30"
+             "or iterations_since_assistant_started >= 30"
+  action: "BUGREPORT"
+  action: "REMOVE_DEVICE"
+}
diff --git a/resources/stress_test.dsp_trigger_on_homescreen.ascii_proto b/resources/stress_test.dsp_trigger_on_homescreen.ascii_proto
new file mode 100644
index 0000000..2fef6be
--- /dev/null
+++ b/resources/stress_test.dsp_trigger_on_homescreen.ascii_proto
@@ -0,0 +1,32 @@
+description: "Stress test DSP hotword on HomeScreen. User must be enrolled for "
+             "DSP hotword"
+
+step {
+  audio_file : "okg-us-female.wav"
+  audio_file_sample_rate : 24000
+  delay_after : 3
+}
+
+step {
+  command: "shell input keyevent 3"
+  delay_after : 3
+}
+
+expected_result {
+    aohd_hotword_detected : 1
+    assistant_started  : 1
+    dsp_false_accept  : 0
+    logcat_iteration  : 1
+    software_hotword  : 1
+    speaker_id_rejected  : 0
+    vis_software_hotword  : 1
+}
+
+event {
+  name: "device_crashed"
+  condition: "iterations_since_aohd_hotword_detected >= 30 "
+             "or iterations_since_assistant_started >= 30"
+  action: "BUGREPORT"
+  action: "NOTIFY"
+  action: "REMOVE_DEVICE"
+}
diff --git a/resources/stress_test.dsp_trigger_sw_rejection.ascii_proto b/resources/stress_test.dsp_trigger_sw_rejection.ascii_proto
new file mode 100644
index 0000000..10a230f
--- /dev/null
+++ b/resources/stress_test.dsp_trigger_sw_rejection.ascii_proto
@@ -0,0 +1,60 @@
+description: "Test that repeats itself"
+
+step {
+  audio_file : "okg-us-female.wav"
+  audio_file_sample_rate : 24000
+  delay_after : 3
+}
+step {
+  command: "shell input keyevent 3"
+  delay_after: 1
+}
+step {
+  command: "shell input keyevent 26"
+  delay_after: 2
+}
+step {
+  command: "shell dumpsys battery unplug"
+  delay_after: 1
+}
+step {
+  command: "shell dumpsys deviceidle force-idle"
+  delay_after: 5
+}
+step {
+  audio_file : "okg-us-male.wav"
+  audio_file_sample_rate : 24000
+  delay_after : 3
+}
+step {
+  command: "shell input keyevent 3"
+  delay_after: 1
+}
+step {
+  command: "shell input keyevent 26"
+  delay_after: 2
+}
+step {
+  command: "shell dumpsys battery unplug"
+  delay_after: 1
+}
+step {
+  command: "shell dumpsys deviceidle force-idle"
+  delay_after: 5
+}
+
+expected_result {
+    aohd_hotword_detected : 2
+    assistant_started  : 1
+    dsp_false_accept  : 0
+    logcat_iteration  : 1
+    software_hotword  : 2
+    speaker_id_rejected  : 1
+    vis_software_hotword  : 2
+}
+
+event {
+  name: "sync_word_not_present"
+  condition: "sync_word_not_present == 1 and iterations_since_sync_word_not_present == 0"
+  action: "NOTIFY"
+}
diff --git a/resources/stress_test.enroll.ascii_proto b/resources/stress_test.enroll.ascii_proto
new file mode 100644
index 0000000..0c7e635
--- /dev/null
+++ b/resources/stress_test.enroll.ascii_proto
@@ -0,0 +1,35 @@
+description: "Simple script that just plays OK Google repeatedly to enroll"
+
+step {
+  audio_file : "okg-us-female.wav"
+  audio_file_sample_rate : 24000
+  delay_after : 2
+}
+
+step {
+  audio_file : "okg-us-female.wav"
+  audio_file_sample_rate : 24000
+  delay_after : 2
+}
+
+step {
+  audio_file : "heyg-us-female.wav"
+  audio_file_sample_rate : 24000
+  delay_after : 2
+}
+
+step {
+  audio_file : "heyg-us-female.wav"
+  audio_file_sample_rate : 24000
+  delay_after : 2
+}
+
+expected_result {
+    aohd_hotword_detected : 0
+    assistant_started  : 0
+    dsp_false_accept  : 0
+    logcat_iteration  : 1
+    software_hotword  : 0
+    speaker_id_rejected  : 0
+    vis_software_hotword  : 0
+}
diff --git a/start_venv.sh b/start_venv.sh
new file mode 100644
index 0000000..cc2af8e
--- /dev/null
+++ b/start_venv.sh
@@ -0,0 +1,19 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+#!/bin/bash
+python -m pip install wheel
+python -m pip install requests
+python -m pip install --upgrade requests
+python -m pip install -r requirements.txt
diff --git a/stress_test.proto b/stress_test.proto
new file mode 100644
index 0000000..3ca2e84
--- /dev/null
+++ b/stress_test.proto
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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.
+ */
+
+syntax = "proto3";
+
+package stress_test;
+
+option optimize_for = CODE_SIZE;
+
+message LoggingEventConfig {
+  // The process name that this event can be found on, or LOGCAT to come from
+  // 'adb logcat'.
+  string source = 1;
+  // The name of the event to trigger.
+  string name = 2;
+  // A list of regexes that can be used to detect the specified event.
+  repeated string regex = 3;
+}
+
+message TimeStampConfig {
+  enum TimeStampType {
+    UNKNOWN = 0;
+    DATE = 1;
+    MONOTONIC = 2;
+  }
+  // If the type is DATE, then we assume it can be parsed by a python datetime.
+  // MONOTONIC sources are from the device's boot time.
+  TimeStampType type = 3;
+  // The regex to extract the string containing the timestamp.
+  string regex = 4;
+  // The format string to pass to the python datetime constructor for the
+  // matched regex.
+  string date_format = 5;
+  // The multiplier used on the parsed monotonic time to convert it to seconds.
+  double monotonic_to_seconds_multiplier = 6;
+}
+
+message FileConfig {
+  // The file that should be cat-ed on device to get information. Will be used
+  // by LoggingEventConfigs.
+  string source = 1;
+  // The filepath to save the output of this file on the local machine. If
+  // blank, will be discarded.
+  string destination = 2;
+  // The information needed to extract timestamps from the config.
+  TimeStampConfig time_stamp = 3;
+  // True if the file spits out a bit of the buffer that it did previously when
+  // reopened (like the logcat does).
+  bool repeats_output_on_open = 4;
+}
+
+message ProcessConfig {
+  // The command to run to produce logging output.
+  string command = 1;
+  // The name of the process to be used in LoggingEventConfigs.
+  string name = 2;
+  // The filepath to save the output of this process on the local machine. If
+  // blank, will be discarded.
+  string destination = 3;
+  // The information needed to extract timestamps from the config.
+  TimeStampConfig time_stamp = 4;
+  // Restart the process if it crashes randomly.
+  bool restart = 5;
+  // True if the file spits out a bit of the buffer that it did previously when
+  // reopened (like the logcat does).
+  bool repeats_output_on_open = 6;
+}
+
+message DeviceConfig {
+  // Commands to run once at the beginning of the test for the device.
+  repeated string setup_command = 1;
+  // Files that should have their contents monitored for events.
+  repeated FileConfig file_to_watch = 2;
+  // Files that should be moved to the local machine after every iteration of
+  // the stress test.
+  repeated FileConfig file_to_move = 3;
+  // All events that can be triggered by this device.
+  repeated LoggingEventConfig event = 5;
+  // Any processes that should be monitored for events.
+  repeated ProcessConfig daemon_process = 6;
+  // Include any other device definitions before processing the contents of this
+  // one. Note that if duplicate entries exist for the repeated elements (based
+  // on the name), only the "latest" in the include chain will be used.
+  repeated string include = 7;
+  // A list of tags that should be squelched from the logcat - useful for super
+  // spammy irrelevant logcat tags.
+  repeated string tag_to_suppress = 8;
+  // Either a TestEventConfig without a condition (but a name) that allows extra
+  // actions to take place when a specific test event occurs, or a standard test
+  // event that will be invoked regardless of which test is running.
+  repeated TestEventConfig test_event = 9;
+}
+
+message TestStep {
+  // Path to raw audio file to play during this test step.
+  string audio_file = 1;
+  // Sample rate of audio file.
+  int32 audio_file_sample_rate = 2;
+  // Number of channels in audio file.
+  int32 audio_file_num_channels = 3;
+  // The sox format of the audio file.
+  string audio_file_format = 4;
+  // Command to run on this test step.
+  string command = 5;
+  // How many seconds to wait before executing the command/playing the sound in
+  // this step.
+  float delay_before = 6;
+  // How many seconds to wait after executing the command/playing the sound in
+  // this step.
+  float delay_after = 7;
+}
+
+message ExpectedResult {
+  int32 aohd_hotword_detected = 1;
+  int32 assistant_started = 2;
+  int32 dsp_false_accept = 3;
+  int32 logcat_iteration = 4;
+  int32 software_hotword = 5;
+  int32 speaker_id_rejected = 6;
+  int32 vis_software_hotword = 7;
+}
+
+message TestEventConfig {
+  // The name of the event. (Used for readability/allowing devices to add custom
+  // actions when an event of a specific name occurs).
+  string name = 1;
+  // An eval condition that should only be true when the actions in this event
+  // should be executed.
+  string condition = 2;
+  // List of actions to take when the condition is satisfied. BUGREPORT, NOTIFY,
+  // REMOVE_DEVICE and ABORT are all special values that take a bugreport, send
+  // an email, remove the device from the test or stops stress testing
+  // respectively.
+  repeated string action = 3;
+}
+
+message StressTestConfig {
+  // Description of what the test does.
+  string description = 1;
+  // List of steps to take in sequence repeatedly to do the stress test.
+  repeated TestStep step = 2;
+  // Events that trigger special actions on the device when the condition is
+  // satisfied.
+  repeated TestEventConfig event = 3;
+  // Commands that should be run at the beginning of the test to setup the
+  // device.
+  repeated string setup_command = 4;
+  // Definition of Expected test result
+  repeated ExpectedResult expected_result = 5;
+}
+
+message EventLogDetails {
+  int32 iteration = 1;
+  string name = 2;
+}
+
+message EventLog {
+  repeated EventLogDetails event = 1;
+}
+
+message DeviceIdentifier {
+  string device_type = 1;
+  string serial_number = 2;
+}
+
+message StressTestInfo {
+  // Metadata info describing the devices involved in the stress test, and a bit
+  // about what test was actually run.
+  string test_name = 1;
+  string test_description = 2;
+  string uuid = 3;
+  repeated DeviceIdentifier device = 4;
+}
diff --git a/stress_test.py b/stress_test.py
new file mode 100644
index 0000000..1e9bfad
--- /dev/null
+++ b/stress_test.py
@@ -0,0 +1,1049 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+"""Stress test utility for repeating actions repeatedly on android devices.
+
+Configures multiple devices to simultaneously run through the same set of
+actions over and over, while keeping logs from various sources. Primarily
+designed for playing audio to the devices and scanning their log output for
+events, while running other adb commands in between.
+"""
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import datetime
+from email import encoders
+from email.mime import text
+import email.mime.base as base
+import email.mime.multipart as multipart
+import logging
+import mimetypes
+import os
+import platform
+import re
+import shlex
+import signal
+import smtplib
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import uuid
+import wave
+from absl import app
+from absl import flags
+import pexpect
+import queue
+import stress_test_common
+import stress_test_pb2
+from google.protobuf import text_format
+
+_SUMMARY_LINES = "-" * 73
+
+if sys.platform.startswith("win"):
+  pexpect = None
+
+_SUMMARY_COLUMNS = (
+    "|        Event Type       |      Event Count     | Consecutive no event |")
+_SUMMARY_COL_FORMATT = "|%-25.25s|% 22d|% 22d|"
+
+FLAGS = flags.FLAGS
+flags.DEFINE_string("notification_address", "",
+                    "Email address where to send notification events. Will "
+                    "default to $USER@google.com if not provided. No emails "
+                    "will be sent if suppress_notification_emails is True.")
+flags.DEFINE_bool("suppress_notification_emails", False,
+                  "Prevents emails from being sent as notifications if True.")
+flags.DEFINE_string("test_name", None,
+                    "Name of stress test to run. For example, if you set this "
+                    "to 'dsp_trigger_sw_rejection', the stress test in "
+                    "'stress_test.dsp_trigger_sw_rejection.ascii_proto' will "
+                    "be loaded and executed.")
+# flags.mark_flag_as_required("test_name")
+flags.DEFINE_string("output_root", "./",
+                    "Path where directory should be generated containing all "
+                    "logs from devices and moved files.")
+flags.DEFINE_integer("num_iterations", None,
+                     "If set to a positive number, the number of iterations of "
+                     "the stress test to run. Otherwise, the test runs "
+                     "forever.")
+flags.DEFINE_list("devices", [],
+                  "Serial numbers of devices that should be included in the "
+                  "stress test. If empty, all devices will be used.")
+flags.DEFINE_integer("print_summary_every_n", 10,
+                     "Prints the summary to the log file every n iterations.")
+
+flags.DEFINE_string("email_sender_address", "",
+                    "Account to use for sending notification emails.")
+flags.DEFINE_string("email_sender_password", "",
+                    "Password to use for notification email account.")
+flags.DEFINE_string("email_smtp_server", "smtp.gmail.com",
+                    "SMTP server to use for sending notification emails.")
+flags.DEFINE_integer("email_smtp_port", 465,
+                     "Port to use for the notification SMTP server.")
+flags.DEFINE_integer("device_settle_time", 5,
+                     "Time to wait for devices to settle.")
+flags.DEFINE_bool("use_sox", platform.system() != "Windows",
+                  "Use sox for playback, otherwise, attempt to use platform "
+                  "specific features.")
+flags.DEFINE_bool("attach_bugreport", True,
+                  "Attach bugreport to email if test failed.")
+flags.DEFINE_bool("delete_data_dir", False,
+                  "If true, code will delete all the files generated by this "
+                  "test at the end.")
+
+if platform.system().startswith("CYGWIN"):
+  FLAGS.device_settle_time = 30
+
+
+def QueueWorker(worker_queue):
+  while True:
+    work = worker_queue.get()
+    try:
+      work()
+    except:  # pylint:disable=bare-except
+      logging.exception("Exception in worker queue - task remains uncompleted.")
+    worker_queue.task_done()
+
+
+def SendNotificationEmail(subject, body, bugreport=None):
+  """Sends an email with the specified subject and body.
+
+     Also attach bugreport if bugreport location is provided as argument
+
+  Args:
+    subject: Subject of the email.
+    body: Body of the email.
+    bugreport: If provided, it will be attach to the email.
+  """
+  if FLAGS.suppress_notification_emails:
+    logging.info("Email with subject '%s' has been suppressed", subject)
+    return
+  try:
+    # Assemble the message to send.
+    recpient_address = FLAGS.notification_address
+    message = multipart.MIMEMultipart("alternative")
+    message["From"] = "Stress Test on %s" % socket.gethostname()
+    message["To"] = recpient_address
+    message["Subject"] = subject
+    message.attach(text.MIMEText(body, "plain"))
+    message.attach(text.MIMEText("<pre>%s</pre>" % body, "html"))
+
+    if FLAGS.attach_bugreport and bugreport:
+      # buildozer: disable=unused-variable
+      ctype, _ = mimetypes.guess_type(bugreport)
+      maintype, subtype = ctype.split("/", 1)
+      with open(bugreport, "rb") as fp:
+        att = base.MIMEBase(maintype, subtype)
+        att.set_payload(fp.read())
+        encoders.encode_base64(att)
+        att.add_header("Content-Disposition", "attachment", filename=bugreport)
+        message.attach(att)
+
+    # Send the message from our special account.
+    server = smtplib.SMTP_SSL(FLAGS.email_smtp_server, FLAGS.email_smtp_port)
+    server.login(FLAGS.email_sender_address, FLAGS.email_sender_password)
+    server.sendmail(FLAGS.email_sender_address, recpient_address,
+                    message.as_string())
+    server.quit()
+    logging.info("Email with subject '%s' has been sent", subject)
+  except:  # pylint:disable=bare-except
+    logging.exception("Failed to send notification email")
+
+
+class ProcessLogger(threading.Thread):
+
+  class EventScanner(object):
+
+    def __init__(self, name, process_name, regexes):
+      """Struct to store the data about an event.
+
+      Args:
+        name: Name of event.
+        process_name: Name of the process that is being logged.
+        regexes: An iteratable of regex strings that indicate an event has
+            happened.
+      """
+
+      self.name = name
+      self.process_name = process_name
+      self.searches = [re.compile(regex).search for regex in regexes]
+      self.count = 0
+
+    def ScanForEvent(self, line, lock=None):
+      """Checks the line for matches. If found, updates the internal counter."""
+
+      for search in self.searches:
+        if search(line.decode("utf-8")):
+          # Grab the lock (if provided), update the counter, and release it.
+          if lock: lock.acquire()
+          self.count += 1
+          if lock: lock.release()
+          logging.info("Event '%s' detected on %s", self.name,
+                       self.process_name)
+
+  def __init__(self, name, command, output, events,
+               restart_process, repeats_output_when_opened):
+    """Threaded class that monitors processes for events, and logs output.
+
+    Args:
+      name: The name of the process being logged.
+      command: A list of arguments to be passed to the subprocess to execute.
+      output: Name of output file to write process stdout to. If blank or None,
+          will not be generated.
+      events: An iterable of LoggingEventConfigs to look for in the output.
+      restart_process: Restart the process if it terminates by itself. This
+          should typically be true, but false for processes that only should be
+          run once and have their output logged.
+      repeats_output_when_opened: Set to true if the process will repeat the
+          output of a previous call when it is restarted. This will prevent
+          duplicate lines from being logged.
+    """
+    super(ProcessLogger, self).__init__()
+    self.name = name
+    self.command = command
+    self.restart_process = restart_process
+    self.repeats_output_when_opened = repeats_output_when_opened
+    self.process = None
+    self.lock = threading.Lock()
+    self.looking = False
+
+    # Compile the list of regexes that we're supposed to be looking for.
+    self.events = []
+    for event in events:
+      self.events.append(ProcessLogger.EventScanner(event.name, self.name,
+                                                    event.regex))
+
+    if output:
+      stress_test_common.MakeDirsIfNeeded(os.path.dirname(output))
+      self.output_fp = open(output, "w")
+      logging.info("Logging device info to %s", output)
+    else:
+      self.output_fp = None
+
+  def GetEventCountsSinceLastCall(self):
+    """Returns the counts of all events since this method was last called."""
+    event_map = {}
+    self.lock.acquire()
+    for event in self.events:
+      event_map[event.name] = event.count
+      event.count = 0
+    self.lock.release()
+    return event_map
+
+  def run(self):
+    last_line = None
+    should_log = True
+    first_run = True
+    self.lock.acquire()
+    last_run_time = 0
+    while self.restart_process:
+      self.lock.release()
+      if not first_run:
+        logging.info("Restarting process %s", "".join(str(self.command)))
+        time_since_last_run = datetime.datetime.now() - last_run_time
+        if time_since_last_run.total_seconds() < 1.0:
+          needed_delay = 1.0 - time_since_last_run.total_seconds()
+          logging.info("Delaying for %.2f seconds", needed_delay)
+          time.sleep(needed_delay)
+      else:
+        first_run = False
+
+      try:
+        if pexpect:
+          self.process = pexpect.spawn(" ".join(self.command), timeout=None)
+          output_source = self.process
+        else:
+          self.process = subprocess.Popen(self.command, stdout=subprocess.PIPE)
+          output_source = self.process.stdout
+        last_run_time = datetime.datetime.now()
+        for line in output_source:
+          # If the process we're logging likes to repeat its output, we need to
+          # look for the last line we saw before we start doing anything with
+          # these lines anymore.
+          if self.repeats_output_when_opened:
+            if not should_log:
+              if last_line == line:
+                should_log = True
+              continue
+
+          if self.output_fp:
+            self.output_fp.write(line.decode("utf-8").rstrip())
+            self.output_fp.write("\n")
+
+          # Loop through all events we're watching for, to see if they occur on
+          # this line. If they do, update the fact that we've seen this event.
+          for event in self.events:
+            if self.looking:
+              event.ScanForEvent(line, lock=self.lock)
+          last_line = line
+      except:  # pylint:disable=bare-except
+        logging.exception("Exception encountered running process")
+      finally:
+        if pexpect:
+          self.process.terminate()
+        else:
+          self.process.send_signal(signal.SIGTERM)
+        should_log = False
+      self.lock.acquire()
+    self.lock.release()
+    if pexpect:
+      if self.process.exitstatus is not None:
+        logging.info("Process finished - exit code %d", self.process.exitstatus)
+      else:
+        logging.info("Process finished - signal code %d",
+                     self.process.signalstatus)
+    else:
+      if self.process.returncode is not None:
+        logging.info("Process finished - return code %d",
+                     self.process.returncode)
+      else:
+        logging.info("Process finished - no return code")
+
+  def StopLogging(self):
+    if self.process:
+      self.lock.acquire()
+      self.restart_process = False
+      self.lock.release()
+
+      if pexpect:
+        self.process.kill(signal.SIGHUP)
+        self.process.kill(signal.SIGINT)
+      else:
+        self.process.send_signal(signal.SIGTERM)
+
+
+class Device(object):
+
+  SECONDS_TO_SLEEP_DURING_ROOT = 0.5
+
+  def __init__(self, serial_number, output_root, test_events, expected_result):
+    """Responsible for monitoring a specific device, and pulling files from it.
+
+    The actual work of the constructor will be handled asynchronously, you must
+    call WaitForTasks() before using the device.
+
+    Args:
+      serial_number: The device serial number.
+      output_root: The directory where to output log files/anything pulled from
+          the device.
+      test_events: The events (with conditions) that come from the StressTest
+          that should be evaluated at every iteration, along with a list of
+          actions to take when one of these events occur. For example, if there
+          have not been any detected hotword triggers, a bugreport can be
+          generated.
+      expected_result: Expected event count to pass the test.
+    """
+    self.serial_number = serial_number
+    self.output_root = output_root
+    self.cmd_string_replacements = {}
+    self.iteration = 0
+    self.cmd_string_replacements["iteration"] = 0
+    self.cmd_string_replacements["serial_number"] = serial_number
+    self.cmd_string_replacements["output_root"] = output_root
+    self.name = None
+    self.process_loggers = []
+    self.event_log = stress_test_pb2.EventLog()
+    self.cnt_per_iteration = expected_result
+
+    # Prepare the work queue, and offload the rest of the init into it.
+    self.work_queue = queue.Queue()
+    self.worker = threading.Thread(target=QueueWorker, args=[self.work_queue])
+    self.worker.daemon = True
+    self.worker.name = self.name
+    self.worker.start()
+    self.abort_requested = False
+    self.remove_device = False
+    self.test_events = test_events
+
+    self.work_queue.put(self.__init_async__)
+
+  def __init_async__(self):
+    # Get the device type, and append it to the serial number.
+    self.device_type = self.Command(["shell", "getprop",
+                                     "ro.product.name"]).strip().decode("utf-8")
+    self.name = "%s_%s" % (self.device_type, self.serial_number)
+    self.worker.name = self.name
+    self.cmd_string_replacements["device"] = self.name
+    logging.info("Setting up device %s", self.name)
+
+    config = stress_test_common.LoadDeviceConfig(self.device_type,
+                                                 self.serial_number)
+
+    # Get the device ready.
+    self.Root()
+
+    # Run any setup commands.
+    for cmd in config.setup_command:
+      result = self.Command(
+          shlex.split(cmd % self.cmd_string_replacements)).strip()
+      if result:
+        for line in result.splitlines():
+          logging.info(line)
+
+    self.files_to_move = config.file_to_move
+
+    self.event_names = set([event.name for event in config.event])
+    self.event_counter = {name: 0 for name in self.event_names}
+    self.iterations_since_event = {name: 0 for name in self.event_names}
+
+    for file_to_watch in config.file_to_watch:
+      # Are there any events that match up with this file?
+      events = [x for x in config.event if x.source == file_to_watch.source]
+
+      if file_to_watch.source == "LOGCAT":
+        command = [
+            "adb", "-s", self.serial_number, "logcat", "-v", "usec", ""
+        ]
+        command.extend(["%s:S" % tag for tag in config.tag_to_suppress])
+        name = "logcat_" + self.serial_number
+      else:
+        command = [
+            "adb", "-s", self.serial_number, "shell",
+            "while : ; do cat %s 2>&1; done" % file_to_watch.source
+        ]
+        name = "%s_%s" % (os.path.basename(
+            file_to_watch.source), self.serial_number)
+
+      process_logger = ProcessLogger(
+          name, command, os.path.join(
+              self.output_root,
+              file_to_watch.destination % self.cmd_string_replacements),
+          events, True, file_to_watch.repeats_output_on_open)
+      self.process_loggers.append(process_logger)
+      process_logger.start()
+
+    # Add any of the background processes.
+    for daemon_process in config.daemon_process:
+      # Are there any events that match up with this file?
+      events = [x for x in config.event if x.source == daemon_process.name]
+      command = shlex.split(
+          daemon_process.command % self.cmd_string_replacements)
+      if daemon_process.destination:
+        output = os.path.join(
+            self.output_root,
+            daemon_process.destination % self.cmd_string_replacements)
+      else:
+        output = None
+      name = "%s_%s" % (daemon_process.name, self.serial_number)
+      process_logger = ProcessLogger(name, command, output, events,
+                                     daemon_process.restart,
+                                     daemon_process.repeats_output_on_open)
+      self.process_loggers.append(process_logger)
+      process_logger.start()
+
+    # Build up the list of events we can actually process.
+    self.__UpdateEventCounters(number_of_iterations=0)
+    test_events = self.test_events
+    self.test_events = []
+    for event in test_events:
+      try:
+        eval(event.condition,  # pylint:disable=eval-used
+             {"__builtins__": None}, self.__ValuesInEval())
+        self.test_events.append(event)
+      except Exception as err:  # pylint:disable=broad-except
+        logging.error("Test event %s is not compatible with %s", event.name,
+                      self.name)
+        logging.error(str(err))
+    # Make sure that device specific events don't have conditions.
+    self.device_events = []
+    for event in config.test_event:
+      if not event.name:
+        logging.error("Device %s test event is missing a name", self.name)
+        continue
+      if event.condition:
+        self.test_events.append(event)
+      else:
+        self.device_events.append(event)
+
+  def StartLookingForEvents(self):
+    """Starts all child ProcessLoggers to start looking for events."""
+    for process_logger in self.process_loggers:
+      process_logger.looking = True
+
+  def __ValuesInEval(self):
+    values_in_eval = {key: value for key, value
+                      in list(self.event_counter.items())}
+    for key, value in list(self.iterations_since_event.items()):
+      values_in_eval["iterations_since_%s" % key] = value
+    return values_in_eval
+
+  def __GetExpectedEventCount(self, event):
+    if event == "logcat_iteration":
+      return -1
+    try:
+      event_cnt = getattr(self.cnt_per_iteration, event)
+    except AttributeError:
+      event_cnt = -1
+      logging.exception("%s is not an attribute of expected_result", event)
+    return event_cnt
+
+  def __UpdateEventCounters(self, number_of_iterations=1):
+    # Update the event counters
+    visited_events = set()
+    error_log = []
+    for process_logger in self.process_loggers:
+      events = process_logger.GetEventCountsSinceLastCall()
+      for event, count in list(events.items()):
+        # Print log when there is any missed event
+        expected_count = self.__GetExpectedEventCount(event)
+
+        if expected_count > 0:
+          if count > expected_count * number_of_iterations:
+            logging.info(
+                "[STRESS_TEST] In iteration %d, got duplicated %s : %d",
+                self.iteration, self.name, count)
+            logging.info("[STRESS_TEST] Will count only : %d",
+                         expected_count * number_of_iterations)
+            count = expected_count * number_of_iterations
+
+        if count:
+          self.event_counter[event] += count
+          visited_events.add(event)
+
+        if expected_count >= 0:
+          if expected_count * number_of_iterations != count:
+            error_log.append(
+                _SUMMARY_COL_FORMATT %
+                (event, count, expected_count * number_of_iterations))
+
+    # Go clear all the events that weren't consecutive.
+    for event in self.iterations_since_event:
+      if event in visited_events:
+        self.iterations_since_event[event] = 0
+      else:
+        self.iterations_since_event[event] += number_of_iterations
+
+    if error_log:
+      logging.info(_SUMMARY_LINES)
+      logging.info(" iteration %d : Something wrong in %s.",
+                   self.iteration, self.name)
+      logging.info(_SUMMARY_LINES)
+      logging.info(_SUMMARY_COLUMNS)
+      logging.info(_SUMMARY_LINES)
+      for line in error_log:
+        logging.info(line)
+      logging.info(_SUMMARY_LINES)
+
+  def ProcessEvents(self):
+    """Updates the event_counter and iterations_since_event maps."""
+    self.work_queue.put(self.__ProcessEventsAsync)
+
+  def __ProcessEventsAsync(self):
+    # Move any files to the local machine that should be moved.
+    if self.files_to_move:
+      for file_to_move in self.files_to_move:
+        try:
+          self.Command(["pull", file_to_move.source, file_to_move.destination])
+        except:  # pylint:disable=bare-except
+          logging.exception("Failed to pull %s", file_to_move.source)
+
+    self.__UpdateEventCounters()
+
+    for event in self.test_events:
+      if eval(event.condition,  # pylint:disable=eval-used
+              {"__builtins__": None}, self.__ValuesInEval()):
+        logging.info("Condition has been met for event '%s'", event.name)
+        # Write the updated event log.
+        event_log_details = self.event_log.event.add()
+        event_log_details.iteration = self.iteration
+        event_log_details.name = event.name
+        with open(os.path.join(self.output_root,
+                               "%s_event_log.ascii_proto" % self.name),
+                  "w") as fp:
+          text_format.PrintMessage(self.event_log, fp)
+
+        # Do whatever other actions that are part of the event.
+        self.__ProcessEventActionQueue(event)
+
+        # Run any device specific actions for this event.
+        for device_event in self.device_events:
+          if device_event.name == event.name:
+            self.__ProcessEventActionQueue(device_event)
+
+    # Set up the next iteration.
+    self.iteration += 1
+    self.cmd_string_replacements["iteration"] = self.iteration
+
+  def __ProcessEventActionQueue(self, event):
+    bugreport = None
+    for action in event.action:
+      if action == "BUGREPORT":
+        bugreport = self.TakeBugReport()
+      elif action.startswith("DUMPSYS "):
+        self.CaptureDumpsys(action[action.find(" ") + 1:])
+      elif action == "NOTIFY":
+        SendNotificationEmail(
+            "%s had event '%s' occur" % (self.name, event.name),
+            "\n".join(["Current Summary:"] + self.GetSummaryLines()), bugreport)
+      elif action == "REMOVE_DEVICE":
+        logging.info("Removing %s from the test", self.serial_number)
+        self.remove_device = True
+      elif action == "ABORT":
+        logging.info("Abort requested")
+        self.abort_requested = True
+      else:
+        action %= self.cmd_string_replacements
+        logging.info("Running command %s on %s", action, self.name)
+        result = self.Command(shlex.split(action)).strip()
+        if result:
+          for line in result.splitlines():
+            logging.info(line)
+
+  def Root(self):
+    self.Command(["root"])
+    time.sleep(Device.SECONDS_TO_SLEEP_DURING_ROOT)
+    self.Command(["wait-for-device"])
+    time.sleep(Device.SECONDS_TO_SLEEP_DURING_ROOT)
+
+  def Stop(self):
+    """Stops all file loggers attached to this device."""
+    for process_logger in self.process_loggers:
+      process_logger.StopLogging()
+    self.process_loggers = []
+
+  def Join(self):
+    for process_logger in self.process_loggers:
+      process_logger.join()
+    self.WaitForTasks()
+
+  def AsyncCommand(self, command, log_output=False):
+    self.work_queue.put(
+        lambda: self.__AsyncCommand(command, log_output=log_output))
+
+  def __AsyncCommand(self, command, log_output=False):
+    result = self.Command(command).strip()
+    if result and log_output:
+      for line in result.splitlines():
+        logging.info(line.decode("utf-8"))
+
+  def Command(self, command):
+    """Runs the provided command on this device."""
+    if command[0] in {"bugreport", "root", "wait-for-device", "shell",
+                      "logcat"}:
+      return subprocess.check_output(
+          ["adb", "-s", self.serial_number] + command)
+    elif command[0] == "DUMPSYS":
+      self.CaptureDumpsys(command[1])
+      return ""
+    elif command[0] == "pull":
+      try:
+        files = subprocess.check_output(
+            ["adb", "-s", self.serial_number, "shell", "ls", command[1]]
+        ).strip().splitlines()
+      except subprocess.CalledProcessError:
+        return ""
+      if len(files) == 1 and "No such file or directory" in files[0]:
+        return ""
+      for source_file in files:
+        destination = os.path.join(self.output_root,
+                                   command[2] % self.cmd_string_replacements)
+        stress_test_common.MakeDirsIfNeeded(os.path.dirname(destination))
+        logging.info("Moving %s from %s to %s", source_file, self.name,
+                     destination)
+        subprocess.check_output(["adb", "-s", self.serial_number, "pull",
+                                 source_file, destination])
+        if FLAGS.delete_data_dir:
+          subprocess.check_output([
+              "adb", "-s", self.serial_number, "shell", "rm", "-rf", source_file
+          ])
+        return ""
+    else:
+      return subprocess.check_output(command)
+
+  def TakeBugReport(self):
+    logging.info("Capturing bugreport on %s", self.name)
+    bugreport = os.path.join(self.output_root,
+                             "%s_bugreport_iteration_%06d.zip" %
+                             (self.name, self.iteration))
+    sdk = int(self.Command(
+        ["shell", "getprop", "ro.build.version.sdk"]).strip())
+    if sdk >= 24:  # SDK 24 = Android N
+      with open(bugreport, "w") as bugreport_fp:
+        bugreport_fp.write(self.Command(["bugreport", bugreport]))
+    else:
+      bugreport_txt = os.path.join(self.output_root,
+                                   "%s_bugreport_iteration_%06d.txt" %
+                                   (self.name, self.iteration))
+      with open(bugreport_txt, "w") as bugreport_fp:
+        bugreport_fp.write(self.Command(["bugreport"]))
+      self.Command(["zip", bugreport, bugreport_txt])
+
+    self.Command(["pull", "/data/anr/traces.txt",
+                  "%s_traces_iteration_%06d.txt" % (self.name, self.iteration)])
+    self.Command(["pull", "/data/anr/traces.txt.bugreport",
+                  "%s_traces_iteration_%06d.txt.bugreport" % (self.name,
+                                                              self.iteration)])
+    return bugreport
+
+  def CaptureDumpsys(self, dumpsys_unit):
+    logging.info("Taking dumpsys %s on %s", dumpsys_unit, self.name)
+    stress_test_common.MakeDirsIfNeeded(os.path.join(self.output_root,
+                                                     self.name))
+    with open(os.path.join(self.output_root, self.name,
+                           "%s_%06d.txt" % (dumpsys_unit, self.iteration)),
+              "w") as dumpsys_fp:
+      dumpsys_fp.write(self.Command(["shell", "dumpsys", dumpsys_unit]))
+
+  def WaitForTasks(self):
+    self.work_queue.join()
+
+  def GetSummaryLines(self):
+    lines = [
+        "Device {}".format(self.name),
+        _SUMMARY_LINES, _SUMMARY_COLUMNS, _SUMMARY_LINES
+    ]
+    for event, count in sorted(self.event_counter.items()):
+      lines.append(_SUMMARY_COL_FORMATT % (
+          event, count, self.iterations_since_event[event]))
+    lines.append(_SUMMARY_LINES)
+    return lines
+
+
+def RunAsyncCommand(devices, command):
+  """Helper function for running async commands on many devices."""
+  for device in devices:
+    device.AsyncCommand(command)
+  for device in devices:
+    device.WaitForTasks()
+
+
+class StressTest(object):
+  """Manages dispatching commands to devices/playing audio and events."""
+
+  def __init__(self, output_root, test_name):
+    self.output_root = output_root
+    self.devices = []
+    self.test_name = test_name
+    config = stress_test_pb2.StressTestConfig()
+    config_contents = stress_test_common.GetResourceContents(
+        os.path.join(stress_test_common.RESOURCE_DIR,
+                     "stress_test.%s.ascii_proto" % test_name))
+    text_format.Merge(config_contents, config)
+    self.events = config.event
+    self.setup_commands = config.setup_command
+    self.steps = config.step
+    self.audio_tempfiles = {}
+    self.uuid = str(uuid.uuid4())
+    self.expected_result = None
+    self.iteration = 0
+    if config.expected_result:
+      self.expected_result = config.expected_result[0]
+
+    # Place all the audio files into temp files.
+    for step in self.steps:
+      if step.audio_file and step.audio_file not in self.audio_tempfiles:
+        # We can't delete the temp file on windows, since it gets nuked too
+        # early.
+        audio_tempfile = tempfile.NamedTemporaryFile(
+            delete=(platform.system() != "Windows"),
+            dir="." if platform.system().startswith("CYGWIN") else None
+        )
+        if platform.system().startswith("CYGWIN"):
+          audio_tempfile.name = os.path.basename(audio_tempfile.name)
+        self.audio_tempfiles[step.audio_file] = audio_tempfile
+        if FLAGS.use_sox:
+          # Write out the raw PCM samples as a wave file.
+          audio_tempfile.write(
+              stress_test_common.GetResourceContents(step.audio_file))
+        else:
+          # Make a temporary wave file for playout if we can't use sox.
+          wavefile = wave.open(audio_tempfile, "wb")
+          if step.audio_file_sample_rate <= 0:
+            step.audio_file_sample_rate = 16000
+          wavefile.setframerate(step.audio_file_sample_rate)
+          if step.audio_file_num_channels <= 0:
+            step.audio_file_num_channels = 1
+          wavefile.setnchannels(step.audio_file_num_channels)
+          if not step.audio_file_format:
+            wavefile.setsampwidth(2)
+          elif step.audio_file_format == "s8":
+            wavefile.setsampwidth(1)
+          elif step.audio_file_format == "s16":
+            wavefile.setsampwidth(2)
+          elif step.audio_file_format == "s32":
+            wavefile.setsampwidth(4)
+          else:
+            raise RuntimeError(
+                "Unsupported wave file format for %s" % step.audio_file)
+          wavefile.writeframes(stress_test_common.GetResourceContents(
+              step.audio_file))
+          wavefile.close()
+        audio_tempfile.flush()
+
+        if platform.system() == "Windows":
+          audio_tempfile.close()
+
+    # Create all the devices that are attached to this machine.
+    for serial_number in self.GetActiveSerialNumbers():
+      self.devices.append(
+          Device(serial_number, output_root, self.events, self.expected_result))
+    if not self.devices:
+      raise app.UsageError("No devices connected")
+
+    self.devices.sort(key=lambda x: x.name)
+
+    # Make sure every device is done with their work for setup.
+    for device in self.devices:
+      device.WaitForTasks()
+
+    # Write out the info meta-data proto. Useful for doing analysis of the logs
+    # after the stress test has completed.
+    stress_test_info = stress_test_pb2.StressTestInfo()
+    stress_test_info.test_name = self.test_name
+    stress_test_info.test_description = config.description
+    stress_test_info.uuid = self.uuid
+    for device in self.devices:
+      device_pb = stress_test_info.device.add()
+      device_pb.device_type = device.device_type
+      device_pb.serial_number = device.serial_number
+
+    text_format.PrintMessage(stress_test_info, open(os.path.join(
+        self.output_root, "stress_test_info.ascii_proto"), "w"))
+
+  def GetActiveSerialNumbers(self):
+    serial_numbers = []
+    for line in sorted(
+        subprocess.check_output(["adb", "devices"]).splitlines()):
+      if line.endswith(b"device"):
+        serial_number = line.split()[0].strip()
+        if FLAGS.devices and serial_number not in FLAGS.devices:
+          continue
+        serial_numbers.append(serial_number.decode("utf-8"))
+    return serial_numbers
+
+  def Start(self):
+    logging.info("Waiting for devices to settle")
+    time.sleep(5)
+    # Make a copy of the device list, as we'll be modifying this actual list.
+    devices = list(self.devices)
+    dropped_devices = []
+
+    # If we have any setup commands, run them.
+    for command in self.setup_commands:
+      logging.info("Running command %s", command)
+      # Can't use the async command helper function since we need to get at
+      # the device cmd_string_replacements.
+      for device in devices:
+        device.AsyncCommand(
+            shlex.split(command % device.cmd_string_replacements),
+            log_output=True)
+      for device in devices:
+        device.WaitForTasks()
+
+    for device in devices:
+      device.StartLookingForEvents()
+      device.AsyncCommand(["shell", "log", "-t", "STRESS_TEST",
+                           "Starting {%s} TZ=$(getprop persist.sys.timezone) "
+                           "YEAR=$(date +%%Y)" % self.uuid], True)
+    self.iteration = 0
+    while True:
+      logging.info("Starting iteration %d", self.iteration)
+      # Perform all the actions specified in the test.
+      RunAsyncCommand(devices, [
+          "shell", "log", "-t", "STRESS_TEST",
+          "Performing iteration %d $(head -n 3 "
+          "/proc/timer_list | tail -n 1)" % self.iteration
+      ])
+
+      for step in self.steps:
+        if step.delay_before:
+          logging.info("Waiting for %.2f seconds", step.delay_before)
+          time.sleep(step.delay_before)
+
+        if step.audio_file:
+          logging.info("Playing %s", step.audio_file)
+          RunAsyncCommand(devices, ["shell", "log", "-t", "STRESS_TEST",
+                                    "Playing %s" % step.audio_file])
+
+          if FLAGS.use_sox:
+            subprocess.check_call(["sox", "-q",
+                                   self.audio_tempfiles[step.audio_file].name,
+                                   "-d"])
+          elif platform.system() == "Windows":
+            import winsound  # pylint:disable=g-import-not-at-top
+            winsound.PlaySound(self.audio_tempfiles[step.audio_file].name,
+                               winsound.SND_FILENAME | winsound.SND_NODEFAULT)
+          else:
+            raise app.RuntimeError("Unsupported platform for audio playback")
+
+        if step.command:
+          logging.info("Running command %s", step.command)
+          # Can't use the async command helper function since we need to get at
+          # the device cmd_string_replacements.
+          for device in devices:
+            device.AsyncCommand(
+                shlex.split(step.command % device.cmd_string_replacements),
+                log_output=True)
+          for device in devices:
+            device.WaitForTasks()
+
+        if step.delay_after:
+          logging.info("Waiting for %.2f seconds", step.delay_after)
+          time.sleep(step.delay_after)
+
+      RunAsyncCommand(devices, [
+          "shell", "log", "-t", "STRESS_TEST",
+          "Iteration %d complete $(head -n 3 "
+          "/proc/timer_list | tail -n 1)" % self.iteration
+      ])
+      self.iteration += 1
+
+      # TODO(somebody): Sometimes the logcat seems to get stuck and buffers for
+      # a bit. This throws off the event counts, so we should probably add some
+      # synchronization rules before we trigger any events.
+
+      # Go through each device, update the event counter, and see if we need to
+      # trigger any events.
+      devices_to_remove = []
+      abort_requested = False
+      active_devices = self.GetActiveSerialNumbers()
+      for device in devices:
+        if device.serial_number in active_devices:
+          device.ProcessEvents()
+        else:
+          logging.error("Dropped device %s", device.name)
+          SendNotificationEmail(
+              "Dropped device %s" % device.name,
+              "Device %s is not longer present in the system" % device.name)
+          dropped_devices.append(device)
+          devices_to_remove.append(device)
+
+      # Check to see if any of the dropped devices have come back. If yes, grab
+      # a bug report.
+      for device in dropped_devices:
+        if device.serial_number in active_devices:
+          logging.info("Device %s reappeared", device.name)
+          device.Root()
+          device.TakeBugReport()
+
+      dropped_devices = [d for d in dropped_devices
+                         if d.serial_number not in active_devices]
+
+      for device in devices:
+        device.WaitForTasks()
+        if device.remove_device:
+          devices_to_remove.append(device)
+        if device.abort_requested:
+          abort_requested = True
+
+      # Remove devices from our list of things to monitor if they've been marked
+      # for deletion.
+      if devices_to_remove:
+        for device in devices_to_remove:
+          device.Stop()
+        devices = [d for d in devices if d not in devices_to_remove]
+
+      # Print out the iteration summary.
+      if self.iteration % FLAGS.print_summary_every_n == 0:
+        for line in self.GetSummaryLines():
+          logging.info(line)
+
+      # See if we need to break out of the outer loop.
+      if abort_requested or not devices:
+        break
+      if FLAGS.num_iterations:
+        if self.iteration >= FLAGS.num_iterations:
+          logging.info("Completed full iteration : %d", self.iteration)
+          break
+    SendNotificationEmail(
+        "Stress test %s completed" % (FLAGS.test_name),
+        "\n".join(["Summary:"] + self.GetSummaryLines()))
+
+  def Stop(self):
+    logging.debug("Stopping devices")
+    for device in self.devices:
+      device.Stop()
+    for device in self.devices:
+      device.Join()
+
+  def GetSummaryLines(self):
+    lines = [
+        _SUMMARY_LINES,
+        "Conducted %d iterations out of %d" %
+        (self.iteration, FLAGS.num_iterations),
+        _SUMMARY_LINES
+    ]
+    for device in self.devices:
+      lines.extend(device.GetSummaryLines())
+    lines.append(_SUMMARY_LINES)
+    return lines
+
+
+def main(unused_argv):
+  # Check to make sure that there are no other instances of ADB running - if
+  # there are, print a warning and wait a bit for them to see it and decide if
+  # they want to keep running, knowing that logs may be invalid.
+  try:
+    if "adb" in subprocess.check_output(["ps", "-ale"]).decode("utf-8"):
+      print("It looks like there are other instances of adb running. If these "
+            "other instances are also cating log files, you will not be "
+            "capturing everything in this stress test (so logs will be "
+            "invalid).")
+      print("Continuing in 3...", end=" ")
+      sys.stdout.flush()
+      for i in [2, 1, 0]:
+        time.sleep(1)
+        if i:
+          print("%d..." % i, end=" ")
+        else:
+          print("")
+        sys.stdout.flush()
+  except OSError:
+    print("Unexpected error:", sys.exc_info()[0])
+    if sys.platform.startswith("win"):
+      pass
+    else:
+      raise
+
+  # Make the base output directory.
+  output_root = os.path.join(FLAGS.output_root, "%s_%s" % (
+      FLAGS.test_name, datetime.datetime.now().strftime("%Y%m%d_%H%M%S")))
+  # output_root = os.path.join(FLAGS.output_root, FLAGS.test_name)
+  stress_test_common.MakeDirsIfNeeded(output_root)
+
+  # Set up logging.
+  formatter = logging.Formatter(
+      "%(levelname)-1.1s %(asctime)s [%(threadName)-16.16s] %(message)s")
+  root_logger = logging.getLogger()
+  root_logger.setLevel(logging.INFO)
+  root_logger.setLevel(logging.DEBUG)
+
+  file_handler = logging.FileHandler(os.path.join(output_root,
+                                                  "stress_test.log"))
+  file_handler.setFormatter(formatter)
+  root_logger.addHandler(file_handler)
+
+  console_handler = logging.StreamHandler()
+  console_handler.setFormatter(formatter)
+  root_logger.addHandler(console_handler)
+
+  stress_test = StressTest(output_root, FLAGS.test_name)
+  try:
+    stress_test.Start()
+  finally:
+    logging.info("Stopping device logging threads")
+    stress_test.Stop()
+    for line in stress_test.GetSummaryLines():
+      logging.info(line)
+    if FLAGS.delete_data_dir:
+      print("Deleting Data Dir")
+      subprocess.check_output(["rm", "-r", "-f", output_root])
+
+
+if __name__ == "__main__":
+  app.run(main)
diff --git a/stress_test_common.py b/stress_test_common.py
new file mode 100644
index 0000000..6bc7baa
--- /dev/null
+++ b/stress_test_common.py
@@ -0,0 +1,104 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+"""Common functions for the stress tester."""
+
+import logging
+import os
+
+from absl import flags
+import stress_test_pb2
+from google.protobuf import text_format
+
+FLAGS = flags.FLAGS
+
+flags.DEFINE_string("resource_path", None,
+                    "Optional override path where to grab resources from. By "
+                    "default, resources are grabbed from "
+                    "stress_test_common.RESOURCE_DIR, specifying this flag "
+                    "will instead result in first looking in this path before "
+                    "the module defined resource directory.")
+
+RESOURCE_DIR = "resources/"
+
+
+def MakeDirsIfNeeded(path):
+  """Helper function to create all the directories on a path."""
+  if not os.path.isdir(path):
+    os.makedirs(path)
+
+
+def GetResourceContents(resource_name):
+  """Gets a string containing the named resource."""
+  # Look in the resource override folder first (just go with the basename to
+  # find the file, rather than the full path).
+  if FLAGS.resource_path:
+    path = os.path.join(FLAGS.resource_path, os.path.basename(resource_name))
+    if os.path.exists(path):
+      return open(path, "rb").read()
+
+  # If the full path exists, grab that, otherwise fall back to the basename.
+  if os.path.exists(resource_name):
+    return open(resource_name, "rb").read()
+  return open(os.path.join(RESOURCE_DIR, os.path.basename(resource_name)),
+              "rb").read()
+
+
+def LoadDeviceConfig(device_type, serial_number):
+  """Assembles a DeviceConfig proto following all includes, or the default."""
+
+  config = stress_test_pb2.DeviceConfig()
+  text_format.Merge(GetResourceContents(
+      os.path.join(RESOURCE_DIR, "device_config.common.ascii_proto")), config)
+  def RecursiveIncludeToConfig(resource_prefix, print_error):
+    """Load configurations recursively."""
+    try:
+      new_config = stress_test_pb2.DeviceConfig()
+      text_format.Merge(GetResourceContents(
+          os.path.join(RESOURCE_DIR,
+                       "device_config.%s.ascii_proto" % resource_prefix)),
+                        new_config)
+      for include_name in new_config.include:
+        # If we've managed to import this level properly, then we should print
+        # out any errors if we hit them on the included files.
+        RecursiveIncludeToConfig(include_name, print_error=True)
+      config.MergeFrom(new_config)
+    except IOError as err:
+      if print_error:
+        logging.error(str(err))
+
+  RecursiveIncludeToConfig(device_type, print_error=True)
+  RecursiveIncludeToConfig(serial_number, print_error=False)
+
+  def TakeOnlyLatestFromRepeatedField(message, field, key):
+    """Take only the latest version."""
+    old_list = list(getattr(message, field))
+    message.ClearField(field)
+    new_list = []
+    for i in range(len(old_list) - 1, -1, -1):
+      element = old_list[i]
+      if not any([getattr(x, key) == getattr(element, key)
+                  for x in old_list[i + 1:]]):
+        new_list.append(element)
+    getattr(message, field).extend(reversed(new_list))
+
+  # We actually need to do a bit of post-processing on the proto - we only want
+  # to take the latest version for each (that way people can override stuff if
+  # they want)
+  TakeOnlyLatestFromRepeatedField(config, "file_to_watch", "source")
+  TakeOnlyLatestFromRepeatedField(config, "file_to_move", "source")
+  TakeOnlyLatestFromRepeatedField(config, "event", "name")
+  TakeOnlyLatestFromRepeatedField(config, "daemon_process", "name")
+
+  return config